From 383a75ea480a2d8c09c705282cb46798bff00ac3 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 30 Jan 2024 23:02:13 +0100 Subject: [PATCH] Improve status updating mechanism (#1210) --- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Diffable/Status/StatusItem.swift | 38 +++- .../Provider/DataSourceFacade+Bookmark.swift | 7 +- .../Provider/DataSourceFacade+Favorite.swift | 7 +- .../Provider/DataSourceFacade+Reblog.swift | 7 +- .../Provider/DataSourceFacade+Status.swift | 6 +- ...er+NotificationTableViewCellDelegate.swift | 52 ++++-- .../Provider/DataSourceProvider.swift | 3 +- ...ityViewController+DataSourceProvider.swift | 12 +- ...stsViewController+DataSourceProvider.swift | 12 +- ...ineViewController+DataSourceProvider.swift | 10 +- ...ineViewController+DataSourceProvider.swift | 10 +- .../HomeTimelineViewController.swift | 5 +- ...omeTimelineViewModel+LoadLatestState.swift | 10 +- ...ineViewController+DataSourceProvider.swift | 10 +- ...tificationTimelineViewModel+Diffable.swift | 2 +- ...arkViewController+DataSourceProvider.swift | 10 +- .../FamiliarFollowersViewController.swift | 6 +- ...iteViewController+DataSourceProvider.swift | 12 +- .../Follower/FollowerListViewController.swift | 6 +- .../FollowingListViewController.swift | 6 +- .../Scene/Profile/ProfileViewController.swift | 16 +- ...ineViewController+DataSourceProvider.swift | 10 +- ...dByViewController+DataSourceProvider.swift | 8 +- ...dByViewController+DataSourceProvider.swift | 6 +- ...oryViewController+DataSourceProvider.swift | 8 +- ...ultViewController+DataSourceProvider.swift | 8 +- .../ViewModel/ListBatchFetchViewModel.swift | 2 +- .../MastodonStatusThreadViewModel+State.swift | 165 +++++++++++++++++ .../MastodonStatusThreadViewModel.swift | 5 +- ...eadViewController+DataSourceProvider.swift | 110 +++--------- .../Scene/Thread/ThreadViewController.swift | 8 +- .../DataController/FeedDataController.swift | 166 ++++++++++++++---- .../StatusDataController.swift | 145 +++++++++++---- .../Service/AuthenticationService.swift | 1 + .../Entity/Mastodon+Entity+Status.swift | 4 +- .../Sources/MastodonSDK/MastodonFeed.swift | 4 +- .../Sources/MastodonSDK/MastodonStatus.swift | 30 +++- .../Content/StatusView+Configuration.swift | 45 ++--- .../View/Content/StatusView+ViewModel.swift | 93 ++++++---- .../MastodonUI/View/Content/StatusView.swift | 3 + 41 files changed, 705 insertions(+), 367 deletions(-) create mode 100644 Mastodon/Scene/Thread/MastodonStatusThreadViewModel+State.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2795b39a6..db2651885 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; + 2A409F832B5955290044E472 /* MastodonStatusThreadViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; }; 2A64515E29642A8A00CD8B8A /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */; }; @@ -642,6 +643,7 @@ 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryView.swift; sourceTree = ""; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; + 2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusThreadViewModel+State.swift"; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; @@ -2690,6 +2692,7 @@ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */, + 2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */, ); path = Thread; sourceTree = ""; @@ -4018,6 +4021,7 @@ 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */, DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, + 2A409F832B5955290044E472 /* MastodonStatusThreadViewModel+State.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */, DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */, diff --git a/Mastodon/Diffable/Status/StatusItem.swift b/Mastodon/Diffable/Status/StatusItem.swift index 938e51eb2..d5c049fc9 100644 --- a/Mastodon/Diffable/Status/StatusItem.swift +++ b/Mastodon/Diffable/Status/StatusItem.swift @@ -26,11 +26,39 @@ extension StatusItem { case leaf(context: Context) public var record: MastodonStatus { - switch self { - case .root(let threadContext), - .reply(let threadContext), - .leaf(let threadContext): - return threadContext.status + get { + switch self { + case .root(let threadContext), + .reply(let threadContext), + .leaf(let threadContext): + return threadContext.status + } + } + + set { + switch self { + case let .root(threadContext): + self = .root(context: .init( + status: newValue, + displayUpperConversationLink: threadContext.displayUpperConversationLink, + displayBottomConversationLink: threadContext.displayBottomConversationLink) + ) + + case let .reply(threadContext): + self = .reply(context: .init( + status: newValue, + displayUpperConversationLink: threadContext.displayUpperConversationLink, + displayBottomConversationLink: threadContext.displayBottomConversationLink) + ) + + case let .leaf(threadContext): + self = .leaf(context: .init( + status: newValue, + displayUpperConversationLink: threadContext.displayUpperConversationLink, + displayBottomConversationLink: threadContext.displayBottomConversationLink) + ) + + } } } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift index 0bc0c9099..5433eb543 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Bookmark.swift @@ -12,12 +12,13 @@ import MastodonCore import MastodonSDK extension DataSourceFacade { + @MainActor public static func responseToStatusBookmarkAction( provider: NeedsDependency & AuthContextProvider & DataSourceProvider, status: MastodonStatus ) async throws { - let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() - await selectionFeedbackGenerator.selectionChanged() + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + selectionFeedbackGenerator.selectionChanged() let updatedStatus = try await provider.context.apiService.bookmark( record: status, @@ -27,6 +28,6 @@ extension DataSourceFacade { let newStatus: MastodonStatus = .fromEntity(updatedStatus) newStatus.isSensitiveToggled = status.isSensitiveToggled - provider.update(status: newStatus) + provider.update(status: newStatus, intent: .bookmark(updatedStatus.bookmarked == true)) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift index 8e96e28fa..8e55198eb 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Favorite.swift @@ -11,12 +11,13 @@ import MastodonSDK import MastodonCore extension DataSourceFacade { + @MainActor public static func responseToStatusFavoriteAction( provider: DataSourceProvider & AuthContextProvider, status: MastodonStatus ) async throws { - let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() - await selectionFeedbackGenerator.selectionChanged() + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + selectionFeedbackGenerator.selectionChanged() let updatedStatus = try await provider.context.apiService.favorite( status: status, @@ -26,6 +27,6 @@ extension DataSourceFacade { let newStatus: MastodonStatus = .fromEntity(updatedStatus) newStatus.isSensitiveToggled = status.isSensitiveToggled - provider.update(status: newStatus) + provider.update(status: newStatus, intent: .favorite(updatedStatus.favourited == true)) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift index f8fa3d5f3..b10fb0e44 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift @@ -11,12 +11,13 @@ import MastodonUI import MastodonSDK extension DataSourceFacade { + @MainActor static func responseToStatusReblogAction( provider: DataSourceProvider & AuthContextProvider, status: MastodonStatus ) async throws { - let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() - await selectionFeedbackGenerator.selectionChanged() + let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + selectionFeedbackGenerator.selectionChanged() let updatedStatus = try await provider.context.apiService.reblog( status: status, @@ -27,6 +28,6 @@ extension DataSourceFacade { newStatus.reblog?.isSensitiveToggled = status.isSensitiveToggled newStatus.isSensitiveToggled = status.isSensitiveToggled - provider.update(status: newStatus) + provider.update(status: newStatus, intent: .reblog(updatedStatus.reblogged == true)) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index e07001b96..2480e0f60 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -28,7 +28,7 @@ extension DataSourceFacade { authenticationBox: dependency.authContext.mastodonAuthenticationBox ).value.asMastodonStatus - dependency.delete(status: deletedStatus) + dependency.update(status: deletedStatus, intent: .delete) } } @@ -430,7 +430,7 @@ extension DataSourceFacade { } extension DataSourceFacade { - + @MainActor static func responseToToggleSensitiveAction( dependency: NeedsDependency & DataSourceProvider, status: MastodonStatus @@ -440,7 +440,7 @@ extension DataSourceFacade { let newStatus: MastodonStatus = .fromEntity(_status.entity) newStatus.isSensitiveToggled = !_status.isSensitiveToggled - dependency.update(status: newStatus) + dependency.update(status: newStatus, intent: .toggleSensitive(newStatus.isSensitiveToggled)) } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index 5ed4fc8b1..c3d2d3f6a 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -89,6 +89,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut // MARK: - Follow Request extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + @MainActor func tableViewCell( _ cell: UITableViewCell, notificationView: NotificationView, @@ -105,14 +106,30 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut return } - try await DataSourceFacade.responseToUserFollowRequestAction( - dependency: self, - notification: notification, - query: .accept - ) + let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState + let originalFollowRequestState = notificationView.viewModel.followRequestState + + notificationView.viewModel.transientFollowRequestState = .init(state: .isAccepting) + notificationView.viewModel.followRequestState = .init(state: .isAccepting) + + do { + try await DataSourceFacade.responseToUserFollowRequestAction( + dependency: self, + notification: notification, + query: .accept + ) + + notificationView.viewModel.transientFollowRequestState = .init(state: .isAccept) + notificationView.viewModel.followRequestState = .init(state: .isAccept) + } catch { + notificationView.viewModel.transientFollowRequestState = originalTransientFollowRequestState + notificationView.viewModel.followRequestState = originalFollowRequestState + throw error + } } // end Task } + @MainActor func tableViewCell( _ cell: UITableViewCell, notificationView: NotificationView, @@ -129,11 +146,26 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut return } - try await DataSourceFacade.responseToUserFollowRequestAction( - dependency: self, - notification: notification, - query: .reject - ) + let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState + let originalFollowRequestState = notificationView.viewModel.followRequestState + + notificationView.viewModel.transientFollowRequestState = .init(state: .isRejecting) + notificationView.viewModel.followRequestState = .init(state: .isRejecting) + + do { + try await DataSourceFacade.responseToUserFollowRequestAction( + dependency: self, + notification: notification, + query: .reject + ) + + notificationView.viewModel.transientFollowRequestState = .init(state: .isReject) + notificationView.viewModel.followRequestState = .init(state: .isReject) + } catch { + notificationView.viewModel.transientFollowRequestState = originalTransientFollowRequestState + notificationView.viewModel.followRequestState = originalFollowRequestState + throw error + } } // end Task } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift index d3b18240e..8f0d6ab51 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -39,6 +39,5 @@ extension DataSourceItem { protocol DataSourceProvider: ViewControllerWithDependencies { func item(from source: DataSourceItem.Source) async -> DataSourceItem? - func update(status: MastodonStatus) - func delete(status: MastodonStatus) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) } diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift index ea3b573e2..6f284faf9 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift @@ -28,16 +28,10 @@ extension DiscoveryCommunityViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - func delete(status: MastodonStatus) { - viewModel.dataController.setRecords( - viewModel.dataController.records.filter { $0.id != status.id } - ) - } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift index 09c92009e..b6982a4ad 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift @@ -28,16 +28,10 @@ extension DiscoveryPostsViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - func delete(status: MastodonStatus) { - viewModel.dataController.setRecords( - viewModel.dataController.records.filter { $0.id != status.id } - ) - } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift index 8c7885017..dfa09d9c0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+DataSourceProvider.swift @@ -28,14 +28,10 @@ extension HashtagTimelineViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - 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/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift index 94a587f79..47a844b28 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -33,14 +33,10 @@ extension HomeTimelineViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - func delete(status: MastodonStatus) { - viewModel.dataController.records = viewModel.dataController.records.filter { $0.id != status.id } - } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 4d0a2613e..06e1b2d8e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -168,9 +168,10 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) - context.publisherService.statusPublishResult.sink { result in - if case .success(.edit) = result { + context.publisherService.statusPublishResult.receive(on: DispatchQueue.main).sink { result in + if case .success(.edit(let status)) = result { self.viewModel.hasPendingStatusEditReload = true + self.viewModel.dataController.update(status: .fromEntity(status.value), intent: .edit) } }.store(in: &disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 65ec13d66..594faba35 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -88,7 +88,7 @@ extension HomeTimelineViewModel.LoadLatestState { Task { let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in - return record.status?.id + return record.status?.reblog?.id ?? record.status?.id } do { @@ -103,7 +103,7 @@ extension HomeTimelineViewModel.LoadLatestState { // stop refresher if no new statuses let statuses = response.value - let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + let newStatuses = statuses.filter { status in !latestStatusIDs.contains(where: { $0 == status.reblog?.id || $0 == status.id }) } if newStatuses.isEmpty { viewModel.didLoadLatest.send() @@ -112,10 +112,10 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } - var newRecords: [MastodonFeed] = newStatuses.map { - MastodonFeed.fromStatus(.fromEntity($0), kind: .home) - } viewModel.dataController.records = { + var newRecords: [MastodonFeed] = newStatuses.map { + MastodonFeed.fromStatus(.fromEntity($0), kind: .home) + } 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 }) { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index fb83eb380..3e227b422 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -37,14 +37,10 @@ extension NotificationTimelineViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - func delete(status: MastodonStatus) { - viewModel.dataController.delete(status: status) - } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index 0598ae006..2e3df3d2b 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -44,7 +44,7 @@ extension NotificationTimelineViewModel { } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - snapshot.appendItems(newItems, toSection: .main) + snapshot.appendItems(newItems.removingDuplicates(), toSection: .main) return snapshot }() diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift index 642f3605b..e396e3aba 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewController+DataSourceProvider.swift @@ -28,14 +28,8 @@ extension BookmarkViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) - } - - func delete(status: MastodonStatus) { - viewModel.dataController.setRecords( - viewModel.dataController.records.filter { $0.id != status.id } - ) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } @MainActor diff --git a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift index 5db16d519..ef8b2b229 100644 --- a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift +++ b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewController.swift @@ -103,11 +103,7 @@ extension FamiliarFollowersViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - assertionFailure("Not required") - } - - func delete(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { assertionFailure("Not required") } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift index ad2f06ef2..86a33622e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+DataSourceProvider.swift @@ -28,16 +28,10 @@ extension FavoriteViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - func delete(status: MastodonStatus) { - viewModel.dataController.setRecords( - viewModel.dataController.records.filter { $0.id != status.id } - ) - } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift index 404eae6fa..19ff325dc 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift @@ -154,11 +154,7 @@ extension FollowerListViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - assertionFailure("Not required") - } - - func delete(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { assertionFailure("Not required") } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index f1bd37ae7..8db8178e5 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -150,11 +150,7 @@ extension FollowingListViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - assertionFailure("Not required") - } - - func delete(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { assertionFailure("Not required") } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 81578a230..89023de90 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -254,6 +254,12 @@ extension ProfileViewController { } .store(in: &disposeBag) + context.publisherService.statusPublishResult.sink { [weak self] result in + if case .success(.edit(let status)) = result { + self?.updateViewModelsWithDataControllers(status: .fromEntity(status.value), intent: .edit) + } + }.store(in: &disposeBag) + addChild(tabBarPagerController) tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tabBarPagerController.view) @@ -971,11 +977,13 @@ extension ProfileViewController: DataSourceProvider { return nil } - func update(status: MastodonStatus) { - assertionFailure("Not required") + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + updateViewModelsWithDataControllers(status: status, intent: intent) } - func delete(status: MastodonStatus) { - assertionFailure("Not required") + func updateViewModelsWithDataControllers(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.postsUserTimelineViewModel.dataController.update(status: status, intent: intent) + viewModel.repliesUserTimelineViewModel.dataController.update(status: status, intent: intent) + viewModel.mediaUserTimelineViewModel.dataController.update(status: status, intent: intent) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift index d161c60e9..8442be102 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+DataSourceProvider.swift @@ -28,14 +28,10 @@ extension UserTimelineViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } - - 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/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift index c027f39f3..cf2d20b3d 100644 --- a/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/UserList/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift @@ -28,14 +28,10 @@ extension FavoritedByViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { 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 90df17264..b0f1efc3b 100644 --- a/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift @@ -29,11 +29,7 @@ extension RebloggedByViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - assertionFailure("Not required") - } - - func delete(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { assertionFailure("Not required") } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift index 1e1a03d5f..1a4bb65cd 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController+DataSourceProvider.swift @@ -29,14 +29,10 @@ extension SearchHistoryViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { 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/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index fde5bceee..d317b22e1 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -33,12 +33,8 @@ extension SearchResultViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { - viewModel.dataController.update(status: status) - } - - func delete(status: MastodonStatus) { - viewModel.dataController.deleteRecord(status) + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + viewModel.dataController.update(status: status, intent: intent) } @MainActor diff --git a/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift b/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift index 78eaf6ae3..4aae9d0a6 100644 --- a/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/ListBatchFetchViewModel.swift @@ -14,7 +14,7 @@ final class ListBatchFetchViewModel { var disposeBag = Set() // timer running on `common` mode - let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common) + let timerPublisher = Timer.publish(every: 30.0, on: .main, in: .common) .autoconnect() .share() .eraseToAnyPublisher() diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel+State.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel+State.swift new file mode 100644 index 000000000..4a00165da --- /dev/null +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel+State.swift @@ -0,0 +1,165 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonSDK + +extension MastodonStatusThreadViewModel { + // Bookmark + func handleBookmark(_ status: MastodonStatus) { + ancestors = handleBookmark(status, items: ancestors) + descendants = handleBookmark(status, items: descendants) + } + + private func handleBookmark(_ status: MastodonStatus, items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + guard let index = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.id }) else { + return items + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status + newRecords[index] = newRecord + return newRecords + } + + // Favorite + func handleFavorite(_ status: MastodonStatus) { + ancestors = handleFavorite(status, items: ancestors) + descendants = handleFavorite(status, items: descendants) + } + + private func handleFavorite(_ status: MastodonStatus, items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + guard let index = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.id }) else { + return items + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status + newRecords[index] = newRecord + return newRecords + } + + // Reblog + func handleReblog(_ status: MastodonStatus, _ isReblogged: Bool) { + ancestors = handleReblog(status, isReblogged, items: ancestors) + descendants = handleReblog(status, isReblogged, items: descendants) + } + + private func handleReblog(_ status: MastodonStatus, _ isReblogged: Bool, items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + + switch isReblogged { + case true: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.mastodonStatus?.reblog?.id == status.reblog?.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.reblog?.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return newRecords + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status.inheritSensitivityToggled(from: newRecord.mastodonStatus) + newRecords[index] = newRecord + case false: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.mastodonStatus?.reblog?.id == status.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return newRecords + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status.inheritSensitivityToggled(from: newRecord.mastodonStatus) + newRecords[index] = newRecord + } + + return newRecords + } + + // Sensitive + func handleSensitive(_ status: MastodonStatus, _ isVisible: Bool) { + ancestors = handleSensitive(status, isVisible, ancestors) + descendants = handleSensitive(status, isVisible, descendants) + } + + private func handleSensitive(_ status: MastodonStatus, _ isVisible: Bool, _ items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + guard let index = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.id }) else { + return items + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status + newRecords[index] = newRecord + return newRecords + } + + // Edit + func handleEdit(_ status: MastodonStatus) { + ancestors = handleEdit(status, items: ancestors) + descendants = handleEdit(status, items: descendants) + } + + private func handleEdit(_ status: MastodonStatus, items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + guard let index = newRecords.firstIndex(where: { $0.mastodonStatus?.id == status.id }) else { + return items + } + var newRecord = newRecords[index] + newRecord.mastodonStatus = status + newRecords[index] = newRecord + return newRecords + } + + // Delete + func handleDelete(_ status: MastodonStatus) { + ancestors = handleDelete(status, ancestors) + descendants = handleDelete(status, descendants) + } + + private func handleDelete(_ status: MastodonStatus, _ items: [StatusItem]) -> [StatusItem] { + var newRecords = Array(items) + newRecords.removeAll(where: { $0.mastodonStatus?.id == status.id }) + return newRecords + } +} + + +private extension StatusItem { + var mastodonStatus: MastodonStatus? { + get { + switch self { + case .feed(let record): + return record.status + case .feedLoader(let record): + return record.status + case .status(let record): + return record + case .thread(let thread): + return thread.record + case .topLoader, .bottomLoader: + return nil + } + } + + set { + guard let status = newValue else { return } + switch self { + case .feed(let record): + self = .feed(record: .fromStatus(status, kind: record.kind)) + case .feedLoader(let record): + self = .feedLoader(record: .fromStatus(status, kind: record.kind)) + case .status: + self = .status(record: status) + case let .thread(thread): + var newThread = thread + newThread.record = status + self = .thread(newThread) + case .topLoader, .bottomLoader: + break + } + } + } +} diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift index 34151e4a6..2e096da9f 100644 --- a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift @@ -13,9 +13,12 @@ import CoreDataStack import MastodonSDK import MastodonCore import MastodonMeta +import os.log final class MastodonStatusThreadViewModel { - + let logger = Logger(subsystem: "MastodonStatusThreadViewModel", category: "Data") + static let entryNotFoundMessage = "Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)." + var disposeBag = Set() // input diff --git a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift index f3373340b..f8b972440 100644 --- a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift @@ -29,121 +29,63 @@ extension ThreadViewController: DataSourceProvider { } } - func update(status: MastodonStatus) { + func update(status _status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + let status = _status.reblog ?? _status + if case MastodonStatus.UpdateIntent.delete = intent { + return handleDelete(status) + } + switch viewModel.root { case let .root(context): if context.status.id == status.id { viewModel.root = .root(context: .init(status: status)) } else { - handle(status: status) + handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent) } case let .reply(context): if context.status.id == status.id { viewModel.root = .reply(context: .init(status: status)) } else { - handle(status: status) + handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent) } case let .leaf(context): if context.status.id == status.id { viewModel.root = .leaf(context: .init(status: status)) } else { - handle(status: status) + handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent) } 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) { + + private func handleDelete(_ 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) + viewModel.mastodonStatusThreadViewModel.handleDelete(status) } @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 - } + private func handleUpdate(status: MastodonStatus, viewModel: MastodonStatusThreadViewModel, intent: MastodonStatus.UpdateIntent) { + switch intent { + case .bookmark: + viewModel.handleBookmark(status) + case let .reblog(isReblogged): + viewModel.handleReblog(status, isReblogged) + case .favorite: + viewModel.handleFavorite(status) + case let .toggleSensitive(isVisible): + viewModel.handleSensitive(status, isVisible) + case .edit: + viewModel.handleEdit(status) + case .delete: + break // this case has already been handled } } } diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index ed8808b32..8912df753 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -82,7 +82,7 @@ extension ThreadViewController { viewModel.onEdit .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] status in - self?.navigationController?.notifyChildrenAboutStatusUpdate(status) + self?.navigationController?.notifyChildrenAboutStatusEdit(status) }) .store(in: &disposeBag) @@ -202,13 +202,13 @@ extension ThreadViewController: StatusTableViewControllerNavigateable { extension UINavigationController { func notifyChildrenAboutStatusDeletion(_ status: MastodonStatus) { viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in - provider?.delete(status: status ) + provider?.update(status: status, intent: .delete) } } - func notifyChildrenAboutStatusUpdate(_ status: MastodonStatus) { + func notifyChildrenAboutStatusEdit(_ status: MastodonStatus) { viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in - provider?.update(status: status ) + provider?.update(status: status, intent: .edit) } } } diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index 46183ec25..eb71db081 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -2,8 +2,11 @@ import Foundation import UIKit import Combine import MastodonSDK +import os.log final public class FeedDataController { + private let logger = Logger(subsystem: "FeedDataController", category: "Data") + private static let entryNotFoundMessage = "Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)." @Published public var records: [MastodonFeed] = [] @@ -17,7 +20,7 @@ final public class FeedDataController { public func loadInitial(kind: MastodonFeed.Kind) { Task { - records = try await load(kind: kind, sinceId: nil) + records = try await load(kind: kind, maxID: nil) } } @@ -26,58 +29,145 @@ final public class FeedDataController { guard let lastId = records.last?.status?.id else { return loadInitial(kind: kind) } - - records = try await load(kind: kind, sinceId: lastId) + + records += try await load(kind: kind, maxID: lastId) } } - public func update(status: MastodonStatus) { + @MainActor + public func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + switch intent { + case .delete: + delete(status) + case .edit: + updateEdited(status) + case let .bookmark(isBookmarked): + updateBookmarked(status, isBookmarked) + case let .favorite(isFavorited): + updateFavorited(status, isFavorited) + case let .reblog(isReblogged): + updateReblogged(status, isReblogged) + case let .toggleSensitive(isVisible): + updateSensitive(status, isVisible) + } + } + + @MainActor + private func delete(_ status: MastodonStatus) { + records.removeAll { $0.id == status.id } + } + + @MainActor + private func updateEdited(_ 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) + guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingRecord = newRecords[index] + let newStatus = status.inheritSensitivityToggled(from: existingRecord.status) + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + records = newRecords + } + + @MainActor + private func updateBookmarked(_ status: MastodonStatus, _ isBookmarked: Bool) { + var newRecords = Array(records) + guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingRecord = newRecords[index] + let newStatus = status.inheritSensitivityToggled(from: existingRecord.status) + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + records = newRecords + } + + @MainActor + private func updateFavorited(_ status: MastodonStatus, _ isFavorited: Bool) { + var newRecords = Array(records) + if let index = newRecords.firstIndex(where: { $0.id == status.id }) { + // Replace old status entity + let existingRecord = newRecords[index] + let newStatus = status.inheritSensitivityToggled(from: existingRecord.status).withOriginal(status: existingRecord.status?.originalStatus) + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + } else if let index = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }) { + // Replace reblogged entity of old "parent" status + let newStatus: MastodonStatus + if let existingEntity = newRecords[index].status?.entity { + newStatus = .fromEntity(existingEntity) + newStatus.originalStatus = newRecords[index].status?.originalStatus + newStatus.reblog = status + } else { + newStatus = status } + newRecords[index] = .fromStatus(newStatus, kind: newRecords[index].kind) + } else { + logger.warning("\(Self.entryNotFoundMessage)") } records = newRecords } - public func delete(status: MastodonStatus) { - self.records.removeAll { $0.id == status.id } + @MainActor + private func updateReblogged(_ status: MastodonStatus, _ isReblogged: Bool) { + var newRecords = Array(records) + + switch isReblogged { + case true: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.reblog?.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.id == status.reblog?.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingRecord = newRecords[index] + newRecords[index] = .fromStatus(status.withOriginal(status: existingRecord.status), kind: existingRecord.kind) + case false: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.status?.id == status.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingRecord = newRecords[index] + let newStatus = existingRecord.status?.originalStatus ?? status.inheritSensitivityToggled(from: existingRecord.status) + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + } + records = newRecords + } + + @MainActor + private func updateSensitive(_ status: MastodonStatus, _ isVisible: Bool) { + var newRecords = Array(records) + if let index = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }), let existingEntity = newRecords[index].status?.entity { + let existingRecord = newRecords[index] + let newStatus: MastodonStatus = .fromEntity(existingEntity) + newStatus.reblog = status + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + } else if let index = newRecords.firstIndex(where: { $0.id == status.id }), let existingEntity = newRecords[index].status?.entity { + let existingRecord = newRecords[index] + let newStatus: MastodonStatus = .fromEntity(existingEntity) + .inheritSensitivityToggled(from: status) + newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + records = newRecords } } private extension FeedDataController { - func load(kind: MastodonFeed.Kind, sinceId: MastodonStatus.ID?) async throws -> [MastodonFeed] { + func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] { switch kind { case .home: - return try await context.apiService.homeTimeline(sinceID: sinceId, authenticationBox: authContext.mastodonAuthenticationBox) + return try await context.apiService.homeTimeline(maxID: maxID, 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) diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift index 13ae313a7..eb6001b42 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift @@ -3,8 +3,12 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import os.log public final class StatusDataController { + private let logger = Logger(subsystem: "StatusDataController", category: "Data") + private static let entryNotFoundMessage = "Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)." + @MainActor @Published public private(set) var records: [MastodonStatus] = [] @@ -35,39 +39,118 @@ public final class StatusDataController { } @MainActor - public func update(status: MastodonStatus) { + public func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + switch intent { + case .delete: + deleteRecord(status) + case .edit: + updateEdited(status) + case let .bookmark(isBookmarked): + updateBookmarked(status, isBookmarked) + case let .favorite(isFavorited): + updateFavorited(status, isFavorited) + case let .reblog(isReblogged): + updateReblogged(status, isReblogged) + case let .toggleSensitive(isVisible): + updateSensitive(status, isVisible) + } + } + + @MainActor + private func updateEdited(_ 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 - } + guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + newRecords[index] = status.inheritSensitivityToggled(from: newRecords[index]) + records = newRecords + } + + @MainActor + private func updateBookmarked(_ status: MastodonStatus, _ isBookmarked: Bool) { + var newRecords = Array(records) + guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + newRecords[index] = status.inheritSensitivityToggled(from: newRecords[index]) + records = newRecords + } + + @MainActor + private func updateFavorited(_ status: MastodonStatus, _ isFavorited: Bool) { + var newRecords = Array(records) + if let index = newRecords.firstIndex(where: { $0.id == status.id }) { + // Replace old status entity + let existingRecord = newRecords[index] + let newStatus = status.inheritSensitivityToggled(from: existingRecord) + .withOriginal(status: existingRecord) + newRecords[index] = newStatus + } else if let index = newRecords.firstIndex(where: { $0.reblog?.id == status.id }) { + // Replace reblogged entity of old "parent" status + let existingRecord = newRecords[index] + let newStatus = status.inheritSensitivityToggled(from: existingRecord) + .withOriginal(status: existingRecord) + newStatus.reblog = status + newRecords[index] = newStatus + } else { + logger.warning("\(Self.entryNotFoundMessage)") } records = newRecords } + + @MainActor + private func updateReblogged(_ status: MastodonStatus, _ isReblogged: Bool) { + var newRecords = Array(records) + + switch isReblogged { + case true: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.reblog?.id == status.reblog?.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.id == status.reblog?.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingStatus = newRecords[index] + newRecords[index] = status.withOriginal(status: existingStatus) + case false: + let index: Int + if let idx = newRecords.firstIndex(where: { $0.reblog?.id == status.id }) { + index = idx + } else if let idx = newRecords.firstIndex(where: { $0.id == status.id }) { + index = idx + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + let existingRecord = newRecords[index] + let newStatus = existingRecord.originalStatus ?? status.inheritSensitivityToggled(from: existingRecord) + newRecords[index] = newStatus + } + + records = newRecords + } + + @MainActor + private func updateSensitive(_ status: MastodonStatus, _ isVisible: Bool) { + var newRecords = Array(records) + if let index = newRecords.firstIndex(where: { $0.reblog?.id == status.id }) { + let newStatus: MastodonStatus = .fromEntity(newRecords[index].entity) + newStatus.reblog = status + newRecords[index] = newStatus + } else if let index = newRecords.firstIndex(where: { $0.id == status.id }) { + let newStatus: MastodonStatus = .fromEntity(newRecords[index].entity) + .inheritSensitivityToggled(from: status) + newRecords[index] = newStatus + } else { + logger.warning("\(Self.entryNotFoundMessage)") + return + } + records = newRecords + } + } diff --git a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift index 8c7963a19..8a92244b5 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift @@ -95,6 +95,7 @@ public final class AuthenticationService: NSObject { super.init() $mastodonAuthenticationBoxes + .throttle(for: 3, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] boxes in Task { [weak self] in for authBox in boxes { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index bcfbf33f5..8949d8dc4 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -142,7 +142,8 @@ extension Mastodon.Entity.Status: Hashable { lhs.favourited == rhs.favourited && lhs.reblogged == rhs.reblogged && lhs.bookmarked == rhs.bookmarked && - lhs.pinned == rhs.pinned + lhs.pinned == rhs.pinned && + lhs.content == rhs.content } public func hash(into hasher: inout Hasher) { @@ -153,5 +154,6 @@ extension Mastodon.Entity.Status: Hashable { hasher.combine(reblogged) hasher.combine(bookmarked) hasher.combine(pinned) + hasher.combine(content) } } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index fbeed892f..707c76bbb 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -62,7 +62,8 @@ extension MastodonFeed: Hashable { lhs.id == rhs.id && lhs.status?.entity == rhs.status?.entity && lhs.status?.reblog?.entity == rhs.status?.reblog?.entity && - lhs.status?.isSensitiveToggled == rhs.status?.isSensitiveToggled + lhs.status?.isSensitiveToggled == rhs.status?.isSensitiveToggled && + lhs.status?.reblog?.isSensitiveToggled == rhs.status?.reblog?.isSensitiveToggled } public func hash(into hasher: inout Hasher) { @@ -70,6 +71,7 @@ extension MastodonFeed: Hashable { hasher.combine(status?.entity) hasher.combine(status?.reblog?.entity) hasher.combine(status?.isSensitiveToggled) + hasher.combine(status?.reblog?.isSensitiveToggled) } } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift index 5f6112da2..c19283ba2 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -7,6 +7,10 @@ import CoreDataStack public final class MastodonStatus: ObservableObject { public typealias ID = Mastodon.Entity.Status.ID + /// `originalStatus` is used to restore a previously re-blogged state when a status + /// has been originally reblogged by another account + @Published public var originalStatus: MastodonStatus? + @Published public var entity: Mastodon.Entity.Status @Published public var reblog: MastodonStatus? @@ -32,19 +36,32 @@ extension MastodonStatus { public static func fromEntity(_ entity: Mastodon.Entity.Status) -> MastodonStatus { return MastodonStatus(entity: entity, isSensitiveToggled: false) } + + public func inheritSensitivityToggled(from status: MastodonStatus?) -> MastodonStatus { + self.isSensitiveToggled = status?.isSensitiveToggled ?? false + self.reblog?.isSensitiveToggled = status?.reblog?.isSensitiveToggled ?? false + return self + } + + public func withOriginal(status: MastodonStatus?) -> MastodonStatus { + originalStatus = status + return self + } } 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 + lhs.isSensitiveToggled == rhs.isSensitiveToggled && + lhs.reblog?.isSensitiveToggled == rhs.reblog?.isSensitiveToggled } public func hash(into hasher: inout Hasher) { hasher.combine(entity) hasher.combine(reblog?.entity) hasher.combine(isSensitiveToggled) + hasher.combine(reblog?.isSensitiveToggled) } } @@ -59,6 +76,17 @@ public extension Mastodon.Entity.Status { } } +public extension MastodonStatus { + enum UpdateIntent { + case bookmark(Bool) + case reblog(Bool) + case favorite(Bool) + case toggleSensitive(Bool) + case delete + case edit + } +} + public extension MastodonStatus { func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? { guard diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 2c98fa4e1..2c3ced43c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -155,17 +155,22 @@ extension StatusView { 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 - } - } + Mastodon.API.Statuses.status( + session: .shared, + domain: authenticationBox.domain, + statusID: inReplyToID, + authorization: authenticationBox.userAuthorization + ) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + // no-op + }, receiveValue: { [weak self] response in + guard let self else { return } + let replyTo = response.value + let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis?.asDictionary ?? [:]) + self.viewModel.header = header + }) + .store(in: &disposeBag) } } else { // B. replyTo status not exist @@ -219,6 +224,8 @@ extension StatusView { } }() + viewModel.authorId = author.id + // author username viewModel.authorUsername = author.acct @@ -232,27 +239,13 @@ extension StatusView { }() // isMuting, isBlocking, Following - guard let auth = viewModel.authContext?.mastodonAuthenticationBox else { return } + guard viewModel.authContext?.mastodonAuthenticationBox != nil 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 - } - } } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 8ca7cd79a..eef476a38 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -46,6 +46,7 @@ extension StatusView { @Published public var authorAvatarImage: UIImage? @Published public var authorAvatarImageURL: URL? @Published public var authorName: MetaContent? + @Published public var authorId: String? @Published public var authorUsername: String? @Published public var locked = false @@ -277,21 +278,20 @@ extension StatusView.ViewModel { // timestamp Publishers.CombineLatest3( $timestamp, - $editedAt, + $editedAt.removeDuplicates(), timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() ) - .compactMap { [weak self] timestamp, editedAt, _ -> String? in - guard let self = self else { return nil } + .sink(receiveValue: { [weak self] timestamp, editedAt, _ in + guard let self = self else { return } if let timestamp = editedAt, let text = self.timestampFormatter?(timestamp, true) { - return text + self.editedAt = editedAt + timestampText = text } else if let timestamp = timestamp, let text = self.timestampFormatter?(timestamp, false) { - return text + timestampText = text } - return "" - } - .removeDuplicates() - .assign(to: &$timestampText) - + }) + .store(in: &disposeBag) + $timestampText .sink { [weak self] text in guard let _ = self else { return } @@ -655,16 +655,12 @@ extension StatusView.ViewModel { private func bindMenu(statusView: StatusView) { let authorView = statusView.authorView - let publisherOne = Publishers.CombineLatest( + let publisherOne = Publishers.CombineLatest3( $authorName, + $authorId, $isMyself ) - let publishersTwo = Publishers.CombineLatest4( - $isMuting, - $isBlocking, - $isBookmark, - $isFollowed - ) + let publishersThree = Publishers.CombineLatest( $translation, $language @@ -672,15 +668,14 @@ extension StatusView.ViewModel { Publishers.CombineLatest3( publisherOne.eraseToAnyPublisher(), - publishersTwo.eraseToAnyPublisher(), + $isBookmark, publishersThree.eraseToAnyPublisher() ).eraseToAnyPublisher() - .sink { tupleOne, tupleTwo, tupleThree in - let (authorName, isMyself) = tupleOne - let (isMuting, isBlocking, isBookmark, isFollowed) = tupleTwo + .sink { tupleOne, isBookmark, tupleThree in + let (authorName, authorId, isMyself) = tupleOne let (translatedFromLanguage, language) = tupleThree - guard let name = authorName?.string, let context = self.context, let authContext = self.authContext else { + guard let name = authorName?.string, let authorId = authorId, let context = self.context, let authContext = self.authContext else { statusView.authorView.menuButton.menu = nil return } @@ -689,21 +684,45 @@ extension StatusView.ViewModel { let instance = authentication.instance(in: context.managedObjectContext) let isTranslationEnabled = instance?.isTranslationEnabled ?? false - let menuContext = StatusAuthorView.AuthorMenuContext( - name: name, - isMuting: isMuting, - isBlocking: isBlocking, - isMyself: isMyself, - isBookmarking: isBookmark, - isFollowed: isFollowed, - isTranslationEnabled: isTranslationEnabled, - isTranslated: translatedFromLanguage != nil, - statusLanguage: language - ) - - let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) - authorView.menuButton.menu = menu - authorView.authorActions = actions + authorView.menuButton.menu = UIMenu(children: [ + UIDeferredMenuElement({ menuElement in + + let domain = authContext.mastodonAuthenticationBox.domain + + Task { @MainActor in + if let relationship = try? await Mastodon.API.Account.relationships( + session: .shared, + domain: domain, + query: .init(ids: [authorId]), + authorization: authContext.mastodonAuthenticationBox.userAuthorization + ).singleOutput().value { + guard let rel = relationship.first else { return } + DispatchQueue.main.async { + + let menuContext = StatusAuthorView.AuthorMenuContext( + name: name, + isMuting: rel.muting ?? false, + isBlocking: rel.blocking, + isMyself: isMyself, + isBookmarking: isBookmark, + isFollowed: rel.followedBy, + isTranslationEnabled: isTranslationEnabled, + isTranslated: translatedFromLanguage != nil, + statusLanguage: language + ) + + let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) + authorView.authorActions = actions + + menuElement(menu.children) + } + } else { + menuElement(MastodonMenu.setupMenu(actions: [[.shareStatus]], delegate: statusView).children) + } + } + }) + ]) + authorView.menuButton.showsMenuAsPrimaryAction = true } .store(in: &disposeBag) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index bdd160435..5248208a1 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -327,6 +327,9 @@ public final class StatusView: UIView { setPollDisplay(isDisplay: false) setFilterHintLabelDisplay(isDisplay: false) setStatusCardControlDisplay(isDisplay: false) + + headerInfoLabel.text = nil + headerIconImageView.image = nil } public override init(frame: CGRect) {