Remove Status from CoreData (#1167)

This commit is contained in:
Marcus Kida 2024-01-08 11:17:40 +01:00 committed by GitHub
parent 2119c9de0b
commit 976f934df9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
140 changed files with 1919 additions and 1997 deletions

View File

@ -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 */,

View File

@ -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()
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
) {

View File

@ -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(

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}()

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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>()

View File

@ -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? {

View File

@ -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 }

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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

View File

@ -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? {

View File

@ -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 }

View File

@ -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 {

View File

@ -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 {

View File

@ -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)

View File

@ -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 }

View File

@ -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)

View File

@ -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
}

View File

@ -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? {

View File

@ -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()
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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])

View File

@ -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)
}
}
}

View File

@ -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? {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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? {

View File

@ -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 }

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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? {

View File

@ -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? {

View File

@ -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 }

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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? {

View File

@ -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(

View File

@ -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")
}
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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
}
}
}()

View File

@ -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 }

View File

@ -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)

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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

View File

@ -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?

View File

@ -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)

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -33,7 +33,7 @@ extension SearchResultViewModel {
diffableDataSource.apply(snapshot, animatingDifferences: false)
Publishers.CombineLatest3(
statusFetchedResultsController.$records,
dataController.$records,
$accounts,
$hashtags
)

View File

@ -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) {

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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),

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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)
)
}
}

View File

@ -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)
}

View File

@ -8,7 +8,7 @@ import UIKit
import MastodonSDK
struct StatusEditHistoryViewModel {
let status: Status
let status: MastodonStatus
let edits: [Mastodon.Entity.StatusEdit]
let appContext: AppContext

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}
}
}
}

View File

@ -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