Remove Status from CoreData (#1167)
This commit is contained in:
parent
2119c9de0b
commit
976f934df9
|
@ -60,6 +60,7 @@
|
|||
2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; };
|
||||
2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; };
|
||||
2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; };
|
||||
2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */; };
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
|
@ -393,7 +394,6 @@
|
|||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; };
|
||||
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; };
|
||||
DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; };
|
||||
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; };
|
||||
DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; };
|
||||
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; };
|
||||
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; };
|
||||
|
@ -696,6 +696,7 @@
|
|||
2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = "<group>"; };
|
||||
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
|
||||
2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Timeline.swift"; sourceTree = "<group>"; };
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
||||
|
@ -1115,7 +1116,6 @@
|
|||
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = "<group>"; };
|
||||
DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = "<group>"; };
|
||||
DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = "<group>"; };
|
||||
DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = "<group>"; };
|
||||
DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = "<group>"; };
|
||||
DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = "<group>"; };
|
||||
DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1898,6 +1898,7 @@
|
|||
children = (
|
||||
D8AC98772B0F62230045EC2B /* Model */,
|
||||
D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */,
|
||||
2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */,
|
||||
);
|
||||
path = Persistence;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2687,7 +2688,6 @@
|
|||
DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */,
|
||||
DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */,
|
||||
DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */,
|
||||
DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */,
|
||||
DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */,
|
||||
DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */,
|
||||
);
|
||||
|
@ -3909,7 +3909,6 @@
|
|||
2AB5011C299243FB00346092 /* WidgetExtension.intentdefinition in Sources */,
|
||||
DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */,
|
||||
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
|
||||
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
|
||||
DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */,
|
||||
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
||||
DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */,
|
||||
|
@ -3978,6 +3977,7 @@
|
|||
DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */,
|
||||
D8318A802A4466D300C0FB73 /* SettingsCoordinator.swift in Sources */,
|
||||
DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */,
|
||||
2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */,
|
||||
DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */,
|
||||
DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */,
|
||||
DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */,
|
||||
|
|
|
@ -569,7 +569,10 @@ public extension SceneCoordinator {
|
|||
@MainActor
|
||||
func showLoading(on viewController: UIViewController?) {
|
||||
guard let viewController else { return }
|
||||
|
||||
|
||||
/// Don't add HUD twice
|
||||
guard MBProgressHUD.forView(viewController.view) == nil else { return }
|
||||
|
||||
MBProgressHUD.showAdded(to: viewController.view, animated: true)
|
||||
}
|
||||
|
||||
|
@ -626,7 +629,10 @@ extension SceneCoordinator: SettingsCoordinatorDelegate {
|
|||
try await self.appContext.authenticationService.signOutMastodonUser(
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
|
||||
let userIdentifier = authContext.mastodonAuthenticationBox
|
||||
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
|
||||
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
|
||||
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
|
||||
self.setup()
|
||||
}
|
||||
|
||||
|
|
|
@ -7,10 +7,10 @@
|
|||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
enum NotificationItem: Hashable {
|
||||
case feed(record: ManagedObjectRecord<Feed>)
|
||||
case feedLoader(record: ManagedObjectRecord<Feed>)
|
||||
case feed(record: MastodonFeed)
|
||||
case feedLoader(record: MastodonFeed)
|
||||
case bottomLoader
|
||||
}
|
||||
|
|
|
@ -41,18 +41,15 @@ extension NotificationSection {
|
|||
|
||||
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
case .feed(let feed):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let feed = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)),
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .feedLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
enum ReportItem: Hashable {
|
||||
case header(context: HeaderContext)
|
||||
case status(record: ManagedObjectRecord<Status>)
|
||||
case status(record: MastodonStatus)
|
||||
case comment(context: CommentContext)
|
||||
case result(record: ManagedObjectRecord<MastodonUser>)
|
||||
case bottomLoader
|
||||
|
|
|
@ -45,18 +45,15 @@ extension ReportSection {
|
|||
cell.primaryLabel.text = headerContext.primaryLabelText
|
||||
cell.secondaryLabel.text = headerContext.secondaryLabelText
|
||||
return cell
|
||||
case .status(let record):
|
||||
case .status(let status):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportStatusTableViewCell.self), for: indexPath) as! ReportStatusTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let status = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: .init(value: status),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: .init(value: status),
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .comment(let commentContext):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportCommentTableViewCell.self), for: indexPath) as! ReportCommentTableViewCell
|
||||
|
|
|
@ -8,11 +8,12 @@
|
|||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
enum StatusItem: Hashable {
|
||||
case feed(record: ManagedObjectRecord<Feed>)
|
||||
case feedLoader(record: ManagedObjectRecord<Feed>)
|
||||
case status(record: ManagedObjectRecord<Status>)
|
||||
case feed(record: MastodonFeed)
|
||||
case feedLoader(record: MastodonFeed)
|
||||
case status(record: MastodonStatus)
|
||||
case thread(Thread)
|
||||
case topLoader
|
||||
case bottomLoader
|
||||
|
@ -24,7 +25,7 @@ extension StatusItem {
|
|||
case reply(context: Context)
|
||||
case leaf(context: Context)
|
||||
|
||||
public var record: ManagedObjectRecord<Status> {
|
||||
public var record: MastodonStatus {
|
||||
switch self {
|
||||
case .root(let threadContext),
|
||||
.reply(let threadContext),
|
||||
|
@ -37,12 +38,12 @@ extension StatusItem {
|
|||
|
||||
extension StatusItem.Thread {
|
||||
class Context: Hashable {
|
||||
let status: ManagedObjectRecord<Status>
|
||||
let status: MastodonStatus
|
||||
var displayUpperConversationLink: Bool
|
||||
var displayBottomConversationLink: Bool
|
||||
|
||||
init(
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
displayUpperConversationLink: Bool = false,
|
||||
displayBottomConversationLink: Bool = false
|
||||
) {
|
||||
|
|
|
@ -44,42 +44,33 @@ extension StatusSection {
|
|||
|
||||
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
case .feed(let feed):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let feed = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)),
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .feedLoader(let record):
|
||||
case .feedLoader(let feed):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let feed = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
cell: cell,
|
||||
feed: feed,
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
cell: cell,
|
||||
feed: feed,
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .status(let record):
|
||||
case .status(let status):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let status = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .thread(let thread):
|
||||
let cell = dequeueConfiguredReusableCell(
|
||||
|
@ -118,36 +109,28 @@ extension StatusSection {
|
|||
tableView: UITableView,
|
||||
indexPath: IndexPath,
|
||||
configuration: ThreadCellRegistrationConfiguration
|
||||
) -> UITableViewCell {
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
|
||||
) -> UITableViewCell {
|
||||
switch configuration.thread {
|
||||
case .root(let threadContext):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell
|
||||
managedObjectContext.performAndWait {
|
||||
guard let status = threadContext.status.object(in: managedObjectContext) else { return }
|
||||
StatusSection.configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration.configuration
|
||||
)
|
||||
}
|
||||
StatusSection.configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(threadContext.status)),
|
||||
configuration: configuration.configuration
|
||||
)
|
||||
return cell
|
||||
case .reply(let threadContext),
|
||||
.leaf(let threadContext):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
managedObjectContext.performAndWait {
|
||||
guard let status = threadContext.status.object(in: managedObjectContext) else { return }
|
||||
StatusSection.configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration.configuration
|
||||
)
|
||||
}
|
||||
StatusSection.configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(threadContext.status)),
|
||||
configuration: configuration.configuration
|
||||
)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
@ -182,7 +165,7 @@ extension StatusSection {
|
|||
return
|
||||
}
|
||||
|
||||
cell.pollOptionView.configure(pollOption: option)
|
||||
cell.pollOptionView.configure(pollOption: option, status: statusView.viewModel.originalStatus)
|
||||
|
||||
// trigger update if needs
|
||||
let needsUpdatePoll: Bool = {
|
||||
|
@ -319,7 +302,7 @@ extension StatusSection {
|
|||
|
||||
static func configure(
|
||||
cell: TimelineMiddleLoaderTableViewCell,
|
||||
feed: Feed,
|
||||
feed: MastodonFeed,
|
||||
configuration: Configuration
|
||||
) {
|
||||
cell.configure(
|
||||
|
|
|
@ -68,8 +68,12 @@ extension FileManager {
|
|||
}
|
||||
}
|
||||
|
||||
extension FileManager {
|
||||
public var documentsDirectory: URL? {
|
||||
return self.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
public extension FileManager {
|
||||
var documentsDirectory: URL? {
|
||||
urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
}
|
||||
|
||||
var cachesDirectory: URL? {
|
||||
urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension FileManager {
|
||||
private static let cacheItemsLimit: Int = 100 // max number of items to cache
|
||||
|
||||
// Retrieve
|
||||
func cachedHomeTimeline(for userId: UserIdentifier) throws -> [MastodonStatus] {
|
||||
try cached(timeline: .homeTimeline(userId)).map(MastodonStatus.fromEntity)
|
||||
}
|
||||
|
||||
func cachedNotificationsAll(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] {
|
||||
try cached(timeline: .notificationsAll(userId))
|
||||
}
|
||||
|
||||
func cachedNotificationsMentions(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] {
|
||||
try cached(timeline: .notificationsMentions(userId))
|
||||
}
|
||||
|
||||
|
||||
private func cached<T: Decodable>(timeline: Persistence) throws -> [T] {
|
||||
guard let cachesDirectory else { return [] }
|
||||
|
||||
let filePath = timeline.filepath(baseURL: cachesDirectory)
|
||||
|
||||
guard let data = try? Data(contentsOf: filePath) else { return [] }
|
||||
|
||||
do {
|
||||
let items = try JSONDecoder().decode([T].self, from: data)
|
||||
|
||||
return items
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Create
|
||||
func cacheHomeTimeline(items: [MastodonStatus], for userIdentifier: UserIdentifier) {
|
||||
cache(items.map { $0.entity }, timeline: .homeTimeline(userIdentifier))
|
||||
}
|
||||
|
||||
func cacheNotificationsAll(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) {
|
||||
cache(items, timeline: .notificationsAll(userIdentifier))
|
||||
}
|
||||
|
||||
func cacheNotificationsMentions(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) {
|
||||
cache(items, timeline: .notificationsMentions(userIdentifier))
|
||||
}
|
||||
|
||||
private func cache<T: Encodable>(_ items: [T], timeline: Persistence) {
|
||||
guard let cachesDirectory else { return }
|
||||
|
||||
let processableItems: [T]
|
||||
if items.count > Self.cacheItemsLimit {
|
||||
processableItems = items.dropLast(items.count - Self.cacheItemsLimit)
|
||||
} else {
|
||||
processableItems = items
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(processableItems)
|
||||
|
||||
let filePath = timeline.filepath(baseURL: cachesDirectory)
|
||||
try data.write(to: filePath)
|
||||
} catch {
|
||||
debugPrint(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
func invalidateHomeTimelineCache(for userId: UserIdentifier) {
|
||||
invalidate(timeline: .homeTimeline(userId))
|
||||
}
|
||||
|
||||
func invalidateNotificationsAll(for userId: UserIdentifier) {
|
||||
invalidate(timeline: .notificationsAll(userId))
|
||||
}
|
||||
|
||||
func invalidateNotificationsMentions(for userId: UserIdentifier) {
|
||||
invalidate(timeline: .notificationsMentions(userId))
|
||||
}
|
||||
|
||||
private func invalidate(timeline: Persistence) {
|
||||
guard let cachesDirectory else { return }
|
||||
|
||||
let filePath = timeline.filepath(baseURL: cachesDirectory)
|
||||
|
||||
try? removeItem(at: filePath)
|
||||
}
|
||||
}
|
|
@ -9,18 +9,24 @@ import UIKit
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
public static func responseToStatusBookmarkAction(
|
||||
provider: UIViewController & NeedsDependency & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>
|
||||
provider: NeedsDependency & AuthContextProvider & DataSourceProvider,
|
||||
status: MastodonStatus
|
||||
) async throws {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
_ = try await provider.context.apiService.bookmark(
|
||||
let updatedStatus = try await provider.context.apiService.bookmark(
|
||||
record: status,
|
||||
authenticationBox: provider.authContext.mastodonAuthenticationBox
|
||||
)
|
||||
).value
|
||||
|
||||
let newStatus: MastodonStatus = .fromEntity(updatedStatus)
|
||||
newStatus.isSensitiveToggled = status.isSensitiveToggled
|
||||
|
||||
provider.update(status: newStatus)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,20 +7,25 @@
|
|||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import MastodonCore
|
||||
|
||||
extension DataSourceFacade {
|
||||
public static func responseToStatusFavoriteAction(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async throws {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
_ = try await provider.context.apiService.favorite(
|
||||
record: status,
|
||||
let updatedStatus = try await provider.context.apiService.favorite(
|
||||
status: status,
|
||||
authenticationBox: provider.authContext.mastodonAuthenticationBox
|
||||
)
|
||||
).value
|
||||
|
||||
let newStatus: MastodonStatus = .fromEntity(updatedStatus)
|
||||
newStatus.isSensitiveToggled = status.isSensitiveToggled
|
||||
|
||||
provider.update(status: newStatus)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,15 +49,14 @@ extension DataSourceFacade {
|
|||
extension DataSourceFacade {
|
||||
static func responseToUserFollowRequestAction(
|
||||
dependency: NeedsDependency & AuthContextProvider,
|
||||
notification: ManagedObjectRecord<Notification>,
|
||||
notification: MastodonNotification,
|
||||
query: Mastodon.API.Account.FollowRequestQuery
|
||||
) async throws {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
|
||||
let managedObjectContext = dependency.context.managedObjectContext
|
||||
let _userID: MastodonUser.ID? = try await managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return nil }
|
||||
return notification.account.id
|
||||
}
|
||||
|
||||
|
@ -66,23 +65,17 @@ extension DataSourceFacade {
|
|||
throw APIService.APIError.implicit(.badRequest)
|
||||
}
|
||||
|
||||
let state: MastodonFollowRequestState = try await managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return .init(state: .none) }
|
||||
return notification.followRequestState
|
||||
}
|
||||
let state: MastodonFollowRequestState = notification.followRequestState
|
||||
|
||||
guard state.state == .none else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await managedObjectContext.performChanges {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return }
|
||||
switch query {
|
||||
case .accept:
|
||||
notification.transientFollowRequestState = .init(state: .isAccepting)
|
||||
case .reject:
|
||||
notification.transientFollowRequestState = .init(state: .isRejecting)
|
||||
}
|
||||
switch query {
|
||||
case .accept:
|
||||
notification.transientFollowRequestState = .init(state: .isAccepting)
|
||||
case .reject:
|
||||
notification.transientFollowRequestState = .init(state: .isRejecting)
|
||||
}
|
||||
|
||||
do {
|
||||
|
@ -93,22 +86,12 @@ extension DataSourceFacade {
|
|||
)
|
||||
} catch {
|
||||
// reset state when failure
|
||||
try? await managedObjectContext.performChanges {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return }
|
||||
notification.transientFollowRequestState = .init(state: .none)
|
||||
}
|
||||
|
||||
notification.transientFollowRequestState = .init(state: .none)
|
||||
|
||||
if let error = error as? Mastodon.API.Error {
|
||||
switch error.httpResponseStatus {
|
||||
case .notFound:
|
||||
let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext
|
||||
try await backgroundManagedObjectContext.performChanges {
|
||||
guard let notification = notification.object(in: backgroundManagedObjectContext) else { return }
|
||||
for feed in notification.feeds {
|
||||
backgroundManagedObjectContext.delete(feed)
|
||||
}
|
||||
backgroundManagedObjectContext.delete(notification)
|
||||
}
|
||||
break
|
||||
default:
|
||||
let alertController = await UIAlertController(for: error, title: nil, preferredStyle: .alert)
|
||||
let okAction = await UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default)
|
||||
|
@ -124,32 +107,14 @@ extension DataSourceFacade {
|
|||
return
|
||||
}
|
||||
|
||||
try? await managedObjectContext.performChanges {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return }
|
||||
switch query {
|
||||
case .accept:
|
||||
notification.transientFollowRequestState = .init(state: .isAccept)
|
||||
case .reject:
|
||||
// do nothing due to will delete notification
|
||||
break
|
||||
}
|
||||
switch query {
|
||||
case .accept:
|
||||
notification.transientFollowRequestState = .init(state: .isAccept)
|
||||
notification.followRequestState = .init(state: .isAccept)
|
||||
case .reject:
|
||||
break
|
||||
}
|
||||
|
||||
let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext
|
||||
try? await backgroundManagedObjectContext.performChanges {
|
||||
guard let notification = notification.object(in: backgroundManagedObjectContext) else { return }
|
||||
switch query {
|
||||
case .accept:
|
||||
notification.followRequestState = .init(state: .isAccept)
|
||||
case .reject:
|
||||
// delete notification
|
||||
for feed in notification.feeds {
|
||||
backgroundManagedObjectContext.delete(feed)
|
||||
}
|
||||
backgroundManagedObjectContext.delete(notification)
|
||||
}
|
||||
}
|
||||
} // end func
|
||||
}
|
||||
}
|
||||
|
||||
extension DataSourceFacade {
|
||||
|
@ -161,4 +126,13 @@ extension DataSourceFacade {
|
|||
for: user,
|
||||
authenticationBox: dependency.authContext.mastodonAuthenticationBox)
|
||||
}
|
||||
|
||||
static func responseToShowHideReblogAction(
|
||||
dependency: NeedsDependency & AuthContextProvider,
|
||||
user: Mastodon.Entity.Account
|
||||
) async throws {
|
||||
_ = try await dependency.context.apiService.toggleShowReblogs(
|
||||
for: user,
|
||||
authenticationBox: dependency.authContext.mastodonAuthenticationBox)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import UIKit
|
|||
import CoreDataStack
|
||||
import MastodonUI
|
||||
import MastodonLocalization
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
|
||||
|
@ -61,15 +62,12 @@ extension DataSourceFacade {
|
|||
@MainActor
|
||||
static func coordinateToMediaPreviewScene(
|
||||
dependency: NeedsDependency & MediaPreviewableViewController,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
previewContext: AttachmentPreviewContext
|
||||
) async throws {
|
||||
let managedObjectContext = dependency.context.managedObjectContext
|
||||
let attachments: [MastodonAttachment] = try await managedObjectContext.perform {
|
||||
guard let _status = status.object(in: managedObjectContext) else { return [] }
|
||||
let status = _status.reblog ?? _status
|
||||
return status.attachments
|
||||
}
|
||||
let status = status.reblog ?? status
|
||||
let attachments = status.entity.mastodonAttachments
|
||||
|
||||
let thumbnails = await previewContext.thumbnails()
|
||||
|
||||
|
|
|
@ -9,21 +9,20 @@ import Foundation
|
|||
import CoreDataStack
|
||||
import MetaTextKit
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
|
||||
static func responseToMetaTextAction(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
target: StatusTarget,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
meta: Meta
|
||||
) async throws {
|
||||
let _redirectRecord = await DataSourceFacade.status(
|
||||
managedObjectContext: provider.context.managedObjectContext,
|
||||
let redirectRecord = DataSourceFacade.status(
|
||||
status: status,
|
||||
target: target
|
||||
)
|
||||
guard let redirectRecord = _redirectRecord else { return }
|
||||
|
||||
await responseToMetaTextAction(
|
||||
provider: provider,
|
||||
|
@ -35,7 +34,7 @@ extension DataSourceFacade {
|
|||
|
||||
static func responseToMetaTextAction(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
meta: Meta
|
||||
) async {
|
||||
switch meta {
|
||||
|
@ -51,11 +50,10 @@ extension DataSourceFacade {
|
|||
|
||||
await responseToURLAction(
|
||||
provider: provider,
|
||||
status: status,
|
||||
url: url
|
||||
)
|
||||
case .hashtag(_, let hashtag, _):
|
||||
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
|
||||
let hashtagTimelineViewModel = await HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
|
||||
_ = await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)
|
||||
case .mention(_, let mention, let userInfo):
|
||||
await coordinateToProfileScene(
|
||||
|
|
|
@ -9,41 +9,13 @@ import Foundation
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func status(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
target: StatusTarget
|
||||
) async -> ManagedObjectRecord<Status>? {
|
||||
return try? await managedObjectContext.perform {
|
||||
guard let object = status.object(in: managedObjectContext) else { return nil }
|
||||
return DataSourceFacade.status(status: object, target: target)
|
||||
.flatMap { ManagedObjectRecord<Status>(objectID: $0.objectID) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func author(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
target: StatusTarget
|
||||
) async -> ManagedObjectRecord<MastodonUser>? {
|
||||
return try? await managedObjectContext.perform {
|
||||
guard let object = status.object(in: managedObjectContext) else { return nil }
|
||||
return DataSourceFacade.status(status: object, target: target)
|
||||
.flatMap { $0.author }
|
||||
.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func status(
|
||||
status: Status,
|
||||
target: StatusTarget
|
||||
) -> Status? {
|
||||
) -> MastodonStatus {
|
||||
switch target {
|
||||
case .status:
|
||||
return status.reblog ?? status
|
||||
|
|
|
@ -12,23 +12,38 @@ import MastodonSDK
|
|||
|
||||
extension DataSourceFacade {
|
||||
|
||||
@MainActor
|
||||
static func coordinateToProfileScene(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
target: StatusTarget,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async {
|
||||
let _redirectRecord = await DataSourceFacade.author(
|
||||
managedObjectContext: provider.context.managedObjectContext,
|
||||
status: status,
|
||||
target: target
|
||||
)
|
||||
let acct: String
|
||||
switch target {
|
||||
case .status:
|
||||
acct = status.reblog?.entity.account.acct ?? status.entity.account.acct
|
||||
case .reblog:
|
||||
acct = status.entity.account.acct
|
||||
}
|
||||
|
||||
provider.coordinator.showLoading()
|
||||
|
||||
let _redirectRecord = try? await Mastodon.API.Account.lookupAccount(
|
||||
session: .shared,
|
||||
domain: provider.authContext.mastodonAuthenticationBox.domain,
|
||||
query: .init(acct: acct),
|
||||
authorization: provider.authContext.mastodonAuthenticationBox.userAuthorization
|
||||
).singleOutput().value
|
||||
|
||||
provider.coordinator.hideLoading()
|
||||
|
||||
guard let redirectRecord = _redirectRecord else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
await coordinateToProfileScene(
|
||||
provider: provider,
|
||||
user: redirectRecord
|
||||
account: redirectRecord
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -83,9 +98,10 @@ extension DataSourceFacade {
|
|||
|
||||
extension DataSourceFacade {
|
||||
|
||||
@MainActor
|
||||
static func coordinateToProfileScene(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
mention: String, // username,
|
||||
userInfo: [AnyHashable: Any]?
|
||||
) async {
|
||||
|
@ -98,13 +114,10 @@ extension DataSourceFacade {
|
|||
return
|
||||
}
|
||||
|
||||
let managedObjectContext = provider.context.managedObjectContext
|
||||
let mentions = try? await managedObjectContext.perform {
|
||||
return status.object(in: managedObjectContext)?.mentions ?? []
|
||||
}
|
||||
let mentions = status.entity.mentions ?? []
|
||||
|
||||
guard let mention = mentions?.first(where: { $0.url == href }) else {
|
||||
_ = await provider.coordinator.present(
|
||||
guard let mention = mentions.first(where: { $0.url == href }) else {
|
||||
_ = provider.coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: provider,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
|
@ -131,7 +144,7 @@ extension DataSourceFacade {
|
|||
}
|
||||
}()
|
||||
|
||||
_ = await provider.coordinator.present(
|
||||
_ = provider.coordinator.present(
|
||||
scene: .profile(viewModel: profileViewModel),
|
||||
from: provider,
|
||||
transition: .show
|
||||
|
|
|
@ -6,21 +6,27 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreDataStack
|
||||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func responseToStatusReblogAction(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async throws {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
_ = try await provider.context.apiService.reblog(
|
||||
record: status,
|
||||
let updatedStatus = try await provider.context.apiService.reblog(
|
||||
status: status,
|
||||
authenticationBox: provider.authContext.mastodonAuthenticationBox
|
||||
)
|
||||
} // end func
|
||||
).value
|
||||
|
||||
let newStatus: MastodonStatus = .fromEntity(updatedStatus)
|
||||
newStatus.reblog?.isSensitiveToggled = status.isSensitiveToggled
|
||||
newStatus.isSensitiveToggled = status.isSensitiveToggled
|
||||
|
||||
provider.update(status: newStatus)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,18 +14,21 @@ import MastodonUI
|
|||
import MastodonLocalization
|
||||
import LinkPresentation
|
||||
import UniformTypeIdentifiers
|
||||
import MastodonSDK
|
||||
|
||||
// Delete
|
||||
extension DataSourceFacade {
|
||||
|
||||
static func responseToDeleteStatus(
|
||||
dependency: NeedsDependency & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>
|
||||
dependency: NeedsDependency & AuthContextProvider & DataSourceProvider,
|
||||
status: MastodonStatus
|
||||
) async throws {
|
||||
_ = try await dependency.context.apiService.deleteStatus(
|
||||
let deletedStatus = try await dependency.context.apiService.deleteStatus(
|
||||
status: status,
|
||||
authenticationBox: dependency.authContext.mastodonAuthenticationBox
|
||||
)
|
||||
).value.asMastodonStatus
|
||||
|
||||
dependency.delete(status: deletedStatus)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -36,7 +39,7 @@ extension DataSourceFacade {
|
|||
@MainActor
|
||||
public static func responseToStatusShareAction(
|
||||
provider: DataSourceProvider,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
button: UIButton
|
||||
) async throws {
|
||||
let activityViewController = try await createActivityViewController(
|
||||
|
@ -56,22 +59,22 @@ extension DataSourceFacade {
|
|||
|
||||
private static func createActivityViewController(
|
||||
dependency: NeedsDependency,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async throws -> UIActivityViewController {
|
||||
var activityItems: [Any] = try await dependency.context.managedObjectContext.perform {
|
||||
guard let status = status.object(in: dependency.context.managedObjectContext),
|
||||
let url = URL(string: status.url ?? status.uri)
|
||||
var activityItems: [Any] = {
|
||||
guard let url = URL(string: status.entity.url ?? status.entity.uri)
|
||||
else { return [] }
|
||||
return [
|
||||
URLActivityItemWithMetadata(url: url) { metadata in
|
||||
metadata.title = "\(status.author.displayName) (@\(status.author.acctWithDomain))"
|
||||
metadata.title = "\(status.entity.account.displayName) (@\(status.entity.account.acctWithDomain))"
|
||||
metadata.iconProvider = ImageProvider(
|
||||
url: status.author.avatarImageURLWithFallback(domain: status.author.domain),
|
||||
url: status.entity.account.avatarImageURLWithFallback(domain: status.entity.account.domain ?? ""),
|
||||
filter: ScaledToSizeFilter(size: CGSize.authorAvatarButtonSize)
|
||||
).itemProvider
|
||||
}
|
||||
] as [Any]
|
||||
}
|
||||
}()
|
||||
|
||||
var applicationActivities: [UIActivity] = [
|
||||
SafariActivity(sceneCoordinator: dependency.coordinator), // open URL
|
||||
]
|
||||
|
@ -94,20 +97,11 @@ extension DataSourceFacade {
|
|||
@MainActor
|
||||
static func responseToActionToolbar(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
status: MastodonStatus,
|
||||
action: ActionToolbarContainer.Action,
|
||||
sender: UIButton
|
||||
) async throws {
|
||||
let managedObjectContext = provider.context.managedObjectContext
|
||||
let _status: ManagedObjectRecord<Status>? = try? await managedObjectContext.perform {
|
||||
guard let object = status.object(in: managedObjectContext) else { return nil }
|
||||
let objectID = (object.reblog ?? object).objectID
|
||||
return .init(objectID: objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
let _status = status.reblog ?? status
|
||||
|
||||
switch action {
|
||||
case .reply:
|
||||
|
@ -118,7 +112,7 @@ extension DataSourceFacade {
|
|||
context: provider.context,
|
||||
authContext: provider.authContext,
|
||||
composeContext: .composeStatus,
|
||||
destination: .reply(parent: status)
|
||||
destination: .reply(parent: _status)
|
||||
)
|
||||
_ = provider.coordinator.present(
|
||||
scene: .compose(viewModel: composeViewModel),
|
||||
|
@ -128,17 +122,17 @@ extension DataSourceFacade {
|
|||
case .reblog:
|
||||
try await DataSourceFacade.responseToStatusReblogAction(
|
||||
provider: provider,
|
||||
status: status
|
||||
status: _status
|
||||
)
|
||||
case .like:
|
||||
try await DataSourceFacade.responseToStatusFavoriteAction(
|
||||
provider: provider,
|
||||
status: status
|
||||
status: _status
|
||||
)
|
||||
case .share:
|
||||
try await DataSourceFacade.responseToStatusShareAction(
|
||||
provider: provider,
|
||||
status: status,
|
||||
status: _status,
|
||||
button: sender
|
||||
)
|
||||
} // end switch
|
||||
|
@ -150,7 +144,8 @@ extension DataSourceFacade {
|
|||
extension DataSourceFacade {
|
||||
|
||||
struct MenuContext {
|
||||
let author: ManagedObjectRecord<MastodonUser>?
|
||||
let author: ManagedObjectRecord<MastodonUser>? // todo: Remove once IOS-192 is ready
|
||||
let authorEntity: Mastodon.Entity.Account?
|
||||
let statusViewModel: StatusView.ViewModel?
|
||||
let button: UIButton?
|
||||
let barButtonItem: UIBarButtonItem?
|
||||
|
@ -158,7 +153,7 @@ extension DataSourceFacade {
|
|||
|
||||
@MainActor
|
||||
static func responseToMenuAction(
|
||||
dependency: UIViewController & NeedsDependency & AuthContextProvider,
|
||||
dependency: UIViewController & NeedsDependency & AuthContextProvider & DataSourceProvider,
|
||||
action: MastodonMenu.Action,
|
||||
menuContext: MenuContext
|
||||
) async throws {
|
||||
|
@ -266,7 +261,7 @@ extension DataSourceFacade {
|
|||
context: dependency.context,
|
||||
authContext: dependency.authContext,
|
||||
user: user,
|
||||
status: menuContext.statusViewModel?.originalStatus?.asRecord
|
||||
status: menuContext.statusViewModel?.originalStatus
|
||||
)
|
||||
|
||||
_ = dependency.coordinator.present(
|
||||
|
@ -297,7 +292,7 @@ extension DataSourceFacade {
|
|||
)
|
||||
case .bookmarkStatus:
|
||||
Task {
|
||||
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else {
|
||||
guard let status = menuContext.statusViewModel?.originalStatus else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -309,11 +304,7 @@ extension DataSourceFacade {
|
|||
case .shareStatus:
|
||||
Task {
|
||||
let managedObjectContext = dependency.context.managedObjectContext
|
||||
guard let status: ManagedObjectRecord<Status> = try? await managedObjectContext.perform(block: {
|
||||
guard let object = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: managedObjectContext) else { return nil }
|
||||
let objectID = (object.reblog ?? object).objectID
|
||||
return .init(objectID: objectID)
|
||||
}) else {
|
||||
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -344,7 +335,7 @@ extension DataSourceFacade {
|
|||
style: .destructive
|
||||
) { [weak dependency] _ in
|
||||
guard let dependency = dependency else { return }
|
||||
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return }
|
||||
guard let status = menuContext.statusViewModel?.originalStatus else { return }
|
||||
Task {
|
||||
try await DataSourceFacade.responseToDeleteStatus(
|
||||
dependency: dependency,
|
||||
|
@ -358,7 +349,7 @@ extension DataSourceFacade {
|
|||
dependency.present(alertController, animated: true)
|
||||
|
||||
case .translateStatus:
|
||||
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return }
|
||||
guard let status = menuContext.statusViewModel?.originalStatus else { return }
|
||||
|
||||
do {
|
||||
let translation = try await DataSourceFacade.translateStatus(provider: dependency,status: status)
|
||||
|
@ -371,7 +362,7 @@ extension DataSourceFacade {
|
|||
}
|
||||
case .editStatus:
|
||||
|
||||
guard let status = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: dependency.context.managedObjectContext) else { return }
|
||||
guard let status = menuContext.statusViewModel?.originalStatus else { return }
|
||||
|
||||
let statusSource = try await dependency.context.apiService.getStatusSource(
|
||||
forStatusID: status.id,
|
||||
|
@ -441,14 +432,15 @@ extension DataSourceFacade {
|
|||
extension DataSourceFacade {
|
||||
|
||||
static func responseToToggleSensitiveAction(
|
||||
dependency: NeedsDependency,
|
||||
status: ManagedObjectRecord<Status>
|
||||
dependency: NeedsDependency & DataSourceProvider,
|
||||
status: MastodonStatus
|
||||
) async throws {
|
||||
try await dependency.context.managedObjectContext.perform {
|
||||
guard let _status = status.object(in: dependency.context.managedObjectContext) else { return }
|
||||
let status = _status.reblog ?? _status
|
||||
status.update(isSensitiveToggled: !status.isSensitiveToggled)
|
||||
}
|
||||
let _status = status.reblog ?? status
|
||||
|
||||
let newStatus: MastodonStatus = .fromEntity(_status.entity)
|
||||
newStatus.isSensitiveToggled = !_status.isSensitiveToggled
|
||||
|
||||
dependency.update(status: newStatus)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,21 +9,20 @@ import UIKit
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func coordinateToStatusThreadScene(
|
||||
provider: ViewControllerWithDependencies & AuthContextProvider,
|
||||
target: StatusTarget,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async {
|
||||
let _root: StatusItem.Thread? = await {
|
||||
let _redirectRecord = await DataSourceFacade.status(
|
||||
managedObjectContext: provider.context.managedObjectContext,
|
||||
let _root: StatusItem.Thread? = {
|
||||
let redirectRecord = DataSourceFacade.status(
|
||||
status: status,
|
||||
target: target
|
||||
)
|
||||
guard let redirectRecord = _redirectRecord else { return nil }
|
||||
|
||||
|
||||
let threadContext = StatusItem.Thread.Context(status: redirectRecord)
|
||||
return StatusItem.Thread.root(context: threadContext)
|
||||
}()
|
||||
|
|
|
@ -20,27 +20,11 @@ extension DataSourceFacade {
|
|||
|
||||
public static func translateStatus(
|
||||
provider: Provider,
|
||||
status: ManagedObjectRecord<Status>
|
||||
status: MastodonStatus
|
||||
) async throws -> Mastodon.Entity.Translation? {
|
||||
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||
await selectionFeedbackGenerator.selectionChanged()
|
||||
|
||||
guard
|
||||
let status = status.object(in: provider.context.managedObjectContext)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let reblog = status.reblog {
|
||||
return try await translateStatus(provider: provider, status: reblog)
|
||||
} else {
|
||||
return try await translateStatus(provider: provider, status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension DataSourceFacade {
|
||||
static func translateStatus(provider: Provider, status: Status) async throws -> Mastodon.Entity.Translation? {
|
||||
do {
|
||||
let value = try await provider.context
|
||||
.apiService
|
||||
|
|
|
@ -9,11 +9,11 @@ import Foundation
|
|||
import CoreDataStack
|
||||
import MetaTextKit
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension DataSourceFacade {
|
||||
static func responseToURLAction(
|
||||
provider: DataSourceProvider & AuthContextProvider,
|
||||
status: ManagedObjectRecord<Status>,
|
||||
url: URL
|
||||
) async {
|
||||
let domain = provider.authContext.mastodonAuthenticationBox.domain
|
||||
|
|
|
@ -10,6 +10,7 @@ import MetaTextKit
|
|||
import CoreDataStack
|
||||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
// MARK: - Notification AuthorMenuAction
|
||||
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
|
||||
|
@ -31,7 +32,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
}
|
||||
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
return .init(objectID: notification.account.objectID)
|
||||
}
|
||||
guard let author = _author else {
|
||||
|
@ -44,6 +44,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
action: action,
|
||||
menuContext: .init(
|
||||
author: author,
|
||||
authorEntity: notification.entity.account,
|
||||
statusViewModel: nil,
|
||||
button: button,
|
||||
barButtonItem: nil
|
||||
|
@ -71,7 +72,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
return
|
||||
}
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
return .init(objectID: notification.account.objectID)
|
||||
}
|
||||
guard let author = _author else {
|
||||
|
@ -155,7 +155,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
}
|
||||
|
||||
private struct NotificationMediaTransitionContext {
|
||||
let status: ManagedObjectRecord<Status>
|
||||
let status: MastodonStatus
|
||||
let needsToggleMediaSensitive: Bool
|
||||
}
|
||||
|
||||
|
@ -180,16 +180,19 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
|
|||
return
|
||||
}
|
||||
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform {
|
||||
guard let notification = record.object(in: managedObjectContext) else { return nil }
|
||||
guard let _status = notification.status else { return nil }
|
||||
let status = _status.reblog ?? _status
|
||||
let _mediaTransitionContext: NotificationMediaTransitionContext? = {
|
||||
guard let status = record.status?.reblog ?? record.status else { return nil }
|
||||
let needsToBeToggled: Bool = {
|
||||
guard let sensitive = status.entity.sensitive else {
|
||||
return false
|
||||
}
|
||||
return status.isSensitiveToggled ? !sensitive : sensitive
|
||||
}()
|
||||
return NotificationMediaTransitionContext(
|
||||
status: .init(objectID: status.objectID),
|
||||
needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive
|
||||
status: status,
|
||||
needsToggleMediaSensitive: needsToBeToggled
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
guard let mediaTransitionContext = _mediaTransitionContext else { return }
|
||||
|
||||
|
@ -233,15 +236,13 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
|
|||
}
|
||||
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform {
|
||||
guard let notification = record.object(in: managedObjectContext) else { return nil }
|
||||
guard let _status = notification.status else { return nil }
|
||||
let status = _status.reblog ?? _status
|
||||
let _mediaTransitionContext: NotificationMediaTransitionContext? = {
|
||||
guard let status = record.status?.reblog ?? record.status else { return nil }
|
||||
return NotificationMediaTransitionContext(
|
||||
status: .init(objectID: status.objectID),
|
||||
needsToggleMediaSensitive: status.isMediaSensitive ? !status.isSensitiveToggled : false
|
||||
status: status,
|
||||
needsToggleMediaSensitive: status.entity.sensitive == true ? !status.isSensitiveToggled : false
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
guard let mediaTransitionContext = _mediaTransitionContext else { return }
|
||||
|
||||
|
@ -286,12 +287,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
guard let status = notification.status?.reblog ?? notification.status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -323,18 +319,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.author.objectID)
|
||||
}
|
||||
guard let author = _author else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
await DataSourceFacade.coordinateToProfileScene(
|
||||
provider: self,
|
||||
user: author
|
||||
user: notification.account.asRecord
|
||||
)
|
||||
} // end Task
|
||||
}
|
||||
|
@ -367,12 +355,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for notification item")
|
||||
return
|
||||
}
|
||||
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
guard let status = notification.status?.reblog ?? notification.status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -400,12 +383,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for notification item")
|
||||
return
|
||||
}
|
||||
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
guard let status = notification.status?.reblog ?? notification.status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -465,12 +443,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for notification item")
|
||||
return
|
||||
}
|
||||
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
guard let status = notification.status?.reblog ?? notification.status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
@ -497,12 +470,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
|
|||
assertionFailure("only works for notification item")
|
||||
return
|
||||
}
|
||||
let _status: ManagedObjectRecord<Status>? = try await self.context.managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let status = _status else {
|
||||
guard let status = notification.status?.reblog ?? notification.status else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
statusView: StatusView,
|
||||
headerDidPressed header: UIView
|
||||
) {
|
||||
let domain = statusView.domain ?? ""
|
||||
Task {
|
||||
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
|
||||
guard let item = await item(from: source) else {
|
||||
|
@ -38,15 +39,15 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
break
|
||||
case .reply:
|
||||
let _replyToAuthor: ManagedObjectRecord<MastodonUser>? = try? await context.managedObjectContext.perform {
|
||||
guard let status = status.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let inReplyToAccountID = status.inReplyToAccountID else { return nil }
|
||||
guard let inReplyToAccountID = status.entity.inReplyToAccountID else { return nil }
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: status.author.domain, id: inReplyToAccountID)
|
||||
request.predicate = MastodonUser.predicate(domain: domain, id: inReplyToAccountID)
|
||||
request.fetchLimit = 1
|
||||
guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil }
|
||||
return .init(objectID: author.objectID)
|
||||
}
|
||||
guard let replyToAuthor = _replyToAuthor else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -147,7 +148,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
|
||||
await DataSourceFacade.responseToURLAction(
|
||||
provider: self,
|
||||
status: status,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
@ -172,7 +172,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
|
||||
await DataSourceFacade.responseToURLAction(
|
||||
provider: self,
|
||||
status: status,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
@ -184,7 +183,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
cardControlMenu statusCardControl: StatusCardControl
|
||||
) -> [LabeledAction]? {
|
||||
guard let card = statusView.viewModel.card,
|
||||
let url = card.url else {
|
||||
let url = URL(string: card.url) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -206,8 +205,8 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
URLActivityItemWithMetadata(url: url) { metadata in
|
||||
metadata.title = card.title
|
||||
|
||||
if let image = card.imageURL {
|
||||
metadata.iconProvider = ImageProvider(url: image, filter: nil).itemProvider
|
||||
if let image = card.image, let url = URL(string: image) {
|
||||
metadata.iconProvider = ImageProvider(url: url, filter: nil).itemProvider
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -440,7 +439,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try await DataSourceFacade.responseToActionToolbar(
|
||||
provider: self,
|
||||
status: status,
|
||||
|
@ -466,13 +465,18 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
assertionFailure()
|
||||
return
|
||||
}
|
||||
guard case let .status(status) = item else {
|
||||
guard case let .status(_status) = item else {
|
||||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
|
||||
let status = _status.reblog ?? _status
|
||||
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = try await self.context.managedObjectContext.perform {
|
||||
guard let _status = status.object(in: self.context.managedObjectContext) else { return nil }
|
||||
let author = (_status.reblog ?? _status).author
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: self.authContext.mastodonAuthenticationBox.domain, id: status.entity.account.id)
|
||||
request.fetchLimit = 1
|
||||
guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil }
|
||||
return .init(objectID: author.objectID)
|
||||
}
|
||||
guard let author = _author else {
|
||||
|
@ -514,6 +518,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
action: action,
|
||||
menuContext: .init(
|
||||
author: author,
|
||||
authorEntity: status.entity.account,
|
||||
statusViewModel: statusViewModel,
|
||||
button: button,
|
||||
barButtonItem: nil
|
||||
|
@ -679,11 +684,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
|
|||
assertionFailure("only works for status data provider")
|
||||
return
|
||||
}
|
||||
|
||||
guard let status = status.object(in: context.managedObjectContext) else {
|
||||
return await coordinator.hideLoading()
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let edits = try await context.apiService.getHistory(forStatusID: status.id, authenticationBox: authContext.mastodonAuthenticationBox).value
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import UIKit
|
||||
import CoreDataStack
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & StatusTableViewControllerNavigateableRelay {
|
||||
|
||||
|
@ -55,7 +56,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
|
|||
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
|
||||
|
||||
@MainActor
|
||||
private func statusRecord() async -> ManagedObjectRecord<Status>? {
|
||||
private func statusRecord() async -> MastodonStatus? {
|
||||
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return nil }
|
||||
let source = DataSourceItem.Source(indexPath: indexPathForSelectedRow)
|
||||
guard let item = await item(from: source) else { return nil }
|
||||
|
@ -64,15 +65,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
|
|||
case .status(let record):
|
||||
return record
|
||||
case .notification(let record):
|
||||
let _statusRecord: ManagedObjectRecord<Status>? = try? await context.managedObjectContext.perform {
|
||||
guard let notification = record.object(in: self.context.managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
guard let statusRecord = _statusRecord else {
|
||||
return nil
|
||||
}
|
||||
return statusRecord
|
||||
return record.status
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import CoreDataStack
|
|||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonLocalization
|
||||
import MastodonSDK
|
||||
|
||||
extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider {
|
||||
|
||||
|
@ -39,14 +40,8 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid
|
|||
provider: self,
|
||||
tag: tag
|
||||
)
|
||||
case .notification(let notification):
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
|
||||
let _status: ManagedObjectRecord<Status>? = try await managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return nil }
|
||||
guard let status = notification.status else { return nil }
|
||||
return .init(objectID: status.objectID)
|
||||
}
|
||||
case .notification(let notification):
|
||||
let _status: MastodonStatus? = notification.status
|
||||
if let status = _status {
|
||||
await DataSourceFacade.coordinateToStatusThreadScene(
|
||||
provider: self,
|
||||
|
@ -54,10 +49,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid
|
|||
status: status
|
||||
)
|
||||
} else {
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = try await managedObjectContext.perform {
|
||||
guard let notification = notification.object(in: managedObjectContext) else { return nil }
|
||||
return .init(objectID: notification.account.objectID)
|
||||
}
|
||||
let _author: ManagedObjectRecord<MastodonUser>? = notification.account.asRecord
|
||||
if let author = _author {
|
||||
await DataSourceFacade.coordinateToProfileScene(
|
||||
provider: self,
|
||||
|
|
|
@ -12,10 +12,10 @@ import MastodonSDK
|
|||
import class CoreDataStack.Notification
|
||||
|
||||
enum DataSourceItem: Hashable {
|
||||
case status(record: ManagedObjectRecord<Status>)
|
||||
case status(record: MastodonStatus)
|
||||
case user(record: ManagedObjectRecord<MastodonUser>)
|
||||
case hashtag(tag: Mastodon.Entity.Tag)
|
||||
case notification(record: ManagedObjectRecord<Notification>)
|
||||
case notification(record: MastodonNotification)
|
||||
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
|
||||
}
|
||||
|
||||
|
@ -39,4 +39,6 @@ extension DataSourceItem {
|
|||
|
||||
protocol DataSourceProvider: ViewControllerWithDependencies {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem?
|
||||
func update(status: MastodonStatus)
|
||||
func delete(status: MastodonStatus)
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ final class ComposeViewModel {
|
|||
|
||||
enum Context {
|
||||
case composeStatus
|
||||
case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource)
|
||||
case editStatus(status: MastodonStatus, statusSource: Mastodon.Entity.StatusSource)
|
||||
}
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension DiscoveryCommunityViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -26,6 +27,16 @@ extension DiscoveryCommunityViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.setRecords(
|
||||
viewModel.dataController.records.filter { $0.id != status.id }
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -29,7 +29,7 @@ extension DiscoveryCommunityViewModel {
|
|||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -145,10 +145,10 @@ extension DiscoveryCommunityViewModel.State {
|
|||
self.maxID = newMaxID
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = isReloading ? [] : await viewModel.dataController.records
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
|
||||
statusIDs.append(.fromEntity(status))
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -158,7 +158,7 @@ extension DiscoveryCommunityViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
await viewModel.dataController.setRecords(statusIDs)
|
||||
viewModel.didLoadLatest.send()
|
||||
|
||||
} catch {
|
||||
|
|
|
@ -21,7 +21,7 @@ final class DiscoveryCommunityViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let viewDidAppeared = PassthroughSubject<Void, Never>()
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
|
@ -41,14 +41,11 @@ final class DiscoveryCommunityViewModel {
|
|||
|
||||
let didLoadLatest = PassthroughSubject<Void, Never>()
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
// end init
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ final class DiscoveryViewModel {
|
|||
|
||||
@Published var viewControllers: [ScrollViewContainer & PageViewController]
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, coordinator: SceneCoordinator, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension DiscoveryPostsViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -26,6 +27,16 @@ extension DiscoveryPostsViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.setRecords(
|
||||
viewModel.dataController.records.filter { $0.id != status.id }
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -29,7 +29,7 @@ extension DiscoveryPostsViewModel {
|
|||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -143,10 +143,10 @@ extension DiscoveryPostsViewModel.State {
|
|||
self.offset = newOffset
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = isReloading ? [] : await viewModel.dataController.records
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
|
||||
statusIDs.append(.fromEntity(status))
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -155,7 +155,7 @@ extension DiscoveryPostsViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
await viewModel.dataController.setRecords(statusIDs)
|
||||
viewModel.didLoadLatest.send()
|
||||
|
||||
} catch {
|
||||
|
|
|
@ -20,7 +20,7 @@ final class DiscoveryPostsViewModel {
|
|||
// input
|
||||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
|
@ -41,14 +41,11 @@ final class DiscoveryPostsViewModel {
|
|||
let didLoadLatest = PassthroughSubject<Void, Never>()
|
||||
@Published var isServerSupportEndpoint = true
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
// end init
|
||||
|
||||
Task {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension HashtagTimelineViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -27,6 +28,14 @@ extension HashtagTimelineViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.deleteRecord(status)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -34,7 +34,7 @@ extension HashtagTimelineViewModel {
|
|||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
fetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import GameplayKit
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension HashtagTimelineViewModel {
|
||||
class State: GKState {
|
||||
|
@ -145,10 +146,10 @@ extension HashtagTimelineViewModel.State {
|
|||
self.maxID = newMaxID
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = isReloading ? [] : viewModel.fetchedResultsController.statusIDs
|
||||
var statusIDs = isReloading ? [] : await viewModel.dataController.records.map { $0.entity }
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(status) else { continue }
|
||||
statusIDs.append(status)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -158,7 +159,7 @@ extension HashtagTimelineViewModel.State {
|
|||
await enter(state: NoMore.self)
|
||||
}
|
||||
|
||||
viewModel.fetchedResultsController.append(statusIDs: statusIDs)
|
||||
await viewModel.dataController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) })
|
||||
viewModel.didLoadLatest.send()
|
||||
} catch {
|
||||
await enter(state: Fail.self)
|
||||
|
|
|
@ -24,7 +24,7 @@ final class HashtagTimelineViewModel {
|
|||
// input
|
||||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let fetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
|
||||
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
|
||||
|
@ -50,15 +50,12 @@ final class HashtagTimelineViewModel {
|
|||
return stateMachine
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, hashtag: String) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.hashtag = hashtag
|
||||
self.fetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
updateTagInformation()
|
||||
// end init
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension HomeTimelineViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -20,22 +21,25 @@ extension HomeTimelineViewController: DataSourceProvider {
|
|||
}
|
||||
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let item: DataSourceItem? = try? await managedObjectContext.perform {
|
||||
guard let feed = record.object(in: managedObjectContext) else { return nil }
|
||||
guard feed.kind == .home else { return nil }
|
||||
if let status = feed.status {
|
||||
return .status(record: .init(objectID: status.objectID))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .feed(let feed):
|
||||
guard feed.kind == .home else { return nil }
|
||||
if let status = feed.status {
|
||||
return .status(record: status)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
return item
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.records = viewModel.dataController.records.filter { $0.id != status.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -386,6 +386,10 @@ extension HomeTimelineViewController {
|
|||
@objc func signOutAction(_ sender: UIAction) {
|
||||
Task { @MainActor in
|
||||
try await context.authenticationService.signOutMastodonUser(authenticationBox: viewModel.authContext.mastodonAuthenticationBox)
|
||||
let userIdentifier = viewModel.authContext.mastodonAuthenticationBox
|
||||
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
|
||||
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
|
||||
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
|
||||
self.coordinator.setup()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,8 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
extension HomeTimelineViewModel {
|
||||
|
||||
|
@ -35,7 +34,7 @@ extension HomeTimelineViewModel {
|
|||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
fetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
@ -46,41 +45,23 @@ extension HomeTimelineViewModel {
|
|||
var newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> = {
|
||||
let newItems = records.map { record in
|
||||
StatusItem.feed(record: record)
|
||||
}
|
||||
}.removingDuplicates()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(newItems, toSection: .main)
|
||||
return snapshot
|
||||
}()
|
||||
|
||||
let parentManagedObjectContext = self.context.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
try? await managedObjectContext.perform {
|
||||
let anchors: [Feed] = {
|
||||
let request = Feed.sortedFetchRequest
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
Feed.hasMorePredicate(),
|
||||
self.fetchedResultsController.predicate,
|
||||
])
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
|
||||
let itemIdentifiers = newSnapshot.itemIdentifiers
|
||||
for (index, item) in itemIdentifiers.enumerated() {
|
||||
guard case let .feed(record) = item else { continue }
|
||||
guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
|
||||
let isLast = index + 1 == itemIdentifiers.count
|
||||
if isLast {
|
||||
newSnapshot.insertItems([.bottomLoader], afterItem: item)
|
||||
} else {
|
||||
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
|
||||
}
|
||||
let anchors: [MastodonFeed] = records.filter { $0.hasMore == true }
|
||||
let itemIdentifiers = newSnapshot.itemIdentifiers
|
||||
for (index, item) in itemIdentifiers.enumerated() {
|
||||
guard case let .feed(record) = item else { continue }
|
||||
guard anchors.contains(where: { feed in feed.id == record.id }) else { continue }
|
||||
let isLast = index + 1 == itemIdentifiers.count
|
||||
if isLast {
|
||||
newSnapshot.insertItems([.bottomLoader], afterItem: item)
|
||||
} else {
|
||||
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,12 +76,12 @@ extension HomeTimelineViewModel {
|
|||
oldSnapshot: oldSnapshot,
|
||||
newSnapshot: newSnapshot
|
||||
) else {
|
||||
self.updateSnapshotUsingReloadData(snapshot: newSnapshot)
|
||||
await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false)
|
||||
self.didLoadLatest.send()
|
||||
return
|
||||
}
|
||||
|
||||
self.updateSnapshotUsingReloadData(snapshot: newSnapshot)
|
||||
await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false)
|
||||
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
|
||||
var contentOffset = tableView.contentOffset
|
||||
contentOffset.y = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge
|
||||
|
|
|
@ -11,6 +11,7 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import MastodonCore
|
||||
import MastodonSDK
|
||||
|
||||
extension HomeTimelineViewModel {
|
||||
class LoadLatestState: GKState {
|
||||
|
@ -83,15 +84,11 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
|
||||
guard let viewModel else { return }
|
||||
|
||||
let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount)
|
||||
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
let latestFeedRecords = viewModel.dataController.records.prefix(APIService.onceRequestStatusMaxCount)
|
||||
|
||||
Task {
|
||||
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in
|
||||
guard let feed = record.object(in: managedObjectContext) else { return nil }
|
||||
return feed.status?.id
|
||||
return record.status?.id
|
||||
}
|
||||
|
||||
do {
|
||||
|
@ -114,6 +111,22 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
if !latestStatusIDs.isEmpty {
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
||||
}
|
||||
|
||||
var newRecords: [MastodonFeed] = newStatuses.map {
|
||||
MastodonFeed.fromStatus(.fromEntity($0), kind: .home)
|
||||
}
|
||||
viewModel.dataController.records = {
|
||||
var oldRecords = viewModel.dataController.records
|
||||
for (i, record) in newRecords.enumerated() {
|
||||
if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) {
|
||||
oldRecords[index] = record
|
||||
if newRecords.count > index {
|
||||
newRecords.remove(at: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (newRecords + oldRecords).removingDuplicates()
|
||||
}()
|
||||
}
|
||||
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ extension HomeTimelineViewModel.LoadOldestState {
|
|||
class Initial: HomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
guard !viewModel.fetchedResultsController.records.isEmpty else { return false }
|
||||
guard !viewModel.dataController.records.isEmpty else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
@ -46,19 +46,13 @@ extension HomeTimelineViewModel.LoadOldestState {
|
|||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
guard let lastFeedRecord = viewModel.fetchedResultsController.records.last else {
|
||||
guard let lastFeedRecord = viewModel.dataController.records.last else {
|
||||
stateMachine.enter(Idle.self)
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let managedObjectContext = viewModel.fetchedResultsController.managedObjectContext
|
||||
let _maxID: Mastodon.Entity.Status.ID? = try await managedObjectContext.perform {
|
||||
guard let feed = lastFeedRecord.object(in: managedObjectContext),
|
||||
let status = feed.status
|
||||
else { return nil }
|
||||
return status.id
|
||||
}
|
||||
let _maxID = lastFeedRecord.status?.id
|
||||
|
||||
guard let maxID = _maxID else {
|
||||
await self.enter(state: Fail.self)
|
||||
|
|
|
@ -15,6 +15,7 @@ import GameplayKit
|
|||
import AlamofireImage
|
||||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
final class HomeTimelineViewModel: NSObject {
|
||||
|
||||
|
@ -24,7 +25,7 @@ final class HomeTimelineViewModel: NSObject {
|
|||
// input
|
||||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let fetchedResultsController: FeedFetchedResultsController
|
||||
let dataController: FeedDataController
|
||||
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
|
@ -80,14 +81,12 @@ final class HomeTimelineViewModel: NSObject {
|
|||
init(context: AppContext, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext)
|
||||
self.dataController = FeedDataController(context: context, authContext: authContext)
|
||||
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.predicate = Feed.predicate(
|
||||
kind: .home,
|
||||
acct: .mastodon(domain: authContext.mastodonAuthenticationBox.domain, userID: authContext.mastodonAuthenticationBox.userID)
|
||||
)
|
||||
self.dataController.records = (try? FileManager.default.cachedHomeTimeline(for: authContext.mastodonAuthenticationBox).map {
|
||||
MastodonFeed.fromStatus($0, kind: .home)
|
||||
}) ?? []
|
||||
|
||||
homeTimelineNeedRefresh
|
||||
.sink { [weak self] _ in
|
||||
|
@ -103,6 +102,20 @@ final class HomeTimelineViewModel: NSObject {
|
|||
self.homeTimelineNeedRefresh.send()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
self.dataController.$records
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { feeds in
|
||||
let items: [MastodonStatus] = feeds.compactMap { feed -> MastodonStatus? in
|
||||
guard let status = feed.status else { return nil }
|
||||
return status
|
||||
}
|
||||
FileManager.default.cacheHomeTimeline(items: items, for: authContext.mastodonAuthenticationBox)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
self.dataController.loadInitial(kind: .home)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,7 +129,7 @@ extension HomeTimelineViewModel {
|
|||
|
||||
extension HomeTimelineViewModel {
|
||||
func timelineDidReachEnd() {
|
||||
fetchedResultsController.fetchNextBatch()
|
||||
dataController.loadNext(kind: .home)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,47 +141,21 @@ extension HomeTimelineViewModel {
|
|||
guard let diffableDataSource = diffableDataSource else { return }
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let key = "LoadMore@\(record.objectID)"
|
||||
|
||||
guard let feed = record.object(in: managedObjectContext) else { return }
|
||||
guard let status = feed.status else { return }
|
||||
|
||||
// keep transient property live
|
||||
managedObjectContext.cache(feed, key: key)
|
||||
defer {
|
||||
managedObjectContext.cache(nil, key: key)
|
||||
}
|
||||
do {
|
||||
// update state
|
||||
try await managedObjectContext.performChanges {
|
||||
feed.update(isLoadingMore: true)
|
||||
}
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let status = record.status else { return }
|
||||
record.isLoadingMore = true
|
||||
|
||||
// reconfigure item
|
||||
snapshot.reconfigureItems([item])
|
||||
await updateSnapshotUsingReloadData(snapshot: snapshot)
|
||||
|
||||
// fetch data
|
||||
do {
|
||||
let maxID = status.id
|
||||
_ = try await context.apiService.homeTimeline(
|
||||
maxID: maxID,
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
} catch {
|
||||
do {
|
||||
// restore state
|
||||
try await managedObjectContext.performChanges {
|
||||
feed.update(isLoadingMore: false)
|
||||
}
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let maxID = status.id
|
||||
_ = try? await context.apiService.homeTimeline(
|
||||
maxID: maxID,
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
|
||||
record.isLoadingMore = false
|
||||
|
||||
// reconfigure item again
|
||||
snapshot.reconfigureItems([item])
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension NotificationTableViewCell {
|
||||
final class ViewModel {
|
||||
|
@ -18,7 +19,7 @@ extension NotificationTableViewCell {
|
|||
}
|
||||
|
||||
enum Value {
|
||||
case feed(Feed)
|
||||
case feed(MastodonFeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension NotificationTimelineViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -20,22 +21,29 @@ extension NotificationTimelineViewController: DataSourceProvider {
|
|||
}
|
||||
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
case .feed(let feed):
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let item: DataSourceItem? = try? await managedObjectContext.perform {
|
||||
guard let feed = record.object(in: managedObjectContext) else { return nil }
|
||||
let item: DataSourceItem? = {
|
||||
guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil }
|
||||
if let notification = feed.notification {
|
||||
return .notification(record: .init(objectID: notification.objectID))
|
||||
if let notification = feed.notification, let mastodonNotification = MastodonNotification.fromEntity(notification, using: managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) {
|
||||
return .notification(record: mastodonNotification)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
return item
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.delete(status: status)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -276,18 +276,18 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
|
|||
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return }
|
||||
let domain = authContext.mastodonAuthenticationBox.domain
|
||||
|
||||
Task { @MainActor in
|
||||
switch item {
|
||||
case .feed(let record):
|
||||
guard let feed = record.object(in: self.context.managedObjectContext) else { return }
|
||||
guard let notification = feed.notification else { return }
|
||||
guard let notification = record.notification else { return }
|
||||
|
||||
if let stauts = notification.status {
|
||||
if let status = notification.status {
|
||||
let threadViewModel = ThreadViewModel(
|
||||
context: self.context,
|
||||
authContext: self.viewModel.authContext,
|
||||
optionalRoot: .root(context: .init(status: .init(objectID: stauts.objectID)))
|
||||
optionalRoot: .root(context: .init(status: .fromEntity(status)))
|
||||
)
|
||||
_ = self.coordinator.present(
|
||||
scene: .thread(viewModel: threadViewModel),
|
||||
|
@ -295,16 +295,25 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
|
|||
transition: .show
|
||||
)
|
||||
} else {
|
||||
let profileViewModel = ProfileViewModel(
|
||||
context: self.context,
|
||||
authContext: self.viewModel.authContext,
|
||||
optionalMastodonUser: notification.account
|
||||
)
|
||||
_ = self.coordinator.present(
|
||||
scene: .profile(viewModel: profileViewModel),
|
||||
from: self,
|
||||
transition: .show
|
||||
)
|
||||
context.managedObjectContext.perform {
|
||||
let mastodonUserRequest = MastodonUser.sortedFetchRequest
|
||||
mastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: notification.account.id)
|
||||
mastodonUserRequest.fetchLimit = 1
|
||||
guard let mastodonUser = try? self.context.managedObjectContext.fetch(mastodonUserRequest).first else {
|
||||
return
|
||||
}
|
||||
|
||||
let profileViewModel = ProfileViewModel(
|
||||
context: self.context,
|
||||
authContext: self.viewModel.authContext,
|
||||
optionalMastodonUser: mastodonUser
|
||||
)
|
||||
_ = self.coordinator.present(
|
||||
scene: .profile(viewModel: profileViewModel),
|
||||
from: self,
|
||||
transition: .show
|
||||
)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension NotificationTimelineViewModel {
|
||||
|
||||
|
@ -30,7 +30,7 @@ extension NotificationTimelineViewModel {
|
|||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
feedFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
@ -48,34 +48,16 @@ extension NotificationTimelineViewModel {
|
|||
return snapshot
|
||||
}()
|
||||
|
||||
let parentManagedObjectContext = self.context.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
try? await managedObjectContext.perform {
|
||||
let anchors: [Feed] = {
|
||||
let request = Feed.sortedFetchRequest
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
Feed.hasMorePredicate(),
|
||||
self.feedFetchedResultsController.predicate,
|
||||
])
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
|
||||
let itemIdentifiers = newSnapshot.itemIdentifiers
|
||||
for (index, item) in itemIdentifiers.enumerated() {
|
||||
guard case let .feed(record) = item else { continue }
|
||||
guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue }
|
||||
let isLast = index + 1 == itemIdentifiers.count
|
||||
if isLast {
|
||||
newSnapshot.insertItems([.bottomLoader], afterItem: item)
|
||||
} else {
|
||||
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
|
||||
}
|
||||
let anchors: [MastodonFeed] = records.filter { $0.hasMore == true }
|
||||
let itemIdentifiers = newSnapshot.itemIdentifiers
|
||||
for (index, item) in itemIdentifiers.enumerated() {
|
||||
guard case let .feed(record) = item else { continue }
|
||||
guard anchors.contains(where: { feed in feed.id == record.id }) else { continue }
|
||||
let isLast = index + 1 == itemIdentifiers.count
|
||||
if isLast {
|
||||
newSnapshot.insertItems([.bottomLoader], afterItem: item)
|
||||
} else {
|
||||
newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
|
|||
class Initial: NotificationTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
guard !viewModel.feedFetchedResultsController.records.isEmpty else { return false }
|
||||
guard !viewModel.dataController.records.isEmpty else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
|
|||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
guard let lastFeedRecord = viewModel.feedFetchedResultsController.records.last else {
|
||||
guard let lastFeedRecord = viewModel.dataController.records.last else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
@ -55,12 +55,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
|
|||
|
||||
Task {
|
||||
let managedObjectContext = viewModel.context.managedObjectContext
|
||||
let _maxID: Mastodon.Entity.Notification.ID? = try await managedObjectContext.perform {
|
||||
guard let feed = lastFeedRecord.object(in: managedObjectContext),
|
||||
let notification = feed.notification
|
||||
else { return nil }
|
||||
return notification.id
|
||||
}
|
||||
let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id
|
||||
|
||||
guard let maxID = _maxID else {
|
||||
await self.enter(state: Fail.self)
|
||||
|
|
|
@ -20,7 +20,7 @@ final class NotificationTimelineViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let scope: Scope
|
||||
let feedFetchedResultsController: FeedFetchedResultsController
|
||||
let dataController: FeedDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
@Published var isLoadingLatest = false
|
||||
@Published var lastAutomaticFetchTimestamp: Date?
|
||||
|
@ -43,6 +43,7 @@ final class NotificationTimelineViewModel {
|
|||
return stateMachine
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
|
@ -51,13 +52,35 @@ final class NotificationTimelineViewModel {
|
|||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.scope = scope
|
||||
self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext)
|
||||
// end init
|
||||
self.dataController = FeedDataController(context: context, authContext: authContext)
|
||||
|
||||
feedFetchedResultsController.predicate = NotificationTimelineViewModel.feedPredicate(
|
||||
authenticationBox: authContext.mastodonAuthenticationBox,
|
||||
scope: scope
|
||||
)
|
||||
switch scope {
|
||||
case .everything:
|
||||
self.dataController.records = (try? FileManager.default.cachedNotificationsAll(for: authContext.mastodonAuthenticationBox))?.map({ notification in
|
||||
MastodonFeed.fromNotification(notification, kind: .notificationAll)
|
||||
}) ?? []
|
||||
case .mentions:
|
||||
self.dataController.records = (try? FileManager.default.cachedNotificationsMentions(for: authContext.mastodonAuthenticationBox))?.map({ notification in
|
||||
MastodonFeed.fromNotification(notification, kind: .notificationMentions)
|
||||
}) ?? []
|
||||
}
|
||||
|
||||
self.dataController.$records
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { feeds in
|
||||
let items: [Mastodon.Entity.Notification] = feeds.compactMap { feed -> Mastodon.Entity.Notification? in
|
||||
guard let status = feed.notification else { return nil }
|
||||
return status
|
||||
}
|
||||
switch self.scope {
|
||||
case .everything:
|
||||
FileManager.default.cacheNotificationsAll(items: items, for: authContext.mastodonAuthenticationBox)
|
||||
case .mentions:
|
||||
FileManager.default.cacheNotificationsMentions(items: items, for: authContext.mastodonAuthenticationBox)
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
|
||||
|
@ -66,41 +89,6 @@ final class NotificationTimelineViewModel {
|
|||
extension NotificationTimelineViewModel {
|
||||
|
||||
typealias Scope = APIService.MastodonNotificationScope
|
||||
|
||||
static func feedPredicate(
|
||||
authenticationBox: MastodonAuthenticationBox,
|
||||
scope: Scope
|
||||
) -> NSPredicate {
|
||||
let domain = authenticationBox.domain
|
||||
let userID = authenticationBox.userID
|
||||
let acct = Feed.Acct.mastodon(
|
||||
domain: domain,
|
||||
userID: userID
|
||||
)
|
||||
|
||||
let predicate: NSPredicate = {
|
||||
switch scope {
|
||||
case .everything:
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
Feed.hasNotificationPredicate(),
|
||||
Feed.predicate(
|
||||
kind: .notificationAll,
|
||||
acct: acct
|
||||
)
|
||||
])
|
||||
case .mentions:
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
Feed.hasNotificationPredicate(),
|
||||
Feed.predicate(
|
||||
kind: .notificationMentions,
|
||||
acct: acct
|
||||
),
|
||||
Feed.notificationTypePredicate(types: scope.includeTypes ?? [])
|
||||
])
|
||||
}
|
||||
}()
|
||||
return predicate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -111,44 +99,23 @@ extension NotificationTimelineViewModel {
|
|||
isLoadingLatest = true
|
||||
defer { isLoadingLatest = false }
|
||||
|
||||
do {
|
||||
_ = try await context.apiService.notifications(
|
||||
maxID: nil,
|
||||
scope: scope,
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
} catch {
|
||||
didLoadLatest.send()
|
||||
switch scope {
|
||||
case .everything:
|
||||
dataController.loadInitial(kind: .notificationAll)
|
||||
case .mentions:
|
||||
dataController.loadInitial(kind: .notificationMentions)
|
||||
}
|
||||
|
||||
didLoadLatest.send()
|
||||
}
|
||||
|
||||
// load timeline gap
|
||||
func loadMore(item: NotificationItem) async {
|
||||
guard case let .feedLoader(record) = item else { return }
|
||||
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let key = "LoadMore@\(record.objectID)"
|
||||
|
||||
// return when already loading state
|
||||
guard managedObjectContext.cache(froKey: key) == nil else { return }
|
||||
|
||||
guard let feed = record.object(in: managedObjectContext) else { return }
|
||||
guard let maxID = feed.notification?.id else { return }
|
||||
// keep transient property live
|
||||
managedObjectContext.cache(feed, key: key)
|
||||
defer {
|
||||
managedObjectContext.cache(nil, key: key)
|
||||
}
|
||||
|
||||
// fetch data
|
||||
do {
|
||||
_ = try await context.apiService.notifications(
|
||||
maxID: maxID,
|
||||
scope: scope,
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
} catch {
|
||||
switch scope {
|
||||
case .everything:
|
||||
dataController.loadNext(kind: .notificationAll)
|
||||
case .mentions:
|
||||
dataController.loadNext(kind: .notificationMentions)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension BookmarkViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -26,6 +27,16 @@ extension BookmarkViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.setRecords(
|
||||
viewModel.dataController.records.filter { $0.id != status.id }
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -32,7 +32,7 @@ extension BookmarkViewModel {
|
|||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -57,7 +57,9 @@ extension BookmarkViewModel.State {
|
|||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs = []
|
||||
DispatchQueue.main.async {
|
||||
viewModel.dataController.reset()
|
||||
}
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
|
@ -128,10 +130,10 @@ extension BookmarkViewModel.State {
|
|||
)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = await viewModel.dataController.records.map { $0.entity }
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(status) else { continue }
|
||||
statusIDs.append(status)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -147,7 +149,9 @@ extension BookmarkViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
|
||||
await viewModel.dataController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) })
|
||||
|
||||
} catch {
|
||||
await enter(state: Fail.self)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ final class BookmarkViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
|
@ -38,14 +38,11 @@ final class BookmarkViewModel {
|
|||
return stateMachine
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import Combine
|
|||
import MastodonCore
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
import MastodonSDK
|
||||
|
||||
final class FamiliarFollowersViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -101,6 +102,14 @@ extension FamiliarFollowersViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension FavoriteViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -26,6 +27,16 @@ extension FavoriteViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.setRecords(
|
||||
viewModel.dataController.records.filter { $0.id != status.id }
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -32,7 +32,7 @@ extension FavoriteViewModel {
|
|||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -55,10 +55,12 @@ extension FavoriteViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs = []
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
Task {
|
||||
// reset
|
||||
await viewModel.dataController.reset()
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,10 +129,10 @@ extension FavoriteViewModel.State {
|
|||
)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = await viewModel.dataController.records
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
|
||||
statusIDs.append(.fromEntity(status))
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -146,7 +148,7 @@ extension FavoriteViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
await viewModel.dataController.setRecords(statusIDs)
|
||||
} catch {
|
||||
await enter(state: Fail.self)
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ final class FavoriteViewModel {
|
|||
// input
|
||||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
|
@ -37,14 +37,11 @@ final class FavoriteViewModel {
|
|||
return stateMachine
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import Combine
|
|||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonLocalization
|
||||
import MastodonSDK
|
||||
|
||||
final class FollowerListViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -153,6 +154,14 @@ extension FollowerListViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -12,6 +12,7 @@ import MastodonLocalization
|
|||
import MastodonCore
|
||||
import MastodonUI
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class FollowingListViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -148,6 +149,14 @@ extension FollowingListViewController: DataSourceProvider {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
|
|
|
@ -14,6 +14,7 @@ import MastodonSDK
|
|||
|
||||
final class MeProfileViewModel: ProfileViewModel {
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext) {
|
||||
let user = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext)
|
||||
super.init(
|
||||
|
|
|
@ -16,6 +16,7 @@ import MastodonLocalization
|
|||
import CoreDataStack
|
||||
import TabBarPager
|
||||
import XLPagerTabStrip
|
||||
import MastodonSDK
|
||||
|
||||
protocol ProfileViewModelEditable {
|
||||
var isEdited: Bool { get }
|
||||
|
@ -916,6 +917,7 @@ extension ProfileViewController: MastodonMenuDelegate {
|
|||
action: action,
|
||||
menuContext: DataSourceFacade.MenuContext(
|
||||
author: userRecord,
|
||||
authorEntity: nil,
|
||||
statusViewModel: nil,
|
||||
button: nil,
|
||||
barButtonItem: self.moreMenuBarButtonItem
|
||||
|
@ -962,3 +964,18 @@ private extension ProfileViewController {
|
|||
authContext.mastodonAuthenticationBox.authentication.instance(in: context.managedObjectContext)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
assertionFailure("Not required")
|
||||
return nil
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ class ProfileViewModel: NSObject {
|
|||
// @Published var protected: Bool? = nil
|
||||
// let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, optionalMastodonUser mastodonUser: MastodonUser?) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
|
|
|
@ -13,6 +13,7 @@ import MastodonCore
|
|||
|
||||
final class RemoteProfileViewModel: ProfileViewModel {
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, userID: Mastodon.Entity.Account.ID) {
|
||||
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)
|
||||
|
||||
|
@ -51,6 +52,7 @@ final class RemoteProfileViewModel: ProfileViewModel {
|
|||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, notificationID: Mastodon.Entity.Notification.ID) {
|
||||
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)
|
||||
|
||||
|
@ -89,6 +91,7 @@ final class RemoteProfileViewModel: ProfileViewModel {
|
|||
} // end Task
|
||||
}
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, acct: String){
|
||||
super.init(context: context, authContext: authContext, optionalMastodonUser: nil)
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension UserTimelineViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -27,6 +28,14 @@ extension UserTimelineViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.deleteRecord(status)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -49,7 +49,7 @@ extension UserTimelineViewModel {
|
|||
).map { $0 || $1 || $2 || $3 }
|
||||
|
||||
Publishers.CombineLatest(
|
||||
statusFetchedResultsController.$records,
|
||||
dataController.$records,
|
||||
needsTimelineHidden.removeDuplicates()
|
||||
)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
|
|
|
@ -54,11 +54,13 @@ extension UserTimelineViewModel.State {
|
|||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs = []
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
Task {
|
||||
// reset
|
||||
await viewModel.dataController.reset()
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,17 +114,17 @@ extension UserTimelineViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
let maxID = viewModel.statusFetchedResultsController.statusIDs.last
|
||||
|
||||
guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let queryFilter = viewModel.queryFilter
|
||||
|
||||
Task {
|
||||
|
||||
let maxID = await viewModel.dataController.records.last?.id
|
||||
|
||||
guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let queryFilter = viewModel.queryFilter
|
||||
|
||||
do {
|
||||
let response = try await viewModel.context.apiService.userTimeline(
|
||||
accountID: userID,
|
||||
|
@ -135,10 +137,10 @@ extension UserTimelineViewModel.State {
|
|||
)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = await viewModel.dataController.records
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
|
||||
statusIDs.append(.fromEntity(status))
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -147,7 +149,7 @@ extension UserTimelineViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
await viewModel.dataController.setRecords(statusIDs)
|
||||
|
||||
} catch {
|
||||
await enter(state: Fail.self)
|
||||
|
|
|
@ -21,7 +21,7 @@ final class UserTimelineViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let title: String
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
@Published var userIdentifier: UserIdentifier?
|
||||
@Published var queryFilter: QueryFilter
|
||||
|
@ -49,6 +49,7 @@ final class UserTimelineViewModel {
|
|||
return stateMachine
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
|
@ -58,11 +59,7 @@ final class UserTimelineViewModel {
|
|||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.title = title
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
self.queryFilter = queryFilter
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension FavoritedByViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
|
@ -27,6 +28,14 @@ extension FavoritedByViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -6,8 +6,10 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension RebloggedByViewController: DataSourceProvider {
|
||||
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
var _indexPath = source.indexPath
|
||||
if _indexPath == nil, let cell = source.tableViewCell {
|
||||
|
@ -27,6 +29,14 @@ extension RebloggedByViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -53,7 +53,7 @@ final class UserListViewModel {
|
|||
extension UserListViewModel {
|
||||
// TODO: refactor follower and following into user list
|
||||
enum Kind {
|
||||
case rebloggedBy(status: ManagedObjectRecord<Status>)
|
||||
case favoritedBy(status: ManagedObjectRecord<Status>)
|
||||
case rebloggedBy(status: MastodonStatus)
|
||||
case favoritedBy(status: MastodonStatus)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,17 +29,18 @@ class ReportViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let user: ManagedObjectRecord<MastodonUser>
|
||||
let status: ManagedObjectRecord<Status>?
|
||||
let status: MastodonStatus?
|
||||
|
||||
// output
|
||||
@Published var isReporting = false
|
||||
@Published var isReportSuccess = false
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
user: ManagedObjectRecord<MastodonUser>,
|
||||
status: ManagedObjectRecord<Status>?
|
||||
status: MastodonStatus?
|
||||
) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
|
@ -101,17 +102,15 @@ extension ReportViewModel {
|
|||
|
||||
// the status picker is essential step in report flow
|
||||
// only check isSkip or not
|
||||
let statusIDs: [Status.ID]? = {
|
||||
let statusIDs: [MastodonStatus.ID]? = {
|
||||
if self.reportStatusViewModel.isSkip {
|
||||
let _id: Status.ID? = self.reportStatusViewModel.status.flatMap { record -> Status.ID? in
|
||||
guard let status = record.object(in: managedObjectContext) else { return nil }
|
||||
return status.id
|
||||
let _id: MastodonStatus.ID? = self.reportStatusViewModel.status.flatMap { record -> MastodonStatus.ID? in
|
||||
return record.id
|
||||
}
|
||||
return _id.flatMap { [$0] }
|
||||
return _id.flatMap { [$0] } ?? []
|
||||
} else {
|
||||
return self.reportStatusViewModel.selectStatuses.compactMap { record -> Status.ID? in
|
||||
guard let status = record.object(in: managedObjectContext) else { return nil }
|
||||
return status.id
|
||||
return self.reportStatusViewModel.selectStatuses.compactMap { record -> MastodonStatus.ID? in
|
||||
return record.id
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -32,7 +32,7 @@ extension ReportStatusViewModel {
|
|||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
dataController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -64,9 +64,10 @@ extension ReportStatusViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel else { return }
|
||||
|
||||
let maxID = viewModel.statusFetchedResultsController.statusIDs.last
|
||||
|
||||
Task {
|
||||
let maxID = await viewModel.dataController.records.last?.id
|
||||
|
||||
let managedObjectContext = viewModel.context.managedObjectContext
|
||||
let _userID: MastodonUser.ID? = try await managedObjectContext.perform {
|
||||
guard let user = viewModel.user.object(in: managedObjectContext) else { return nil }
|
||||
|
@ -89,10 +90,10 @@ extension ReportStatusViewModel.State {
|
|||
)
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs
|
||||
var statusIDs = await viewModel.dataController.records
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
guard !statusIDs.contains(where: { $0.id == status.id }) else { continue }
|
||||
statusIDs.append(.fromEntity(status))
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
|
@ -101,7 +102,7 @@ extension ReportStatusViewModel.State {
|
|||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs = statusIDs
|
||||
await viewModel.dataController.setRecords(statusIDs)
|
||||
|
||||
} catch {
|
||||
await enter(state: Fail.self)
|
||||
|
|
|
@ -25,12 +25,12 @@ class ReportStatusViewModel {
|
|||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
let user: ManagedObjectRecord<MastodonUser>
|
||||
let status: ManagedObjectRecord<Status>?
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let status: MastodonStatus?
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
@Published var isSkip = false
|
||||
@Published var selectStatuses = OrderedSet<ManagedObjectRecord<Status>>()
|
||||
@Published var selectStatuses = OrderedSet<MastodonStatus>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<ReportSection, ReportItem>?
|
||||
|
@ -48,21 +48,18 @@ class ReportStatusViewModel {
|
|||
|
||||
@Published var isNextButtonEnabled = false
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
user: ManagedObjectRecord<MastodonUser>,
|
||||
status: ManagedObjectRecord<Status>?
|
||||
status: MastodonStatus?
|
||||
) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
self.user = user
|
||||
self.status = status
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
// end init
|
||||
|
||||
if let status = status {
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension ReportStatusTableViewCell {
|
||||
// todo: refactor / remove this
|
||||
final class ViewModel {
|
||||
let value: Status
|
||||
let value: MastodonStatus
|
||||
|
||||
init(value: Status) {
|
||||
init(value: MastodonStatus) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,6 +87,7 @@ class MainTabBarController: UITabBarController {
|
|||
return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) -> UIViewController {
|
||||
guard let authContext = authContext else {
|
||||
return UITableViewController()
|
||||
|
@ -171,7 +172,9 @@ extension MainTabBarController {
|
|||
|
||||
// seealso: `ThemeService.apply(theme:)`
|
||||
let tabs = Tab.allCases
|
||||
let viewControllers: [UIViewController] = tabs.map { tab in
|
||||
var viewControllers = [UIViewController]()
|
||||
|
||||
for tab in tabs {
|
||||
let viewController = tab.viewController(context: context, authContext: authContext, coordinator: coordinator)
|
||||
viewController.tabBarItem.tag = tab.tag
|
||||
viewController.tabBarItem.title = tab.title // needs for acessiblity large content label
|
||||
|
@ -180,12 +183,14 @@ extension MainTabBarController {
|
|||
viewController.tabBarItem.accessibilityLabel = tab.title
|
||||
viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels
|
||||
viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
|
||||
return viewController
|
||||
viewControllers.append(viewController)
|
||||
}
|
||||
|
||||
_viewControllers = viewControllers
|
||||
setViewControllers(viewControllers, animated: false)
|
||||
selectedIndex = 0
|
||||
|
||||
|
||||
|
||||
// hacky workaround for FB11986255 (Setting accessibilityUserInputLabels on a UITabBarItem has no effect)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
|
||||
if let searchItem = self.tabBar.subviews.first(where: { $0.accessibilityLabel == Tab.search.title }) {
|
||||
|
@ -214,7 +219,7 @@ extension MainTabBarController {
|
|||
.store(in: &disposeBag)
|
||||
|
||||
// handle post failure
|
||||
|
||||
|
||||
// handle push notification.
|
||||
// toggle entry when finish fetch latest notification
|
||||
Publishers.CombineLatest(
|
||||
|
@ -231,7 +236,7 @@ extension MainTabBarController {
|
|||
let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.accessToken)
|
||||
return count > 0
|
||||
} ?? false
|
||||
|
||||
|
||||
let image: UIImage
|
||||
if hasUnreadPushNotification {
|
||||
let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.red, SystemTheme.tabBarItemNormalIconColor])
|
||||
|
@ -270,12 +275,12 @@ extension MainTabBarController {
|
|||
guard user.managedObjectContext != nil else { return }
|
||||
self.avatarURL = user.avatarImageURL()
|
||||
}
|
||||
|
||||
|
||||
// a11y
|
||||
let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag }
|
||||
guard let profileTabItem = _profileTabItem else { return }
|
||||
profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(user.displayNameWithFallback)
|
||||
|
||||
|
||||
self.context.authenticationService.updateActiveUserAccountPublisher
|
||||
.sink { [weak self] in
|
||||
self?.updateUserAccount()
|
||||
|
@ -286,18 +291,18 @@ extension MainTabBarController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
let tabBarLongPressGestureRecognizer = UILongPressGestureRecognizer()
|
||||
tabBarLongPressGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarLongPressGestureRecognizerHandler(_:)))
|
||||
tabBarLongPressGestureRecognizer.delegate = self
|
||||
tabBar.addGestureRecognizer(tabBarLongPressGestureRecognizer)
|
||||
|
||||
|
||||
let tabBarDoubleTapGestureRecognizer = UITapGestureRecognizer()
|
||||
tabBarDoubleTapGestureRecognizer.numberOfTapsRequired = 2
|
||||
tabBarDoubleTapGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarDoubleTapGestureRecognizerHandler(_:)))
|
||||
tabBarDoubleTapGestureRecognizer.delaysTouchesEnded = false
|
||||
tabBar.addGestureRecognizer(tabBarDoubleTapGestureRecognizer)
|
||||
|
||||
|
||||
self.isReadyForWizardAvatarButton = authContext != nil
|
||||
|
||||
$currentTab
|
||||
|
|
|
@ -81,22 +81,10 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl
|
|||
showProfile(viewController, for: account)
|
||||
} else if let status = searchResult.statuses.first {
|
||||
|
||||
let status = try await managedObjectContext.perform {
|
||||
return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext(
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
entity: status,
|
||||
me: authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext),
|
||||
statusCache: nil,
|
||||
userCache: nil,
|
||||
networkDate: Date()))
|
||||
}
|
||||
|
||||
guard let status else { return }
|
||||
|
||||
await DataSourceFacade.coordinateToStatusThreadScene(
|
||||
provider: viewController,
|
||||
target: .status, // remove reblog wrapper
|
||||
status: status.asRecord
|
||||
status: MastodonStatus.fromEntity(status)
|
||||
)
|
||||
} else if let url = URL(string: urlString) {
|
||||
let prefixedURL: URL?
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
// MARK: - DataSourceProvider
|
||||
extension SearchHistoryViewController: DataSourceProvider {
|
||||
|
@ -28,6 +29,14 @@ extension SearchHistoryViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
assertionFailure("Not required")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UICollectionViewCell) async -> IndexPath? {
|
||||
return collectionView.indexPath(for: cell)
|
||||
|
|
|
@ -12,7 +12,7 @@ import MastodonSDK
|
|||
|
||||
enum SearchResultItem: Hashable {
|
||||
case account(Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
|
||||
case status(ManagedObjectRecord<Status>)
|
||||
case status(MastodonStatus)
|
||||
case hashtag(tag: Mastodon.Entity.Tag)
|
||||
case bottomLoader(attribute: BottomLoaderAttribute)
|
||||
}
|
||||
|
|
|
@ -54,20 +54,16 @@ extension SearchResultSection {
|
|||
relationship: relationship,
|
||||
delegate: configuration.userTableViewCellDelegate
|
||||
)
|
||||
|
||||
return cell
|
||||
case .status(let record):
|
||||
return cell
|
||||
case .status(let status):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let status = record.object(in: context.managedObjectContext) else { return }
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
configure(
|
||||
context: context,
|
||||
tableView: tableView,
|
||||
cell: cell,
|
||||
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
|
||||
configuration: configuration
|
||||
)
|
||||
return cell
|
||||
case .hashtag(let tag):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
// MARK: - DataSourceProvider
|
||||
extension SearchResultViewController: DataSourceProvider {
|
||||
|
@ -32,6 +33,14 @@ extension SearchResultViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
viewModel.dataController.update(status: status)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
viewModel.dataController.deleteRecord(status)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
|
|
|
@ -33,7 +33,7 @@ extension SearchResultViewModel {
|
|||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
statusFetchedResultsController.$records,
|
||||
dataController.$records,
|
||||
$accounts,
|
||||
$hashtags
|
||||
)
|
||||
|
|
|
@ -123,6 +123,8 @@ extension SearchResultViewModel.State {
|
|||
// discard result when state is not Loading
|
||||
guard stateMachine.currentState is Loading else { return }
|
||||
|
||||
let statuses = searchResults.statuses.map { MastodonStatus.fromEntity($0) }
|
||||
|
||||
let accounts = searchResults.accounts
|
||||
|
||||
let relationships: [Mastodon.Entity.Relationship]
|
||||
|
@ -135,9 +137,7 @@ extension SearchResultViewModel.State {
|
|||
relationships = []
|
||||
}
|
||||
|
||||
let statusIDs = searchResults.statuses.map { $0.id }
|
||||
|
||||
let isNoMore = accounts.isEmpty && statusIDs.isEmpty
|
||||
let isNoMore = accounts.isEmpty && statuses.isEmpty
|
||||
|
||||
if viewModel.searchScope == .all || isNoMore {
|
||||
await enter(state: NoMore.self)
|
||||
|
@ -149,19 +149,18 @@ extension SearchResultViewModel.State {
|
|||
if offset == nil {
|
||||
viewModel.relationships = []
|
||||
viewModel.accounts = []
|
||||
viewModel.statusFetchedResultsController.statusIDs = []
|
||||
await viewModel.dataController.reset()
|
||||
viewModel.hashtags = []
|
||||
}
|
||||
|
||||
// due to combine relationships must be set first
|
||||
|
||||
// due to combine relationships must be set first
|
||||
var existingRelationships = viewModel.relationships
|
||||
for hashtag in relationships where !existingRelationships.contains(hashtag) {
|
||||
existingRelationships.append(hashtag)
|
||||
}
|
||||
viewModel.relationships = existingRelationships
|
||||
|
||||
viewModel.statusFetchedResultsController.append(statusIDs: statusIDs)
|
||||
|
||||
await viewModel.dataController.appendRecords(statuses)
|
||||
|
||||
var existingHashtags = viewModel.hashtags
|
||||
for hashtag in searchResults.hashtags where !existingHashtags.contains(hashtag) {
|
||||
|
|
|
@ -24,7 +24,7 @@ final class SearchResultViewModel {
|
|||
@Published var hashtags: [Mastodon.Entity.Tag] = []
|
||||
@Published var accounts: [Mastodon.Entity.Account] = []
|
||||
var relationships: [Mastodon.Entity.Relationship] = []
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let dataController: StatusDataController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
@ -46,6 +46,7 @@ final class SearchResultViewModel {
|
|||
}()
|
||||
let didDataSourceUpdate = PassthroughSubject<Void, Never>()
|
||||
|
||||
@MainActor
|
||||
init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all, searchText: String) {
|
||||
self.context = context
|
||||
self.authContext = authContext
|
||||
|
@ -54,10 +55,6 @@ final class SearchResultViewModel {
|
|||
self.accounts = []
|
||||
self.relationships = []
|
||||
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
self.dataController = StatusDataController()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,30 +16,33 @@ import MastodonAsset
|
|||
import MastodonCore
|
||||
import MastodonLocalization
|
||||
import class CoreDataStack.Notification
|
||||
import MastodonSDK
|
||||
|
||||
extension NotificationView {
|
||||
public func configure(feed: Feed) {
|
||||
guard let notification = feed.notification else {
|
||||
public func configure(feed: MastodonFeed) {
|
||||
guard
|
||||
let notification = feed.notification,
|
||||
let managedObjectContext = viewModel.context?.managedObjectContext
|
||||
else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
configure(notification: notification)
|
||||
|
||||
MastodonNotification.fromEntity(
|
||||
notification,
|
||||
using: managedObjectContext,
|
||||
domain: viewModel.authContext?.mastodonAuthenticationBox.domain ?? ""
|
||||
).map(configure(notification:))
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationView {
|
||||
public func configure(notification: Notification) {
|
||||
public func configure(notification: MastodonNotification) {
|
||||
viewModel.objects.insert(notification)
|
||||
|
||||
configureAuthor(notification: notification)
|
||||
|
||||
guard let type = MastodonNotificationType(rawValue: notification.typeRaw) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
switch type {
|
||||
|
||||
switch notification.entity.type {
|
||||
case .follow:
|
||||
setAuthorContainerBottomPaddingViewDisplay()
|
||||
case .followRequest:
|
||||
|
@ -63,7 +66,7 @@ extension NotificationView {
|
|||
}
|
||||
|
||||
extension NotificationView {
|
||||
private func configureAuthor(notification: Notification) {
|
||||
private func configureAuthor(notification: MastodonNotification) {
|
||||
let author = notification.account
|
||||
// author avatar
|
||||
|
||||
|
@ -98,19 +101,18 @@ extension NotificationView {
|
|||
.assign(to: \.authorUsername, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// timestamp
|
||||
viewModel.timestamp = notification.createAt
|
||||
viewModel.timestamp = notification.entity.createdAt
|
||||
|
||||
viewModel.visibility = notification.status?.visibility ?? ._other("")
|
||||
viewModel.visibility = notification.entity.status?.mastodonVisibility ?? ._other("")
|
||||
|
||||
// notification type indicator
|
||||
Publishers.CombineLatest3(
|
||||
notification.publisher(for: \.typeRaw),
|
||||
Publishers.CombineLatest(
|
||||
author.publisher(for: \.displayName),
|
||||
author.publisher(for: \.emojis)
|
||||
)
|
||||
.sink { [weak self] typeRaw, _, emojis in
|
||||
.sink { [weak self] _, emojis in
|
||||
guard let self = self else { return }
|
||||
guard let type = MastodonNotificationType(rawValue: typeRaw) else {
|
||||
guard let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) else {
|
||||
self.viewModel.notificationIndicatorText = nil
|
||||
return
|
||||
}
|
||||
|
@ -205,13 +207,8 @@ extension NotificationView {
|
|||
.store(in: &disposeBag)
|
||||
|
||||
// follow request state
|
||||
notification.publisher(for: \.followRequestState)
|
||||
.assign(to: \.followRequestState, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
notification.publisher(for: \.transientFollowRequestState)
|
||||
.assign(to: \.transientFollowRequestState, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.followRequestState = notification.followRequestState
|
||||
viewModel.transientFollowRequestState = notification.transientFollowRequestState
|
||||
|
||||
// Following
|
||||
author.publisher(for: \.followingBy)
|
||||
|
|
|
@ -14,8 +14,8 @@ import MastodonUI
|
|||
import MastodonSDK
|
||||
|
||||
extension PollOptionView {
|
||||
public func configure(pollOption option: PollOption) {
|
||||
guard let poll = option.poll, let status = poll.status else {
|
||||
public func configure(pollOption option: PollOption, status: MastodonStatus?) {
|
||||
guard let poll = option.poll else {
|
||||
assertionFailure("PollOption to be configured is expected to be part of Poll with Status")
|
||||
return
|
||||
}
|
||||
|
@ -48,8 +48,8 @@ extension PollOptionView {
|
|||
viewModel.isMultiple = poll.multiple
|
||||
|
||||
let optionIndex = option.index
|
||||
let authorDomain = status.author.domain
|
||||
let authorID = status.author.id
|
||||
let authorDomain = status?.entity.account.domain ?? ""
|
||||
let authorID = status?.entity.account.id ?? ""
|
||||
// isSelect, isPollVoted, isMyPoll
|
||||
Publishers.CombineLatest4(
|
||||
option.publisher(for: \.poll),
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension StatusTableViewCell {
|
||||
final class ViewModel {
|
||||
|
@ -17,8 +17,8 @@ extension StatusTableViewCell {
|
|||
}
|
||||
|
||||
enum Value {
|
||||
case feed(Feed)
|
||||
case status(Status)
|
||||
case feed(MastodonFeed)
|
||||
case status(MastodonStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,13 +38,7 @@ extension StatusTableViewCell {
|
|||
switch viewModel.value {
|
||||
case .feed(let feed):
|
||||
statusView.configure(feed: feed)
|
||||
|
||||
feed.publisher(for: \.hasMore)
|
||||
.sink { [weak self] hasMore in
|
||||
guard let self = self else { return }
|
||||
self.separatorLine.isHidden = hasMore
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
self.separatorLine.isHidden = feed.hasMore
|
||||
|
||||
case .status(let status):
|
||||
statusView.configure(status: status)
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension StatusThreadRootTableViewCell {
|
||||
final class ViewModel {
|
||||
|
@ -17,7 +17,7 @@ extension StatusThreadRootTableViewCell {
|
|||
}
|
||||
|
||||
enum Value {
|
||||
case status(Status)
|
||||
case status(MastodonStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
//
|
||||
// CachedThreadViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonCore
|
||||
|
||||
final class CachedThreadViewModel: ThreadViewModel {
|
||||
init(context: AppContext, authContext: AuthContext, status: Status) {
|
||||
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
|
||||
super.init(
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
optionalRoot: .root(context: threadContext)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -72,7 +72,7 @@ class StatusEditHistoryTableViewCell: UITableViewCell {
|
|||
NSLayoutConstraint.activate(constraints)
|
||||
}
|
||||
|
||||
func configure(status: Status, statusEdit: Mastodon.Entity.StatusEdit, dateText: String) {
|
||||
func configure(status: MastodonStatus, statusEdit: Mastodon.Entity.StatusEdit, dateText: String) {
|
||||
dateLabel.text = dateText
|
||||
statusHistoryView.statusView.configure(status: status, statusEdit: statusEdit)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import UIKit
|
|||
import MastodonSDK
|
||||
|
||||
struct StatusEditHistoryViewModel {
|
||||
let status: Status
|
||||
let status: MastodonStatus
|
||||
let edits: [Mastodon.Entity.StatusEdit]
|
||||
|
||||
let appContext: AppContext
|
||||
|
|
|
@ -20,7 +20,7 @@ final class MastodonStatusThreadViewModel {
|
|||
|
||||
// input
|
||||
let context: AppContext
|
||||
@Published private(set) var deletedObjectIDs: Set<NSManagedObjectID> = Set()
|
||||
@Published private(set) var deletedObjectIDs: Set<MastodonStatus.ID> = Set()
|
||||
|
||||
// output
|
||||
@Published var __ancestors: [StatusItem] = []
|
||||
|
@ -41,7 +41,7 @@ final class MastodonStatusThreadViewModel {
|
|||
let newItems = items.filter { item in
|
||||
switch item {
|
||||
case .thread(let thread):
|
||||
return !deletedObjectIDs.contains(thread.record.objectID)
|
||||
return !deletedObjectIDs.contains(thread.record.id)
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
|
@ -60,7 +60,7 @@ final class MastodonStatusThreadViewModel {
|
|||
let newItems = items.filter { item in
|
||||
switch item {
|
||||
case .thread(let thread):
|
||||
return !deletedObjectIDs.contains(thread.record.objectID)
|
||||
return !deletedObjectIDs.contains(thread.record.id)
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
|
@ -77,82 +77,39 @@ final class MastodonStatusThreadViewModel {
|
|||
extension MastodonStatusThreadViewModel {
|
||||
|
||||
func appendAncestor(
|
||||
domain: String,
|
||||
nodes: [Node]
|
||||
) {
|
||||
let ids = nodes.map { $0.statusID }
|
||||
var dictionary: [Status.ID: Status] = [:]
|
||||
do {
|
||||
let request = Status.sortedFetchRequest
|
||||
request.predicate = Status.predicate(domain: domain, ids: ids)
|
||||
let statuses = try self.context.managedObjectContext.fetch(request)
|
||||
for status in statuses {
|
||||
dictionary[status.id] = status
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
var newItems: [StatusItem] = []
|
||||
for (i, node) in nodes.enumerated() {
|
||||
guard let status = dictionary[node.statusID] else { continue }
|
||||
let isLast = i == nodes.count - 1
|
||||
|
||||
let record = ManagedObjectRecord<Status>(objectID: status.objectID)
|
||||
let context = StatusItem.Thread.Context(
|
||||
status: record,
|
||||
displayUpperConversationLink: !isLast,
|
||||
displayBottomConversationLink: true
|
||||
)
|
||||
let item = StatusItem.thread(.leaf(context: context))
|
||||
for node in nodes {
|
||||
let item = StatusItem.thread(.leaf(context: .init(status: node.status)))
|
||||
newItems.append(item)
|
||||
}
|
||||
|
||||
let items = self.__ancestors + newItems
|
||||
self.__ancestors = items
|
||||
self.__ancestors = items.removingDuplicates()
|
||||
}
|
||||
|
||||
func appendDescendant(
|
||||
domain: String,
|
||||
nodes: [Node]
|
||||
) {
|
||||
let childrenIDs = nodes
|
||||
.map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } }
|
||||
.flatMap { $0 }
|
||||
var dictionary: [Status.ID: Status] = [:]
|
||||
do {
|
||||
let request = Status.sortedFetchRequest
|
||||
request.predicate = Status.predicate(domain: domain, ids: childrenIDs)
|
||||
let statuses = try self.context.managedObjectContext.fetch(request)
|
||||
for status in statuses {
|
||||
dictionary[status.id] = status
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var newItems: [StatusItem] = []
|
||||
|
||||
for node in nodes {
|
||||
guard let status = dictionary[node.statusID] else { continue }
|
||||
// first tier
|
||||
let record = ManagedObjectRecord<Status>(objectID: status.objectID)
|
||||
let context = StatusItem.Thread.Context(
|
||||
status: record
|
||||
)
|
||||
let context = StatusItem.Thread.Context(status: node.status)
|
||||
let item = StatusItem.thread(.leaf(context: context))
|
||||
newItems.append(item)
|
||||
|
||||
// second tier
|
||||
if let child = node.children.first {
|
||||
guard let secondaryStatus = dictionary[child.statusID] else { continue }
|
||||
let secondaryRecord = ManagedObjectRecord<Status>(objectID: secondaryStatus.objectID)
|
||||
guard let secondaryStatus = node.children.first(where: { $0.status.id == child.status.id}) else { continue }
|
||||
let secondaryContext = StatusItem.Thread.Context(
|
||||
status: secondaryRecord,
|
||||
status: secondaryStatus.status,
|
||||
displayUpperConversationLink: true
|
||||
)
|
||||
let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext))
|
||||
newItems.append(secondaryItem)
|
||||
|
||||
|
||||
// update first tier context
|
||||
context.displayBottomConversationLink = true
|
||||
}
|
||||
|
@ -163,23 +120,21 @@ extension MastodonStatusThreadViewModel {
|
|||
guard !items.contains(item) else { continue }
|
||||
items.append(item)
|
||||
}
|
||||
self.__descendants = items
|
||||
self.__descendants = items.removingDuplicates()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonStatusThreadViewModel {
|
||||
class Node {
|
||||
typealias ID = String
|
||||
|
||||
let statusID: ID
|
||||
let status: MastodonStatus
|
||||
let children: [Node]
|
||||
|
||||
init(
|
||||
statusID: ID,
|
||||
status: MastodonStatus,
|
||||
children: [MastodonStatusThreadViewModel.Node]
|
||||
) {
|
||||
self.statusID = statusID
|
||||
self.status = status
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
@ -204,7 +159,7 @@ extension MastodonStatusThreadViewModel.Node {
|
|||
while let _nextID = nextID {
|
||||
guard let status = dict[_nextID] else { break }
|
||||
nodes.append(MastodonStatusThreadViewModel.Node(
|
||||
statusID: _nextID,
|
||||
status: .fromEntity(status),
|
||||
children: []
|
||||
))
|
||||
nextID = status.inReplyToID
|
||||
|
@ -216,11 +171,11 @@ extension MastodonStatusThreadViewModel.Node {
|
|||
|
||||
extension MastodonStatusThreadViewModel.Node {
|
||||
static func children(
|
||||
of statusID: ID,
|
||||
of status: MastodonStatus,
|
||||
from statuses: [Mastodon.Entity.Status]
|
||||
) -> [MastodonStatusThreadViewModel.Node] {
|
||||
var dictionary: [ID: Mastodon.Entity.Status] = [:]
|
||||
var mapping: [ID: Set<ID>] = [:]
|
||||
var dictionary: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status] = [:]
|
||||
var mapping: [Mastodon.Entity.Status.ID: Set<Mastodon.Entity.Status.ID>] = [:]
|
||||
|
||||
for status in statuses {
|
||||
dictionary[status.id] = status
|
||||
|
@ -234,40 +189,31 @@ extension MastodonStatusThreadViewModel.Node {
|
|||
}
|
||||
|
||||
var children: [MastodonStatusThreadViewModel.Node] = []
|
||||
let replies = Array(mapping[statusID] ?? Set())
|
||||
let replies = Array(mapping[status.id] ?? Set())
|
||||
.compactMap { dictionary[$0] }
|
||||
.sorted(by: { $0.createdAt > $1.createdAt })
|
||||
for reply in replies {
|
||||
let child = child(of: reply.id, dictionary: dictionary, mapping: mapping)
|
||||
let child = child(of: reply, dictionary: dictionary, mapping: mapping)
|
||||
children.append(child)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
static func child(
|
||||
of statusID: ID,
|
||||
dictionary: [ID: Mastodon.Entity.Status],
|
||||
mapping: [ID: Set<ID>]
|
||||
of status: Mastodon.Entity.Status,
|
||||
dictionary: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status],
|
||||
mapping: [Mastodon.Entity.Status.ID: Set<Mastodon.Entity.Status.ID>]
|
||||
) -> MastodonStatusThreadViewModel.Node {
|
||||
let childrenIDs = mapping[statusID] ?? []
|
||||
let childrenIDs = mapping[status.id] ?? []
|
||||
let children = Array(childrenIDs)
|
||||
.compactMap { dictionary[$0] }
|
||||
.sorted(by: { $0.createdAt > $1.createdAt })
|
||||
.map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) }
|
||||
.map { status in child(of: status, dictionary: dictionary, mapping: mapping) }
|
||||
return MastodonStatusThreadViewModel.Node(
|
||||
statusID: statusID,
|
||||
status: .fromEntity(status),
|
||||
children: children
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonStatusThreadViewModel {
|
||||
func delete(objectIDs: [NSManagedObjectID]) {
|
||||
var set = deletedObjectIDs
|
||||
for objectID in objectIDs {
|
||||
set.insert(objectID)
|
||||
}
|
||||
self.deletedObjectIDs = set
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,15 +30,7 @@ final class RemoteThreadViewModel: ThreadViewModel {
|
|||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let request = Status.sortedFetchRequest
|
||||
request.fetchLimit = 1
|
||||
request.predicate = Status.predicate(domain: domain, id: response.value.id)
|
||||
guard let status = managedObjectContext.safeFetch(request).first else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
|
||||
let threadContext = StatusItem.Thread.Context(status: .fromEntity(response.value))
|
||||
self.root = .root(context: threadContext)
|
||||
|
||||
} // end Task
|
||||
|
@ -62,17 +54,9 @@ final class RemoteThreadViewModel: ThreadViewModel {
|
|||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
)
|
||||
|
||||
guard let statusID = response.value.status?.id else { return }
|
||||
guard let status = response.value.status else { return }
|
||||
|
||||
let managedObjectContext = context.managedObjectContext
|
||||
let request = Status.sortedFetchRequest
|
||||
request.fetchLimit = 1
|
||||
request.predicate = Status.predicate(domain: domain, id: statusID)
|
||||
guard let status = managedObjectContext.safeFetch(request).first else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID))
|
||||
let threadContext = StatusItem.Thread.Context(status: .fromEntity(status))
|
||||
self.root = .root(context: threadContext)
|
||||
} // end Task
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
// MARK: - DataSourceProvider
|
||||
extension ThreadViewController: DataSourceProvider {
|
||||
|
@ -28,8 +29,121 @@ extension ThreadViewController: DataSourceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
func update(status: MastodonStatus) {
|
||||
switch viewModel.root {
|
||||
case let .root(context):
|
||||
if context.status.id == status.id {
|
||||
viewModel.root = .root(context: .init(status: status))
|
||||
} else {
|
||||
handle(status: status)
|
||||
}
|
||||
case let .reply(context):
|
||||
if context.status.id == status.id {
|
||||
viewModel.root = .reply(context: .init(status: status))
|
||||
} else {
|
||||
handle(status: status)
|
||||
}
|
||||
case let .leaf(context):
|
||||
if context.status.id == status.id {
|
||||
viewModel.root = .leaf(context: .init(status: status))
|
||||
} else {
|
||||
handle(status: status)
|
||||
}
|
||||
case .none:
|
||||
assertionFailure("This should not have happened")
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(status: MastodonStatus) {
|
||||
viewModel.mastodonStatusThreadViewModel.ancestors.handleUpdate(status: status, for: viewModel)
|
||||
viewModel.mastodonStatusThreadViewModel.descendants.handleUpdate(status: status, for: viewModel)
|
||||
}
|
||||
|
||||
func delete(status: MastodonStatus) {
|
||||
if viewModel.root?.record.id == status.id {
|
||||
viewModel.root = nil
|
||||
viewModel.onDismiss.send(status)
|
||||
}
|
||||
viewModel.mastodonStatusThreadViewModel.ancestors.handleDelete(status: status, for: viewModel)
|
||||
viewModel.mastodonStatusThreadViewModel.descendants.handleDelete(status: status, for: viewModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
}
|
||||
}
|
||||
|
||||
private extension [StatusItem] {
|
||||
mutating func handleUpdate(status: MastodonStatus, for viewModel: ThreadViewModel) {
|
||||
for (index, ancestor) in enumerated() {
|
||||
switch ancestor {
|
||||
case let .feed(record):
|
||||
if record.status?.id == status.id {
|
||||
self[index] = .feed(record: .fromStatus(status, kind: record.kind))
|
||||
}
|
||||
case let.feedLoader(record):
|
||||
if record.status?.id == status.id {
|
||||
self[index] = .feedLoader(record: .fromStatus(status, kind: record.kind))
|
||||
}
|
||||
case let .status(record):
|
||||
if record.id == status.id {
|
||||
self[index] = .status(record: status)
|
||||
}
|
||||
case let .thread(thread):
|
||||
switch thread {
|
||||
case let .root(context):
|
||||
if context.status.id == status.id {
|
||||
self[index] = .thread(.root(context: .init(status: status)))
|
||||
}
|
||||
case let .reply(context):
|
||||
if context.status.id == status.id {
|
||||
self[index] = .thread(.reply(context: .init(status: status)))
|
||||
}
|
||||
case let .leaf(context):
|
||||
if context.status.id == status.id {
|
||||
self[index] = .thread(.leaf(context: .init(status: status)))
|
||||
}
|
||||
}
|
||||
case .bottomLoader, .topLoader:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func handleDelete(status: MastodonStatus, for viewModel: ThreadViewModel) {
|
||||
for (index, ancestor) in enumerated() {
|
||||
switch ancestor {
|
||||
case let .feed(record):
|
||||
if record.status?.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
case let.feedLoader(record):
|
||||
if record.status?.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
case let .status(record):
|
||||
if record.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
case let .thread(thread):
|
||||
switch thread {
|
||||
case let .root(context):
|
||||
if context.status.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
case let .reply(context):
|
||||
if context.status.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
case let .leaf(context):
|
||||
if context.status.id == status.id {
|
||||
self.remove(at: index)
|
||||
}
|
||||
}
|
||||
case .bottomLoader, .topLoader:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import MastodonAsset
|
|||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonLocalization
|
||||
import MastodonSDK
|
||||
|
||||
final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
|
@ -70,6 +71,21 @@ extension ThreadViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.onDismiss
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
self?.navigationController?.notifyChildrenAboutStatusDeletion(status)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.onEdit
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
self?.navigationController?.notifyChildrenAboutStatusUpdate(status)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
tableView.pinToParent()
|
||||
|
@ -182,3 +198,17 @@ extension ThreadViewController: StatusTableViewControllerNavigateable {
|
|||
statusKeyCommandHandler(sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension UINavigationController {
|
||||
func notifyChildrenAboutStatusDeletion(_ status: MastodonStatus) {
|
||||
viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in
|
||||
provider?.delete(status: status )
|
||||
}
|
||||
}
|
||||
|
||||
func notifyChildrenAboutStatusUpdate(_ status: MastodonStatus) {
|
||||
viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in
|
||||
provider?.update(status: status )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue