From b010b6112eab36665a917739eee8337e138f5089 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 22 Nov 2023 12:32:04 +0100 Subject: [PATCH] Remove usage of Status (IOS-176) --- .../Notification/NotificationItem.swift | 6 +- .../Notification/NotificationSection.swift | 19 +- Mastodon/Diffable/Report/ReportItem.swift | 3 +- Mastodon/Diffable/Report/ReportSection.swift | 19 +- Mastodon/Diffable/Status/StatusItem.swift | 13 +- Mastodon/Diffable/Status/StatusSection.swift | 89 ++-- .../Provider/DataSourceFacade+Bookmark.swift | 3 +- .../Provider/DataSourceFacade+Favorite.swift | 6 +- .../Provider/DataSourceFacade+Follow.swift | 71 +-- .../Provider/DataSourceFacade+Media.swift | 10 +- .../Provider/DataSourceFacade+Meta.swift | 9 +- .../Provider/DataSourceFacade+Model.swift | 49 +-- .../Provider/DataSourceFacade+Profile.swift | 11 +- .../Provider/DataSourceFacade+Reblog.swift | 6 +- .../Provider/DataSourceFacade+Status.swift | 54 +-- .../Provider/DataSourceFacade+Thread.swift | 7 +- .../Provider/DataSourceFacade+Translate.swift | 18 +- .../Provider/DataSourceFacade+URL.swift | 3 +- ...er+NotificationTableViewCellDelegate.swift | 77 +--- ...Provider+StatusTableViewCellDelegate.swift | 23 +- ...tatusTableViewControllerNavigateable.swift | 10 +- ...taSourceProvider+UITableViewDelegate.swift | 12 +- .../Provider/DataSourceProvider.swift | 4 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- .../DiscoveryCommunityViewModel+State.swift | 8 +- .../DiscoveryCommunityViewModel.swift | 7 +- .../Scene/Discovery/DiscoveryViewModel.swift | 1 + .../Posts/DiscoveryPostsViewModel+State.swift | 8 +- .../Posts/DiscoveryPostsViewModel.swift | 7 +- .../HashtagTimelineViewModel+State.swift | 9 +- .../HashtagTimelineViewModel.swift | 7 +- ...ineViewController+DataSourceProvider.swift | 9 +- .../HomeTimelineViewModel+Diffable.swift | 62 +-- ...omeTimelineViewModel+LoadLatestState.swift | 6 +- ...omeTimelineViewModel+LoadOldestState.swift | 9 +- .../HomeTimeline/HomeTimelineViewModel.swift | 70 ++- .../NotificationTableViewCell+ViewModel.swift | 3 +- ...ineViewController+DataSourceProvider.swift | 12 +- .../NotificationTimelineViewController.swift | 36 +- ...tificationTimelineViewModel+Diffable.swift | 61 +-- ...ionTimelineViewModel+LoadOldestState.swift | 7 +- .../NotificationTimelineViewModel.swift | 38 +- .../Bookmark/BookmarkViewModel+State.swift | 14 +- .../Profile/Bookmark/BookmarkViewModel.swift | 7 +- .../Profile/CachedProfileViewModel.swift | 1 + .../Favorite/FavoriteViewModel+State.swift | 18 +- .../Profile/Favorite/FavoriteViewModel.swift | 7 +- .../Scene/Profile/MeProfileViewModel.swift | 1 + Mastodon/Scene/Profile/ProfileViewModel.swift | 1 + .../Profile/RemoteProfileViewModel.swift | 3 + .../UserTimelineViewModel+State.swift | 36 +- .../Timeline/UserTimelineViewModel.swift | 7 +- .../Profile/UserLIst/UserListViewModel.swift | 5 +- .../Scene/Report/Report/ReportViewModel.swift | 19 +- .../ReportStatusViewModel+State.swift | 11 +- .../ReportStatus/ReportStatusViewModel.swift | 13 +- .../ReportStatusTableViewCell+ViewModel.swift | 6 +- .../Root/MainTab/MainTabBarController.swift | 36 +- .../SearchResultOverviewCoordinator.swift | 14 +- .../SearchResult/SearchResultItem.swift | 2 +- .../SearchResult/SearchResultSection.swift | 19 +- .../SearchResultViewModel+State.swift | 6 +- .../SearchResult/SearchResultViewModel.swift | 7 +- .../NotificationView+Configuration.swift | 45 +- .../StatusTableViewCell+ViewModel.swift | 14 +- ...tusThreadRootTableViewCell+ViewModel.swift | 4 +- .../Scene/Thread/CachedThreadViewModel.swift | 6 +- .../StatusEditHistoryTableViewCell.swift | 2 +- .../StatusEditHistoryViewModel.swift | 2 +- .../MastodonStatusThreadViewModel.swift | 93 ++-- .../Scene/Thread/RemoteThreadViewModel.swift | 22 +- .../Thread/ThreadViewModel+Diffable.swift | 6 +- Mastodon/Scene/Thread/ThreadViewModel.swift | 40 +- .../FeedFetchedResultsController.swift | 84 +--- .../StatusFetchedResultsController.swift | 100 +---- .../Service/API/APIService+Bookmark.swift | 44 +- .../Service/API/APIService+Favorite.swift | 77 +--- .../Service/API/APIService+Reblog.swift | 67 +-- .../Service/API/APIService+Status.swift | 11 +- .../Entity/Mastodon+Entity+Account.swift | 9 + .../Entity/Mastodon+Entity+Card.swift | 10 + .../Entity/Mastodon+Entity+Notification.swift | 10 + .../Entity/Mastodon+Entity+Status.swift | 11 + .../Sources/MastodonSDK/MastodonFeed.swift | 56 +++ .../MastodonSDK/MastodonNotification.swift | 45 ++ .../Sources/MastodonSDK/MastodonStatus.swift | 54 +++ .../Protocol/StatusCompatible.swift | 1 + .../ComposeContentViewModel+DataSource.swift | 6 +- .../ComposeContentViewModel.swift | 91 ++-- .../Publisher/MastodonStatusPublisher.swift | 6 +- .../Content/MediaView+Configuration.swift | 8 +- .../Content/NotificationView+ViewModel.swift | 2 +- .../View/Content/StatusCardControl.swift | 25 +- .../Content/StatusView+Configuration.swift | 415 +++++++----------- .../View/Content/StatusView+ViewModel.swift | 7 +- .../MastodonUI/View/Content/StatusView.swift | 2 +- ...eMiddleLoaderTableViewCell+ViewModel.swift | 11 +- 97 files changed, 1048 insertions(+), 1455 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift diff --git a/Mastodon/Diffable/Notification/NotificationItem.swift b/Mastodon/Diffable/Notification/NotificationItem.swift index b0fdddb7f..d5727e813 100644 --- a/Mastodon/Diffable/Notification/NotificationItem.swift +++ b/Mastodon/Diffable/Notification/NotificationItem.swift @@ -7,10 +7,10 @@ import CoreData import Foundation -import CoreDataStack +import MastodonSDK enum NotificationItem: Hashable { - case feed(record: ManagedObjectRecord) - case feedLoader(record: ManagedObjectRecord) + case feed(record: MastodonFeed) + case feedLoader(record: MastodonFeed) case bottomLoader } diff --git a/Mastodon/Diffable/Notification/NotificationSection.swift b/Mastodon/Diffable/Notification/NotificationSection.swift index 0271aac20..0b446336f 100644 --- a/Mastodon/Diffable/Notification/NotificationSection.swift +++ b/Mastodon/Diffable/Notification/NotificationSection.swift @@ -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 diff --git a/Mastodon/Diffable/Report/ReportItem.swift b/Mastodon/Diffable/Report/ReportItem.swift index f5ea387b6..ed083f427 100644 --- a/Mastodon/Diffable/Report/ReportItem.swift +++ b/Mastodon/Diffable/Report/ReportItem.swift @@ -7,10 +7,11 @@ import Foundation import CoreDataStack +import MastodonSDK enum ReportItem: Hashable { case header(context: HeaderContext) - case status(record: ManagedObjectRecord) + case status(record: MastodonStatus) case comment(context: CommentContext) case result(record: ManagedObjectRecord) case bottomLoader diff --git a/Mastodon/Diffable/Report/ReportSection.swift b/Mastodon/Diffable/Report/ReportSection.swift index 99e04ea1f..94161f28c 100644 --- a/Mastodon/Diffable/Report/ReportSection.swift +++ b/Mastodon/Diffable/Report/ReportSection.swift @@ -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 diff --git a/Mastodon/Diffable/Status/StatusItem.swift b/Mastodon/Diffable/Status/StatusItem.swift index 1d08ea41d..938e51eb2 100644 --- a/Mastodon/Diffable/Status/StatusItem.swift +++ b/Mastodon/Diffable/Status/StatusItem.swift @@ -8,11 +8,12 @@ import Foundation import CoreDataStack import MastodonUI +import MastodonSDK enum StatusItem: Hashable { - case feed(record: ManagedObjectRecord) - case feedLoader(record: ManagedObjectRecord) - case status(record: ManagedObjectRecord) + 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 { + 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 + let status: MastodonStatus var displayUpperConversationLink: Bool var displayBottomConversationLink: Bool init( - status: ManagedObjectRecord, + status: MastodonStatus, displayUpperConversationLink: Bool = false, displayBottomConversationLink: Bool = false ) { diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index 586764f42..d6dbeccf6 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -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( @@ -124,30 +115,24 @@ extension StatusSection { 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 } } @@ -319,7 +304,7 @@ extension StatusSection { static func configure( cell: TimelineMiddleLoaderTableViewCell, - feed: Feed, + feed: MastodonFeed, configuration: Configuration ) { cell.configure( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift index 2c54653ba..70a3fdbc0 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift @@ -9,11 +9,12 @@ import UIKit import CoreData import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { public static func responseToStatusBookmarkAction( provider: UIViewController & NeedsDependency & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift index 92945b9ee..8797ea986 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift @@ -7,19 +7,19 @@ import UIKit import CoreData -import CoreDataStack +import MastodonSDK import MastodonCore extension DataSourceFacade { public static func responseToStatusFavoriteAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() _ = try await provider.context.apiService.favorite( - record: status, + status: status, authenticationBox: provider.authContext.mastodonAuthenticationBox ) } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index 6fe0005a0..88bc41911 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -46,15 +46,14 @@ extension DataSourceFacade { extension DataSourceFacade { static func responseToUserFollowRequestAction( dependency: NeedsDependency & AuthContextProvider, - notification: ManagedObjectRecord, + 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 } @@ -63,23 +62,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 { @@ -90,22 +83,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) @@ -121,32 +104,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 { diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift index 8379f08e9..45622dba4 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift @@ -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: 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() diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift index ca3bbd474..3cb21fb0f 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift @@ -9,16 +9,17 @@ import Foundation import CoreDataStack import MetaTextKit import MastodonCore +import MastodonSDK extension DataSourceFacade { static func responseToMetaTextAction( provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord, + status: MastodonStatus, meta: Meta ) async throws { - let _redirectRecord = await DataSourceFacade.status( + let _redirectRecord = DataSourceFacade.status( managedObjectContext: provider.context.managedObjectContext, status: status, target: target @@ -35,7 +36,7 @@ extension DataSourceFacade { static func responseToMetaTextAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, meta: Meta ) async { switch meta { @@ -55,7 +56,7 @@ extension DataSourceFacade { 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( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift index efdf41dbd..9364cbc24 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift @@ -9,41 +9,14 @@ import Foundation import CoreData import CoreDataStack import MastodonUI +import MastodonSDK extension DataSourceFacade { static func status( managedObjectContext: NSManagedObjectContext, - status: ManagedObjectRecord, + status: MastodonStatus, target: StatusTarget - ) async -> ManagedObjectRecord? { - return try? await managedObjectContext.perform { - guard let object = status.object(in: managedObjectContext) else { return nil } - return DataSourceFacade.status(status: object, target: target) - .flatMap { ManagedObjectRecord(objectID: $0.objectID) } - } - } -} - -extension DataSourceFacade { - static func author( - managedObjectContext: NSManagedObjectContext, - status: ManagedObjectRecord, - target: StatusTarget - ) async -> ManagedObjectRecord? { - 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(objectID: $0.objectID) } - } - } -} - -extension DataSourceFacade { - static func status( - status: Status, - target: StatusTarget - ) -> Status? { + ) -> MastodonStatus? { switch target { case .status: return status.reblog ?? status @@ -52,3 +25,19 @@ extension DataSourceFacade { } } } + +extension DataSourceFacade { + static func author( + managedObjectContext: NSManagedObjectContext, + status: MastodonStatus, + target: StatusTarget + ) async -> ManagedObjectRecord? { + return try? await managedObjectContext.perform { + return DataSourceFacade.status(managedObjectContext: managedObjectContext, status: status, target: target) + .flatMap { $0.entity.account } + .flatMap { + MastodonUser.findOrFetch(in: managedObjectContext, matching: MastodonUser.predicate(domain: $0.domain ?? "", id: $0.id))?.asRecord + } + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index 30c024f54..3cf14f83b 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -15,7 +15,7 @@ extension DataSourceFacade { static func coordinateToProfileScene( provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord + status: MastodonStatus ) async { let _redirectRecord = await DataSourceFacade.author( managedObjectContext: provider.context.managedObjectContext, @@ -83,9 +83,10 @@ extension DataSourceFacade { extension DataSourceFacade { + @MainActor static func coordinateToProfileScene( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, mention: String, // username, userInfo: [AnyHashable: Any]? ) async { @@ -100,11 +101,11 @@ extension DataSourceFacade { let managedObjectContext = provider.context.managedObjectContext let mentions = try? await managedObjectContext.perform { - return status.object(in: managedObjectContext)?.mentions ?? [] + return status.entity.mentions ?? [] } guard let mention = mentions?.first(where: { $0.url == href }) else { - _ = await provider.coordinator.present( + _ = provider.coordinator.present( scene: .safari(url: url), from: provider, transition: .safariPresent(animated: true, completion: nil) @@ -131,7 +132,7 @@ extension DataSourceFacade { } }() - _ = await provider.coordinator.present( + _ = provider.coordinator.present( scene: .profile(viewModel: profileViewModel), from: provider, transition: .show diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift index ff3e95820..c16a9e415 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift @@ -6,20 +6,20 @@ // import UIKit -import CoreDataStack import MastodonCore import MastodonUI +import MastodonSDK extension DataSourceFacade { static func responseToStatusReblogAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() _ = try await provider.context.apiService.reblog( - record: status, + status: status, authenticationBox: provider.authContext.mastodonAuthenticationBox ) } // end func diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index d304816d0..fdf39b876 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -14,13 +14,14 @@ import MastodonUI import MastodonLocalization import LinkPresentation import UniformTypeIdentifiers +import MastodonSDK // Delete extension DataSourceFacade { static func responseToDeleteStatus( dependency: NeedsDependency & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { _ = try await dependency.context.apiService.deleteStatus( status: status, @@ -36,7 +37,7 @@ extension DataSourceFacade { @MainActor public static func responseToStatusShareAction( provider: DataSourceProvider, - status: ManagedObjectRecord, + status: MastodonStatus, button: UIButton ) async throws { let activityViewController = try await createActivityViewController( @@ -56,22 +57,22 @@ extension DataSourceFacade { private static func createActivityViewController( dependency: NeedsDependency, - status: ManagedObjectRecord + 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 +95,12 @@ extension DataSourceFacade { @MainActor static func responseToActionToolbar( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, action: ActionToolbarContainer.Action, sender: UIButton ) async throws { let managedObjectContext = provider.context.managedObjectContext - let _status: ManagedObjectRecord? = 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: @@ -266,7 +259,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 +290,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 +302,7 @@ extension DataSourceFacade { case .shareStatus: Task { let managedObjectContext = dependency.context.managedObjectContext - guard let status: ManagedObjectRecord = 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 +333,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 +347,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 +360,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, @@ -402,12 +391,11 @@ extension DataSourceFacade { static func responseToToggleSensitiveAction( dependency: NeedsDependency, - status: ManagedObjectRecord + 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 + _status.isSensitiveToggled = !_status.isSensitiveToggled } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift index ad8d0e671..61075d436 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift @@ -9,15 +9,16 @@ import UIKit import CoreData import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { static func coordinateToStatusThreadScene( provider: ViewControllerWithDependencies & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord + status: MastodonStatus ) async { - let _root: StatusItem.Thread? = await { - let _redirectRecord = await DataSourceFacade.status( + let _root: StatusItem.Thread? = { + let _redirectRecord = DataSourceFacade.status( managedObjectContext: provider.context.managedObjectContext, status: status, target: target diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index 7560d9008..2523be1b4 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -20,27 +20,11 @@ extension DataSourceFacade { public static func translateStatus( provider: Provider, - status: ManagedObjectRecord + 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 diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift b/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift index a65de9537..286618a2c 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift @@ -9,11 +9,12 @@ import Foundation import CoreDataStack import MetaTextKit import MastodonCore +import MastodonSDK extension DataSourceFacade { static func responseToURLAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, url: URL ) async { let domain = provider.authContext.mastodonAuthenticationBox.domain diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index 0974510bf..8d8e62bd9 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -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? = 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 { @@ -71,7 +71,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut return } let _author: ManagedObjectRecord? = 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 +154,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut } private struct NotificationMediaTransitionContext { - let status: ManagedObjectRecord + let status: MastodonStatus let needsToggleMediaSensitive: Bool } @@ -180,16 +179,13 @@ 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 } return NotificationMediaTransitionContext( - status: .init(objectID: status.objectID), - needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive + status: status, + needsToggleMediaSensitive: status.isSensitiveToggled ? !(status.entity.sensitive == true) : (status.entity.sensitive == true) ) - } + }() guard let mediaTransitionContext = _mediaTransitionContext else { return } @@ -233,15 +229,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 +280,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - let _status: ManagedObjectRecord? = 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 +312,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - let _author: ManagedObjectRecord? = 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 +348,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = 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 +376,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = 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 +436,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = 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 +463,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = 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 } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 68be145e2..554bb92f1 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -38,10 +38,9 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte break case .reply: let _replyToAuthor: ManagedObjectRecord? = 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: status.entity.account.domain ?? "", id: inReplyToAccountID) request.fetchLimit = 1 guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } return .init(objectID: author.objectID) @@ -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 } } ], @@ -471,8 +470,10 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte return } let _author: ManagedObjectRecord? = 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: status.entity.account.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 { @@ -679,11 +680,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 diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift index f90827863..97081cc6f 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift @@ -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? { + 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,12 +65,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid case .status(let record): return record case .notification(let record): - let _statusRecord: ManagedObjectRecord? = 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 { + guard let statusRecord = record.status else { return nil } return statusRecord diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index 0944cee6c..41838b0e6 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -10,6 +10,7 @@ import CoreDataStack import MastodonCore import MastodonUI import MastodonLocalization +import MastodonSDK extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider { @@ -42,11 +43,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid case .notification(let notification): let managedObjectContext = context.managedObjectContext - let _status: ManagedObjectRecord? = 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) - } + let _status: MastodonStatus? = notification.status if let status = _status { await DataSourceFacade.coordinateToStatusThreadScene( provider: self, @@ -54,10 +51,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid status: status ) } else { - let _author: ManagedObjectRecord? = try await managedObjectContext.perform { - guard let notification = notification.object(in: managedObjectContext) else { return nil } - return .init(objectID: notification.account.objectID) - } + let _author: ManagedObjectRecord? = notification.account.asRecord if let author = _author { await DataSourceFacade.coordinateToProfileScene( provider: self, diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift index b92aadcef..ab6df8c2c 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -12,10 +12,10 @@ import MastodonSDK import class CoreDataStack.Notification enum DataSourceItem: Hashable { - case status(record: ManagedObjectRecord) + case status(record: MastodonStatus) case user(record: ManagedObjectRecord) case hashtag(tag: TagKind) - case notification(record: ManagedObjectRecord) + case notification(record: MastodonNotification) case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 442a47de4..798e198f1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -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() diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift index f61df078d..6dccb355d 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift @@ -145,10 +145,10 @@ extension DiscoveryCommunityViewModel.State { self.maxID = newMaxID var hasNewStatusesAppend = false - var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs + var statusIDs = isReloading ? [] : await viewModel.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs) viewModel.didLoadLatest.send() } catch { diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift index 6169e0830..34b9895b2 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift @@ -41,14 +41,11 @@ final class DiscoveryCommunityViewModel { let didLoadLatest = PassthroughSubject() + @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.statusFetchedResultsController = StatusFetchedResultsController() // end init } } diff --git a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift index 244a2e8d4..b1403f693 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift @@ -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 diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift index 75794258d..47dabe5a1 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift @@ -143,10 +143,10 @@ extension DiscoveryPostsViewModel.State { self.offset = newOffset var hasNewStatusesAppend = false - var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs + var statusIDs = isReloading ? [] : await viewModel.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs) viewModel.didLoadLatest.send() } catch { diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift index 3024f03be..d7ea73e9b 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift @@ -41,14 +41,11 @@ final class DiscoveryPostsViewModel { let didLoadLatest = PassthroughSubject() @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.statusFetchedResultsController = StatusFetchedResultsController() // end init Task { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift index 579060bda..aa4d89d77 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift @@ -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.fetchedResultsController.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.fetchedResultsController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) }) viewModel.didLoadLatest.send() } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index fbdc42a1c..84f8a62aa 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -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.fetchedResultsController = StatusFetchedResultsController() updateTagInformation() // end init } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift index b141d386a..73e72170c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -20,17 +20,16 @@ extension HomeTimelineViewController: 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 == .home else { return nil } if let status = feed.status { - return .status(record: .init(objectID: status.objectID)) + return .status(record: status) } else { return nil } - } + }() return item default: return nil diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 18cbf18d2..769ea44cc 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -52,37 +52,37 @@ extension HomeTimelineViewModel { 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) - } - } - } + #warning("We probably need to replace the code below") +// 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 hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers if !hasChanges && !self.hasPendingStatusEditReload { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 7f056928c..cfad3f24c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -84,14 +84,10 @@ 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 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 { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 1b6e4499d..a2bf3e224 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -52,13 +52,10 @@ extension HomeTimelineViewModel.LoadOldestState { } 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 } + let _maxID: Mastodon.Entity.Status.ID? = { + guard let status = lastFeedRecord.status else { return nil } return status.id - } + }() guard let maxID = _maxID else { await self.enter(state: Fail.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 040fe45b5..196b72152 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -80,15 +80,9 @@ final class HomeTimelineViewModel: NSObject { init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) + self.fetchedResultsController = FeedFetchedResultsController() self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() - - fetchedResultsController.predicate = Feed.predicate( - kind: .home, - acct: .mastodon(domain: authContext.mastodonAuthenticationBox.domain, userID: authContext.mastodonAuthenticationBox.userID) - ) - homeTimelineNeedRefresh .sink { [weak self] _ in self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) @@ -116,7 +110,7 @@ extension HomeTimelineViewModel { extension HomeTimelineViewModel { func timelineDidReachEnd() { - fetchedResultsController.fetchNextBatch() + #warning("Check if required, e.g. when locally caching MastodonStatus") } } @@ -128,47 +122,41 @@ 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 } +// let managedObjectContext = context.managedObjectContext +// let key = "LoadMore@\(record.objectID)" +// +// guard let feed = record.object(in: managedObjectContext) 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 + +// // 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) +// } // 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]) diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift index 1d2b40ebc..135a7adc2 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -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) } } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index c058ee921..5ccc2a2ff 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension NotificationTimelineViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -20,17 +21,16 @@ 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) { + return .notification(record: mastodonNotification) } else { return nil } - } + }() return item default: return nil diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index d081327d3..d4c68c09f 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -280,14 +280,13 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { 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 +294,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: notification.account.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 diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index c412c39a4..e3460a72b 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -48,36 +48,37 @@ 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) - } - } - } + #warning("Do we still need the code below?") +// 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 hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers if !hasChanges { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index 3be724701..39d97ba6e 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -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) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index a6412d365..f4c082909 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -43,6 +43,7 @@ final class NotificationTimelineViewModel { return stateMachine }() + @MainActor init( context: AppContext, authContext: AuthContext, @@ -51,13 +52,7 @@ final class NotificationTimelineViewModel { self.context = context self.authContext = authContext self.scope = scope - self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) - // end init - - feedFetchedResultsController.predicate = NotificationTimelineViewModel.feedPredicate( - authenticationBox: authContext.mastodonAuthenticationBox, - scope: scope - ) + self.feedFetchedResultsController = FeedFetchedResultsController() } @@ -125,29 +120,16 @@ extension NotificationTimelineViewModel { // 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) - } - + guard let maxID = record.notification?.id else { return } + // fetch data - do { - _ = try await context.apiService.notifications( - maxID: maxID, - scope: scope, - authenticationBox: authContext.mastodonAuthenticationBox - ) - } catch { + if let notifications = try? await context.apiService.notifications( + maxID: maxID, + scope: scope, + authenticationBox: authContext.mastodonAuthenticationBox + ) { + self.feedFetchedResultsController.records += notifications.value.map { MastodonFeed.fromNotification($0, kind: record.kind) } } } diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift index f746a5acb..66ffe866f 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift @@ -57,7 +57,9 @@ extension BookmarkViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.statusFetchedResultsController.statusIDs = [] + DispatchQueue.main.async { + viewModel.statusFetchedResultsController.reset() + } stateMachine.enter(Loading.self) } @@ -128,10 +130,10 @@ extension BookmarkViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) }) + } catch { await enter(state: Fail.self) } diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift index f56e65526..260e65e9b 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift @@ -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.statusFetchedResultsController = StatusFetchedResultsController() } } diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift index a769f2a9f..afefd4cdf 100644 --- a/Mastodon/Scene/Profile/CachedProfileViewModel.swift +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -11,6 +11,7 @@ import MastodonCore final class CachedProfileViewModel: ProfileViewModel { + @MainActor init(context: AppContext, authContext: AuthContext, mastodonUser: MastodonUser) { super.init(context: context, authContext: authContext, optionalMastodonUser: mastodonUser) } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift index a1a8d0f99..10f6f269a 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -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.statusFetchedResultsController.reset() + + stateMachine.enter(Loading.self) + } } } @@ -127,10 +129,10 @@ extension FavoriteViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs) } catch { await enter(state: Fail.self) } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift index 0dd3c7203..b79bbbbfc 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -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.statusFetchedResultsController = StatusFetchedResultsController() } } diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift index 7f88d2ffe..ecbaef01e 100644 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -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( diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 630205371..8ed3cf03d 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -56,6 +56,7 @@ class ProfileViewModel: NSObject { // @Published var protected: Bool? = nil // let needsPagePinToTop = CurrentValueSubject(false) + @MainActor init(context: AppContext, authContext: AuthContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context self.authContext = authContext diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index 832c25858..b0a2f9f48 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -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) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index cd0110a87..5469cc4bb 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -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.statusFetchedResultsController.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.statusFetchedResultsController.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.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs) } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 0c8d634e5..f2b67c3ee 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -48,6 +48,7 @@ final class UserTimelineViewModel { return stateMachine }() + @MainActor init( context: AppContext, authContext: AuthContext, @@ -57,11 +58,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.statusFetchedResultsController = StatusFetchedResultsController() self.queryFilter = queryFilter } } diff --git a/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift b/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift index d27562b94..9e26b81e6 100644 --- a/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift +++ b/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift @@ -10,6 +10,7 @@ import Combine import CoreDataStack import GameplayKit import MastodonCore +import MastodonSDK final class UserListViewModel { var disposeBag = Set() @@ -55,7 +56,7 @@ final class UserListViewModel { extension UserListViewModel { // TODO: refactor follower and following into user list enum Kind { - case rebloggedBy(status: ManagedObjectRecord) - case favoritedBy(status: ManagedObjectRecord) + case rebloggedBy(status: MastodonStatus) + case favoritedBy(status: MastodonStatus) } } diff --git a/Mastodon/Scene/Report/Report/ReportViewModel.swift b/Mastodon/Scene/Report/Report/ReportViewModel.swift index cb840d213..ba71da66f 100644 --- a/Mastodon/Scene/Report/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/Report/ReportViewModel.swift @@ -29,17 +29,18 @@ class ReportViewModel { let context: AppContext let authContext: AuthContext let user: ManagedObjectRecord - let status: ManagedObjectRecord? + let status: MastodonStatus? // output @Published var isReporting = false @Published var isReportSuccess = false + @MainActor init( context: AppContext, authContext: AuthContext, user: ManagedObjectRecord, - status: ManagedObjectRecord? + 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 } } }() diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift index d4fb507b2..0bb2e0cef 100644 --- a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift +++ b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift @@ -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.statusFetchedResultsController.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.statusFetchedResultsController.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.statusFetchedResultsController.setRecords(statusIDs) } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift index 8c41e1ce0..186a2806c 100644 --- a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift +++ b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift @@ -25,12 +25,12 @@ class ReportStatusViewModel { let context: AppContext let authContext: AuthContext let user: ManagedObjectRecord - let status: ManagedObjectRecord? + let status: MastodonStatus? let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() @Published var isSkip = false - @Published var selectStatuses = OrderedSet>() + @Published var selectStatuses = OrderedSet() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -48,21 +48,18 @@ class ReportStatusViewModel { @Published var isNextButtonEnabled = false + @MainActor init( context: AppContext, authContext: AuthContext, user: ManagedObjectRecord, - status: ManagedObjectRecord? + 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.statusFetchedResultsController = StatusFetchedResultsController() // end init if let status = status { diff --git a/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift index 00d079cfa..a5ad90bc4 100644 --- a/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift @@ -6,13 +6,13 @@ // import UIKit -import CoreDataStack +import MastodonSDK extension ReportStatusTableViewCell { final class ViewModel { - let value: Status + let value: MastodonStatus - init(value: Status) { + init(value: MastodonStatus) { self.value = value } } diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index cd0804b24..036349690 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -87,7 +87,8 @@ class MainTabBarController: UITabBarController { return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80)) } - func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) -> UIViewController { + @MainActor + func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) async -> UIViewController { guard let authContext = authContext else { return UITableViewController() } @@ -170,21 +171,26 @@ extension MainTabBarController { view.backgroundColor = .systemBackground // seealso: `ThemeService.apply(theme:)` - let tabs = Tab.allCases - let viewControllers: [UIViewController] = tabs.map { tab in - 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 - viewController.tabBarItem.image = tab.image.imageWithoutBaseline() - viewController.tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline() - viewController.tabBarItem.accessibilityLabel = tab.title - viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels - viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) - return viewController + Task { @MainActor in + let tabs = Tab.allCases + var viewControllers = [UIViewController]() + + for tab in tabs { + let viewController = await tab.viewController(context: context, authContext: authContext, coordinator: coordinator) + viewController.tabBarItem.tag = tab.tag + viewController.tabBarItem.title = tab.title // needs for acessiblity large content label + viewController.tabBarItem.image = tab.image.imageWithoutBaseline() + viewController.tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline() + viewController.tabBarItem.accessibilityLabel = tab.title + viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels + viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) + viewControllers.append(viewController) + } + + _viewControllers = viewControllers + setViewControllers(viewControllers, animated: false) + selectedIndex = 0 } - _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)) { diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift index 66ec904f7..3c82636f3 100644 --- a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift @@ -75,22 +75,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? diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift index 813836925..0ee40d384 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift @@ -12,7 +12,7 @@ import MastodonSDK enum SearchResultItem: Hashable { case user(ManagedObjectRecord) - case status(ManagedObjectRecord) + case status(MastodonStatus) case hashtag(tag: Mastodon.Entity.Tag) case bottomLoader(attribute: BottomLoaderAttribute) } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift index 32a913587..e1d7b294e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift @@ -58,18 +58,15 @@ extension SearchResultSection { ) } 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 .hashtag(let tag): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index e332b13d9..cc8c46c64 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -124,7 +124,7 @@ extension SearchResultViewModel.State { guard stateMachine.currentState is Loading else { return } let userIDs = response.value.accounts.map { $0.id } - let statusIDs = response.value.statuses.map { $0.id } + let statusIDs = response.value.statuses.map { MastodonStatus.fromEntity($0) } let isNoMore = userIDs.isEmpty && statusIDs.isEmpty @@ -137,12 +137,12 @@ extension SearchResultViewModel.State { // reset data source when the search is refresh if offset == nil { viewModel.userFetchedResultsController.userIDs = [] - viewModel.statusFetchedResultsController.statusIDs = [] + await viewModel.statusFetchedResultsController.reset() viewModel.hashtags = [] } viewModel.userFetchedResultsController.append(userIDs: userIDs) - viewModel.statusFetchedResultsController.append(statusIDs: statusIDs) + await viewModel.statusFetchedResultsController.appendRecords(statusIDs) var hashtags = viewModel.hashtags for hashtag in response.value.hashtags where !hashtags.contains(hashtag) { diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index c3ddc2f0a..43e678a83 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -45,6 +45,7 @@ final class SearchResultViewModel { }() let didDataSourceUpdate = PassthroughSubject() + @MainActor init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all, searchText: String) { self.context = context self.authContext = authContext @@ -56,10 +57,6 @@ final class SearchResultViewModel { domain: authContext.mastodonAuthenticationBox.domain, additionalPredicate: nil ) - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.statusFetchedResultsController = StatusFetchedResultsController() } } diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift index 576d2eadf..9600461df 100644 --- a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -16,30 +16,29 @@ 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).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 +62,7 @@ extension NotificationView { } extension NotificationView { - private func configureAuthor(notification: Notification) { + private func configureAuthor(notification: MastodonNotification) { let author = notification.account // author avatar @@ -98,19 +97,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 +203,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) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift index c3455fd0e..ce3fd232d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift @@ -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) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift index 568552c16..7c363c916 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell+ViewModel.swift @@ -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) } } } diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift index 00c29e157..9301d1876 100644 --- a/Mastodon/Scene/Thread/CachedThreadViewModel.swift +++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift @@ -6,12 +6,12 @@ // import Foundation -import CoreDataStack +import MastodonSDK import MastodonCore final class CachedThreadViewModel: ThreadViewModel { - init(context: AppContext, authContext: AuthContext, status: Status) { - let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID)) + init(context: AppContext, authContext: AuthContext, status: MastodonStatus) { + let threadContext = StatusItem.Thread.Context(status: status) super.init( context: context, authContext: authContext, diff --git a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift index 3485cfeab..9200b9189 100644 --- a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift +++ b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift @@ -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) } diff --git a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift index 525f7e72a..3a16a96ce 100644 --- a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift +++ b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift @@ -8,7 +8,7 @@ import UIKit import MastodonSDK struct StatusEditHistoryViewModel { - let status: Status + let status: MastodonStatus let edits: [Mastodon.Entity.StatusEdit] let appContext: AppContext diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift index ad69791b2..8fb717cd1 100644 --- a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift @@ -20,7 +20,7 @@ final class MastodonStatusThreadViewModel { // input let context: AppContext - @Published private(set) var deletedObjectIDs: Set = Set() + @Published private(set) var deletedObjectIDs: Set = 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 @@ -94,19 +94,20 @@ extension MastodonStatusThreadViewModel { } 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(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: record, - displayUpperConversationLink: !isLast, - displayBottomConversationLink: true - ) - let item = StatusItem.thread(.leaf(context: context)) - newItems.append(item) - } + #warning("Potentially this can be removed and replaced by native threading logic") +// for (i, node) in nodes.enumerated() { +// guard let status = dictionary[node.statusID] else { continue } +// let isLast = i == nodes.count - 1 +// +// let record = ManagedObjectRecord(objectID: status.objectID) +// let context = StatusItem.Thread.Context( +// status: record, +// displayUpperConversationLink: !isLast, +// displayBottomConversationLink: true +// ) +// let item = StatusItem.thread(.leaf(context: context)) +// newItems.append(item) +// } let items = self.__ancestors + newItems self.__ancestors = items @@ -132,31 +133,32 @@ extension MastodonStatusThreadViewModel { } var newItems: [StatusItem] = [] - for node in nodes { - guard let status = dictionary[node.statusID] else { continue } - // first tier - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: record - ) - 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(objectID: secondaryStatus.objectID) - let secondaryContext = StatusItem.Thread.Context( - status: secondaryRecord, - displayUpperConversationLink: true - ) - let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) - newItems.append(secondaryItem) - - // update first tier context - context.displayBottomConversationLink = true - } - } +#warning("Potentially this can be removed and replaced by native threading logic") +// for node in nodes { +// guard let status = dictionary[node.statusID] else { continue } +// // first tier +// let record = ManagedObjectRecord(objectID: status.objectID) +// let context = StatusItem.Thread.Context( +// status: record +// ) +// 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(objectID: secondaryStatus.objectID) +// let secondaryContext = StatusItem.Thread.Context( +// status: secondaryRecord, +// displayUpperConversationLink: true +// ) +// let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) +// newItems.append(secondaryItem) +// +// // update first tier context +// context.displayBottomConversationLink = true +// } +// } var items = self.__descendants for item in newItems { @@ -262,12 +264,3 @@ extension MastodonStatusThreadViewModel.Node { } -extension MastodonStatusThreadViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - var set = deletedObjectIDs - for objectID in objectIDs { - set.insert(objectID) - } - self.deletedObjectIDs = set - } -} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index 696e10492..1585b524b 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -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 } diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index aa5f33cec..9ab629ad9 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -38,8 +38,7 @@ extension ThreadViewModel { snapshot.appendSections([.main]) if let root = self.root { if case let .root(threadContext) = root, - let status = threadContext.status.object(in: context.managedObjectContext), - status.inReplyToID != nil + threadContext.status.entity.inReplyToID != nil { snapshot.appendItems([.topLoader], toSection: .main) } @@ -81,8 +80,7 @@ extension ThreadViewModel { // top loader let _hasReplyTo: Bool? = try? await self.context.managedObjectContext.perform { guard case let .root(threadContext) = root else { return nil } - guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return nil } - return status.inReplyToID != nil + return threadContext.status.entity.inReplyToID != nil } if let hasReplyTo = _hasReplyTo, hasReplyTo { let state = self.loadThreadStateMachine.currentState diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 69dc73e48..b4bf03d93 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -55,40 +55,25 @@ class ThreadViewModel { self.root = optionalRoot self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context) // end init - - ManagedObjectObserver.observe(context: context.managedObjectContext) - .sink(receiveCompletion: { completion in - // do nohting - }, receiveValue: { [weak self] changes in - guard let self = self else { return } - - let objectIDs: [NSManagedObjectID] = changes.changeTypes.compactMap { changeType in - guard case let .delete(object) = changeType else { return nil } - return object.objectID - } - - self.delete(objectIDs: objectIDs) - }) - .store(in: &disposeBag) - + $root .receive(on: DispatchQueue.main) .sink { [weak self] root in guard let self = self else { return } guard case let .root(threadContext) = root else { return } - guard let status = threadContext.status.object(in: self.context.managedObjectContext) else { return } + let status = threadContext.status // bind threadContext self.threadContext = .init( - domain: status.domain, + domain: authContext.mastodonAuthenticationBox.domain, //status.domain, statusID: status.id, - replyToID: status.inReplyToID + replyToID: status.entity.inReplyToID ) // bind titleView self.navigationBarTitle = { - let title = L10n.Scene.Thread.title(status.author.displayNameWithFallback) - let content = MastodonContent(content: title, emojis: status.author.emojis.asDictionary) + let title = L10n.Scene.Thread.title(status.entity.account.displayNameWithFallback) + let content = MastodonContent(content: title, emojis: status.entity.account.emojis?.asDictionary ?? [:]) return try? MastodonMetaContent.convert(document: content) }() } @@ -116,16 +101,3 @@ extension ThreadViewModel { } } - -extension ThreadViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - if let root = self.root, - case let .root(threadContext) = root, - objectIDs.contains(threadContext.status.objectID) - { - self.root = nil - } - - self.mastodonStatusThreadViewModel.delete(objectIDs: objectIDs) - } -} diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift index 682c83815..c578ce404 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift @@ -9,87 +9,11 @@ import Foundation import UIKit import Combine -import CoreData -import CoreDataStack import MastodonSDK -final public class FeedFetchedResultsController: NSObject { - - private enum Constants { - static let defaultFetchLimit = 100 - } - - var disposeBag = Set() - - private let fetchedResultsController: NSFetchedResultsController - - public var managedObjectContext: NSManagedObjectContext { - fetchedResultsController.managedObjectContext - } - - // input - @Published public var predicate = Feed.predicate(kind: .none, acct: .none) - - // output - private let _objectIDs = PassthroughSubject<[NSManagedObjectID], Never>() - @Published public var records: [ManagedObjectRecord] = [] +final public class FeedFetchedResultsController { - public func fetchNextBatch() { - fetchedResultsController.fetchRequest.fetchLimit += Constants.defaultFetchLimit - try? fetchedResultsController.performFetch() - } - - public init(managedObjectContext: NSManagedObjectContext) { - self.fetchedResultsController = { - let fetchRequest = Feed.sortedFetchRequest - // make sure initial query return empty results - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.shouldRefreshRefetchedObjects = true - fetchRequest.fetchBatchSize = 15 - fetchRequest.fetchLimit = Constants.defaultFetchLimit - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - // debounce output to prevent UI update issues - _objectIDs - .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) - .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } - .assign(to: &$records) - - fetchedResultsController.delegate = self - - $predicate - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } + @Published public var records: [MastodonFeed] = [] + + public init() {} } - -// MARK: - NSFetchedResultsControllerDelegate -extension FeedFetchedResultsController: NSFetchedResultsControllerDelegate { - public func controller( - _ controller: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference - ) { - let snapshot = snapshot as NSDiffableDataSourceSnapshot - self._objectIDs.send(snapshot.itemIdentifiers) - } -} - diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift index 1bc5426c6..d34cd1add 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift @@ -11,93 +11,27 @@ import CoreData import CoreDataStack import MastodonSDK -public final class StatusFetchedResultsController: NSObject { - - var disposeBag = Set() - - let fetchedResultsController: NSFetchedResultsController - - // input - @Published public var domain: String? = nil - @Published public var statusIDs: [Mastodon.Entity.Status.ID] = [] +public final class StatusFetchedResultsController { + @MainActor + @Published public private(set) var records: [MastodonStatus] = [] - // output - let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - @Published public private(set) var records: [ManagedObjectRecord] = [] - - public init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { - self.domain = domain ?? "" - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: []) - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - // debounce output to prevent UI update issues - _objectIDs - .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) - .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } - .assign(to: &$records) - - fetchedResultsController.delegate = self - - Publishers.CombineLatest( - self.$domain.removeDuplicates(), - self.$statusIDs.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] domain, ids in - guard let self = self else { return } - var predicates = [Status.predicate(domain: domain ?? "", ids: ids)] - if let additionalPredicate = additionalTweetPredicate { - predicates.append(additionalPredicate) - } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) + @MainActor + public init(records: [MastodonStatus] = []) { + self.records = records } -} - -extension StatusFetchedResultsController { - - public func append(statusIDs: [Mastodon.Entity.Status.ID]) { - var result = self.statusIDs - for statusID in statusIDs where !result.contains(statusID) { - result.append(statusID) - } - self.statusIDs = result + @MainActor + public func reset() { + records = [] } -} - -// MARK: - NSFetchedResultsControllerDelegate -extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { - public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - let indexes = statusIDs - let objects = fetchedResultsController.fetchedObjects ?? [] - - let items: [NSManagedObjectID] = objects - .compactMap { object in - indexes.firstIndex(of: object.id).map { index in (index, object) } - } - .sorted { $0.0 < $1.0 } - .map { $0.1.objectID } - self._objectIDs.value = items + @MainActor + public func setRecords(_ records: [MastodonStatus]) { + self.records = records + } + + @MainActor + public func appendRecords(_ records: [MastodonStatus]) { + self.records += records } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift index 8c4e48417..4f31c91bc 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift @@ -19,26 +19,26 @@ extension APIService { } public func bookmark( - record: ManagedObjectRecord, + record: MastodonStatus, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { let managedObjectContext = backgroundManagedObjectContext - + // update bookmark state and retrieve bookmark context let bookmarkContext: MastodonBookmarkContext = try await managedObjectContext.performChanges { let authentication = authenticationBox.authentication guard - let _status = record.object(in: managedObjectContext), let me = authentication.user(in: managedObjectContext) else { throw APIError.implicit(.badRequest) } - + + let _status = record.entity let status = _status.reblog ?? _status - let isBookmarked = status.bookmarkedBy.contains(me) - status.update(bookmarked: !isBookmarked, by: me) + let isBookmarked = status.bookmarked == true + let context = MastodonBookmarkContext( statusID: status.id, isBookmarked: isBookmarked @@ -60,38 +60,12 @@ extension APIService { } catch { result = .failure(error) } + + let response = try result.get() // update bookmark state - try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let _status = record.object(in: managedObjectContext), - let me = authentication.user(in: managedObjectContext) - else { return } - - let status = _status.reblog ?? _status - - switch result { - case .success(let response): - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: authenticationBox.domain, - entity: response.value, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - case .failure: - // rollback - status.update(bookmarked: bookmarkContext.isBookmarked, by: me) - } - } + record.entity = response.value - let response = try result.get() return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift index ec44afa93..e5bd28231 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift @@ -14,13 +14,13 @@ import CoreDataStack extension APIService { private struct MastodonFavoriteContext { - let statusID: Status.ID + let statusID: MastodonStatus.ID let isFavorited: Bool let favoritedCount: Int64 } public func favorite( - record: ManagedObjectRecord, + status: MastodonStatus, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { @@ -31,18 +31,15 @@ extension APIService { let authentication = authenticationBox.authentication guard - let _status = record.object(in: managedObjectContext), let me = authentication.user(in: managedObjectContext) else { throw APIError.implicit(.badRequest) } - let status = _status.reblog ?? _status - let isFavorited = status.favouritedBy.contains(me) - let favoritedCount = status.favouritesCount - let favoriteCount = isFavorited ? favoritedCount - 1 : favoritedCount + 1 - status.update(liked: !isFavorited, by: me) - status.update(favouritesCount: favoriteCount) + let _status = status.reblog ?? status + let isFavorited = status.entity.favourited == true + let favoritedCount = Int64(status.entity.favouritesCount) + let context = MastodonFavoriteContext( statusID: status.id, isFavorited: isFavorited, @@ -66,40 +63,6 @@ extension APIService { result = .failure(error) } - // update like state - try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let _status = record.object(in: managedObjectContext), - let me = authentication.user(in: managedObjectContext) - else { return } - - let status = _status.reblog ?? _status - - switch result { - case .success(let response): - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: authenticationBox.domain, - entity: response.value, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - if favoriteContext.isFavorited { - status.update(favouritesCount: max(0, status.favouritesCount - 1)) // undo API return count has delay. Needs -1 local - } - case .failure: - // rollback - status.update(liked: favoriteContext.isFavorited, by: me) - status.update(favouritesCount: favoriteContext.favoritedCount) - } - } - let response = try result.get() return response } @@ -152,19 +115,11 @@ extension APIService { extension APIService { public func favoritedBy( - status: ManagedObjectRecord, + status: MastodonStatus, query: Mastodon.API.Statuses.FavoriteByQuery, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { - let managedObjectContext = backgroundManagedObjectContext - let _statusID: Status.ID? = try? await managedObjectContext.perform { - guard let _status = status.object(in: managedObjectContext) else { return nil } - let status = _status.reblog ?? _status - return status.id - } - guard let statusID = _statusID else { - throw APIError.implicit(.badRequest) - } + let statusID: String = status.reblog?.id ?? status.id let response = try await Mastodon.API.Statuses.favoriteBy( session: session, @@ -173,21 +128,7 @@ extension APIService { query: query, authorization: authenticationBox.userAuthorization ).singleOutput() - - try await managedObjectContext.performChanges { - for entity in response.value { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: .init( - domain: authenticationBox.domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - } // end for … in - } - + return response } // end func } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift index d3d5e1c15..6bcc8dab1 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift @@ -20,7 +20,7 @@ extension APIService { } public func reblog( - record: ManagedObjectRecord, + status: MastodonStatus, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { let managedObjectContext = backgroundManagedObjectContext @@ -30,19 +30,16 @@ extension APIService { let authentication = authenticationBox.authentication guard - let me = authentication.user(in: managedObjectContext), - let _status = record.object(in: managedObjectContext) + let me = authentication.user(in: managedObjectContext) else { return nil } - let status = _status.reblog ?? _status - let isReblogged = status.rebloggedBy.contains(me) - let rebloggedCount = status.reblogsCount - let reblogCount = isReblogged ? rebloggedCount - 1 : rebloggedCount + 1 - status.update(reblogged: !isReblogged, by: me) - status.update(reblogsCount: Int64(max(0, reblogCount))) + let _status = status.reblog ?? status + let isReblogged = _status.entity.reblogged == true + let rebloggedCount = Int64(_status.entity.reblogsCount) + let reblogContext = MastodonReblogContext( - statusID: status.id, - isReblogged: isReblogged, + statusID: _status.id, + isReblogged: !isReblogged, rebloggedCount: rebloggedCount ) return reblogContext @@ -65,41 +62,7 @@ extension APIService { } catch { result = .failure(error) } - - // update repost state - try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let me = authentication.user(in: managedObjectContext), - let _status = record.object(in: managedObjectContext) - else { return } - - let status = _status.reblog ?? _status - - switch result { - case .success(let response): - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: authentication.domain, - entity: response.value, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - if reblogContext.isReblogged { - status.update(reblogsCount: max(0, status.reblogsCount - 1)) // undo API return count has delay. Needs -1 local - } - case .failure: - // rollback - status.update(reblogged: reblogContext.isReblogged, by: me) - status.update(reblogsCount: reblogContext.rebloggedCount) - } - } - + let response = try result.get() return response } @@ -108,19 +71,12 @@ extension APIService { extension APIService { public func rebloggedBy( - status: ManagedObjectRecord, + status: MastodonStatus, query: Mastodon.API.Statuses.RebloggedByQuery, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { let managedObjectContext = backgroundManagedObjectContext - let _statusID: Status.ID? = try? await managedObjectContext.perform { - guard let _status = status.object(in: managedObjectContext) else { return nil } - let status = _status.reblog ?? _status - return status.id - } - guard let statusID = _statusID else { - throw APIError.implicit(.badRequest) - } + let statusID: Status.ID = status.reblog?.id ?? status.id let response = try await Mastodon.API.Statuses.rebloggedBy( session: session, @@ -130,6 +86,7 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() + #warning("Is this still required?") try await managedObjectContext.performChanges { for entity in response.value { _ = Persistence.MastodonUser.createOrMerge( diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift index b67b79349..6e1d6055d 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift @@ -47,14 +47,14 @@ extension APIService { } public func deleteStatus( - status: ManagedObjectRecord, + status: MastodonStatus, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { let authorization = authenticationBox.userAuthorization let managedObjectContext = backgroundManagedObjectContext let _query: Mastodon.API.Statuses.DeleteStatusQuery? = try? await managedObjectContext.perform { - guard let _status = status.object(in: managedObjectContext) else { return nil } + let _status = status.entity let status = _status.reblog ?? _status return Mastodon.API.Statuses.DeleteStatusQuery(id: status.id) } @@ -68,12 +68,7 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - try await managedObjectContext.performChanges { - guard let status = status.object(in: managedObjectContext) else { return } - managedObjectContext.delete(status) - } - + return response } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 9e37770f9..f189a131e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -104,6 +104,15 @@ extension Mastodon.Entity.Account: Equatable { //MARK: - Convenience extension Mastodon.Entity.Account { + public var acctWithDomain: String { + if !acct.contains("@") { + // Safe concat due to username cannot contains "@" + return username + "@" + (domain ?? "") + } else { + return acct + } + } + public func acctWithDomainIfMissing(_ localDomain: String) -> String { guard acct.contains("@") else { return "\(acct)@\(localDomain)" diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift index 69b759045..6e022b7af 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift @@ -83,3 +83,13 @@ extension Mastodon.Entity.Card { } } } + +extension Mastodon.Entity.Card: Hashable { + public static func == (lhs: Mastodon.Entity.Card, rhs: Mastodon.Entity.Card) -> Bool { + lhs.url == rhs.url + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 7b500089e..d6a2f038d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -88,3 +88,13 @@ extension Mastodon.Entity.Notification { } } } + +extension Mastodon.Entity.Notification: Hashable { + public static func == (lhs: Mastodon.Entity.Notification, rhs: Mastodon.Entity.Notification) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 66b9667a2..b56b9067d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -133,3 +133,14 @@ extension Mastodon.Entity.Status { } } } + +extension Mastodon.Entity.Status: Hashable { + public static func == (lhs: Mastodon.Entity.Status, rhs: Mastodon.Entity.Status) -> Bool { + lhs.uri == rhs.uri && lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(uri) + hasher.combine(id) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift new file mode 100644 index 000000000..463909e72 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -0,0 +1,56 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack + +public final class MastodonFeed { + public var hasMore: Bool = false + public var isLoadingMore: Bool = false + + public let status: MastodonStatus? + public let notification: Mastodon.Entity.Notification? + + public let kind: Feed.Kind + + init(hasMore: Bool, isLoadingMore: Bool, status: MastodonStatus?, notification: Mastodon.Entity.Notification?, kind: Feed.Kind) { + self.hasMore = hasMore + self.isLoadingMore = isLoadingMore + self.status = status + self.notification = notification + self.kind = kind + } +} + +public extension MastodonFeed { + static func fromStatus(_ status: MastodonStatus, kind: Feed.Kind) -> MastodonFeed { + MastodonFeed( + hasMore: false, + isLoadingMore: false, + status: status, + notification: nil, + kind: kind + ) + } + + static func fromNotification(_ notification: Mastodon.Entity.Notification, kind: Feed.Kind) -> MastodonFeed { + MastodonFeed( + hasMore: false, + isLoadingMore: false, + status: nil, + notification: notification, + kind: kind + ) + } +} + +extension MastodonFeed: Hashable { + public static func == (lhs: MastodonFeed, rhs: MastodonFeed) -> Bool { + lhs.status?.id == rhs.status?.id || lhs.notification?.id == rhs.notification?.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(status) + hasher.combine(notification) + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift new file mode 100644 index 000000000..4cffc393f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift @@ -0,0 +1,45 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack + +public final class MastodonNotification { + public let entity: Mastodon.Entity.Notification + + public var id: Mastodon.Entity.Notification.ID { + entity.id + } + + public let account: MastodonUser + public let status: MastodonStatus? + public let feeds: [MastodonFeed] + + public var followRequestState: MastodonFollowRequestState = .init(state: .none) + public var transientFollowRequestState: MastodonFollowRequestState = .init(state: .none) + + public init(entity: Mastodon.Entity.Notification, account: MastodonUser, status: MastodonStatus?, feeds: [MastodonFeed]) { + self.entity = entity + self.account = account + self.status = status + self.feeds = feeds + } +} + +public extension MastodonNotification { + static func fromEntity(_ entity: Mastodon.Entity.Notification, using managedObjectContext: NSManagedObjectContext) -> MastodonNotification? { + guard let user = MastodonUser.fetch(in: managedObjectContext, configurationBlock: { request in + request.predicate = MastodonUser.predicate(domain: entity.account.domain ?? "", id: entity.account.id) + }).first else { return nil } + return MastodonNotification(entity: entity, account: user, status: entity.status.map(MastodonStatus.fromEntity), feeds: []) + } +} + +extension MastodonNotification: Hashable { + public static func == (lhs: MastodonNotification, rhs: MastodonNotification) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift new file mode 100644 index 000000000..24dbfa3a7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -0,0 +1,54 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import Combine +import CoreDataStack + +public final class MastodonStatus: ObservableObject { + public typealias ID = Mastodon.Entity.Status.ID + + @Published public var entity: Mastodon.Entity.Status + @Published public private(set) var reblog: MastodonStatus? + + @Published public var isSensitiveToggled: Bool = false + + init(entity: Mastodon.Entity.Status, isSensitiveToggled: Bool) { + self.entity = entity + self.isSensitiveToggled = isSensitiveToggled + + if let reblog = entity.reblog { + self.reblog = MastodonStatus.fromEntity(reblog) + } else { + self.reblog = nil + } + } + + public var id: ID { + entity.id + } +} + +extension MastodonStatus { + public static func fromEntity(_ entity: Mastodon.Entity.Status) -> MastodonStatus { + return MastodonStatus(entity: entity, isSensitiveToggled: false) + } +} + +extension MastodonStatus: Hashable { + public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool { + lhs.entity.id == rhs.entity.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(entity) + hasher.combine(isSensitiveToggled) + } +} + +public extension Mastodon.Entity.Status { + var mastodonVisibility: MastodonVisibility? { + guard let visibility = visibility?.rawValue else { return nil } + return MastodonVisibility(rawValue: visibility) + } +} + diff --git a/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift index 5977cba12..92b94ee61 100644 --- a/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift +++ b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift @@ -2,6 +2,7 @@ import Foundation import CoreDataStack +import MastodonSDK public protocol StatusCompatible { var reblog: Status? { get } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift index 9f036643b..a562e26c4 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift @@ -60,10 +60,8 @@ extension ComposeContentViewModel { cell.statusView.frame.size.width = tableView.frame.width // configure status - context.managedObjectContext.performAndWait { - guard let replyTo = status.object(in: context.managedObjectContext) else { return } - cell.statusView.configure(status: replyTo) - } + cell.statusView.configure(status: status) + } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index cd4b67780..cac5b71da 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -23,7 +23,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { public enum ComposeContext { case composeStatus - case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource) + case editStatus(status: MastodonStatus, statusSource: Mastodon.Entity.StatusSource) } var disposeBag = Set() @@ -163,24 +163,18 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { }() // set visibility for reply post if case .reply(let record) = destination { - context.managedObjectContext.performAndWait { - guard let status = record.object(in: context.managedObjectContext) else { - assertionFailure() - return - } - let repliedStatusVisibility = status.visibility - switch repliedStatusVisibility { - case .public, .unlisted: - // keep default - break - case .private: - visibility = .private - case .direct: - visibility = .direct - case ._other: - assertionFailure() - break - } + let repliedStatusVisibility = record.entity.visibility + switch repliedStatusVisibility { + case .public, .unlisted: + // keep default + break + case .private: + visibility = .private + case .direct: + visibility = .direct + case ._other, .none: + assertionFailure() + break } } return visibility @@ -189,26 +183,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel( for: authContext.mastodonAuthenticationBox.domain ) - - if case let ComposeContext.editStatus(status, _) = composeContext { - if status.isContentSensitive { - isContentWarningActive = true - contentWarning = status.spoilerText ?? "" - } - if let poll = status.poll { - isPollActive = !poll.expired - pollMultipleConfigurationOption = poll.multiple - if let pollExpiresAt = poll.expiresAt { - pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt) - } - pollOptions = poll.options.sortedByIndex().map { - let option = PollComposeItem.Option() - option.text = $0.title - return option - } - } - } - + let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? [] self.recentLanguages = recentLanguages self.language = recentLanguages.first ?? Locale.current.language.languageCode?.identifier ?? "en" @@ -220,17 +195,14 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { switch destination { case .reply(let record): context.managedObjectContext.performAndWait { - guard let status = record.object(in: context.managedObjectContext) else { - assertionFailure() - return - } + let status = record.entity let author = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) var mentionAccts: [String] = [] - if author?.id != status.author.id { - mentionAccts.append("@" + status.author.acct) + if author?.id != status.account.id { + mentionAccts.append("@" + status.account.acct) } - let mentions = status.mentions + let mentions = status.mentions ?? [] .filter { author?.id != $0.id } for mention in mentions { let acct = "@" + mention.acct @@ -288,11 +260,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { case .composeStatus: self.isVisibilityButtonEnabled = true case let .editStatus(status, _): - if let visibility = Mastodon.Entity.Status.Visibility(rawValue: status.visibility.rawValue) { + if let visibility = status.entity.visibility { self.visibility = visibility } self.isVisibilityButtonEnabled = false - self.attachmentViewModels = status.attachments.compactMap { + self.attachmentViewModels = status.entity.mastodonAttachments.compactMap { guard let assetURL = $0.assetURL, let url = URL(string: assetURL) else { return nil } let attachmentViewModel = AttachmentViewModel( api: context.apiService, @@ -306,6 +278,27 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } } + if case let ComposeContext.editStatus(status, _) = composeContext { + if status.entity.sensitive == true { + isContentWarningActive = true + contentWarning = status.entity.spoilerText ?? "" + } + Task { + if let poll = await status.getPoll(in: context.managedObjectContext) { + isPollActive = !poll.expired + pollMultipleConfigurationOption = poll.multiple + if let pollExpiresAt = poll.expiresAt { + pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt) + } + pollOptions = poll.options.sortedByIndex().map { + let option = PollComposeItem.Option() + option.text = $0.title + return option + } + } + } + } + bind() } @@ -503,7 +496,7 @@ extension ComposeContentViewModel { extension ComposeContentViewModel { public enum Destination { case topLevel - case reply(parent: ManagedObjectRecord) + case reply(parent: MastodonStatus) } public enum ScrollViewState { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index dfa7d3ef7..9db9faed9 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -19,7 +19,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { // author public let author: ManagedObjectRecord // refer - public let replyTo: ManagedObjectRecord? + public let replyTo: MastodonStatus? // content warning public let isContentWarningComposing: Bool public let contentWarning: String @@ -48,7 +48,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { public init( author: ManagedObjectRecord, - replyTo: ManagedObjectRecord?, + replyTo: MastodonStatus?, isContentWarningComposing: Bool, contentWarning: String, content: String, @@ -162,7 +162,7 @@ extension MastodonStatusPublisher: StatusPublisher { return self.pollExpireConfigurationOption.seconds }() let inReplyToID: Mastodon.Entity.Status.ID? = try await api.backgroundManagedObjectContext.perform { - guard let replyTo = self.replyTo?.object(in: api.backgroundManagedObjectContext) else { return nil } + guard let replyTo = self.replyTo else { return nil } return replyTo.id } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index e3bed16ae..67372f544 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -180,7 +180,7 @@ extension MediaView.Configuration { } extension MediaView { - public static func configuration(status: StatusCompatible) -> [MediaView.Configuration] { + public static func configuration(status: MastodonStatus) -> [MediaView.Configuration] { func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo { MediaView.Configuration.VideoInfo( aspectRadio: attachment.size, @@ -191,8 +191,8 @@ extension MediaView { ) } - let status: StatusCompatible = status.reblog ?? status - let attachments = status.attachments +// let status: StatusCompatible = status.reblog ?? status + let attachments = status.entity.mastodonAttachments let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in let configuration: MediaView.Configuration = { switch attachment.kind { @@ -236,7 +236,7 @@ extension MediaView { }() configuration.load() - configuration.isReveal = status.isMediaSensitive ? status.isSensitiveToggled : true + configuration.isReveal = status.entity.sensitive == true ? status.isSensitiveToggled : true return configuration } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index b51011e54..ad59df742 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -19,7 +19,7 @@ import CoreDataStack extension NotificationView { public final class ViewModel: ObservableObject { public var disposeBag = Set() - public var objects = Set() + public var objects = Set() @Published public var context: AppContext? @Published public var authContext: AuthContext? diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusCardControl.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusCardControl.swift index 5fead2f3c..14a87c6ea 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusCardControl.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusCardControl.swift @@ -13,6 +13,7 @@ import MastodonLocalization import CoreDataStack import UIKit import WebKit +import MastodonSDK public protocol StatusCardControlDelegate: AnyObject { func statusCardControl(_ statusCardControl: StatusCardControl, didTapURL url: URL) @@ -133,20 +134,24 @@ public final class StatusCardControl: UIControl { fatalError("init(coder:) has not been implemented") } - public func configure(card: Card) { + public func configure(card: Mastodon.Entity.Card) { let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) - if let host = card.url?.host { + let url = URL(string: card.url) + if let host = url?.host { accessibilityLabel = "\(title) \(host)" } else { accessibilityLabel = title } titleLabel.text = title - linkLabel.text = card.url?.host + linkLabel.text = url?.host imageView.contentMode = .center imageView.sd_setImage( - with: card.imageURL, + with: { + guard let image = card.image else { return nil } + return URL(string: image) + }(), placeholderImage: icon(for: card.layout) ) { [weak self] image, _, _, _ in if image != nil { @@ -333,6 +338,18 @@ private extension Card { } } +private extension Mastodon.Entity.Card { + var layout: StatusCardControl.Layout { + var aspectRatio = CGFloat(width ?? 1) / CGFloat(height ?? 1) + if !aspectRatio.isFinite { + aspectRatio = 1 + } + return (abs(aspectRatio - 1) < 0.05 || image == nil) && html == nil + ? .compact + : .large(aspectRatio: aspectRatio) + } +} + private extension UILayoutPriority { static let zero = UILayoutPriority(rawValue: 0) } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 5257fa483..647c31ba2 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -19,7 +19,7 @@ extension StatusView { static let statusFilterWorkingQueue = DispatchQueue(label: "StatusFilterWorkingQueue") - public func configure(feed: Feed) { + public func configure(feed: MastodonFeed) { switch feed.kind { case .home: guard let status = feed.status else { @@ -40,18 +40,12 @@ extension StatusView { extension StatusView { - public func configure(status: Status, statusEdit: Mastodon.Entity.StatusEdit) { - viewModel.objects.insert(status) - if let reblog = status.reblog { - viewModel.objects.insert(reblog) - } - + public func configure(status: MastodonStatus, statusEdit: Mastodon.Entity.StatusEdit) { configureHeader(status: status) - let author = (status.reblog ?? status).author + let author = (status.reblog ?? status).entity.account configureAuthor(author: author) - let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) - configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) - configureApplicationName(status.application?.name) + configureTimestamp(timestamp: (status.reblog ?? status).entity.createdAt) + configureApplicationName(status.entity.application?.name) configureMedia(status: status) configurePollHistory(statusEdit: statusEdit) configureCard(status: status) @@ -66,18 +60,13 @@ extension StatusView { viewModel.isContentReveal = true } - public func configure(status: Status) { - viewModel.objects.insert(status) - if let reblog = status.reblog { - viewModel.objects.insert(reblog) - } - + public func configure(status: MastodonStatus) { configureHeader(status: status) - let author = (status.reblog ?? status).author + let author = (status.reblog ?? status).entity.account configureAuthor(author: author) - let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) - configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) - configureApplicationName(status.application?.name) + let timestamp = (status.reblog ?? status).entity.createdAt + configureTimestamp(timestamp: timestamp) + configureApplicationName(status.entity.application?.name) configureContent(status: status) configureMedia(status: status) configurePoll(status: status) @@ -96,14 +85,13 @@ extension StatusView { } extension StatusView { - private func configureHeader(status: Status) { + private func configureHeader(status: MastodonStatus) { if let _ = status.reblog { - Publishers.CombineLatest( - status.author.publisher(for: \.displayName), - status.author.publisher(for: \.emojis) - ) - .map { name, emojis -> StatusView.ViewModel.Header in - let text = L10n.Common.Controls.Status.userReblogged(status.author.displayNameWithFallback) + let name = status.entity.account.displayName + let emojis = status.entity.account.emojis ?? [] + + viewModel.header = { + let text = L10n.Common.Controls.Status.userReblogged(status.entity.account.displayNameWithFallback) let content = MastodonContent(content: text, emojis: emojis.asDictionary) do { let metaContent = try MastodonMetaContent.convert(document: content) @@ -112,12 +100,10 @@ extension StatusView { let metaContent = PlaintextMetaContent(string: name) return .repost(info: .init(header: metaContent)) } - - } - .assign(to: \.header, on: viewModel) - .store(in: &disposeBag) - } else if let _ = status.inReplyToID, - let inReplyToAccountID = status.inReplyToAccountID + }() + + } else if let _ = status.entity.inReplyToID, + let inReplyToAccountID = status.entity.inReplyToAccountID { func createHeader( name: String?, @@ -139,20 +125,31 @@ extension StatusView { return header } - if let replyTo = status.replyTo { + if let inReplyToID = status.entity.inReplyToID { // A. replyTo status exist - let header = createHeader(name: replyTo.author.displayNameWithFallback, emojis: replyTo.author.emojis.asDictionary) - viewModel.header = header + if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox { + Task { + if let replyTo = try? await Mastodon.API.Statuses.status( + session: .shared, + domain: authenticationBox.domain, + statusID: inReplyToID, + authorization: authenticationBox.userAuthorization + ).singleOutput().value { + let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis?.asDictionary ?? [:]) + viewModel.header = header + } + } + } } else { // B. replyTo status not exist - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID) - if let user = status.managedObjectContext?.safeFetch(request).first { - // B1. replyTo user exist - let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis.asDictionary) - viewModel.header = header - } else { +// let request = MastodonUser.sortedFetchRequest +// request.predicate = MastodonUser.predicate(domain: status.domain, id: inReplyToAccountID) +// if let user = status.managedObjectContext?.safeFetch(request).first { +// // B1. replyTo user exist +// let header = createHeader(name: user.displayNameWithFallback, emojis: user.emojis.asDictionary) +// viewModel.header = header +// } else { // B2. replyTo user not exist let header = createHeader(name: nil, emojis: nil) viewModel.header = header @@ -178,7 +175,7 @@ extension StatusView { } .store(in: &disposeBag) } // end if let - } // end else B2. +// } // end else B2. } // end else B. } else { @@ -186,90 +183,56 @@ extension StatusView { } } - public func configureAuthor(author: MastodonUser) { + public func configureAuthor(author: Mastodon.Entity.Account) { // author avatar - Publishers.CombineLatest( - author.publisher(for: \.avatar), - UserDefaults.shared.publisher(for: \.preferredStaticAvatar) - ) - .map { _ in author.avatarImageURL() } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) + viewModel.authorAvatarImageURL = author.avatarImageURL() + let emojis = author.emojis?.asDictionary ?? [:] + // author name - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .map { _, emojis in + viewModel.authorName = { do { - let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary) + let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis) let metaContent = try MastodonMetaContent.convert(document: content) return metaContent } catch { assertionFailure(error.localizedDescription) return PlaintextMetaContent(string: author.displayNameWithFallback) } - } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) + }() + // author username - author.publisher(for: \.acct) - .map { $0 as String? } - .assign(to: \.authorUsername, on: viewModel) - .store(in: &disposeBag) - // locked - author.publisher(for: \.locked) - .assign(to: \.locked, on: viewModel) - .store(in: &disposeBag) - // isMuting - author.publisher(for: \.mutingBy) - .map { [weak viewModel] mutingBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return mutingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isMuting, on: viewModel) - .store(in: &disposeBag) - // isBlocking - author.publisher(for: \.blockingBy) - .map { [weak viewModel] blockingBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return blockingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isBlocking, on: viewModel) - .store(in: &disposeBag) - // isMyself - Publishers.CombineLatest( - author.publisher(for: \.domain), - author.publisher(for: \.id) - ) - .map { [weak viewModel] domain, id in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return authContext.mastodonAuthenticationBox.domain == domain && authContext.mastodonAuthenticationBox.userID == id - } - .assign(to: \.isMyself, on: viewModel) - .store(in: &disposeBag) + viewModel.authorUsername = author.acct - // Following - author.publisher(for: \.followingBy) - .map { [weak viewModel] followingBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return followingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) + // locked + viewModel.locked = author.locked + + // isMuting, isBlocking, Following + Task { + guard let auth = viewModel.authContext?.mastodonAuthenticationBox else { return } + if let relationship = try? await Mastodon.API.Account.relationships( + session: .shared, + domain: auth.domain, + query: .init(ids: [author.id]), + authorization: auth.userAuthorization + ).singleOutput().value { + guard let rel = relationship.first else { return } + DispatchQueue.main.async { [self] in + viewModel.isMuting = rel.muting ?? false + viewModel.isBlocking = rel.blocking + viewModel.isFollowed = rel.followedBy + } } - .assign(to: \.isFollowed, on: viewModel) - .store(in: &disposeBag) + } + + // isMyself + viewModel.isMyself = { + guard let authContext = viewModel.authContext else { return false } + return authContext.mastodonAuthenticationBox.domain == author.domain && authContext.mastodonAuthenticationBox.userID == author.id + }() + } - private func configureTimestamp(timestamp: AnyPublisher) { + private func configureTimestamp(timestamp: Date) { // timestamp viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in if isEdited { @@ -277,10 +240,7 @@ extension StatusView { } return date.localizedSlowedTimeAgoSinceNow } - timestamp - .map { $0 as Date? } - .assign(to: \.timestamp, on: viewModel) - .store(in: &disposeBag) + viewModel.timestamp = timestamp } private func configureApplicationName(_ applicationName: String?) { @@ -294,7 +254,7 @@ extension StatusView { configure(status: originalStatus) } - func configureTranslated(status: Status) { + func configureTranslated(status: MastodonStatus) { guard let translation = viewModel.translation, let translatedContent = translation.content else { viewModel.isCurrentlyTranslating = false @@ -303,7 +263,7 @@ extension StatusView { // content do { - let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary) + let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.isCurrentlyTranslating = false @@ -313,13 +273,13 @@ extension StatusView { } } - private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: Status) { + private func configureContent(statusEdit: Mastodon.Entity.StatusEdit, status: MastodonStatus) { statusEdit.spoilerText.map { viewModel.spoilerContent = PlaintextMetaContent(string: $0) } // language - viewModel.language = (status.reblog ?? status).language + viewModel.language = (status.reblog ?? status).entity.language // content do { let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis?.asDictionary ?? [:]) @@ -332,7 +292,7 @@ extension StatusView { } } - private func configureContent(status: Status) { + private func configureContent(status: MastodonStatus) { guard viewModel.translation == nil else { return configureTranslated(status: status) } @@ -340,9 +300,9 @@ extension StatusView { let status = status.reblog ?? status // spoilerText - if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + if let spoilerText = status.entity.spoilerText, !spoilerText.isEmpty { do { - let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary) + let content = MastodonContent(content: spoilerText, emojis: status.entity.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.spoilerContent = metaContent } catch { @@ -353,10 +313,10 @@ extension StatusView { viewModel.spoilerContent = nil } // language - viewModel.language = (status.reblog ?? status).language + viewModel.language = (status.reblog ?? status).entity.language // content do { - let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) + let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis?.asDictionary ?? [:]) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.isCurrentlyTranslating = false @@ -365,21 +325,18 @@ extension StatusView { viewModel.content = PlaintextMetaContent(string: "") } // visibility - status.publisher(for: \.visibilityRaw) - .compactMap { MastodonVisibility(rawValue: $0) } - .assign(to: \.visibility, on: viewModel) - .store(in: &disposeBag) + viewModel.visibility = status.entity.mastodonVisibility + // sensitive - viewModel.isContentSensitive = status.isContentSensitive - status.publisher(for: \.isSensitiveToggled) - .assign(to: \.isSensitiveToggled, on: viewModel) - .store(in: &disposeBag) + viewModel.isContentSensitive = status.entity.sensitive == true + viewModel.isSensitiveToggled = status.isSensitiveToggled + } - private func configureMedia(status: StatusCompatible) { + private func configureMedia(status: MastodonStatus) { let status = status.reblog ?? status - viewModel.isMediaSensitive = status.isMediaSensitive + viewModel.isMediaSensitive = status.entity.sensitive == true let configurations = MediaView.configuration(status: status) viewModel.mediaViewConfigurations = configurations @@ -405,39 +362,32 @@ extension StatusView { pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) } - private func configurePoll(status: Status) { - let status = status.reblog ?? status - - if let poll = status.poll { - viewModel.objects.insert(poll) - } + private func configurePoll(status: MastodonStatus) { + Task { + guard + let context = viewModel.context?.managedObjectContext, + let poll = await status.getPoll(in: context) + else { return } + + let status = status.reblog ?? status + + viewModel.managedObjects.insert(poll) - // pollItems - status.publisher(for: \.poll) - .sink { [weak self] poll in - guard let self = self else { return } - guard let poll = poll else { - self.viewModel.pollItems = [] - return + // pollItems + let options = poll.options.sorted(by: { $0.index < $1.index }) + let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) } + self.viewModel.pollItems = items + + // isVoteButtonEnabled + poll.publisher(for: \.updatedAt) + .sink { [weak self] _ in + guard let self = self else { return } + let options = poll.options + let hasSelectedOption = options.contains(where: { $0.isSelected }) + self.viewModel.isVoteButtonEnabled = hasSelectedOption } - - let options = poll.options.sorted(by: { $0.index < $1.index }) - let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) } - self.viewModel.pollItems = items - } - .store(in: &disposeBag) - // isVoteButtonEnabled - status.poll?.publisher(for: \.updatedAt) - .sink { [weak self] _ in - guard let self = self else { return } - guard let poll = status.poll else { return } - let options = poll.options - let hasSelectedOption = options.contains(where: { $0.isSelected }) - self.viewModel.isVoteButtonEnabled = hasSelectedOption - } - .store(in: &disposeBag) - // isVotable - if let poll = status.poll { + .store(in: &disposeBag) + // isVotable Publishers.CombineLatest( poll.publisher(for: \.votedBy), poll.publisher(for: \.expired) @@ -451,100 +401,62 @@ extension StatusView { return !isVoted && !expired } .assign(to: &viewModel.$isVotable) + + // votesCount + poll.publisher(for: \.votesCount) + .map { Int($0) } + .assign(to: \.voteCount, on: viewModel) + .store(in: &disposeBag) + // voterCount + poll.publisher(for: \.votersCount) + .map { Int($0) } + .assign(to: \.voterCount, on: viewModel) + .store(in: &disposeBag) + // expireAt + poll.publisher(for: \.expiresAt) + .assign(to: \.expireAt, on: viewModel) + .store(in: &disposeBag) + // expired + poll.publisher(for: \.expired) + .assign(to: \.expired, on: viewModel) + .store(in: &disposeBag) + // isVoting + poll.publisher(for: \.isVoting) + .assign(to: \.isVoting, on: viewModel) + .store(in: &disposeBag) } - // votesCount - status.poll?.publisher(for: \.votesCount) - .map { Int($0) } - .assign(to: \.voteCount, on: viewModel) - .store(in: &disposeBag) - // voterCount - status.poll?.publisher(for: \.votersCount) - .map { Int($0) } - .assign(to: \.voterCount, on: viewModel) - .store(in: &disposeBag) - // expireAt - status.poll?.publisher(for: \.expiresAt) - .assign(to: \.expireAt, on: viewModel) - .store(in: &disposeBag) - // expired - status.poll?.publisher(for: \.expired) - .assign(to: \.expired, on: viewModel) - .store(in: &disposeBag) - // isVoting - status.poll?.publisher(for: \.isVoting) - .assign(to: \.isVoting, on: viewModel) - .store(in: &disposeBag) } - private func configureCard(status: Status) { + private func configureCard(status: MastodonStatus) { let status = status.reblog ?? status if viewModel.mediaViewConfigurations.isEmpty { - status.publisher(for: \.card) - .assign(to: \.card, on: viewModel) - .store(in: &disposeBag) + viewModel.card = status.entity.card } else { viewModel.card = nil } } - private func configureToolbar(status: Status) { + private func configureToolbar(status: MastodonStatus) { let status = status.reblog ?? status - status.publisher(for: \.repliesCount) - .map(Int.init) - .assign(to: \.replyCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.reblogsCount) - .map(Int.init) - .assign(to: \.reblogCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.favouritesCount) - .map(Int.init) - .assign(to: \.favoriteCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.editedAt) - .assign(to: \.editedAt, on: viewModel) - .store(in: &disposeBag) + viewModel.replyCount = status.entity.repliesCount ?? 0 + + viewModel.reblogCount = status.entity.reblogsCount + + viewModel.favoriteCount = status.entity.favouritesCount + + viewModel.editedAt = status.entity.editedAt // relationship - status.publisher(for: \.rebloggedBy) - .map { [weak viewModel] rebloggedBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return rebloggedBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isReblog, on: viewModel) - .store(in: &disposeBag) - - status.publisher(for: \.favouritedBy) - .map { [weak viewModel]favouritedBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return favouritedBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isFavorite, on: viewModel) - .store(in: &disposeBag) - - status.publisher(for: \.bookmarkedBy) - .map { [weak viewModel] bookmarkedBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return bookmarkedBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isBookmark, on: viewModel) - .store(in: &disposeBag) + viewModel.isReblog = status.entity.reblogged == true + viewModel.isFavorite = status.entity.favourited == true + viewModel.isBookmark = status.entity.bookmarked == true } - private func configureFilter(status: Status) { + private func configureFilter(status: MastodonStatus) { let status = status.reblog ?? status - let content = status.content.lowercased() + guard let content = status.entity.content?.lowercased() else { return } Publishers.CombineLatest( viewModel.$activeFilters, @@ -595,3 +507,16 @@ extension StatusView { } } + +extension MastodonStatus { + func getPoll(in context: NSManagedObjectContext) async -> Poll? { + guard + let domain = entity.account.domain, + let pollId = entity.poll?.id + else { return nil } + return try? await context.perform { + let predicate = Poll.predicate(domain: domain, id: pollId) + return Poll.findOrFetch(in: context, matching: predicate) + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 2ff5b6f85..735e4c472 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -22,11 +22,12 @@ extension StatusView { public final class ViewModel: ObservableObject { var disposeBag = Set() var observations = Set() - public var objects = Set() + public var objects = Set() + public var managedObjects = Set() public var context: AppContext? public var authContext: AuthContext? - public var originalStatus: Status? + public var originalStatus: MastodonStatus? // Header @Published public var header: Header = .none @@ -77,7 +78,7 @@ extension StatusView { @Published public var expired: Bool = false // Card - @Published public var card: Card? + @Published public var card: Mastodon.Entity.Card? // Visibility @Published public var visibility: MastodonVisibility = .public diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index c636620e6..5c57db96b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -404,7 +404,7 @@ extension StatusView { } @objc private func statusCardControlPressed(_ sender: StatusCardControl) { - guard let url = viewModel.card?.url else { return } + guard let urlString = viewModel.card?.url, let url = URL(string: urlString) else { return } delegate?.statusView(self, didTapCardWithURL: url) } diff --git a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift index 19f2bbdaa..fc8a4f365 100644 --- a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift @@ -7,7 +7,7 @@ import UIKit import Combine -import CoreDataStack +import MastodonSDK extension TimelineMiddleLoaderTableViewCell { public class ViewModel { @@ -34,15 +34,10 @@ extension TimelineMiddleLoaderTableViewCell.ViewModel { extension TimelineMiddleLoaderTableViewCell { public func configure( - feed: Feed, + feed: MastodonFeed, delegate: TimelineMiddleLoaderTableViewCellDelegate? ) { - feed.publisher(for: \.isLoadingMore) - .sink { [weak self] isLoadingMore in - guard let self = self else { return } - self.viewModel.isFetching = isLoadingMore - } - .store(in: &disposeBag) + self.viewModel.isFetching = feed.isLoadingMore self.delegate = delegate }