From 976f934df95225eda3248ec766d4c9651ba8bd21 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 8 Jan 2024 11:17:40 +0100 Subject: [PATCH] Remove Status from CoreData (#1167) --- Mastodon.xcodeproj/project.pbxproj | 8 +- Mastodon/Coordinator/SceneCoordinator.swift | 10 +- .../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 | 95 ++-- .../FileManager+SearchHistory.swift | 10 +- .../Persistence/FileManager+Timeline.swift | 93 ++++ .../Provider/DataSourceFacade+Bookmark.swift | 14 +- .../Provider/DataSourceFacade+Favorite.swift | 15 +- .../Provider/DataSourceFacade+Follow.swift | 80 ++-- .../Provider/DataSourceFacade+Media.swift | 10 +- .../Provider/DataSourceFacade+Meta.swift | 12 +- .../Provider/DataSourceFacade+Model.swift | 34 +- .../Provider/DataSourceFacade+Profile.swift | 43 +- .../Provider/DataSourceFacade+Reblog.swift | 18 +- .../Provider/DataSourceFacade+Status.swift | 84 ++-- .../Provider/DataSourceFacade+Thread.swift | 11 +- .../Provider/DataSourceFacade+Translate.swift | 18 +- .../Provider/DataSourceFacade+URL.swift | 2 +- ...er+NotificationTableViewCellDelegate.swift | 84 ++-- ...Provider+StatusTableViewCellDelegate.swift | 35 +- ...tatusTableViewControllerNavigateable.swift | 13 +- ...taSourceProvider+UITableViewDelegate.swift | 16 +- .../Provider/DataSourceProvider.swift | 6 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- ...ityViewController+DataSourceProvider.swift | 11 + ...DiscoveryCommunityViewModel+Diffable.swift | 2 +- .../DiscoveryCommunityViewModel+State.swift | 8 +- .../DiscoveryCommunityViewModel.swift | 9 +- .../Scene/Discovery/DiscoveryViewModel.swift | 1 + ...stsViewController+DataSourceProvider.swift | 11 + .../DiscoveryPostsViewModel+Diffable.swift | 2 +- .../Posts/DiscoveryPostsViewModel+State.swift | 8 +- .../Posts/DiscoveryPostsViewModel.swift | 9 +- ...ineViewController+DataSourceProvider.swift | 9 + .../HashtagTimelineViewModel+Diffable.swift | 2 +- .../HashtagTimelineViewModel+State.swift | 9 +- .../HashtagTimelineViewModel.swift | 9 +- ...ineViewController+DataSourceProvider.swift | 26 +- .../HomeTimelineViewController.swift | 4 + .../HomeTimelineViewModel+Diffable.swift | 49 +- ...omeTimelineViewModel+LoadLatestState.swift | 25 +- ...omeTimelineViewModel+LoadOldestState.swift | 12 +- .../HomeTimeline/HomeTimelineViewModel.swift | 75 ++-- .../NotificationTableViewCell+ViewModel.swift | 3 +- ...ineViewController+DataSourceProvider.swift | 20 +- .../NotificationTimelineViewController.swift | 37 +- ...tificationTimelineViewModel+Diffable.swift | 42 +- ...ionTimelineViewModel+LoadOldestState.swift | 11 +- .../NotificationTimelineViewModel.swift | 117 ++--- ...arkViewController+DataSourceProvider.swift | 11 + .../Bookmark/BookmarkViewModel+Diffable.swift | 2 +- .../Bookmark/BookmarkViewModel+State.swift | 14 +- .../Profile/Bookmark/BookmarkViewModel.swift | 9 +- .../FamiliarFollowersViewController.swift | 9 + ...iteViewController+DataSourceProvider.swift | 11 + .../Favorite/FavoriteViewModel+Diffable.swift | 2 +- .../Favorite/FavoriteViewModel+State.swift | 18 +- .../Profile/Favorite/FavoriteViewModel.swift | 9 +- .../Follower/FollowerListViewController.swift | 9 + .../FollowingListViewController.swift | 9 + .../Scene/Profile/MeProfileViewModel.swift | 1 + .../Scene/Profile/ProfileViewController.swift | 17 + Mastodon/Scene/Profile/ProfileViewModel.swift | 1 + .../Profile/RemoteProfileViewModel.swift | 3 + ...ineViewController+DataSourceProvider.swift | 9 + .../UserTimelineViewModel+Diffable.swift | 2 +- .../UserTimelineViewModel+State.swift | 36 +- .../Timeline/UserTimelineViewModel.swift | 9 +- ...dByViewController+DataSourceProvider.swift | 9 + ...dByViewController+DataSourceProvider.swift | 10 + .../Profile/UserList/UserListViewModel.swift | 4 +- .../Scene/Report/Report/ReportViewModel.swift | 19 +- .../ReportStatusViewModel+Diffable.swift | 2 +- .../ReportStatusViewModel+State.swift | 11 +- .../ReportStatus/ReportStatusViewModel.swift | 15 +- .../ReportStatusTableViewCell+ViewModel.swift | 7 +- .../Root/MainTab/MainTabBarController.swift | 25 +- .../SearchResultOverviewCoordinator.swift | 14 +- ...oryViewController+DataSourceProvider.swift | 9 + .../SearchResult/SearchResultItem.swift | 2 +- .../SearchResult/SearchResultSection.swift | 22 +- ...ultViewController+DataSourceProvider.swift | 9 + .../SearchResultViewModel+Diffable.swift | 2 +- .../SearchResultViewModel+State.swift | 15 +- .../SearchResult/SearchResultViewModel.swift | 9 +- .../NotificationView+Configuration.swift | 49 +- .../PollOptionView+Configuration.swift | 8 +- .../StatusTableViewCell+ViewModel.swift | 14 +- ...tusThreadRootTableViewCell+ViewModel.swift | 4 +- .../Scene/Thread/CachedThreadViewModel.swift | 21 - .../StatusEditHistoryTableViewCell.swift | 2 +- .../StatusEditHistoryViewModel.swift | 2 +- .../MastodonStatusThreadViewModel.swift | 110 ++--- .../Scene/Thread/RemoteThreadViewModel.swift | 22 +- ...eadViewController+DataSourceProvider.swift | 114 +++++ .../Scene/Thread/ThreadViewController.swift | 30 ++ .../Thread/ThreadViewModel+Diffable.swift | 6 +- .../ThreadViewModel+LoadThreadState.swift | 7 +- Mastodon/Scene/Thread/ThreadViewModel.swift | 60 +-- .../DataController/FeedDataController.swift | 90 ++++ .../FeedFetchedResultsController.swift | 95 ---- .../StatusDataController.swift | 73 +++ .../StatusFetchedResultsController.swift | 103 ----- .../MastodonCore/Model/UserIdentifier.swift | 6 + .../Persistence/Persistence.swift | 13 +- .../Service/API/APIService+Bookmark.swift | 93 +--- .../Service/API/APIService+Favorite.swift | 125 +----- .../Service/API/APIService+Follow.swift | 34 ++ .../API/APIService+HashtagTimeline.swift | 23 +- .../Service/API/APIService+HomeTimeline.swift | 79 +--- .../API/APIService+PublicTimeline.swift | 26 +- .../Service/API/APIService+Reblog.swift | 85 +--- .../Service/API/APIService+Search.swift | 16 +- .../API/APIService+Status+Publish.swift | 20 +- .../Service/API/APIService+Status.swift | 32 +- .../Service/API/APIService+Thread.swift | 22 +- .../Service/API/APIService+Trend.swift | 19 +- .../Service/API/APIService+UserTimeline.swift | 20 +- .../Entity/Mastodon+Entity+Account.swift | 9 + .../Entity/Mastodon+Entity+Card.swift | 10 + .../Entity/Mastodon+Entity+Notification.swift | 10 + .../Entity/Mastodon+Entity+Status.swift | 22 + .../Sources/MastodonSDK/MastodonFeed.swift | 75 ++++ .../MastodonSDK/MastodonNotification.swift | 48 ++ .../Sources/MastodonSDK/MastodonStatus.swift | 72 +++ .../Protocol/StatusCompatible.swift | 1 + .../ComposeContentViewModel+DataSource.swift | 6 +- .../ComposeContentViewModel.swift | 91 ++-- .../Publisher/MastodonStatusPublisher.swift | 6 +- .../Content/MediaView+Configuration.swift | 7 +- .../Content/NotificationView+ViewModel.swift | 2 +- .../View/Content/StatusCardControl.swift | 28 +- .../Content/StatusView+Configuration.swift | 422 ++++++++---------- .../View/Content/StatusView+ViewModel.swift | 18 +- .../MastodonUI/View/Content/StatusView.swift | 6 +- ...eMiddleLoaderTableViewCell+ViewModel.swift | 11 +- 140 files changed, 1919 insertions(+), 1997 deletions(-) create mode 100644 Mastodon/Persistence/FileManager+Timeline.swift delete mode 100644 Mastodon/Scene/Thread/CachedThreadViewModel.swift create mode 100644 MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift delete mode 100644 MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift create mode 100644 MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift delete mode 100644 MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift 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.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index fe4e6c863..2795b39a6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; }; 2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; + 2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -393,7 +394,6 @@ DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; }; DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; }; - DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; }; DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; }; DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; }; DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; }; @@ -696,6 +696,7 @@ 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; + 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Timeline.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -1115,7 +1116,6 @@ DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; }; DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; - DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = ""; }; DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = ""; }; DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; }; DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1898,6 +1898,7 @@ children = ( D8AC98772B0F62230045EC2B /* Model */, D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */, + 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */, ); path = Persistence; sourceTree = ""; @@ -2687,7 +2688,6 @@ DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, - DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */, DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */, ); @@ -3909,7 +3909,6 @@ 2AB5011C299243FB00346092 /* WidgetExtension.intentdefinition in Sources */, DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, - DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */, @@ -3978,6 +3977,7 @@ DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, D8318A802A4466D300C0FB73 /* SettingsCoordinator.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, + 2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */, DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 400042065..f85a7c96f 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -569,7 +569,10 @@ public extension SceneCoordinator { @MainActor func showLoading(on viewController: UIViewController?) { guard let viewController else { return } - + + /// Don't add HUD twice + guard MBProgressHUD.forView(viewController.view) == nil else { return } + MBProgressHUD.showAdded(to: viewController.view, animated: true) } @@ -626,7 +629,10 @@ extension SceneCoordinator: SettingsCoordinatorDelegate { try await self.appContext.authenticationService.signOutMastodonUser( authenticationBox: authContext.mastodonAuthenticationBox ) - + let userIdentifier = authContext.mastodonAuthenticationBox + FileManager.default.invalidateHomeTimelineCache(for: userIdentifier) + FileManager.default.invalidateNotificationsAll(for: userIdentifier) + FileManager.default.invalidateNotificationsMentions(for: userIdentifier) self.setup() } 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..12fce16d8 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( @@ -118,36 +109,28 @@ extension StatusSection { tableView: UITableView, indexPath: IndexPath, configuration: ThreadCellRegistrationConfiguration - ) -> UITableViewCell { - let managedObjectContext = context.managedObjectContext - + ) -> UITableViewCell { switch configuration.thread { case .root(let threadContext): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell - managedObjectContext.performAndWait { - guard let status = threadContext.status.object(in: managedObjectContext) else { return } - StatusSection.configure( - context: context, - tableView: tableView, - cell: cell, - viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(status)), - configuration: configuration.configuration - ) - } + StatusSection.configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusThreadRootTableViewCell.ViewModel(value: .status(threadContext.status)), + configuration: configuration.configuration + ) return cell case .reply(let threadContext), .leaf(let threadContext): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - managedObjectContext.performAndWait { - guard let status = threadContext.status.object(in: managedObjectContext) else { return } - StatusSection.configure( - context: context, - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .status(status)), - configuration: configuration.configuration - ) - } + StatusSection.configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .status(threadContext.status)), + configuration: configuration.configuration + ) return cell } } @@ -182,7 +165,7 @@ extension StatusSection { return } - cell.pollOptionView.configure(pollOption: option) + cell.pollOptionView.configure(pollOption: option, status: statusView.viewModel.originalStatus) // trigger update if needs let needsUpdatePoll: Bool = { @@ -319,7 +302,7 @@ extension StatusSection { static func configure( cell: TimelineMiddleLoaderTableViewCell, - feed: Feed, + feed: MastodonFeed, configuration: Configuration ) { cell.configure( diff --git a/Mastodon/Persistence/FileManager+SearchHistory.swift b/Mastodon/Persistence/FileManager+SearchHistory.swift index 8c1cabd1d..95dd29a52 100644 --- a/Mastodon/Persistence/FileManager+SearchHistory.swift +++ b/Mastodon/Persistence/FileManager+SearchHistory.swift @@ -68,8 +68,12 @@ extension FileManager { } } -extension FileManager { - public var documentsDirectory: URL? { - return self.urls(for: .documentDirectory, in: .userDomainMask).first +public extension FileManager { + var documentsDirectory: URL? { + urls(for: .documentDirectory, in: .userDomainMask).first + } + + var cachesDirectory: URL? { + urls(for: .cachesDirectory, in: .userDomainMask).first } } diff --git a/Mastodon/Persistence/FileManager+Timeline.swift b/Mastodon/Persistence/FileManager+Timeline.swift new file mode 100644 index 000000000..0a7046eaa --- /dev/null +++ b/Mastodon/Persistence/FileManager+Timeline.swift @@ -0,0 +1,93 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonCore +import MastodonSDK + +extension FileManager { + private static let cacheItemsLimit: Int = 100 // max number of items to cache + + // Retrieve + func cachedHomeTimeline(for userId: UserIdentifier) throws -> [MastodonStatus] { + try cached(timeline: .homeTimeline(userId)).map(MastodonStatus.fromEntity) + } + + func cachedNotificationsAll(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] { + try cached(timeline: .notificationsAll(userId)) + } + + func cachedNotificationsMentions(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] { + try cached(timeline: .notificationsMentions(userId)) + } + + + private func cached(timeline: Persistence) throws -> [T] { + guard let cachesDirectory else { return [] } + + let filePath = timeline.filepath(baseURL: cachesDirectory) + + guard let data = try? Data(contentsOf: filePath) else { return [] } + + do { + let items = try JSONDecoder().decode([T].self, from: data) + + return items + } catch { + return [] + } + } + + // Create + func cacheHomeTimeline(items: [MastodonStatus], for userIdentifier: UserIdentifier) { + cache(items.map { $0.entity }, timeline: .homeTimeline(userIdentifier)) + } + + func cacheNotificationsAll(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { + cache(items, timeline: .notificationsAll(userIdentifier)) + } + + func cacheNotificationsMentions(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { + cache(items, timeline: .notificationsMentions(userIdentifier)) + } + + private func cache(_ items: [T], timeline: Persistence) { + guard let cachesDirectory else { return } + + let processableItems: [T] + if items.count > Self.cacheItemsLimit { + processableItems = items.dropLast(items.count - Self.cacheItemsLimit) + } else { + processableItems = items + } + + do { + let data = try JSONEncoder().encode(processableItems) + + let filePath = timeline.filepath(baseURL: cachesDirectory) + try data.write(to: filePath) + } catch { + debugPrint(error.localizedDescription) + } + } + + // Delete + func invalidateHomeTimelineCache(for userId: UserIdentifier) { + invalidate(timeline: .homeTimeline(userId)) + } + + func invalidateNotificationsAll(for userId: UserIdentifier) { + invalidate(timeline: .notificationsAll(userId)) + } + + func invalidateNotificationsMentions(for userId: UserIdentifier) { + invalidate(timeline: .notificationsMentions(userId)) + } + + private func invalidate(timeline: Persistence) { + guard let cachesDirectory else { return } + + let filePath = timeline.filepath(baseURL: cachesDirectory) + + try? removeItem(at: filePath) + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift index 2c54653ba..0bc0c9099 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift @@ -9,18 +9,24 @@ import UIKit import CoreData import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { public static func responseToStatusBookmarkAction( - provider: UIViewController & NeedsDependency & AuthContextProvider, - status: ManagedObjectRecord + provider: NeedsDependency & AuthContextProvider & DataSourceProvider, + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - _ = try await provider.context.apiService.bookmark( + let updatedStatus = try await provider.context.apiService.bookmark( record: status, authenticationBox: provider.authContext.mastodonAuthenticationBox - ) + ).value + + let newStatus: MastodonStatus = .fromEntity(updatedStatus) + newStatus.isSensitiveToggled = status.isSensitiveToggled + + provider.update(status: newStatus) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift index 92945b9ee..8e96e28fa 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift @@ -7,20 +7,25 @@ import UIKit import CoreData -import CoreDataStack +import MastodonSDK import MastodonCore extension DataSourceFacade { public static func responseToStatusFavoriteAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - _ = try await provider.context.apiService.favorite( - record: status, + let updatedStatus = try await provider.context.apiService.favorite( + status: status, authenticationBox: provider.authContext.mastodonAuthenticationBox - ) + ).value + + let newStatus: MastodonStatus = .fromEntity(updatedStatus) + newStatus.isSensitiveToggled = status.isSensitiveToggled + + provider.update(status: newStatus) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index 6711380b8..e3445115d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -49,15 +49,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 } @@ -66,23 +65,17 @@ extension DataSourceFacade { throw APIService.APIError.implicit(.badRequest) } - let state: MastodonFollowRequestState = try await managedObjectContext.perform { - guard let notification = notification.object(in: managedObjectContext) else { return .init(state: .none) } - return notification.followRequestState - } + let state: MastodonFollowRequestState = notification.followRequestState guard state.state == .none else { return } - try? await managedObjectContext.performChanges { - guard let notification = notification.object(in: managedObjectContext) else { return } - switch query { - case .accept: - notification.transientFollowRequestState = .init(state: .isAccepting) - case .reject: - notification.transientFollowRequestState = .init(state: .isRejecting) - } + switch query { + case .accept: + notification.transientFollowRequestState = .init(state: .isAccepting) + case .reject: + notification.transientFollowRequestState = .init(state: .isRejecting) } do { @@ -93,22 +86,12 @@ extension DataSourceFacade { ) } catch { // reset state when failure - try? await managedObjectContext.performChanges { - guard let notification = notification.object(in: managedObjectContext) else { return } - notification.transientFollowRequestState = .init(state: .none) - } - + notification.transientFollowRequestState = .init(state: .none) + if let error = error as? Mastodon.API.Error { switch error.httpResponseStatus { case .notFound: - let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext - try await backgroundManagedObjectContext.performChanges { - guard let notification = notification.object(in: backgroundManagedObjectContext) else { return } - for feed in notification.feeds { - backgroundManagedObjectContext.delete(feed) - } - backgroundManagedObjectContext.delete(notification) - } + break default: let alertController = await UIAlertController(for: error, title: nil, preferredStyle: .alert) let okAction = await UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) @@ -124,32 +107,14 @@ extension DataSourceFacade { return } - try? await managedObjectContext.performChanges { - guard let notification = notification.object(in: managedObjectContext) else { return } - switch query { - case .accept: - notification.transientFollowRequestState = .init(state: .isAccept) - case .reject: - // do nothing due to will delete notification - break - } + switch query { + case .accept: + notification.transientFollowRequestState = .init(state: .isAccept) + notification.followRequestState = .init(state: .isAccept) + case .reject: + break } - - let backgroundManagedObjectContext = dependency.context.backgroundManagedObjectContext - try? await backgroundManagedObjectContext.performChanges { - guard let notification = notification.object(in: backgroundManagedObjectContext) else { return } - switch query { - case .accept: - notification.followRequestState = .init(state: .isAccept) - case .reject: - // delete notification - for feed in notification.feeds { - backgroundManagedObjectContext.delete(feed) - } - backgroundManagedObjectContext.delete(notification) - } - } - } // end func + } } extension DataSourceFacade { @@ -161,4 +126,13 @@ extension DataSourceFacade { for: user, authenticationBox: dependency.authContext.mastodonAuthenticationBox) } + + static func responseToShowHideReblogAction( + dependency: NeedsDependency & AuthContextProvider, + user: Mastodon.Entity.Account + ) async throws { + _ = try await dependency.context.apiService.toggleShowReblogs( + for: user, + authenticationBox: dependency.authContext.mastodonAuthenticationBox) + } } 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..3fdf08e4e 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Meta.swift @@ -9,21 +9,20 @@ import Foundation import CoreDataStack import MetaTextKit import MastodonCore +import MastodonSDK extension DataSourceFacade { static func responseToMetaTextAction( provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord, + status: MastodonStatus, meta: Meta ) async throws { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, + let redirectRecord = DataSourceFacade.status( status: status, target: target ) - guard let redirectRecord = _redirectRecord else { return } await responseToMetaTextAction( provider: provider, @@ -35,7 +34,7 @@ extension DataSourceFacade { static func responseToMetaTextAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, meta: Meta ) async { switch meta { @@ -51,11 +50,10 @@ extension DataSourceFacade { await responseToURLAction( provider: provider, - status: status, url: url ) case .hashtag(_, let hashtag, _): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag) + let hashtagTimelineViewModel = await HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag) _ = await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show) case .mention(_, let mention, let userInfo): await coordinateToProfileScene( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift index efdf41dbd..d5deef32d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift @@ -9,41 +9,13 @@ 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 diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index c34b62704..b1d39a4dd 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -12,23 +12,38 @@ import MastodonSDK extension DataSourceFacade { + @MainActor static func coordinateToProfileScene( provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord + status: MastodonStatus ) async { - let _redirectRecord = await DataSourceFacade.author( - managedObjectContext: provider.context.managedObjectContext, - status: status, - target: target - ) + let acct: String + switch target { + case .status: + acct = status.reblog?.entity.account.acct ?? status.entity.account.acct + case .reblog: + acct = status.entity.account.acct + } + + provider.coordinator.showLoading() + + let _redirectRecord = try? await Mastodon.API.Account.lookupAccount( + session: .shared, + domain: provider.authContext.mastodonAuthenticationBox.domain, + query: .init(acct: acct), + authorization: provider.authContext.mastodonAuthenticationBox.userAuthorization + ).singleOutput().value + + provider.coordinator.hideLoading() + guard let redirectRecord = _redirectRecord else { assertionFailure() return } await coordinateToProfileScene( provider: provider, - user: redirectRecord + account: redirectRecord ) } @@ -83,9 +98,10 @@ extension DataSourceFacade { extension DataSourceFacade { + @MainActor static func coordinateToProfileScene( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: MastodonStatus, mention: String, // username, userInfo: [AnyHashable: Any]? ) async { @@ -98,13 +114,10 @@ extension DataSourceFacade { return } - let managedObjectContext = provider.context.managedObjectContext - let mentions = try? await managedObjectContext.perform { - return status.object(in: managedObjectContext)?.mentions ?? [] - } + let mentions = status.entity.mentions ?? [] - guard let mention = mentions?.first(where: { $0.url == href }) else { - _ = await provider.coordinator.present( + guard let mention = mentions.first(where: { $0.url == href }) else { + _ = provider.coordinator.present( scene: .safari(url: url), from: provider, transition: .safariPresent(animated: true, completion: nil) @@ -131,7 +144,7 @@ extension DataSourceFacade { } }() - _ = await provider.coordinator.present( + _ = provider.coordinator.present( scene: .profile(viewModel: profileViewModel), from: provider, transition: .show diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift index ff3e95820..f8fa3d5f3 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift @@ -6,21 +6,27 @@ // import UIKit -import CoreDataStack import MastodonCore import MastodonUI +import MastodonSDK extension DataSourceFacade { static func responseToStatusReblogAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord + status: MastodonStatus ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - _ = try await provider.context.apiService.reblog( - record: status, + let updatedStatus = try await provider.context.apiService.reblog( + status: status, authenticationBox: provider.authContext.mastodonAuthenticationBox - ) - } // end func + ).value + + let newStatus: MastodonStatus = .fromEntity(updatedStatus) + newStatus.reblog?.isSensitiveToggled = status.isSensitiveToggled + newStatus.isSensitiveToggled = status.isSensitiveToggled + + provider.update(status: newStatus) + } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index c324da73b..e07001b96 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -14,18 +14,21 @@ import MastodonUI import MastodonLocalization import LinkPresentation import UniformTypeIdentifiers +import MastodonSDK // Delete extension DataSourceFacade { static func responseToDeleteStatus( - dependency: NeedsDependency & AuthContextProvider, - status: ManagedObjectRecord + dependency: NeedsDependency & AuthContextProvider & DataSourceProvider, + status: MastodonStatus ) async throws { - _ = try await dependency.context.apiService.deleteStatus( + let deletedStatus = try await dependency.context.apiService.deleteStatus( status: status, authenticationBox: dependency.authContext.mastodonAuthenticationBox - ) + ).value.asMastodonStatus + + dependency.delete(status: deletedStatus) } } @@ -36,7 +39,7 @@ extension DataSourceFacade { @MainActor public static func responseToStatusShareAction( provider: DataSourceProvider, - status: ManagedObjectRecord, + status: MastodonStatus, button: UIButton ) async throws { let activityViewController = try await createActivityViewController( @@ -56,22 +59,22 @@ extension DataSourceFacade { private static func createActivityViewController( dependency: NeedsDependency, - status: ManagedObjectRecord + status: MastodonStatus ) async throws -> UIActivityViewController { - var activityItems: [Any] = try await dependency.context.managedObjectContext.perform { - guard let status = status.object(in: dependency.context.managedObjectContext), - let url = URL(string: status.url ?? status.uri) + var activityItems: [Any] = { + guard let url = URL(string: status.entity.url ?? status.entity.uri) else { return [] } return [ URLActivityItemWithMetadata(url: url) { metadata in - metadata.title = "\(status.author.displayName) (@\(status.author.acctWithDomain))" + metadata.title = "\(status.entity.account.displayName) (@\(status.entity.account.acctWithDomain))" metadata.iconProvider = ImageProvider( - url: status.author.avatarImageURLWithFallback(domain: status.author.domain), + url: status.entity.account.avatarImageURLWithFallback(domain: status.entity.account.domain ?? ""), filter: ScaledToSizeFilter(size: CGSize.authorAvatarButtonSize) ).itemProvider } ] as [Any] - } + }() + var applicationActivities: [UIActivity] = [ SafariActivity(sceneCoordinator: dependency.coordinator), // open URL ] @@ -94,20 +97,11 @@ extension DataSourceFacade { @MainActor static func responseToActionToolbar( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, + status: 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: @@ -118,7 +112,7 @@ extension DataSourceFacade { context: provider.context, authContext: provider.authContext, composeContext: .composeStatus, - destination: .reply(parent: status) + destination: .reply(parent: _status) ) _ = provider.coordinator.present( scene: .compose(viewModel: composeViewModel), @@ -128,17 +122,17 @@ extension DataSourceFacade { case .reblog: try await DataSourceFacade.responseToStatusReblogAction( provider: provider, - status: status + status: _status ) case .like: try await DataSourceFacade.responseToStatusFavoriteAction( provider: provider, - status: status + status: _status ) case .share: try await DataSourceFacade.responseToStatusShareAction( provider: provider, - status: status, + status: _status, button: sender ) } // end switch @@ -150,7 +144,8 @@ extension DataSourceFacade { extension DataSourceFacade { struct MenuContext { - let author: ManagedObjectRecord? + let author: ManagedObjectRecord? // todo: Remove once IOS-192 is ready + let authorEntity: Mastodon.Entity.Account? let statusViewModel: StatusView.ViewModel? let button: UIButton? let barButtonItem: UIBarButtonItem? @@ -158,7 +153,7 @@ extension DataSourceFacade { @MainActor static func responseToMenuAction( - dependency: UIViewController & NeedsDependency & AuthContextProvider, + dependency: UIViewController & NeedsDependency & AuthContextProvider & DataSourceProvider, action: MastodonMenu.Action, menuContext: MenuContext ) async throws { @@ -266,7 +261,7 @@ extension DataSourceFacade { context: dependency.context, authContext: dependency.authContext, user: user, - status: menuContext.statusViewModel?.originalStatus?.asRecord + status: menuContext.statusViewModel?.originalStatus ) _ = dependency.coordinator.present( @@ -297,7 +292,7 @@ extension DataSourceFacade { ) case .bookmarkStatus: Task { - guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { + guard let status = menuContext.statusViewModel?.originalStatus else { assertionFailure() return } @@ -309,11 +304,7 @@ extension DataSourceFacade { case .shareStatus: Task { let managedObjectContext = dependency.context.managedObjectContext - guard let status: ManagedObjectRecord = try? await managedObjectContext.perform(block: { - guard let object = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: managedObjectContext) else { return nil } - let objectID = (object.reblog ?? object).objectID - return .init(objectID: objectID) - }) else { + guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else { assertionFailure() return } @@ -344,7 +335,7 @@ extension DataSourceFacade { style: .destructive ) { [weak dependency] _ in guard let dependency = dependency else { return } - guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return } + guard let status = menuContext.statusViewModel?.originalStatus else { return } Task { try await DataSourceFacade.responseToDeleteStatus( dependency: dependency, @@ -358,7 +349,7 @@ extension DataSourceFacade { dependency.present(alertController, animated: true) case .translateStatus: - guard let status = menuContext.statusViewModel?.originalStatus?.asRecord else { return } + guard let status = menuContext.statusViewModel?.originalStatus else { return } do { let translation = try await DataSourceFacade.translateStatus(provider: dependency,status: status) @@ -371,7 +362,7 @@ extension DataSourceFacade { } case .editStatus: - guard let status = menuContext.statusViewModel?.originalStatus?.asRecord.object(in: dependency.context.managedObjectContext) else { return } + guard let status = menuContext.statusViewModel?.originalStatus else { return } let statusSource = try await dependency.context.apiService.getStatusSource( forStatusID: status.id, @@ -441,14 +432,15 @@ extension DataSourceFacade { extension DataSourceFacade { static func responseToToggleSensitiveAction( - dependency: NeedsDependency, - status: ManagedObjectRecord + dependency: NeedsDependency & DataSourceProvider, + status: MastodonStatus ) async throws { - try await dependency.context.managedObjectContext.perform { - guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } - let status = _status.reblog ?? _status - status.update(isSensitiveToggled: !status.isSensitiveToggled) - } + let _status = status.reblog ?? status + + let newStatus: MastodonStatus = .fromEntity(_status.entity) + newStatus.isSensitiveToggled = !_status.isSensitiveToggled + + dependency.update(status: newStatus) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift index ad8d0e671..169acc073 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift @@ -9,21 +9,20 @@ import UIKit import CoreData import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { static func coordinateToStatusThreadScene( provider: ViewControllerWithDependencies & AuthContextProvider, target: StatusTarget, - status: ManagedObjectRecord + status: MastodonStatus ) async { - let _root: StatusItem.Thread? = await { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, + let _root: StatusItem.Thread? = { + let redirectRecord = DataSourceFacade.status( status: status, target: target ) - guard let redirectRecord = _redirectRecord else { return nil } - + let threadContext = StatusItem.Thread.Context(status: redirectRecord) return StatusItem.Thread.root(context: threadContext) }() 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..ff1578af9 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+URL.swift @@ -9,11 +9,11 @@ import Foundation import CoreDataStack import MetaTextKit import MastodonCore +import MastodonSDK extension DataSourceFacade { static func responseToURLAction( provider: DataSourceProvider & AuthContextProvider, - status: ManagedObjectRecord, 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..5ed4fc8b1 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 { @@ -44,6 +44,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut action: action, menuContext: .init( author: author, + authorEntity: notification.entity.account, statusViewModel: nil, button: button, barButtonItem: nil @@ -71,7 +72,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut return } let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } return .init(objectID: notification.account.objectID) } guard let author = _author else { @@ -155,7 +155,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut } private struct NotificationMediaTransitionContext { - let status: ManagedObjectRecord + let status: MastodonStatus let needsToggleMediaSensitive: Bool } @@ -180,16 +180,19 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med return } - let managedObjectContext = self.context.managedObjectContext - let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform { - guard let notification = record.object(in: managedObjectContext) else { return nil } - guard let _status = notification.status else { return nil } - let status = _status.reblog ?? _status + let _mediaTransitionContext: NotificationMediaTransitionContext? = { + guard let status = record.status?.reblog ?? record.status else { return nil } + let needsToBeToggled: Bool = { + guard let sensitive = status.entity.sensitive else { + return false + } + return status.isSensitiveToggled ? !sensitive : sensitive + }() return NotificationMediaTransitionContext( - status: .init(objectID: status.objectID), - needsToggleMediaSensitive: status.isSensitiveToggled ? !status.sensitive : status.sensitive + status: status, + needsToggleMediaSensitive: needsToBeToggled ) - } + }() guard let mediaTransitionContext = _mediaTransitionContext else { return } @@ -233,15 +236,13 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med } let managedObjectContext = self.context.managedObjectContext - let _mediaTransitionContext: NotificationMediaTransitionContext? = try await managedObjectContext.perform { - guard let notification = record.object(in: managedObjectContext) else { return nil } - guard let _status = notification.status else { return nil } - let status = _status.reblog ?? _status + let _mediaTransitionContext: NotificationMediaTransitionContext? = { + guard let status = record.status?.reblog ?? record.status else { return nil } return NotificationMediaTransitionContext( - status: .init(objectID: status.objectID), - needsToggleMediaSensitive: status.isMediaSensitive ? !status.isSensitiveToggled : false + status: status, + needsToggleMediaSensitive: status.entity.sensitive == true ? !status.isSensitiveToggled : false ) - } + }() guard let mediaTransitionContext = _mediaTransitionContext else { return } @@ -286,12 +287,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.objectID) - } - guard let status = _status else { + guard let status = notification.status?.reblog ?? notification.status else { assertionFailure() return } @@ -323,18 +319,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.author.objectID) - } - guard let author = _author else { - assertionFailure() - return - } + await DataSourceFacade.coordinateToProfileScene( provider: self, - user: author + user: notification.account.asRecord ) } // end Task } @@ -367,12 +355,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.objectID) - } - guard let status = _status else { + guard let status = notification.status?.reblog ?? notification.status else { assertionFailure() return } @@ -400,12 +383,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.objectID) - } - guard let status = _status else { + guard let status = notification.status?.reblog ?? notification.status else { assertionFailure() return } @@ -465,12 +443,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.objectID) - } - guard let status = _status else { + guard let status = notification.status?.reblog ?? notification.status else { assertionFailure() return } @@ -497,12 +470,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for notification item") return } - let _status: ManagedObjectRecord? = 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..57360f0c6 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -22,6 +22,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte statusView: StatusView, headerDidPressed header: UIView ) { + let domain = statusView.domain ?? "" Task { let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) guard let item = await item(from: source) else { @@ -38,15 +39,15 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte break case .reply: let _replyToAuthor: ManagedObjectRecord? = try? await context.managedObjectContext.perform { - guard let status = status.object(in: self.context.managedObjectContext) else { return nil } - guard let inReplyToAccountID = status.inReplyToAccountID else { return nil } + guard let inReplyToAccountID = status.entity.inReplyToAccountID else { return nil } let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: status.author.domain, id: inReplyToAccountID) + request.predicate = MastodonUser.predicate(domain: domain, id: inReplyToAccountID) request.fetchLimit = 1 guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } return .init(objectID: author.objectID) } guard let replyToAuthor = _replyToAuthor else { + assertionFailure() return } @@ -147,7 +148,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte await DataSourceFacade.responseToURLAction( provider: self, - status: status, url: url ) } @@ -172,7 +172,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte await DataSourceFacade.responseToURLAction( provider: self, - status: status, url: url ) } @@ -184,7 +183,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte cardControlMenu statusCardControl: StatusCardControl ) -> [LabeledAction]? { guard let card = statusView.viewModel.card, - let url = card.url else { + let url = URL(string: card.url) else { return nil } @@ -206,8 +205,8 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte URLActivityItemWithMetadata(url: url) { metadata in metadata.title = card.title - if let image = card.imageURL { - metadata.iconProvider = ImageProvider(url: image, filter: nil).itemProvider + if let image = card.image, let url = URL(string: image) { + metadata.iconProvider = ImageProvider(url: url, filter: nil).itemProvider } } ], @@ -440,7 +439,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte assertionFailure("only works for status data provider") return } - + try await DataSourceFacade.responseToActionToolbar( provider: self, status: status, @@ -466,13 +465,18 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte assertionFailure() return } - guard case let .status(status) = item else { + guard case let .status(_status) = item else { assertionFailure("only works for status data provider") return } + + let status = _status.reblog ?? _status + let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - guard let _status = status.object(in: self.context.managedObjectContext) else { return nil } - let author = (_status.reblog ?? _status).author + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: self.authContext.mastodonAuthenticationBox.domain, id: status.entity.account.id) + request.fetchLimit = 1 + guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } return .init(objectID: author.objectID) } guard let author = _author else { @@ -514,6 +518,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte action: action, menuContext: .init( author: author, + authorEntity: status.entity.account, statusViewModel: statusViewModel, button: button, barButtonItem: nil @@ -679,11 +684,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte assertionFailure("only works for status data provider") return } - - guard let status = status.object(in: context.managedObjectContext) else { - return await coordinator.hideLoading() - } - + do { let edits = try await context.apiService.getHistory(forStatusID: status.id, authenticationBox: authContext.mastodonAuthenticationBox).value diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift index f90827863..cfc3e07f3 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,15 +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 { - return nil - } - return statusRecord + return record.status default: return nil } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index 0944cee6c..276500c03 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 { @@ -39,14 +40,8 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid provider: self, tag: tag ) - case .notification(let notification): - let managedObjectContext = context.managedObjectContext - - let _status: ManagedObjectRecord? = try await managedObjectContext.perform { - guard let notification = notification.object(in: managedObjectContext) else { return nil } - guard let status = notification.status else { return nil } - return .init(objectID: status.objectID) - } + case .notification(let notification): + let _status: MastodonStatus? = notification.status if let status = _status { await DataSourceFacade.coordinateToStatusThreadScene( provider: self, @@ -54,10 +49,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid status: status ) } else { - let _author: ManagedObjectRecord? = 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 fb1892bdf..d3b18240e 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: Mastodon.Entity.Tag) - case notification(record: ManagedObjectRecord) + case notification(record: MastodonNotification) case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) } @@ -39,4 +39,6 @@ extension DataSourceItem { protocol DataSourceProvider: ViewControllerWithDependencies { func item(from source: DataSourceItem.Source) async -> DataSourceItem? + func update(status: MastodonStatus) + func delete(status: MastodonStatus) } 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/DiscoveryCommunityViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift index e9441b5d3..ea3b573e2 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension DiscoveryCommunityViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -26,6 +27,16 @@ extension DiscoveryCommunityViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.setRecords( + viewModel.dataController.records.filter { $0.id != status.id } + ) + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift index a25dbaf1c..0453544aa 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift @@ -29,7 +29,7 @@ extension DiscoveryCommunityViewModel { stateMachine.enter(State.Reloading.self) - statusFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift index f61df078d..6533482c8 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.dataController.records for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(where: { $0.id == status.id }) else { continue } + statusIDs.append(.fromEntity(status)) hasNewStatusesAppend = true } @@ -158,7 +158,7 @@ extension DiscoveryCommunityViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + await viewModel.dataController.setRecords(statusIDs) viewModel.didLoadLatest.send() } catch { diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift index 6169e0830..8dacc8936 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift @@ -21,7 +21,7 @@ final class DiscoveryCommunityViewModel { let context: AppContext let authContext: AuthContext let viewDidAppeared = PassthroughSubject() - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() // output @@ -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.dataController = StatusDataController() // 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/DiscoveryPostsViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift index c3495b245..09c92009e 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension DiscoveryPostsViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -26,6 +27,16 @@ extension DiscoveryPostsViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.setRecords( + viewModel.dataController.records.filter { $0.id != status.id } + ) + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift index 99d68796d..6e00255fe 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift @@ -29,7 +29,7 @@ extension DiscoveryPostsViewModel { stateMachine.enter(State.Reloading.self) - statusFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift index 75794258d..2799733da 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.dataController.records for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(where: { $0.id == status.id }) else { continue } + statusIDs.append(.fromEntity(status)) hasNewStatusesAppend = true } @@ -155,7 +155,7 @@ extension DiscoveryPostsViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + await viewModel.dataController.setRecords(statusIDs) viewModel.didLoadLatest.send() } catch { diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift index 3024f03be..feff4f77a 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift @@ -20,7 +20,7 @@ final class DiscoveryPostsViewModel { // input let context: AppContext let authContext: AuthContext - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() // output @@ -41,14 +41,11 @@ final class DiscoveryPostsViewModel { let didLoadLatest = PassthroughSubject() @Published var isServerSupportEndpoint = true + @MainActor init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() // end init Task { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift index 6cd97fcca..8c7885017 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension HashtagTimelineViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -27,6 +28,14 @@ extension HashtagTimelineViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.deleteRecord(status) + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 8cc185382..fe98ac78a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -34,7 +34,7 @@ extension HashtagTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - fetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift index 579060bda..b78ec0287 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.dataController.records.map { $0.entity } for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(status) else { continue } + statusIDs.append(status) hasNewStatusesAppend = true } @@ -158,7 +159,7 @@ extension HashtagTimelineViewModel.State { await enter(state: NoMore.self) } - viewModel.fetchedResultsController.append(statusIDs: statusIDs) + await viewModel.dataController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) }) viewModel.didLoadLatest.send() } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index fbdc42a1c..2e3054f80 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -24,7 +24,7 @@ final class HashtagTimelineViewModel { // input let context: AppContext let authContext: AuthContext - let fetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -50,15 +50,12 @@ final class HashtagTimelineViewModel { return stateMachine }() + @MainActor init(context: AppContext, authContext: AuthContext, hashtag: String) { self.context = context self.authContext = authContext self.hashtag = hashtag - self.fetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() updateTagInformation() // end init } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift index b141d386a..94a587f79 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension HomeTimelineViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -20,22 +21,25 @@ extension HomeTimelineViewController: DataSourceProvider { } switch item { - case .feed(let record): - let managedObjectContext = context.managedObjectContext - let item: DataSourceItem? = try? await managedObjectContext.perform { - guard let feed = record.object(in: managedObjectContext) else { return nil } - guard feed.kind == .home else { return nil } - if let status = feed.status { - return .status(record: .init(objectID: status.objectID)) - } else { - return nil - } + case .feed(let feed): + guard feed.kind == .home else { return nil } + if let status = feed.status { + return .status(record: status) + } else { + return nil } - return item default: return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.records = viewModel.dataController.records.filter { $0.id != status.id } + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index ed363f397..4d0a2613e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -386,6 +386,10 @@ extension HomeTimelineViewController { @objc func signOutAction(_ sender: UIAction) { Task { @MainActor in try await context.authenticationService.signOutMastodonUser(authenticationBox: viewModel.authContext.mastodonAuthenticationBox) + let userIdentifier = viewModel.authContext.mastodonAuthenticationBox + FileManager.default.invalidateHomeTimelineCache(for: userIdentifier) + FileManager.default.invalidateNotificationsAll(for: userIdentifier) + FileManager.default.invalidateNotificationsMentions(for: userIdentifier) self.coordinator.setup() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 18cbf18d2..c9e81737f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -6,9 +6,8 @@ // import UIKit -import CoreData -import CoreDataStack import MastodonUI +import MastodonSDK extension HomeTimelineViewModel { @@ -35,7 +34,7 @@ extension HomeTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - fetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } @@ -46,41 +45,23 @@ extension HomeTimelineViewModel { var newSnapshot: NSDiffableDataSourceSnapshot = { let newItems = records.map { record in StatusItem.feed(record: record) - } + }.removingDuplicates() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(newItems, toSection: .main) return snapshot }() - let parentManagedObjectContext = self.context.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - try? await managedObjectContext.perform { - let anchors: [Feed] = { - let request = Feed.sortedFetchRequest - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Feed.hasMorePredicate(), - self.fetchedResultsController.predicate, - ]) - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - - let itemIdentifiers = newSnapshot.itemIdentifiers - for (index, item) in itemIdentifiers.enumerated() { - guard case let .feed(record) = item else { continue } - guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue } - let isLast = index + 1 == itemIdentifiers.count - if isLast { - newSnapshot.insertItems([.bottomLoader], afterItem: item) - } else { - newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) - } + let anchors: [MastodonFeed] = records.filter { $0.hasMore == true } + let itemIdentifiers = newSnapshot.itemIdentifiers + for (index, item) in itemIdentifiers.enumerated() { + guard case let .feed(record) = item else { continue } + guard anchors.contains(where: { feed in feed.id == record.id }) else { continue } + let isLast = index + 1 == itemIdentifiers.count + if isLast { + newSnapshot.insertItems([.bottomLoader], afterItem: item) + } else { + newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) } } @@ -95,12 +76,12 @@ extension HomeTimelineViewModel { oldSnapshot: oldSnapshot, newSnapshot: newSnapshot ) else { - self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) self.didLoadLatest.send() return } - self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) var contentOffset = tableView.contentOffset contentOffset.y = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 7f056928c..65ec13d66 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -11,6 +11,7 @@ import CoreData import CoreDataStack import GameplayKit import MastodonCore +import MastodonSDK extension HomeTimelineViewModel { class LoadLatestState: GKState { @@ -83,15 +84,11 @@ extension HomeTimelineViewModel.LoadLatestState { guard let viewModel else { return } - let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount) - let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext + let latestFeedRecords = viewModel.dataController.records.prefix(APIService.onceRequestStatusMaxCount) Task { let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in - guard let feed = record.object(in: managedObjectContext) else { return nil } - return feed.status?.id + return record.status?.id } do { @@ -114,6 +111,22 @@ extension HomeTimelineViewModel.LoadLatestState { if !latestStatusIDs.isEmpty { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } + + var newRecords: [MastodonFeed] = newStatuses.map { + MastodonFeed.fromStatus(.fromEntity($0), kind: .home) + } + viewModel.dataController.records = { + var oldRecords = viewModel.dataController.records + for (i, record) in newRecords.enumerated() { + if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) { + oldRecords[index] = record + if newRecords.count > index { + newRecords.remove(at: i) + } + } + } + return (newRecords + oldRecords).removingDuplicates() + }() } viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 1b6e4499d..3f980bada 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -31,7 +31,7 @@ extension HomeTimelineViewModel.LoadOldestState { class Initial: HomeTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !viewModel.fetchedResultsController.records.isEmpty else { return false } + guard !viewModel.dataController.records.isEmpty else { return false } return stateClass == Loading.self } } @@ -46,19 +46,13 @@ extension HomeTimelineViewModel.LoadOldestState { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let lastFeedRecord = viewModel.fetchedResultsController.records.last else { + guard let lastFeedRecord = viewModel.dataController.records.last else { stateMachine.enter(Idle.self) return } Task { - let managedObjectContext = viewModel.fetchedResultsController.managedObjectContext - let _maxID: Mastodon.Entity.Status.ID? = try await managedObjectContext.perform { - guard let feed = lastFeedRecord.object(in: managedObjectContext), - let status = feed.status - else { return nil } - return status.id - } + let _maxID = lastFeedRecord.status?.id guard let maxID = _maxID else { await self.enter(state: Fail.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 040fe45b5..d56155d9a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -15,6 +15,7 @@ import GameplayKit import AlamofireImage import MastodonCore import MastodonUI +import MastodonSDK final class HomeTimelineViewModel: NSObject { @@ -24,7 +25,7 @@ final class HomeTimelineViewModel: NSObject { // input let context: AppContext let authContext: AuthContext - let fetchedResultsController: FeedFetchedResultsController + let dataController: FeedDataController let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel let listBatchFetchViewModel = ListBatchFetchViewModel() @@ -80,14 +81,12 @@ final class HomeTimelineViewModel: NSObject { init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) + self.dataController = FeedDataController(context: context, authContext: authContext) self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() - - fetchedResultsController.predicate = Feed.predicate( - kind: .home, - acct: .mastodon(domain: authContext.mastodonAuthenticationBox.domain, userID: authContext.mastodonAuthenticationBox.userID) - ) + self.dataController.records = (try? FileManager.default.cachedHomeTimeline(for: authContext.mastodonAuthenticationBox).map { + MastodonFeed.fromStatus($0, kind: .home) + }) ?? [] homeTimelineNeedRefresh .sink { [weak self] _ in @@ -103,6 +102,20 @@ final class HomeTimelineViewModel: NSObject { self.homeTimelineNeedRefresh.send() } .store(in: &disposeBag) + + self.dataController.$records + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { feeds in + let items: [MastodonStatus] = feeds.compactMap { feed -> MastodonStatus? in + guard let status = feed.status else { return nil } + return status + } + FileManager.default.cacheHomeTimeline(items: items, for: authContext.mastodonAuthenticationBox) + }) + .store(in: &disposeBag) + + self.dataController.loadInitial(kind: .home) } } @@ -116,7 +129,7 @@ extension HomeTimelineViewModel { extension HomeTimelineViewModel { func timelineDidReachEnd() { - fetchedResultsController.fetchNextBatch() + dataController.loadNext(kind: .home) } } @@ -128,47 +141,21 @@ extension HomeTimelineViewModel { guard let diffableDataSource = diffableDataSource else { return } var snapshot = diffableDataSource.snapshot() - let managedObjectContext = context.managedObjectContext - let key = "LoadMore@\(record.objectID)" - - guard let feed = record.object(in: managedObjectContext) else { return } - guard let status = feed.status else { return } - - // keep transient property live - managedObjectContext.cache(feed, key: key) - defer { - managedObjectContext.cache(nil, key: key) - } - do { - // update state - try await managedObjectContext.performChanges { - feed.update(isLoadingMore: true) - } - } catch { - assertionFailure(error.localizedDescription) - } - + guard let status = record.status else { return } + record.isLoadingMore = true + // reconfigure item snapshot.reconfigureItems([item]) await updateSnapshotUsingReloadData(snapshot: snapshot) // fetch data - do { - let maxID = status.id - _ = try await context.apiService.homeTimeline( - maxID: maxID, - authenticationBox: authContext.mastodonAuthenticationBox - ) - } catch { - do { - // restore state - try await managedObjectContext.performChanges { - feed.update(isLoadingMore: false) - } - } catch { - assertionFailure(error.localizedDescription) - } - } + let maxID = status.id + _ = try? await context.apiService.homeTimeline( + maxID: maxID, + authenticationBox: authContext.mastodonAuthenticationBox + ) + + record.isLoadingMore = false // reconfigure item again snapshot.reconfigureItems([item]) 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..fb83eb380 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,22 +21,29 @@ extension NotificationTimelineViewController: DataSourceProvider { } switch item { - case .feed(let record): + case .feed(let feed): let managedObjectContext = context.managedObjectContext - let item: DataSourceItem? = try? await managedObjectContext.perform { - guard let feed = record.object(in: managedObjectContext) else { return nil } + let item: DataSourceItem? = { guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } - if let notification = feed.notification { - return .notification(record: .init(objectID: notification.objectID)) + if let notification = feed.notification, let mastodonNotification = MastodonNotification.fromEntity(notification, using: managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) { + return .notification(record: mastodonNotification) } else { return nil } - } + }() return item default: return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.delete(status: status) + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index d081327d3..053ce9e76 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -276,18 +276,18 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return } + let domain = authContext.mastodonAuthenticationBox.domain Task { @MainActor in switch item { case .feed(let record): - guard let feed = record.object(in: self.context.managedObjectContext) else { return } - guard let notification = feed.notification else { return } + guard let notification = record.notification else { return } - if let stauts = notification.status { + if let status = notification.status { let threadViewModel = ThreadViewModel( context: self.context, authContext: self.viewModel.authContext, - optionalRoot: .root(context: .init(status: .init(objectID: stauts.objectID))) + optionalRoot: .root(context: .init(status: .fromEntity(status))) ) _ = self.coordinator.present( scene: .thread(viewModel: threadViewModel), @@ -295,16 +295,25 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { transition: .show ) } else { - let profileViewModel = ProfileViewModel( - context: self.context, - authContext: self.viewModel.authContext, - optionalMastodonUser: notification.account - ) - _ = self.coordinator.present( - scene: .profile(viewModel: profileViewModel), - from: self, - transition: .show - ) + context.managedObjectContext.perform { + let mastodonUserRequest = MastodonUser.sortedFetchRequest + mastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: notification.account.id) + mastodonUserRequest.fetchLimit = 1 + guard let mastodonUser = try? self.context.managedObjectContext.fetch(mastodonUserRequest).first else { + return + } + + let profileViewModel = ProfileViewModel( + context: self.context, + authContext: self.viewModel.authContext, + optionalMastodonUser: mastodonUser + ) + _ = self.coordinator.present( + scene: .profile(viewModel: profileViewModel), + from: self, + transition: .show + ) + } } default: break diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index c412c39a4..0598ae006 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -7,7 +7,7 @@ import UIKit import CoreData -import CoreDataStack +import MastodonSDK extension NotificationTimelineViewModel { @@ -30,7 +30,7 @@ extension NotificationTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - feedFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } @@ -48,34 +48,16 @@ extension NotificationTimelineViewModel { return snapshot }() - let parentManagedObjectContext = self.context.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - try? await managedObjectContext.perform { - let anchors: [Feed] = { - let request = Feed.sortedFetchRequest - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Feed.hasMorePredicate(), - self.feedFetchedResultsController.predicate, - ]) - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - - let itemIdentifiers = newSnapshot.itemIdentifiers - for (index, item) in itemIdentifiers.enumerated() { - guard case let .feed(record) = item else { continue } - guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue } - let isLast = index + 1 == itemIdentifiers.count - if isLast { - newSnapshot.insertItems([.bottomLoader], afterItem: item) - } else { - newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) - } + let anchors: [MastodonFeed] = records.filter { $0.hasMore == true } + let itemIdentifiers = newSnapshot.itemIdentifiers + for (index, item) in itemIdentifiers.enumerated() { + guard case let .feed(record) = item else { continue } + guard anchors.contains(where: { feed in feed.id == record.id }) else { continue } + let isLast = index + 1 == itemIdentifiers.count + if isLast { + newSnapshot.insertItems([.bottomLoader], afterItem: item) + } else { + newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index 3be724701..17222acd7 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -32,7 +32,7 @@ extension NotificationTimelineViewModel.LoadOldestState { class Initial: NotificationTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !viewModel.feedFetchedResultsController.records.isEmpty else { return false } + guard !viewModel.dataController.records.isEmpty else { return false } return stateClass == Loading.self } } @@ -47,7 +47,7 @@ extension NotificationTimelineViewModel.LoadOldestState { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let lastFeedRecord = viewModel.feedFetchedResultsController.records.last else { + guard let lastFeedRecord = viewModel.dataController.records.last else { stateMachine.enter(Fail.self) return } @@ -55,12 +55,7 @@ extension NotificationTimelineViewModel.LoadOldestState { Task { let managedObjectContext = viewModel.context.managedObjectContext - let _maxID: Mastodon.Entity.Notification.ID? = try await managedObjectContext.perform { - guard let feed = lastFeedRecord.object(in: managedObjectContext), - let notification = feed.notification - else { return nil } - return notification.id - } + let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id guard let maxID = _maxID else { await self.enter(state: Fail.self) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index a6412d365..a3bcbb877 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -20,7 +20,7 @@ final class NotificationTimelineViewModel { let context: AppContext let authContext: AuthContext let scope: Scope - let feedFetchedResultsController: FeedFetchedResultsController + let dataController: FeedDataController let listBatchFetchViewModel = ListBatchFetchViewModel() @Published var isLoadingLatest = false @Published var lastAutomaticFetchTimestamp: Date? @@ -43,6 +43,7 @@ final class NotificationTimelineViewModel { return stateMachine }() + @MainActor init( context: AppContext, authContext: AuthContext, @@ -51,13 +52,35 @@ final class NotificationTimelineViewModel { self.context = context self.authContext = authContext self.scope = scope - self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) - // end init + self.dataController = FeedDataController(context: context, authContext: authContext) - feedFetchedResultsController.predicate = NotificationTimelineViewModel.feedPredicate( - authenticationBox: authContext.mastodonAuthenticationBox, - scope: scope - ) + switch scope { + case .everything: + self.dataController.records = (try? FileManager.default.cachedNotificationsAll(for: authContext.mastodonAuthenticationBox))?.map({ notification in + MastodonFeed.fromNotification(notification, kind: .notificationAll) + }) ?? [] + case .mentions: + self.dataController.records = (try? FileManager.default.cachedNotificationsMentions(for: authContext.mastodonAuthenticationBox))?.map({ notification in + MastodonFeed.fromNotification(notification, kind: .notificationMentions) + }) ?? [] + } + + self.dataController.$records + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { feeds in + let items: [Mastodon.Entity.Notification] = feeds.compactMap { feed -> Mastodon.Entity.Notification? in + guard let status = feed.notification else { return nil } + return status + } + switch self.scope { + case .everything: + FileManager.default.cacheNotificationsAll(items: items, for: authContext.mastodonAuthenticationBox) + case .mentions: + FileManager.default.cacheNotificationsMentions(items: items, for: authContext.mastodonAuthenticationBox) + } + }) + .store(in: &disposeBag) } @@ -66,41 +89,6 @@ final class NotificationTimelineViewModel { extension NotificationTimelineViewModel { typealias Scope = APIService.MastodonNotificationScope - - static func feedPredicate( - authenticationBox: MastodonAuthenticationBox, - scope: Scope - ) -> NSPredicate { - let domain = authenticationBox.domain - let userID = authenticationBox.userID - let acct = Feed.Acct.mastodon( - domain: domain, - userID: userID - ) - - let predicate: NSPredicate = { - switch scope { - case .everything: - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - Feed.hasNotificationPredicate(), - Feed.predicate( - kind: .notificationAll, - acct: acct - ) - ]) - case .mentions: - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - Feed.hasNotificationPredicate(), - Feed.predicate( - kind: .notificationMentions, - acct: acct - ), - Feed.notificationTypePredicate(types: scope.includeTypes ?? []) - ]) - } - }() - return predicate - } } @@ -111,44 +99,23 @@ extension NotificationTimelineViewModel { isLoadingLatest = true defer { isLoadingLatest = false } - do { - _ = try await context.apiService.notifications( - maxID: nil, - scope: scope, - authenticationBox: authContext.mastodonAuthenticationBox - ) - } catch { - didLoadLatest.send() + switch scope { + case .everything: + dataController.loadInitial(kind: .notificationAll) + case .mentions: + dataController.loadInitial(kind: .notificationMentions) } + + didLoadLatest.send() } // load timeline gap func loadMore(item: NotificationItem) async { - guard case let .feedLoader(record) = item else { return } - - let managedObjectContext = context.managedObjectContext - let key = "LoadMore@\(record.objectID)" - - // return when already loading state - guard managedObjectContext.cache(froKey: key) == nil else { return } - - guard let feed = record.object(in: managedObjectContext) else { return } - guard let maxID = feed.notification?.id else { return } - // keep transient property live - managedObjectContext.cache(feed, key: key) - defer { - managedObjectContext.cache(nil, key: key) - } - - // fetch data - do { - _ = try await context.apiService.notifications( - maxID: maxID, - scope: scope, - authenticationBox: authContext.mastodonAuthenticationBox - ) - } catch { + switch scope { + case .everything: + dataController.loadNext(kind: .notificationAll) + case .mentions: + dataController.loadNext(kind: .notificationMentions) } } - } diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift index a22fb4309..642f3605b 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension BookmarkViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -26,6 +27,16 @@ extension BookmarkViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.setRecords( + viewModel.dataController.records.filter { $0.id != status.id } + ) + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift index d52309e92..0a5cd3d3a 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift @@ -32,7 +32,7 @@ extension BookmarkViewModel { stateMachine.enter(State.Reloading.self) - statusFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+State.swift index f746a5acb..74faf0741 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.dataController.reset() + } stateMachine.enter(Loading.self) } @@ -128,10 +130,10 @@ extension BookmarkViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.dataController.records.map { $0.entity } for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(status) else { continue } + statusIDs.append(status) hasNewStatusesAppend = true } @@ -147,7 +149,9 @@ extension BookmarkViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + + await viewModel.dataController.setRecords(statusIDs.map { MastodonStatus.fromEntity($0) }) + } catch { await enter(state: Fail.self) } diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift index f56e65526..420f0f94f 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel.swift @@ -20,7 +20,7 @@ final class BookmarkViewModel { let context: AppContext let authContext: AuthContext - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() // output @@ -38,14 +38,11 @@ final class BookmarkViewModel { return stateMachine }() + @MainActor init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() } } diff --git a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift index 57b2ac897..5db16d519 100644 --- a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift +++ b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift @@ -10,6 +10,7 @@ import Combine import MastodonCore import MastodonLocalization import MastodonUI +import MastodonSDK final class FamiliarFollowersViewController: UIViewController, NeedsDependency { @@ -101,6 +102,14 @@ extension FamiliarFollowersViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift index 8fe8d1bd7..ad2f06ef2 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension FavoriteViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -26,6 +27,16 @@ extension FavoriteViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.setRecords( + viewModel.dataController.records.filter { $0.id != status.id } + ) + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 367a4d51f..50a1708fb 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -32,7 +32,7 @@ extension FavoriteViewModel { stateMachine.enter(State.Reloading.self) - statusFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift index a1a8d0f99..cb246448c 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.dataController.reset() + + stateMachine.enter(Loading.self) + } } } @@ -127,10 +129,10 @@ extension FavoriteViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.dataController.records for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(where: { $0.id == status.id }) else { continue } + statusIDs.append(.fromEntity(status)) hasNewStatusesAppend = true } @@ -146,7 +148,7 @@ extension FavoriteViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + await viewModel.dataController.setRecords(statusIDs) } catch { await enter(state: Fail.self) } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift index 0dd3c7203..8e86b0256 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -19,7 +19,7 @@ final class FavoriteViewModel { // input let context: AppContext let authContext: AuthContext - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() // output @@ -37,14 +37,11 @@ final class FavoriteViewModel { return stateMachine }() + @MainActor init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() } } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift index daa2838a5..404eae6fa 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift @@ -11,6 +11,7 @@ import Combine import MastodonCore import MastodonUI import MastodonLocalization +import MastodonSDK final class FollowerListViewController: UIViewController, NeedsDependency { @@ -153,6 +154,14 @@ extension FollowerListViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index 28fdac266..f1bd37ae7 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -12,6 +12,7 @@ import MastodonLocalization import MastodonCore import MastodonUI import CoreDataStack +import MastodonSDK final class FollowingListViewController: UIViewController, NeedsDependency { @@ -148,6 +149,14 @@ extension FollowingListViewController: DataSourceProvider { return nil } } + + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { 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/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 6fa6c1372..81578a230 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -16,6 +16,7 @@ import MastodonLocalization import CoreDataStack import TabBarPager import XLPagerTabStrip +import MastodonSDK protocol ProfileViewModelEditable { var isEdited: Bool { get } @@ -916,6 +917,7 @@ extension ProfileViewController: MastodonMenuDelegate { action: action, menuContext: DataSourceFacade.MenuContext( author: userRecord, + authorEntity: nil, statusViewModel: nil, button: nil, barButtonItem: self.moreMenuBarButtonItem @@ -962,3 +964,18 @@ private extension ProfileViewController { authContext.mastodonAuthenticationBox.authentication.instance(in: context.managedObjectContext) } } + +extension ProfileViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + assertionFailure("Not required") + return nil + } + + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } +} 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/UserTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift index 2b18fad56..d161c60e9 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension UserTimelineViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -27,6 +28,14 @@ extension UserTimelineViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.deleteRecord(status) + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 422440020..a9c96337a 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -49,7 +49,7 @@ extension UserTimelineViewModel { ).map { $0 || $1 || $2 || $3 } Publishers.CombineLatest( - statusFetchedResultsController.$records, + dataController.$records, needsTimelineHidden.removeDuplicates() ) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index cd0110a87..32c9adc60 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.dataController.reset() + + stateMachine.enter(Loading.self) + } } } @@ -112,17 +114,17 @@ extension UserTimelineViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - let maxID = viewModel.statusFetchedResultsController.statusIDs.last - - guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else { - stateMachine.enter(Fail.self) - return - } - - let queryFilter = viewModel.queryFilter Task { - + let maxID = await viewModel.dataController.records.last?.id + + guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + let queryFilter = viewModel.queryFilter + do { let response = try await viewModel.context.apiService.userTimeline( accountID: userID, @@ -135,10 +137,10 @@ extension UserTimelineViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.dataController.records for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(where: { $0.id == status.id }) else { continue } + statusIDs.append(.fromEntity(status)) hasNewStatusesAppend = true } @@ -147,7 +149,7 @@ extension UserTimelineViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + await viewModel.dataController.setRecords(statusIDs) } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 7a4925b8d..8598aafb9 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -21,7 +21,7 @@ final class UserTimelineViewModel { let context: AppContext let authContext: AuthContext let title: String - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() @Published var userIdentifier: UserIdentifier? @Published var queryFilter: QueryFilter @@ -49,6 +49,7 @@ final class UserTimelineViewModel { return stateMachine }() + @MainActor init( context: AppContext, authContext: AuthContext, @@ -58,11 +59,7 @@ final class UserTimelineViewModel { self.context = context self.authContext = authContext self.title = title - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() self.queryFilter = queryFilter } } diff --git a/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift index bae2325c5..c027f39f3 100644 --- a/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK extension FavoritedByViewController: DataSourceProvider { func item(from source: DataSourceItem.Source) async -> DataSourceItem? { @@ -27,6 +28,14 @@ extension FavoritedByViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift index c7afa6e9c..90df17264 100644 --- a/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift @@ -6,8 +6,10 @@ // import UIKit +import MastodonSDK extension RebloggedByViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { var _indexPath = source.indexPath if _indexPath == nil, let cell = source.tableViewCell { @@ -27,6 +29,14 @@ extension RebloggedByViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Profile/UserList/UserListViewModel.swift b/Mastodon/Scene/Profile/UserList/UserListViewModel.swift index f9610ee61..14665334a 100644 --- a/Mastodon/Scene/Profile/UserList/UserListViewModel.swift +++ b/Mastodon/Scene/Profile/UserList/UserListViewModel.swift @@ -53,7 +53,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+Diffable.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+Diffable.swift index fce56a2b9..92be10465 100644 --- a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+Diffable.swift +++ b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+Diffable.swift @@ -32,7 +32,7 @@ extension ReportStatusViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - statusFetchedResultsController.$records + dataController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift index d4fb507b2..5238cdd70 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.dataController.records.last?.id + let managedObjectContext = viewModel.context.managedObjectContext let _userID: MastodonUser.ID? = try await managedObjectContext.perform { guard let user = viewModel.user.object(in: managedObjectContext) else { return nil } @@ -89,10 +90,10 @@ extension ReportStatusViewModel.State { ) var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs + var statusIDs = await viewModel.dataController.records for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) + guard !statusIDs.contains(where: { $0.id == status.id }) else { continue } + statusIDs.append(.fromEntity(status)) hasNewStatusesAppend = true } @@ -101,7 +102,7 @@ extension ReportStatusViewModel.State { } else { await enter(state: NoMore.self) } - viewModel.statusFetchedResultsController.statusIDs = statusIDs + await viewModel.dataController.setRecords(statusIDs) } catch { await enter(state: Fail.self) diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift index 8c41e1ce0..47aaeeb86 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 statusFetchedResultsController: StatusFetchedResultsController + let status: MastodonStatus? + let dataController: StatusDataController 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.dataController = StatusDataController() // 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..f5078847a 100644 --- a/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Report/Share/Cell/ReportStatusTableViewCell+ViewModel.swift @@ -6,13 +6,14 @@ // import UIKit -import CoreDataStack +import MastodonSDK extension ReportStatusTableViewCell { + // todo: refactor / remove this final class ViewModel { - let value: Status + let value: MastodonStatus - init(value: Status) { + init(value: MastodonStatus) { self.value = value } } diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index a115331ce..bbb690541 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -87,6 +87,7 @@ class MainTabBarController: UITabBarController { return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80)) } + @MainActor func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) -> UIViewController { guard let authContext = authContext else { return UITableViewController() @@ -171,7 +172,9 @@ extension MainTabBarController { // seealso: `ThemeService.apply(theme:)` let tabs = Tab.allCases - let viewControllers: [UIViewController] = tabs.map { tab in + var viewControllers = [UIViewController]() + + for tab in tabs { let viewController = tab.viewController(context: context, authContext: authContext, coordinator: coordinator) viewController.tabBarItem.tag = tab.tag viewController.tabBarItem.title = tab.title // needs for acessiblity large content label @@ -180,12 +183,14 @@ extension MainTabBarController { viewController.tabBarItem.accessibilityLabel = tab.title viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) - return viewController + viewControllers.append(viewController) } + _viewControllers = viewControllers setViewControllers(viewControllers, animated: false) selectedIndex = 0 - + + // hacky workaround for FB11986255 (Setting accessibilityUserInputLabels on a UITabBarItem has no effect) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { if let searchItem = self.tabBar.subviews.first(where: { $0.accessibilityLabel == Tab.search.title }) { @@ -214,7 +219,7 @@ extension MainTabBarController { .store(in: &disposeBag) // handle post failure - + // handle push notification. // toggle entry when finish fetch latest notification Publishers.CombineLatest( @@ -231,7 +236,7 @@ extension MainTabBarController { let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.accessToken) return count > 0 } ?? false - + let image: UIImage if hasUnreadPushNotification { let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.red, SystemTheme.tabBarItemNormalIconColor]) @@ -270,12 +275,12 @@ extension MainTabBarController { guard user.managedObjectContext != nil else { return } self.avatarURL = user.avatarImageURL() } - + // a11y let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } guard let profileTabItem = _profileTabItem else { return } profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(user.displayNameWithFallback) - + self.context.authenticationService.updateActiveUserAccountPublisher .sink { [weak self] in self?.updateUserAccount() @@ -286,18 +291,18 @@ extension MainTabBarController { } } .store(in: &disposeBag) - + let tabBarLongPressGestureRecognizer = UILongPressGestureRecognizer() tabBarLongPressGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarLongPressGestureRecognizerHandler(_:))) tabBarLongPressGestureRecognizer.delegate = self tabBar.addGestureRecognizer(tabBarLongPressGestureRecognizer) - + let tabBarDoubleTapGestureRecognizer = UITapGestureRecognizer() tabBarDoubleTapGestureRecognizer.numberOfTapsRequired = 2 tabBarDoubleTapGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarDoubleTapGestureRecognizerHandler(_:))) tabBarDoubleTapGestureRecognizer.delaysTouchesEnded = false tabBar.addGestureRecognizer(tabBarDoubleTapGestureRecognizer) - + self.isReadyForWizardAvatarButton = authContext != nil $currentTab diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift index 7bc2fa65d..71b0e599f 100644 --- a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift @@ -81,22 +81,10 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl showProfile(viewController, for: account) } else if let status = searchResult.statuses.first { - let status = try await managedObjectContext.perform { - return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext( - domain: authContext.mastodonAuthenticationBox.domain, - entity: status, - me: authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext), - statusCache: nil, - userCache: nil, - networkDate: Date())) - } - - guard let status else { return } - await DataSourceFacade.coordinateToStatusThreadScene( provider: viewController, target: .status, // remove reblog wrapper - status: status.asRecord + status: MastodonStatus.fromEntity(status) ) } else if let url = URL(string: urlString) { let prefixedURL: URL? diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift index 8ec211193..1e1a03d5f 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK // MARK: - DataSourceProvider extension SearchHistoryViewController: DataSourceProvider { @@ -28,6 +29,14 @@ extension SearchHistoryViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + assertionFailure("Not required") + } + + func delete(status: MastodonStatus) { + assertionFailure("Not required") + } + @MainActor private func indexPath(for cell: UICollectionViewCell) async -> IndexPath? { return collectionView.indexPath(for: cell) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift index 21f0d4b72..2ec6312a2 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 account(Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) - 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 87b554e34..42b1a1585 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift @@ -54,20 +54,16 @@ extension SearchResultSection { relationship: relationship, delegate: configuration.userTableViewCellDelegate ) - - return cell - case .status(let record): + return cell + case .status(let status): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - context.managedObjectContext.performAndWait { - guard let status = record.object(in: context.managedObjectContext) else { return } - configure( - context: context, - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .status(status)), - configuration: configuration - ) - } + configure( + context: context, + tableView: tableView, + cell: cell, + viewModel: StatusTableViewCell.ViewModel(value: .status(status)), + configuration: configuration + ) return cell case .hashtag(let tag): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index ecd9270e3..fde5bceee 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK // MARK: - DataSourceProvider extension SearchResultViewController: DataSourceProvider { @@ -32,6 +33,14 @@ extension SearchResultViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + viewModel.dataController.update(status: status) + } + + func delete(status: MastodonStatus) { + viewModel.dataController.deleteRecord(status) + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift index e08ab0275..5499248e7 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift @@ -33,7 +33,7 @@ extension SearchResultViewModel { diffableDataSource.apply(snapshot, animatingDifferences: false) Publishers.CombineLatest3( - statusFetchedResultsController.$records, + dataController.$records, $accounts, $hashtags ) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index f9bdb2baa..63835ea1e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -123,6 +123,8 @@ extension SearchResultViewModel.State { // discard result when state is not Loading guard stateMachine.currentState is Loading else { return } + let statuses = searchResults.statuses.map { MastodonStatus.fromEntity($0) } + let accounts = searchResults.accounts let relationships: [Mastodon.Entity.Relationship] @@ -135,9 +137,7 @@ extension SearchResultViewModel.State { relationships = [] } - let statusIDs = searchResults.statuses.map { $0.id } - - let isNoMore = accounts.isEmpty && statusIDs.isEmpty + let isNoMore = accounts.isEmpty && statuses.isEmpty if viewModel.searchScope == .all || isNoMore { await enter(state: NoMore.self) @@ -149,19 +149,18 @@ extension SearchResultViewModel.State { if offset == nil { viewModel.relationships = [] viewModel.accounts = [] - viewModel.statusFetchedResultsController.statusIDs = [] + await viewModel.dataController.reset() viewModel.hashtags = [] } - - // due to combine relationships must be set first + // due to combine relationships must be set first var existingRelationships = viewModel.relationships for hashtag in relationships where !existingRelationships.contains(hashtag) { existingRelationships.append(hashtag) } viewModel.relationships = existingRelationships - - viewModel.statusFetchedResultsController.append(statusIDs: statusIDs) + + await viewModel.dataController.appendRecords(statuses) var existingHashtags = viewModel.hashtags for hashtag in searchResults.hashtags where !existingHashtags.contains(hashtag) { diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index c7459ebbe..34b4f4299 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -24,7 +24,7 @@ final class SearchResultViewModel { @Published var hashtags: [Mastodon.Entity.Tag] = [] @Published var accounts: [Mastodon.Entity.Account] = [] var relationships: [Mastodon.Entity.Relationship] = [] - let statusFetchedResultsController: StatusFetchedResultsController + let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() var cellFrameCache = NSCache() @@ -46,6 +46,7 @@ final class SearchResultViewModel { }() let didDataSourceUpdate = PassthroughSubject() + @MainActor init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all, searchText: String) { self.context = context self.authContext = authContext @@ -54,10 +55,6 @@ final class SearchResultViewModel { self.accounts = [] self.relationships = [] - self.statusFetchedResultsController = StatusFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: authContext.mastodonAuthenticationBox.domain, - additionalTweetPredicate: nil - ) + self.dataController = StatusDataController() } } diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift index 576d2eadf..98dec5397 100644 --- a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -16,30 +16,33 @@ import MastodonAsset import MastodonCore import MastodonLocalization import class CoreDataStack.Notification +import MastodonSDK extension NotificationView { - public func configure(feed: Feed) { - guard let notification = feed.notification else { + public func configure(feed: MastodonFeed) { + guard + let notification = feed.notification, + let managedObjectContext = viewModel.context?.managedObjectContext + else { assertionFailure() return } - - configure(notification: notification) + + MastodonNotification.fromEntity( + notification, + using: managedObjectContext, + domain: viewModel.authContext?.mastodonAuthenticationBox.domain ?? "" + ).map(configure(notification:)) } } extension NotificationView { - public func configure(notification: Notification) { + public func configure(notification: MastodonNotification) { viewModel.objects.insert(notification) configureAuthor(notification: notification) - - guard let type = MastodonNotificationType(rawValue: notification.typeRaw) else { - assertionFailure() - return - } - - switch type { + + switch notification.entity.type { case .follow: setAuthorContainerBottomPaddingViewDisplay() case .followRequest: @@ -63,7 +66,7 @@ extension NotificationView { } extension NotificationView { - private func configureAuthor(notification: Notification) { + private func configureAuthor(notification: MastodonNotification) { let author = notification.account // author avatar @@ -98,19 +101,18 @@ extension NotificationView { .assign(to: \.authorUsername, on: viewModel) .store(in: &disposeBag) // timestamp - viewModel.timestamp = notification.createAt + viewModel.timestamp = notification.entity.createdAt - viewModel.visibility = notification.status?.visibility ?? ._other("") + viewModel.visibility = notification.entity.status?.mastodonVisibility ?? ._other("") // notification type indicator - Publishers.CombineLatest3( - notification.publisher(for: \.typeRaw), + Publishers.CombineLatest( author.publisher(for: \.displayName), author.publisher(for: \.emojis) ) - .sink { [weak self] typeRaw, _, emojis in + .sink { [weak self] _, emojis in guard let self = self else { return } - guard let type = MastodonNotificationType(rawValue: typeRaw) else { + guard let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) else { self.viewModel.notificationIndicatorText = nil return } @@ -205,13 +207,8 @@ extension NotificationView { .store(in: &disposeBag) // follow request state - notification.publisher(for: \.followRequestState) - .assign(to: \.followRequestState, on: viewModel) - .store(in: &disposeBag) - - notification.publisher(for: \.transientFollowRequestState) - .assign(to: \.transientFollowRequestState, on: viewModel) - .store(in: &disposeBag) + viewModel.followRequestState = notification.followRequestState + viewModel.transientFollowRequestState = notification.transientFollowRequestState // Following author.publisher(for: \.followingBy) diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift index 631e5b337..c815190be 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -14,8 +14,8 @@ import MastodonUI import MastodonSDK extension PollOptionView { - public func configure(pollOption option: PollOption) { - guard let poll = option.poll, let status = poll.status else { + public func configure(pollOption option: PollOption, status: MastodonStatus?) { + guard let poll = option.poll else { assertionFailure("PollOption to be configured is expected to be part of Poll with Status") return } @@ -48,8 +48,8 @@ extension PollOptionView { viewModel.isMultiple = poll.multiple let optionIndex = option.index - let authorDomain = status.author.domain - let authorID = status.author.id + let authorDomain = status?.entity.account.domain ?? "" + let authorID = status?.entity.account.id ?? "" // isSelect, isPollVoted, isMyPoll Publishers.CombineLatest4( option.publisher(for: \.poll), 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 deleted file mode 100644 index 00c29e157..000000000 --- a/Mastodon/Scene/Thread/CachedThreadViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// CachedThreadViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-12. -// - -import Foundation -import CoreDataStack -import MastodonCore - -final class CachedThreadViewModel: ThreadViewModel { - init(context: AppContext, authContext: AuthContext, status: Status) { - let threadContext = StatusItem.Thread.Context(status: .init(objectID: status.objectID)) - super.init( - context: context, - authContext: authContext, - optionalRoot: .root(context: threadContext) - ) - } -} 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..34151e4a6 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 @@ -77,82 +77,39 @@ final class MastodonStatusThreadViewModel { extension MastodonStatusThreadViewModel { func appendAncestor( - domain: String, nodes: [Node] ) { - let ids = nodes.map { $0.statusID } - var dictionary: [Status.ID: Status] = [:] - do { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, ids: ids) - let statuses = try self.context.managedObjectContext.fetch(request) - for status in statuses { - dictionary[status.id] = status - } - } catch { - return - } - var newItems: [StatusItem] = [] - for (i, node) in nodes.enumerated() { - guard let status = dictionary[node.statusID] else { continue } - let isLast = i == nodes.count - 1 - - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: record, - displayUpperConversationLink: !isLast, - displayBottomConversationLink: true - ) - let item = StatusItem.thread(.leaf(context: context)) + for node in nodes { + let item = StatusItem.thread(.leaf(context: .init(status: node.status))) newItems.append(item) } let items = self.__ancestors + newItems - self.__ancestors = items + self.__ancestors = items.removingDuplicates() } func appendDescendant( - domain: String, nodes: [Node] ) { - let childrenIDs = nodes - .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } - .flatMap { $0 } - var dictionary: [Status.ID: Status] = [:] - do { - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, ids: childrenIDs) - let statuses = try self.context.managedObjectContext.fetch(request) - for status in statuses { - dictionary[status.id] = status - } - } catch { - return - } - + var newItems: [StatusItem] = [] + for node in nodes { - guard let status = dictionary[node.statusID] else { continue } - // first tier - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: record - ) + let context = StatusItem.Thread.Context(status: node.status) let item = StatusItem.thread(.leaf(context: context)) newItems.append(item) // second tier if let child = node.children.first { - guard let secondaryStatus = dictionary[child.statusID] else { continue } - let secondaryRecord = ManagedObjectRecord(objectID: secondaryStatus.objectID) + guard let secondaryStatus = node.children.first(where: { $0.status.id == child.status.id}) else { continue } let secondaryContext = StatusItem.Thread.Context( - status: secondaryRecord, + status: secondaryStatus.status, displayUpperConversationLink: true ) let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) newItems.append(secondaryItem) - + // update first tier context context.displayBottomConversationLink = true } @@ -163,23 +120,21 @@ extension MastodonStatusThreadViewModel { guard !items.contains(item) else { continue } items.append(item) } - self.__descendants = items + self.__descendants = items.removingDuplicates() } } extension MastodonStatusThreadViewModel { class Node { - typealias ID = String - - let statusID: ID + let status: MastodonStatus let children: [Node] init( - statusID: ID, + status: MastodonStatus, children: [MastodonStatusThreadViewModel.Node] ) { - self.statusID = statusID + self.status = status self.children = children } } @@ -204,7 +159,7 @@ extension MastodonStatusThreadViewModel.Node { while let _nextID = nextID { guard let status = dict[_nextID] else { break } nodes.append(MastodonStatusThreadViewModel.Node( - statusID: _nextID, + status: .fromEntity(status), children: [] )) nextID = status.inReplyToID @@ -216,11 +171,11 @@ extension MastodonStatusThreadViewModel.Node { extension MastodonStatusThreadViewModel.Node { static func children( - of statusID: ID, + of status: MastodonStatus, from statuses: [Mastodon.Entity.Status] ) -> [MastodonStatusThreadViewModel.Node] { - var dictionary: [ID: Mastodon.Entity.Status] = [:] - var mapping: [ID: Set] = [:] + var dictionary: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status] = [:] + var mapping: [Mastodon.Entity.Status.ID: Set] = [:] for status in statuses { dictionary[status.id] = status @@ -234,40 +189,31 @@ extension MastodonStatusThreadViewModel.Node { } var children: [MastodonStatusThreadViewModel.Node] = [] - let replies = Array(mapping[statusID] ?? Set()) + let replies = Array(mapping[status.id] ?? Set()) .compactMap { dictionary[$0] } .sorted(by: { $0.createdAt > $1.createdAt }) for reply in replies { - let child = child(of: reply.id, dictionary: dictionary, mapping: mapping) + let child = child(of: reply, dictionary: dictionary, mapping: mapping) children.append(child) } return children } static func child( - of statusID: ID, - dictionary: [ID: Mastodon.Entity.Status], - mapping: [ID: Set] + of status: Mastodon.Entity.Status, + dictionary: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status], + mapping: [Mastodon.Entity.Status.ID: Set] ) -> MastodonStatusThreadViewModel.Node { - let childrenIDs = mapping[statusID] ?? [] + let childrenIDs = mapping[status.id] ?? [] let children = Array(childrenIDs) .compactMap { dictionary[$0] } .sorted(by: { $0.createdAt > $1.createdAt }) - .map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) } + .map { status in child(of: status, dictionary: dictionary, mapping: mapping) } return MastodonStatusThreadViewModel.Node( - statusID: statusID, + status: .fromEntity(status), children: children ) } } -extension MastodonStatusThreadViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - var set = deletedObjectIDs - for objectID in objectIDs { - set.insert(objectID) - } - self.deletedObjectIDs = set - } -} 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/ThreadViewController+DataSourceProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift index 06d510a82..f3373340b 100644 --- a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonSDK // MARK: - DataSourceProvider extension ThreadViewController: DataSourceProvider { @@ -28,8 +29,121 @@ extension ThreadViewController: DataSourceProvider { } } + func update(status: MastodonStatus) { + switch viewModel.root { + case let .root(context): + if context.status.id == status.id { + viewModel.root = .root(context: .init(status: status)) + } else { + handle(status: status) + } + case let .reply(context): + if context.status.id == status.id { + viewModel.root = .reply(context: .init(status: status)) + } else { + handle(status: status) + } + case let .leaf(context): + if context.status.id == status.id { + viewModel.root = .leaf(context: .init(status: status)) + } else { + handle(status: status) + } + case .none: + assertionFailure("This should not have happened") + } + } + + private func handle(status: MastodonStatus) { + viewModel.mastodonStatusThreadViewModel.ancestors.handleUpdate(status: status, for: viewModel) + viewModel.mastodonStatusThreadViewModel.descendants.handleUpdate(status: status, for: viewModel) + } + + func delete(status: MastodonStatus) { + if viewModel.root?.record.id == status.id { + viewModel.root = nil + viewModel.onDismiss.send(status) + } + viewModel.mastodonStatusThreadViewModel.ancestors.handleDelete(status: status, for: viewModel) + viewModel.mastodonStatusThreadViewModel.descendants.handleDelete(status: status, for: viewModel) + } + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) } } + +private extension [StatusItem] { + mutating func handleUpdate(status: MastodonStatus, for viewModel: ThreadViewModel) { + for (index, ancestor) in enumerated() { + switch ancestor { + case let .feed(record): + if record.status?.id == status.id { + self[index] = .feed(record: .fromStatus(status, kind: record.kind)) + } + case let.feedLoader(record): + if record.status?.id == status.id { + self[index] = .feedLoader(record: .fromStatus(status, kind: record.kind)) + } + case let .status(record): + if record.id == status.id { + self[index] = .status(record: status) + } + case let .thread(thread): + switch thread { + case let .root(context): + if context.status.id == status.id { + self[index] = .thread(.root(context: .init(status: status))) + } + case let .reply(context): + if context.status.id == status.id { + self[index] = .thread(.reply(context: .init(status: status))) + } + case let .leaf(context): + if context.status.id == status.id { + self[index] = .thread(.leaf(context: .init(status: status))) + } + } + case .bottomLoader, .topLoader: + break + } + } + } + + mutating func handleDelete(status: MastodonStatus, for viewModel: ThreadViewModel) { + for (index, ancestor) in enumerated() { + switch ancestor { + case let .feed(record): + if record.status?.id == status.id { + self.remove(at: index) + } + case let.feedLoader(record): + if record.status?.id == status.id { + self.remove(at: index) + } + case let .status(record): + if record.id == status.id { + self.remove(at: index) + } + case let .thread(thread): + switch thread { + case let .root(context): + if context.status.id == status.id { + self.remove(at: index) + } + case let .reply(context): + if context.status.id == status.id { + self.remove(at: index) + } + case let .leaf(context): + if context.status.id == status.id { + self.remove(at: index) + } + } + case .bottomLoader, .topLoader: + break + } + } + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 584f60753..ed8808b32 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -14,6 +14,7 @@ import MastodonAsset import MastodonCore import MastodonUI import MastodonLocalization +import MastodonSDK final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -70,6 +71,21 @@ extension ThreadViewController { } .store(in: &disposeBag) + viewModel.onDismiss + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] status in + self?.navigationController?.popViewController(animated: true) + self?.navigationController?.notifyChildrenAboutStatusDeletion(status) + }) + .store(in: &disposeBag) + + viewModel.onEdit + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] status in + self?.navigationController?.notifyChildrenAboutStatusUpdate(status) + }) + .store(in: &disposeBag) + tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) tableView.pinToParent() @@ -182,3 +198,17 @@ extension ThreadViewController: StatusTableViewControllerNavigateable { statusKeyCommandHandler(sender) } } + +extension UINavigationController { + func notifyChildrenAboutStatusDeletion(_ status: MastodonStatus) { + viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in + provider?.delete(status: status ) + } + } + + func notifyChildrenAboutStatusUpdate(_ status: MastodonStatus) { + viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in + provider?.update(status: status ) + } + } +} 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+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift index 4679e0976..efd812ac4 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -74,7 +74,6 @@ extension ThreadViewModel.LoadThreadState { authenticationBox: viewModel.authContext.mastodonAuthenticationBox) viewModel.mastodonStatusThreadViewModel.appendAncestor( - domain: threadContext.domain, nodes: MastodonStatusThreadViewModel.Node.replyToThread( for: threadContext.replyToID, from: response.value.ancestors @@ -82,9 +81,8 @@ extension ThreadViewModel.LoadThreadState { ) viewModel.mastodonStatusThreadViewModel.appendDescendant( - domain: threadContext.domain, nodes: response.value.descendants.map { status in - return .init(statusID: status.id, children: []) + return .init(status: .fromEntity(status), children: []) } ) } catch { @@ -115,8 +113,7 @@ extension ThreadViewModel.LoadThreadState { class NoMore: ThreadViewModel.LoadThreadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false + stateClass is Loading.Type } } - } diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 69dc73e48..2e6e69426 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -32,6 +32,9 @@ class ThreadViewModel { @Published var threadContext: ThreadContext? @Published var hasPendingStatusEditReload = false + let onDismiss = PassthroughSubject() + let onEdit = PassthroughSubject() + private(set) lazy var loadThreadStateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ LoadThreadState.Initial(viewModel: self), @@ -55,40 +58,24 @@ 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, 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) }() } @@ -97,8 +84,21 @@ class ThreadViewModel { context.publisherService .statusPublishResult .sink { [weak self] value in - if case let Result.success(result) = value, case StatusPublishResult.edit = result { - self?.hasPendingStatusEditReload = true + guard let self else { return } + if case let Result.success(result) = value { + switch result { + case let .edit(content): + let status = content.value + let mastodonStatus = MastodonStatus.fromEntity(status) + self.hasPendingStatusEditReload = true + if status.id == root?.record.id { + self.root = .root(context: .init(status: mastodonStatus)) + } + self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) + self.onEdit.send(mastodonStatus) + case .post: + self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) + } } } .store(in: &disposeBag) @@ -110,22 +110,8 @@ class ThreadViewModel { extension ThreadViewModel { struct ThreadContext { - let domain: String let statusID: Mastodon.Entity.Status.ID let replyToID: Mastodon.Entity.Status.ID? } } - -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/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift new file mode 100644 index 000000000..46183ec25 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -0,0 +1,90 @@ +import Foundation +import UIKit +import Combine +import MastodonSDK + +final public class FeedDataController { + + @Published public var records: [MastodonFeed] = [] + + private let context: AppContext + private let authContext: AuthContext + + public init(context: AppContext, authContext: AuthContext) { + self.context = context + self.authContext = authContext + } + + public func loadInitial(kind: MastodonFeed.Kind) { + Task { + records = try await load(kind: kind, sinceId: nil) + } + } + + public func loadNext(kind: MastodonFeed.Kind) { + Task { + guard let lastId = records.last?.status?.id else { + return loadInitial(kind: kind) + } + + records = try await load(kind: kind, sinceId: lastId) + } + } + + public func update(status: MastodonStatus) { + var newRecords = Array(records) + for (i, record) in newRecords.enumerated() { + if record.status?.id == status.id { + newRecords[i] = .fromStatus(status, kind: record.kind) + } else if let reblog = status.reblog, reblog.id == record.status?.id { + newRecords[i] = .fromStatus(status, kind: record.kind) + } else if let reblog = record.status?.reblog, reblog.id == status.id { + // Handle reblogged state + let isRebloggedByAnyOne: Bool = records[i].status!.reblog != nil + + let newStatus: MastodonStatus + if isRebloggedByAnyOne { + // if status was previously reblogged by me: remove reblogged status + if records[i].status!.entity.reblogged == true && status.entity.reblogged == false { + newStatus = .fromEntity(status.entity) + } else { + newStatus = .fromEntity(records[i].status!.entity) + } + + } else { + newStatus = .fromEntity(status.entity) + } + + newStatus.isSensitiveToggled = status.isSensitiveToggled + newStatus.reblog = isRebloggedByAnyOne ? .fromEntity(status.entity) : nil + + newRecords[i] = .fromStatus(newStatus, kind: record.kind) + + } else if let reblog = record.status?.reblog, reblog.id == status.reblog?.id { + // Handle re-reblogged state + newRecords[i] = .fromStatus(status, kind: record.kind) + } + } + records = newRecords + } + + public func delete(status: MastodonStatus) { + self.records.removeAll { $0.id == status.id } + } +} + +private extension FeedDataController { + func load(kind: MastodonFeed.Kind, sinceId: MastodonStatus.ID?) async throws -> [MastodonFeed] { + switch kind { + case .home: + return try await context.apiService.homeTimeline(sinceID: sinceId, authenticationBox: authContext.mastodonAuthenticationBox) + .value.map { .fromStatus(.fromEntity($0), kind: .home) } + case .notificationAll: + return try await context.apiService.notifications(maxID: nil, scope: .everything, authenticationBox: authContext.mastodonAuthenticationBox) + .value.map { .fromNotification($0, kind: .notificationAll) } + case .notificationMentions: + return try await context.apiService.notifications(maxID: nil, scope: .mentions, authenticationBox: authContext.mastodonAuthenticationBox) + .value.map { .fromNotification($0, kind: .notificationMentions) } + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift deleted file mode 100644 index 682c83815..000000000 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FeedFetchedResultsController.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// FeedFetchedResultsController.swift -// FeedFetchedResultsController -// -// Created by Cirno MainasuK on 2021-8-19. -// Copyright © 2021 Twidere. All rights reserved. -// - -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] = [] - - 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) - } -} - -// 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/StatusDataController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift new file mode 100644 index 000000000..13ae313a7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift @@ -0,0 +1,73 @@ +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +public final class StatusDataController { + @MainActor + @Published + public private(set) var records: [MastodonStatus] = [] + + @MainActor + public init(records: [MastodonStatus] = []) { + self.records = records + } + + @MainActor + public func reset() { + records = [] + } + + @MainActor + public func setRecords(_ records: [MastodonStatus]) { + self.records = records + } + + @MainActor + public func appendRecords(_ records: [MastodonStatus]) { + self.records += records + } + + @MainActor + public func deleteRecord(_ record: MastodonStatus) { + self.records = self.records.filter { $0.id != record.id } + } + + @MainActor + public func update(status: MastodonStatus) { + var newRecords = Array(records) + for (i, record) in newRecords.enumerated() { + if record.id == status.id { + newRecords[i] = status + } else if let reblog = status.reblog, reblog.id == record.id { + newRecords[i] = status + } else if let reblog = record.reblog, reblog.id == status.id { + // Handle reblogged state + let isRebloggedByAnyOne: Bool = records[i].reblog != nil + + let newStatus: MastodonStatus + if isRebloggedByAnyOne { + // if status was previously reblogged by me: remove reblogged status + if records[i].entity.reblogged == true && status.entity.reblogged == false { + newStatus = .fromEntity(status.entity) + } else { + newStatus = .fromEntity(records[i].entity) + } + + } else { + newStatus = .fromEntity(status.entity) + } + + newStatus.isSensitiveToggled = status.isSensitiveToggled + newStatus.reblog = isRebloggedByAnyOne ? .fromEntity(status.entity) : nil + + newRecords[i] = newStatus + } else if let reblog = record.reblog, reblog.id == status.reblog?.id { + // Handle re-reblogged state + newRecords[i] = status + } + } + records = newRecords + } +} diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift deleted file mode 100644 index 1bc5426c6..000000000 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// StatusFetchedResultsController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit -import Combine -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] = [] - - // 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) - } - -} - -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 - } - -} - -// 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 - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift b/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift index 6db7499c6..a02593f66 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift @@ -13,6 +13,12 @@ public protocol UserIdentifier { var userID: Mastodon.Entity.Account.ID { get } } +public extension UserIdentifier { + var uniqueUserDomainIdentifier: String { + "\(userID)@\(domain)" + } +} + public struct MastodonUserIdentifier: UserIdentifier { public let domain: String public var userID: Mastodon.Entity.Account.ID diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift index 8445d87ba..f11ff61e1 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift @@ -10,11 +10,20 @@ import Foundation public enum Persistence { case searchHistory - + case homeTimeline(UserIdentifier) + case notificationsMentions(UserIdentifier) + case notificationsAll(UserIdentifier) + private var filename: String { switch self { case .searchHistory: - return "search_history" + return "search_history" // todo: @zeitschlag should this be user-scoped as well? + case let .homeTimeline(userIdentifier): + return "home_timeline_\(userIdentifier.uniqueUserDomainIdentifier)" + case let .notificationsMentions(userIdentifier): + return "notifications_mentions_\(userIdentifier.uniqueUserDomainIdentifier)" + case let .notificationsAll(userIdentifier): + return "notifications_all_\(userIdentifier.uniqueUserDomainIdentifier)" } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift index 8c4e48417..c535135a6 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Bookmark.swift @@ -19,32 +19,19 @@ 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.bookmarked == true - let status = _status.reblog ?? _status - let isBookmarked = status.bookmarkedBy.contains(me) - status.update(bookmarked: !isBookmarked, by: me) - let context = MastodonBookmarkContext( - statusID: status.id, - isBookmarked: isBookmarked - ) - return context - } + let bookmarkContext = MastodonBookmarkContext( + statusID: status.id, + isBookmarked: isBookmarked + ) // request bookmark or undo bookmark let result: Result, Error> @@ -60,38 +47,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 } @@ -111,35 +72,7 @@ extension APIService { authorization: authenticationBox.userAuthorization, query: query ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - - guard - let me = authenticationBox.authentication.user(in: managedObjectContext) - else { - assertionFailure() - return - } - - for entity in response.value { - let result = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: authenticationBox.domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - - result.status.update(bookmarked: true, by: me) - result.status.reblog?.update(bookmarked: true, by: me) - } // end for … in - } - + return response } // end func } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift index ec44afa93..104af8fed 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Favorite.swift @@ -14,42 +14,26 @@ 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 { - - let managedObjectContext = backgroundManagedObjectContext // update like state and retrieve like context - let favoriteContext: MastodonFavoriteContext = 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 = status.reblog ?? status + let isFavorited = _status.entity.favourited == true + let favoritedCount = Int64(_status.entity.favouritesCount) - 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 context = MastodonFavoriteContext( - statusID: status.id, - isFavorited: isFavorited, - favoritedCount: favoritedCount - ) - return context - } + let favoriteContext = MastodonFavoriteContext( + statusID: _status.id, + isFavorited: isFavorited, + favoritedCount: favoritedCount + ) // request like or undo like let result: Result, Error> @@ -66,40 +50,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 } @@ -120,51 +70,18 @@ extension APIService { authorization: authenticationBox.userAuthorization, query: query ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { - assertionFailure() - return - } - - for entity in response.value { - let result = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: authenticationBox.domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - - result.status.update(liked: true, by: me) - result.status.reblog?.update(liked: true, by: me) - } // end for … in - } - + return response } // end func } 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 +90,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+Follow.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift index e31dbedce..b3a046bad 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift @@ -195,4 +195,38 @@ extension APIService { return try result.get() } + + public func toggleShowReblogs( + for user: Mastodon.Entity.Account, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + + let result: Result, Error> + + let relationship = try await Mastodon.API.Account.relationships( + session: session, + domain: authenticationBox.domain, + query: .init(ids: [user.id]), + authorization: authenticationBox.userAuthorization + ).singleOutput().value.first + + let oldShowReblogs = relationship?.showingReblogs == true + let newShowReblogs = (oldShowReblogs == false) + + do { + let response = try await Mastodon.API.Account.follow( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + followQueryType: .follow(query: .init(reblogs: newShowReblogs)), + authorization: authenticationBox.userAuthorization + ).singleOutput() + + result = .success(response) + } catch { + result = .failure(error) + } + + return try result.get() + } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift index c8d1bfb73..26ef9625f 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift @@ -42,23 +42,18 @@ extension APIService { authorization: authorization ).singleOutput() + #warning("TODO: Remove this with IOS-181, IOS-182") let managedObjectContext = self.backgroundManagedObjectContext try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) + let me = authenticationBox.authentication.user(in: managedObjectContext) - for entity in response.value { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } + for entity in response.value { + guard let poll = entity.poll else { continue } + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) + ) + } } return response diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift index f806f856f..272d81fa2 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift @@ -41,8 +41,20 @@ extension APIService { authorization: authorization ).singleOutput() + #warning("TODO: Remove this with IOS-181, IOS-182") let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authentication.user(in: managedObjectContext) + for entity in response.value { + guard let poll = entity.poll else { continue } + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) + ) + } + } + // FIXME: This is a dirty hack to make the performance-stuff work. // Problem is, that we don't persist the user on disk anymore. So we have to fetch // it when we need it to display on the home timeline. @@ -54,72 +66,7 @@ extension APIService { } NotificationCenter.default.post(name: .userFetched, object: nil) - - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { - assertionFailure() - return - } - - // persist status - var statuses: [Status] = [] - for entity in response.value { - let result = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, // TODO: add cache - userCache: nil, // TODO: add cache - networkDate: response.networkDate - ) - ) - statuses.append(result.status) - } - - // locate anchor status - let anchorStatus: Status? = { - guard let maxID = maxID else { return nil } - let request = Status.sortedFetchRequest - request.predicate = Status.predicate(domain: domain, id: maxID) - request.fetchLimit = 1 - return try? managedObjectContext.fetch(request).first - }() - - // update hasMore flag for anchor status - let acct = Feed.Acct.mastodon(domain: authenticationBox.domain, userID: authenticationBox.userID) - if let anchorStatus = anchorStatus, - let feed = anchorStatus.feed(kind: .home, acct: acct) { - feed.update(hasMore: false) - } - - // persist Feed relationship - let sortedStatuses = statuses.sorted(by: { $0.createdAt < $1.createdAt }) - let oldestStatus = sortedStatuses.first - for status in sortedStatuses { - let _feed = status.feed(kind: .home, acct: acct) - if let feed = _feed { - feed.update(updatedAt: response.networkDate) - } else { - let feedProperty = Feed.Property( - acct: acct, - kind: .home, - hasMore: false, - createdAt: status.createdAt, - updatedAt: response.networkDate - ) - let feed = Feed.insert(into: managedObjectContext, property: feedProperty) - status.attach(feed: feed) - - // set hasMore on oldest status if is new feed - if status === oldestStatus { - feed.update(hasMore: true) - } - } - } - } - + return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift index 67d77463a..ed36a57bc 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift @@ -27,24 +27,20 @@ extension APIService { authorization: authorization ).singleOutput() + #warning("TODO: Remove this with IOS-181, IOS-182") let managedObjectContext = self.backgroundManagedObjectContext try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - for entity in response.value { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } + let me = authenticationBox.authentication.user(in: managedObjectContext) + + for entity in response.value { + guard let poll = entity.poll else { continue } + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) + ) + } } - + 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..09ca59a16 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift @@ -20,36 +20,20 @@ extension APIService { } public func reblog( - record: ManagedObjectRecord, + status: MastodonStatus, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - let managedObjectContext = backgroundManagedObjectContext - + // update repost state and retrieve repost context - let _reblogContext: MastodonReblogContext? = try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let me = authentication.user(in: managedObjectContext), - let _status = record.object(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 reblogContext = MastodonReblogContext( - statusID: status.id, - isReblogged: isReblogged, - rebloggedCount: rebloggedCount - ) - return reblogContext - } - guard let reblogContext = _reblogContext else { - throw APIError.implicit(.badRequest) - } + 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, + rebloggedCount: rebloggedCount + ) // request repost or undo repost let result: Result, Error> @@ -65,41 +49,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 +58,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, diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift index df607df93..123c01d17 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift @@ -41,21 +41,7 @@ extension APIService { ) ) } - - // statuses - for entity in response.value.statuses { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } + } // ent try await managedObjectContext.performChanges { … } return response diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Publish.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Publish.swift index 0f4896949..7536118f0 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Publish.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+Publish.swift @@ -29,25 +29,7 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - #if !APP_EXTENSION - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: response.value, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } - #endif - + return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift index b67b79349..a52d4506c 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift @@ -27,34 +27,31 @@ extension APIService { authorization: authorization ).singleOutput() + #warning("TODO: Remove this with IOS-181, IOS-182") let managedObjectContext = self.backgroundManagedObjectContext try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: response.value, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate + let me = authenticationBox.authentication.user(in: managedObjectContext) + + if let poll = response.value.poll { + _ = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) ) - ) + } } - + return response } 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 +65,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/MastodonCore/Service/API/APIService+Thread.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Thread.swift index 7006a7477..293e8ccb9 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Thread.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Thread.swift @@ -26,27 +26,7 @@ extension APIService { statusID: statusID, authorization: authorization ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - let value = response.value.ancestors + response.value.descendants - - for entity in value { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } - } - + return response } // end func } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Trend.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Trend.swift index 06e92874e..a20c78541 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Trend.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Trend.swift @@ -37,24 +37,7 @@ extension APIService { query: query, authorization: authenticationBox.userAuthorization ).singleOutput() - - let managedObjectContext = backgroundManagedObjectContext - try await managedObjectContext.performChanges { - for entity in response.value { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: nil, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } // end for … in - } - + return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+UserTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+UserTimeline.swift index b661c282b..669e1636d 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+UserTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+UserTimeline.swift @@ -42,25 +42,7 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - for entity in response.value { - _ = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: domain, - entity: entity, - me: me, - statusCache: nil, - userCache: nil, - networkDate: response.networkDate - ) - ) - } - } - + return response } // end func 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..bcfbf33f5 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -133,3 +133,25 @@ 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 && + lhs.reblog == rhs.reblog && + lhs.favourited == rhs.favourited && + lhs.reblogged == rhs.reblogged && + lhs.bookmarked == rhs.bookmarked && + lhs.pinned == rhs.pinned + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(uri) + hasher.combine(id) + hasher.combine(reblog) + hasher.combine(favourited) + hasher.combine(reblogged) + hasher.combine(bookmarked) + hasher.combine(pinned) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift new file mode 100644 index 000000000..fbeed892f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -0,0 +1,75 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack + +public final class MastodonFeed { + + public enum Kind { + case home + case notificationAll + case notificationMentions + } + + public let id: String + 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.id = notification?.id ?? status?.id ?? UUID().uuidString + 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: { + guard let status = notification.status else { + return nil + } + return .fromEntity(status) + }(), + notification: notification, + kind: kind + ) + } +} + +extension MastodonFeed: Hashable { + public static func == (lhs: MastodonFeed, rhs: MastodonFeed) -> Bool { + lhs.id == rhs.id && + lhs.status?.entity == rhs.status?.entity && + lhs.status?.reblog?.entity == rhs.status?.reblog?.entity && + lhs.status?.isSensitiveToggled == rhs.status?.isSensitiveToggled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(status?.entity) + hasher.combine(status?.reblog?.entity) + hasher.combine(status?.isSensitiveToggled) + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift new file mode 100644 index 000000000..b32d59c29 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift @@ -0,0 +1,48 @@ +// 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, domain: String) -> MastodonNotification? { + guard let user = MastodonUser.fetch(in: managedObjectContext, configurationBlock: { request in + request.predicate = MastodonUser.predicate(domain: domain, id: entity.account.id) + }).first else { + assertionFailure() + 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..5f6112da2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -0,0 +1,72 @@ +// 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 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 == rhs.entity && + lhs.reblog?.entity == rhs.reblog?.entity && + lhs.isSensitiveToggled == rhs.isSensitiveToggled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(entity) + hasher.combine(reblog?.entity) + hasher.combine(isSensitiveToggled) + } +} + +public extension Mastodon.Entity.Status { + var asMastodonStatus: MastodonStatus { + .fromEntity(self) + } + + var mastodonVisibility: MastodonVisibility? { + guard let visibility = visibility?.rawValue else { return nil } + return MastodonVisibility(rawValue: visibility) + } +} + +public extension MastodonStatus { + func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? { + guard + 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/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..5539f80ee 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 { @MainActor in + if let poll = await status.getPoll(in: context.managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) { + 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..f4e656210 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,7 @@ extension MediaView { ) } - let status: StatusCompatible = status.reblog ?? status - let attachments = status.attachments + let attachments = status.entity.mastodonAttachments let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in let configuration: MediaView.Configuration = { switch attachment.kind { @@ -236,7 +235,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..c096428a5 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,21 @@ 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 + } + + if (abs(aspectRatio - 1) < 0.05 || image == nil) && html == nil { + return .compact + } else { + return .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..2c98fa4e1 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,21 @@ extension StatusView { } extension StatusView { - private func configureHeader(status: Status) { - if let _ = status.reblog { - Publishers.CombineLatest( - status.author.publisher(for: \.displayName), - status.author.publisher(for: \.emojis) + private func configureHeader(status: MastodonStatus) { + if status.entity.reblogged == true, + let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox, + let managedObjectContext = viewModel.context?.managedObjectContext { + + let user = MastodonUser.findOrFetch( + in: managedObjectContext, + matching: MastodonUser.predicate(domain: authenticationBox.domain, id: authenticationBox.userID) ) - .map { name, emojis -> StatusView.ViewModel.Header in - let text = L10n.Common.Controls.Status.userReblogged(status.author.displayNameWithFallback) + + let name = user?.displayNameWithFallback ?? authenticationBox.authentication.username + let emojis = user?.emojis ?? [] + + viewModel.header = { + let text = L10n.Common.Controls.Status.userReblogged(name) let content = MastodonContent(content: text, emojis: emojis.asDictionary) do { let metaContent = try MastodonMetaContent.convert(document: content) @@ -112,12 +108,25 @@ 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 status.reblog != nil { + let name = status.entity.account.displayNameWithFallback + let emojis = status.entity.account.emojis ?? [] + + viewModel.header = { + let text = L10n.Common.Controls.Status.userReblogged(name) + let content = MastodonContent(content: text, emojis: emojis.asDictionary) + do { + let metaContent = try MastodonMetaContent.convert(document: content) + return .repost(info: .init(header: metaContent)) + } catch { + let metaContent = PlaintextMetaContent(string: name) + return .repost(info: .init(header: metaContent)) + } + }() + + } else if let _ = status.entity.inReplyToID, + let inReplyToAccountID = status.entity.inReplyToAccountID { func createHeader( name: String?, @@ -139,21 +148,27 @@ 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 + + /// we need to initially set an empty header, otherwise the layout gets messed up + viewModel.header = createHeader(name: "", emojis: [:]) + /// finally we can load the status information and display the correct header + if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox { + Task { @MainActor in + 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 { - // B2. replyTo user not exist let header = createHeader(name: nil, emojis: nil) viewModel.header = header @@ -178,7 +193,6 @@ extension StatusView { } .store(in: &disposeBag) } // end if let - } // end else B2. } // end else B. } else { @@ -186,90 +200,63 @@ extension StatusView { } } - public func configureAuthor(author: MastodonUser) { - // 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) - // author name - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .map { _, emojis in - do { - let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure(error.localizedDescription) - return PlaintextMetaContent(string: author.displayNameWithFallback) + public func configureAuthor(author: Mastodon.Entity.Account) { + Task { @MainActor in + + // author avatar + viewModel.authorAvatarImageURL = author.avatarImageURL() + let emojis = author.emojis?.asDictionary ?? [:] + + // author name + viewModel.authorName = { + do { + 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) + } + }() + + // author username + viewModel.authorUsername = author.acct + + // locked + viewModel.locked = author.locked + + // isMyself + viewModel.isMyself = { + guard let authContext = viewModel.authContext else { return false } + return authContext.mastodonAuthenticationBox.domain == author.domain && authContext.mastodonAuthenticationBox.userID == author.id + }() + + // isMuting, isBlocking, Following + guard let auth = viewModel.authContext?.mastodonAuthenticationBox else { return } + guard !viewModel.isMyself else { + viewModel.isMuting = false + viewModel.isBlocking = false + viewModel.isFollowed = false + 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: \.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) - - // 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 - }) - } - .assign(to: \.isFollowed, on: viewModel) - .store(in: &disposeBag) } - private func configureTimestamp(timestamp: AnyPublisher) { + private func configureTimestamp(timestamp: Date) { // timestamp viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in if isEdited { @@ -277,10 +264,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 +278,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 +287,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 +297,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 +316,7 @@ extension StatusView { } } - private func configureContent(status: Status) { + private func configureContent(status: MastodonStatus) { guard viewModel.translation == nil else { return configureTranslated(status: status) } @@ -340,9 +324,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 +337,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 +349,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,146 +386,105 @@ extension StatusView { pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) } - private func configurePoll(status: Status) { + private func configurePoll(status: MastodonStatus) { let status = status.reblog ?? status - if let poll = status.poll { - viewModel.objects.insert(poll) + guard + let context = viewModel.context?.managedObjectContext, + let domain = viewModel.authContext?.mastodonAuthenticationBox.domain, + let pollId = status.entity.poll?.id + else { + return } + let predicate = Poll.predicate(domain: domain, id: pollId) + guard let poll = Poll.findOrFetch(in: context, matching: predicate) else { return } + + 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 - } - - 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) + 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 - status.poll?.publisher(for: \.updatedAt) + 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 { - Publishers.CombineLatest( - poll.publisher(for: \.votedBy), - poll.publisher(for: \.expired) - ) - .map { [weak viewModel] votedBy, expired in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - let domain = authContext.mastodonAuthenticationBox.domain - let userID = authContext.mastodonAuthenticationBox.userID - let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false - return !isVoted && !expired - } - .assign(to: &viewModel.$isVotable) + Publishers.CombineLatest( + poll.publisher(for: \.votedBy), + poll.publisher(for: \.expired) + ) + .map { [weak viewModel] votedBy, expired in + guard let viewModel = viewModel else { return false } + guard let authContext = viewModel.authContext else { return false } + let domain = authContext.mastodonAuthenticationBox.domain + let userID = authContext.mastodonAuthenticationBox.userID + let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false + return !isVoted && !expired } + .assign(to: &viewModel.$isVotable) + // votesCount - status.poll?.publisher(for: \.votesCount) + poll.publisher(for: \.votesCount) .map { Int($0) } .assign(to: \.voteCount, on: viewModel) .store(in: &disposeBag) // voterCount - status.poll?.publisher(for: \.votersCount) + poll.publisher(for: \.votersCount) .map { Int($0) } .assign(to: \.voterCount, on: viewModel) .store(in: &disposeBag) // expireAt - status.poll?.publisher(for: \.expiresAt) + poll.publisher(for: \.expiresAt) .assign(to: \.expireAt, on: viewModel) .store(in: &disposeBag) // expired - status.poll?.publisher(for: \.expired) + poll.publisher(for: \.expired) .assign(to: \.expired, on: viewModel) .store(in: &disposeBag) // isVoting - status.poll?.publisher(for: \.isVoting) + 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, diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 2ff5b6f85..8ca7cd79a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -22,11 +22,22 @@ 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? { + didSet { + originalStatus?.$entity + .receive(on: DispatchQueue.main) + .sink(receiveValue: { status in + self.isBookmark = status.bookmarked == true + self.isMuting = status.muted == true + }) + .store(in: &disposeBag) + } + } // Header @Published public var header: Header = .none @@ -77,7 +88,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 @@ -147,6 +158,7 @@ extension StatusView { isMediaSensitive = false isSensitiveToggled = false isCurrentlyTranslating = false + isBookmark = false translation = nil activeFilters = [] diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index c636620e6..bdd160435 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -50,6 +50,10 @@ public final class StatusView: UIView { public weak var delegate: StatusViewDelegate? public private(set) var style: Style? + + public var domain: String? { + viewModel.authContext?.mastodonAuthenticationBox.domain + } // accessibility actions var toolbarActions = [UIAccessibilityCustomAction]() @@ -404,7 +408,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 }