1
0
mirror of https://github.com/mastodon/mastodon-ios.git synced 2025-02-03 10:47:35 +01:00

Improve status updating mechanism (#1210)

This commit is contained in:
Marcus Kida 2024-01-30 23:02:13 +01:00 committed by GitHub
parent c0c795e473
commit 383a75ea48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 705 additions and 367 deletions

View File

@ -29,6 +29,7 @@
2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */; }; 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */; };
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; };
2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.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 */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; }; 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; };
2A64515E29642A8A00CD8B8A /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */; }; 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 = "<group>"; }; 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryView.swift; sourceTree = "<group>"; };
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = "<group>"; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = "<group>"; };
2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusThreadViewModel+State.swift"; sourceTree = "<group>"; };
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; }; 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; };
2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 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 */, DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */,
DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */,
DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */, DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */,
2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */,
); );
path = Thread; path = Thread;
sourceTree = "<group>"; sourceTree = "<group>";
@ -4018,6 +4021,7 @@
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */, DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */,
DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */,
2A409F832B5955290044E472 /* MastodonStatusThreadViewModel+State.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */, D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */,
DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */, DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */,

View File

@ -26,11 +26,39 @@ extension StatusItem {
case leaf(context: Context) case leaf(context: Context)
public var record: MastodonStatus { public var record: MastodonStatus {
switch self { get {
case .root(let threadContext), switch self {
.reply(let threadContext), case .root(let threadContext),
.leaf(let threadContext): .reply(let threadContext),
return threadContext.status .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)
)
}
} }
} }
} }

View File

@ -12,12 +12,13 @@ import MastodonCore
import MastodonSDK import MastodonSDK
extension DataSourceFacade { extension DataSourceFacade {
@MainActor
public static func responseToStatusBookmarkAction( public static func responseToStatusBookmarkAction(
provider: NeedsDependency & AuthContextProvider & DataSourceProvider, provider: NeedsDependency & AuthContextProvider & DataSourceProvider,
status: MastodonStatus status: MastodonStatus
) async throws { ) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged() selectionFeedbackGenerator.selectionChanged()
let updatedStatus = try await provider.context.apiService.bookmark( let updatedStatus = try await provider.context.apiService.bookmark(
record: status, record: status,
@ -27,6 +28,6 @@ extension DataSourceFacade {
let newStatus: MastodonStatus = .fromEntity(updatedStatus) let newStatus: MastodonStatus = .fromEntity(updatedStatus)
newStatus.isSensitiveToggled = status.isSensitiveToggled newStatus.isSensitiveToggled = status.isSensitiveToggled
provider.update(status: newStatus) provider.update(status: newStatus, intent: .bookmark(updatedStatus.bookmarked == true))
} }
} }

View File

@ -11,12 +11,13 @@ import MastodonSDK
import MastodonCore import MastodonCore
extension DataSourceFacade { extension DataSourceFacade {
@MainActor
public static func responseToStatusFavoriteAction( public static func responseToStatusFavoriteAction(
provider: DataSourceProvider & AuthContextProvider, provider: DataSourceProvider & AuthContextProvider,
status: MastodonStatus status: MastodonStatus
) async throws { ) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged() selectionFeedbackGenerator.selectionChanged()
let updatedStatus = try await provider.context.apiService.favorite( let updatedStatus = try await provider.context.apiService.favorite(
status: status, status: status,
@ -26,6 +27,6 @@ extension DataSourceFacade {
let newStatus: MastodonStatus = .fromEntity(updatedStatus) let newStatus: MastodonStatus = .fromEntity(updatedStatus)
newStatus.isSensitiveToggled = status.isSensitiveToggled newStatus.isSensitiveToggled = status.isSensitiveToggled
provider.update(status: newStatus) provider.update(status: newStatus, intent: .favorite(updatedStatus.favourited == true))
} }
} }

View File

@ -11,12 +11,13 @@ import MastodonUI
import MastodonSDK import MastodonSDK
extension DataSourceFacade { extension DataSourceFacade {
@MainActor
static func responseToStatusReblogAction( static func responseToStatusReblogAction(
provider: DataSourceProvider & AuthContextProvider, provider: DataSourceProvider & AuthContextProvider,
status: MastodonStatus status: MastodonStatus
) async throws { ) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged() selectionFeedbackGenerator.selectionChanged()
let updatedStatus = try await provider.context.apiService.reblog( let updatedStatus = try await provider.context.apiService.reblog(
status: status, status: status,
@ -27,6 +28,6 @@ extension DataSourceFacade {
newStatus.reblog?.isSensitiveToggled = status.isSensitiveToggled newStatus.reblog?.isSensitiveToggled = status.isSensitiveToggled
newStatus.isSensitiveToggled = status.isSensitiveToggled newStatus.isSensitiveToggled = status.isSensitiveToggled
provider.update(status: newStatus) provider.update(status: newStatus, intent: .reblog(updatedStatus.reblogged == true))
} }
} }

View File

@ -28,7 +28,7 @@ extension DataSourceFacade {
authenticationBox: dependency.authContext.mastodonAuthenticationBox authenticationBox: dependency.authContext.mastodonAuthenticationBox
).value.asMastodonStatus ).value.asMastodonStatus
dependency.delete(status: deletedStatus) dependency.update(status: deletedStatus, intent: .delete)
} }
} }
@ -430,7 +430,7 @@ extension DataSourceFacade {
} }
extension DataSourceFacade { extension DataSourceFacade {
@MainActor
static func responseToToggleSensitiveAction( static func responseToToggleSensitiveAction(
dependency: NeedsDependency & DataSourceProvider, dependency: NeedsDependency & DataSourceProvider,
status: MastodonStatus status: MastodonStatus
@ -440,7 +440,7 @@ extension DataSourceFacade {
let newStatus: MastodonStatus = .fromEntity(_status.entity) let newStatus: MastodonStatus = .fromEntity(_status.entity)
newStatus.isSensitiveToggled = !_status.isSensitiveToggled newStatus.isSensitiveToggled = !_status.isSensitiveToggled
dependency.update(status: newStatus) dependency.update(status: newStatus, intent: .toggleSensitive(newStatus.isSensitiveToggled))
} }
} }

View File

@ -89,6 +89,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
// MARK: - Follow Request // MARK: - Follow Request
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
@MainActor
func tableViewCell( func tableViewCell(
_ cell: UITableViewCell, _ cell: UITableViewCell,
notificationView: NotificationView, notificationView: NotificationView,
@ -105,14 +106,30 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
return return
} }
try await DataSourceFacade.responseToUserFollowRequestAction( let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState
dependency: self, let originalFollowRequestState = notificationView.viewModel.followRequestState
notification: notification,
query: .accept 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 } // end Task
} }
@MainActor
func tableViewCell( func tableViewCell(
_ cell: UITableViewCell, _ cell: UITableViewCell,
notificationView: NotificationView, notificationView: NotificationView,
@ -129,11 +146,26 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
return return
} }
try await DataSourceFacade.responseToUserFollowRequestAction( let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState
dependency: self, let originalFollowRequestState = notificationView.viewModel.followRequestState
notification: notification,
query: .reject 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 } // end Task
} }

View File

@ -39,6 +39,5 @@ extension DataSourceItem {
protocol DataSourceProvider: ViewControllerWithDependencies { protocol DataSourceProvider: ViewControllerWithDependencies {
func item(from source: DataSourceItem.Source) async -> DataSourceItem? func item(from source: DataSourceItem.Source) async -> DataSourceItem?
func update(status: MastodonStatus) func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent)
func delete(status: MastodonStatus)
} }

View File

@ -28,16 +28,10 @@ extension DiscoveryCommunityViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.setRecords(
viewModel.dataController.records.filter { $0.id != status.id }
)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -28,16 +28,10 @@ extension DiscoveryPostsViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.setRecords(
viewModel.dataController.records.filter { $0.id != status.id }
)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -28,14 +28,10 @@ extension HashtagTimelineViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.deleteRecord(status)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -33,14 +33,10 @@ extension HomeTimelineViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.records = viewModel.dataController.records.filter { $0.id != status.id }
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -168,9 +168,10 @@ extension HomeTimelineViewController {
} }
.store(in: &disposeBag) .store(in: &disposeBag)
context.publisherService.statusPublishResult.sink { result in context.publisherService.statusPublishResult.receive(on: DispatchQueue.main).sink { result in
if case .success(.edit) = result { if case .success(.edit(let status)) = result {
self.viewModel.hasPendingStatusEditReload = true self.viewModel.hasPendingStatusEditReload = true
self.viewModel.dataController.update(status: .fromEntity(status.value), intent: .edit)
} }
}.store(in: &disposeBag) }.store(in: &disposeBag)

View File

@ -88,7 +88,7 @@ extension HomeTimelineViewModel.LoadLatestState {
Task { Task {
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in
return record.status?.id return record.status?.reblog?.id ?? record.status?.id
} }
do { do {
@ -103,7 +103,7 @@ extension HomeTimelineViewModel.LoadLatestState {
// stop refresher if no new statuses // stop refresher if no new statuses
let statuses = response.value 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 { if newStatuses.isEmpty {
viewModel.didLoadLatest.send() viewModel.didLoadLatest.send()
@ -112,10 +112,10 @@ extension HomeTimelineViewModel.LoadLatestState {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
} }
var newRecords: [MastodonFeed] = newStatuses.map {
MastodonFeed.fromStatus(.fromEntity($0), kind: .home)
}
viewModel.dataController.records = { viewModel.dataController.records = {
var newRecords: [MastodonFeed] = newStatuses.map {
MastodonFeed.fromStatus(.fromEntity($0), kind: .home)
}
var oldRecords = viewModel.dataController.records var oldRecords = viewModel.dataController.records
for (i, record) in newRecords.enumerated() { for (i, record) in newRecords.enumerated() {
if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) { if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) {

View File

@ -37,14 +37,10 @@ extension NotificationTimelineViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.delete(status: status)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -44,7 +44,7 @@ extension NotificationTimelineViewModel {
} }
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>() var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(newItems, toSection: .main) snapshot.appendItems(newItems.removingDuplicates(), toSection: .main)
return snapshot return snapshot
}() }()

View File

@ -28,14 +28,8 @@ extension BookmarkViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
}
func delete(status: MastodonStatus) {
viewModel.dataController.setRecords(
viewModel.dataController.records.filter { $0.id != status.id }
)
} }
@MainActor @MainActor

View File

@ -103,11 +103,7 @@ extension FamiliarFollowersViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required")
}
func delete(status: MastodonStatus) {
assertionFailure("Not required") assertionFailure("Not required")
} }

View File

@ -28,16 +28,10 @@ extension FavoriteViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.setRecords(
viewModel.dataController.records.filter { $0.id != status.id }
)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -154,11 +154,7 @@ extension FollowerListViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required")
}
func delete(status: MastodonStatus) {
assertionFailure("Not required") assertionFailure("Not required")
} }

View File

@ -150,11 +150,7 @@ extension FollowingListViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required")
}
func delete(status: MastodonStatus) {
assertionFailure("Not required") assertionFailure("Not required")
} }

View File

@ -254,6 +254,12 @@ extension ProfileViewController {
} }
.store(in: &disposeBag) .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) addChild(tabBarPagerController)
tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tabBarPagerController.view) view.addSubview(tabBarPagerController.view)
@ -971,11 +977,13 @@ extension ProfileViewController: DataSourceProvider {
return nil return nil
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required") updateViewModelsWithDataControllers(status: status, intent: intent)
} }
func delete(status: MastodonStatus) { func updateViewModelsWithDataControllers(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required") viewModel.postsUserTimelineViewModel.dataController.update(status: status, intent: intent)
viewModel.repliesUserTimelineViewModel.dataController.update(status: status, intent: intent)
viewModel.mediaUserTimelineViewModel.dataController.update(status: status, intent: intent)
} }
} }

View File

@ -28,14 +28,10 @@ extension UserTimelineViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
} }
func delete(status: MastodonStatus) {
viewModel.dataController.deleteRecord(status)
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -28,14 +28,10 @@ extension FavoritedByViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required") assertionFailure("Not required")
} }
func delete(status: MastodonStatus) {
assertionFailure("Not required")
}
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) return tableView.indexPath(for: cell)

View File

@ -29,11 +29,7 @@ extension RebloggedByViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required")
}
func delete(status: MastodonStatus) {
assertionFailure("Not required") assertionFailure("Not required")
} }

View File

@ -29,14 +29,10 @@ extension SearchHistoryViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
assertionFailure("Not required") assertionFailure("Not required")
} }
func delete(status: MastodonStatus) {
assertionFailure("Not required")
}
@MainActor @MainActor
private func indexPath(for cell: UICollectionViewCell) async -> IndexPath? { private func indexPath(for cell: UICollectionViewCell) async -> IndexPath? {
return collectionView.indexPath(for: cell) return collectionView.indexPath(for: cell)

View File

@ -33,12 +33,8 @@ extension SearchResultViewController: DataSourceProvider {
} }
} }
func update(status: MastodonStatus) { func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) {
viewModel.dataController.update(status: status) viewModel.dataController.update(status: status, intent: intent)
}
func delete(status: MastodonStatus) {
viewModel.dataController.deleteRecord(status)
} }
@MainActor @MainActor

View File

@ -14,7 +14,7 @@ final class ListBatchFetchViewModel {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// timer running on `common` mode // 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() .autoconnect()
.share() .share()
.eraseToAnyPublisher() .eraseToAnyPublisher()

View File

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

View File

@ -13,9 +13,12 @@ import CoreDataStack
import MastodonSDK import MastodonSDK
import MastodonCore import MastodonCore
import MastodonMeta import MastodonMeta
import os.log
final class MastodonStatusThreadViewModel { 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<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// input // input

View File

@ -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 { switch viewModel.root {
case let .root(context): case let .root(context):
if context.status.id == status.id { if context.status.id == status.id {
viewModel.root = .root(context: .init(status: status)) viewModel.root = .root(context: .init(status: status))
} else { } else {
handle(status: status) handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent)
} }
case let .reply(context): case let .reply(context):
if context.status.id == status.id { if context.status.id == status.id {
viewModel.root = .reply(context: .init(status: status)) viewModel.root = .reply(context: .init(status: status))
} else { } else {
handle(status: status) handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent)
} }
case let .leaf(context): case let .leaf(context):
if context.status.id == status.id { if context.status.id == status.id {
viewModel.root = .leaf(context: .init(status: status)) viewModel.root = .leaf(context: .init(status: status))
} else { } else {
handle(status: status) handleUpdate(status: status, viewModel: viewModel.mastodonStatusThreadViewModel, intent: intent)
} }
case .none: case .none:
assertionFailure("This should not have happened") assertionFailure("This should not have happened")
} }
} }
private func handle(status: MastodonStatus) { private func handleDelete(_ 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 { if viewModel.root?.record.id == status.id {
viewModel.root = nil viewModel.root = nil
viewModel.onDismiss.send(status) viewModel.onDismiss.send(status)
} }
viewModel.mastodonStatusThreadViewModel.ancestors.handleDelete(status: status, for: viewModel) viewModel.mastodonStatusThreadViewModel.handleDelete(status)
viewModel.mastodonStatusThreadViewModel.descendants.handleDelete(status: status, for: viewModel)
} }
@MainActor @MainActor
private func indexPath(for cell: UITableViewCell) async -> IndexPath? { private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
return tableView.indexPath(for: cell) 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) { private func handleUpdate(status: MastodonStatus, viewModel: MastodonStatusThreadViewModel, intent: MastodonStatus.UpdateIntent) {
for (index, ancestor) in enumerated() { switch intent {
switch ancestor { case .bookmark:
case let .feed(record): viewModel.handleBookmark(status)
if record.status?.id == status.id { case let .reblog(isReblogged):
self.remove(at: index) viewModel.handleReblog(status, isReblogged)
} case .favorite:
case let.feedLoader(record): viewModel.handleFavorite(status)
if record.status?.id == status.id { case let .toggleSensitive(isVisible):
self.remove(at: index) viewModel.handleSensitive(status, isVisible)
} case .edit:
case let .status(record): viewModel.handleEdit(status)
if record.id == status.id { case .delete:
self.remove(at: index) break // this case has already been handled
}
case let .thread(thread):
switch thread {
case let .root(context):
if context.status.id == status.id {
self.remove(at: index)
}
case let .reply(context):
if context.status.id == status.id {
self.remove(at: index)
}
case let .leaf(context):
if context.status.id == status.id {
self.remove(at: index)
}
}
case .bottomLoader, .topLoader:
break
}
} }
} }
} }

View File

@ -82,7 +82,7 @@ extension ThreadViewController {
viewModel.onEdit viewModel.onEdit
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] status in .sink(receiveValue: { [weak self] status in
self?.navigationController?.notifyChildrenAboutStatusUpdate(status) self?.navigationController?.notifyChildrenAboutStatusEdit(status)
}) })
.store(in: &disposeBag) .store(in: &disposeBag)
@ -202,13 +202,13 @@ extension ThreadViewController: StatusTableViewControllerNavigateable {
extension UINavigationController { extension UINavigationController {
func notifyChildrenAboutStatusDeletion(_ status: MastodonStatus) { func notifyChildrenAboutStatusDeletion(_ status: MastodonStatus) {
viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in 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 viewControllers.compactMap { $0 as? DataSourceProvider }.forEach { provider in
provider?.update(status: status ) provider?.update(status: status, intent: .edit)
} }
} }
} }

View File

@ -2,8 +2,11 @@ import Foundation
import UIKit import UIKit
import Combine import Combine
import MastodonSDK import MastodonSDK
import os.log
final public class FeedDataController { 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] = [] @Published public var records: [MastodonFeed] = []
@ -17,7 +20,7 @@ final public class FeedDataController {
public func loadInitial(kind: MastodonFeed.Kind) { public func loadInitial(kind: MastodonFeed.Kind) {
Task { 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 { guard let lastId = records.last?.status?.id else {
return loadInitial(kind: kind) 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) var newRecords = Array(records)
for (i, record) in newRecords.enumerated() { guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else {
if record.status?.id == status.id { logger.warning("\(Self.entryNotFoundMessage)")
newRecords[i] = .fromStatus(status, kind: record.kind) return
} else if let reblog = status.reblog, reblog.id == record.status?.id { }
newRecords[i] = .fromStatus(status, kind: record.kind) let existingRecord = newRecords[index]
} else if let reblog = record.status?.reblog, reblog.id == status.id { let newStatus = status.inheritSensitivityToggled(from: existingRecord.status)
// Handle reblogged state newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind)
let isRebloggedByAnyOne: Bool = records[i].status!.reblog != nil records = newRecords
}
let newStatus: MastodonStatus
if isRebloggedByAnyOne { @MainActor
// if status was previously reblogged by me: remove reblogged status private func updateBookmarked(_ status: MastodonStatus, _ isBookmarked: Bool) {
if records[i].status!.entity.reblogged == true && status.entity.reblogged == false { var newRecords = Array(records)
newStatus = .fromEntity(status.entity) guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else {
} else { logger.warning("\(Self.entryNotFoundMessage)")
newStatus = .fromEntity(records[i].status!.entity) return
} }
let existingRecord = newRecords[index]
} else { let newStatus = status.inheritSensitivityToggled(from: existingRecord.status)
newStatus = .fromEntity(status.entity) newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind)
} records = newRecords
}
newStatus.isSensitiveToggled = status.isSensitiveToggled
newStatus.reblog = isRebloggedByAnyOne ? .fromEntity(status.entity) : nil @MainActor
private func updateFavorited(_ status: MastodonStatus, _ isFavorited: Bool) {
newRecords[i] = .fromStatus(newStatus, kind: record.kind) var newRecords = Array(records)
if let index = newRecords.firstIndex(where: { $0.id == status.id }) {
} else if let reblog = record.status?.reblog, reblog.id == status.reblog?.id { // Replace old status entity
// Handle re-reblogged state let existingRecord = newRecords[index]
newRecords[i] = .fromStatus(status, kind: record.kind) 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 records = newRecords
} }
public func delete(status: MastodonStatus) { @MainActor
self.records.removeAll { $0.id == status.id } 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 { 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 { switch kind {
case .home: 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) } .value.map { .fromStatus(.fromEntity($0), kind: .home) }
case .notificationAll: case .notificationAll:
return try await context.apiService.notifications(maxID: nil, scope: .everything, authenticationBox: authContext.mastodonAuthenticationBox) return try await context.apiService.notifications(maxID: nil, scope: .everything, authenticationBox: authContext.mastodonAuthenticationBox)

View File

@ -3,8 +3,12 @@ import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import MastodonSDK import MastodonSDK
import os.log
public final class StatusDataController { 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 @MainActor
@Published @Published
public private(set) var records: [MastodonStatus] = [] public private(set) var records: [MastodonStatus] = []
@ -35,39 +39,118 @@ public final class StatusDataController {
} }
@MainActor @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) var newRecords = Array(records)
for (i, record) in newRecords.enumerated() { guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else {
if record.id == status.id { logger.warning("\(Self.entryNotFoundMessage)")
newRecords[i] = status return
} else if let reblog = status.reblog, reblog.id == record.id { }
newRecords[i] = status newRecords[index] = status.inheritSensitivityToggled(from: newRecords[index])
} else if let reblog = record.reblog, reblog.id == status.id { records = newRecords
// Handle reblogged state }
let isRebloggedByAnyOne: Bool = records[i].reblog != nil
@MainActor
let newStatus: MastodonStatus private func updateBookmarked(_ status: MastodonStatus, _ isBookmarked: Bool) {
if isRebloggedByAnyOne { var newRecords = Array(records)
// if status was previously reblogged by me: remove reblogged status guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else {
if records[i].entity.reblogged == true && status.entity.reblogged == false { logger.warning("\(Self.entryNotFoundMessage)")
newStatus = .fromEntity(status.entity) return
} else { }
newStatus = .fromEntity(records[i].entity) newRecords[index] = status.inheritSensitivityToggled(from: newRecords[index])
} records = newRecords
}
} else {
newStatus = .fromEntity(status.entity) @MainActor
} private func updateFavorited(_ status: MastodonStatus, _ isFavorited: Bool) {
var newRecords = Array(records)
newStatus.isSensitiveToggled = status.isSensitiveToggled if let index = newRecords.firstIndex(where: { $0.id == status.id }) {
newStatus.reblog = isRebloggedByAnyOne ? .fromEntity(status.entity) : nil // Replace old status entity
let existingRecord = newRecords[index]
newRecords[i] = newStatus let newStatus = status.inheritSensitivityToggled(from: existingRecord)
} else if let reblog = record.reblog, reblog.id == status.reblog?.id { .withOriginal(status: existingRecord)
// Handle re-reblogged state newRecords[index] = newStatus
newRecords[i] = status } 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 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
}
} }

View File

@ -95,6 +95,7 @@ public final class AuthenticationService: NSObject {
super.init() super.init()
$mastodonAuthenticationBoxes $mastodonAuthenticationBoxes
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] boxes in .sink { [weak self] boxes in
Task { [weak self] in Task { [weak self] in
for authBox in boxes { for authBox in boxes {

View File

@ -142,7 +142,8 @@ extension Mastodon.Entity.Status: Hashable {
lhs.favourited == rhs.favourited && lhs.favourited == rhs.favourited &&
lhs.reblogged == rhs.reblogged && lhs.reblogged == rhs.reblogged &&
lhs.bookmarked == rhs.bookmarked && lhs.bookmarked == rhs.bookmarked &&
lhs.pinned == rhs.pinned lhs.pinned == rhs.pinned &&
lhs.content == rhs.content
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
@ -153,5 +154,6 @@ extension Mastodon.Entity.Status: Hashable {
hasher.combine(reblogged) hasher.combine(reblogged)
hasher.combine(bookmarked) hasher.combine(bookmarked)
hasher.combine(pinned) hasher.combine(pinned)
hasher.combine(content)
} }
} }

View File

@ -62,7 +62,8 @@ extension MastodonFeed: Hashable {
lhs.id == rhs.id && lhs.id == rhs.id &&
lhs.status?.entity == rhs.status?.entity && lhs.status?.entity == rhs.status?.entity &&
lhs.status?.reblog?.entity == rhs.status?.reblog?.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) { public func hash(into hasher: inout Hasher) {
@ -70,6 +71,7 @@ extension MastodonFeed: Hashable {
hasher.combine(status?.entity) hasher.combine(status?.entity)
hasher.combine(status?.reblog?.entity) hasher.combine(status?.reblog?.entity)
hasher.combine(status?.isSensitiveToggled) hasher.combine(status?.isSensitiveToggled)
hasher.combine(status?.reblog?.isSensitiveToggled)
} }
} }

View File

@ -7,6 +7,10 @@ import CoreDataStack
public final class MastodonStatus: ObservableObject { public final class MastodonStatus: ObservableObject {
public typealias ID = Mastodon.Entity.Status.ID 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 entity: Mastodon.Entity.Status
@Published public var reblog: MastodonStatus? @Published public var reblog: MastodonStatus?
@ -32,19 +36,32 @@ extension MastodonStatus {
public static func fromEntity(_ entity: Mastodon.Entity.Status) -> MastodonStatus { public static func fromEntity(_ entity: Mastodon.Entity.Status) -> MastodonStatus {
return MastodonStatus(entity: entity, isSensitiveToggled: false) 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 { extension MastodonStatus: Hashable {
public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool { public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool {
lhs.entity == rhs.entity && lhs.entity == rhs.entity &&
lhs.reblog?.entity == rhs.reblog?.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) { public func hash(into hasher: inout Hasher) {
hasher.combine(entity) hasher.combine(entity)
hasher.combine(reblog?.entity) hasher.combine(reblog?.entity)
hasher.combine(isSensitiveToggled) 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 { public extension MastodonStatus {
func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? { func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? {
guard guard

View File

@ -155,17 +155,22 @@ extension StatusView {
viewModel.header = createHeader(name: "", emojis: [:]) viewModel.header = createHeader(name: "", emojis: [:])
/// finally we can load the status information and display the correct header /// finally we can load the status information and display the correct header
if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox { if let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox {
Task { @MainActor in Mastodon.API.Statuses.status(
if let replyTo = try? await Mastodon.API.Statuses.status( session: .shared,
session: .shared, domain: authenticationBox.domain,
domain: authenticationBox.domain, statusID: inReplyToID,
statusID: inReplyToID, authorization: authenticationBox.userAuthorization
authorization: authenticationBox.userAuthorization )
).singleOutput().value { .receive(on: DispatchQueue.main)
let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis?.asDictionary ?? [:]) .sink(receiveCompletion: { completion in
viewModel.header = header // 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 { } else {
// B. replyTo status not exist // B. replyTo status not exist
@ -219,6 +224,8 @@ extension StatusView {
} }
}() }()
viewModel.authorId = author.id
// author username // author username
viewModel.authorUsername = author.acct viewModel.authorUsername = author.acct
@ -232,27 +239,13 @@ extension StatusView {
}() }()
// isMuting, isBlocking, Following // isMuting, isBlocking, Following
guard let auth = viewModel.authContext?.mastodonAuthenticationBox else { return } guard viewModel.authContext?.mastodonAuthenticationBox != nil else { return }
guard !viewModel.isMyself else { guard !viewModel.isMyself else {
viewModel.isMuting = false viewModel.isMuting = false
viewModel.isBlocking = false viewModel.isBlocking = false
viewModel.isFollowed = false viewModel.isFollowed = false
return 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
}
}
} }
} }

View File

@ -46,6 +46,7 @@ extension StatusView {
@Published public var authorAvatarImage: UIImage? @Published public var authorAvatarImage: UIImage?
@Published public var authorAvatarImageURL: URL? @Published public var authorAvatarImageURL: URL?
@Published public var authorName: MetaContent? @Published public var authorName: MetaContent?
@Published public var authorId: String?
@Published public var authorUsername: String? @Published public var authorUsername: String?
@Published public var locked = false @Published public var locked = false
@ -277,21 +278,20 @@ extension StatusView.ViewModel {
// timestamp // timestamp
Publishers.CombineLatest3( Publishers.CombineLatest3(
$timestamp, $timestamp,
$editedAt, $editedAt.removeDuplicates(),
timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher()
) )
.compactMap { [weak self] timestamp, editedAt, _ -> String? in .sink(receiveValue: { [weak self] timestamp, editedAt, _ in
guard let self = self else { return nil } guard let self = self else { return }
if let timestamp = editedAt, let text = self.timestampFormatter?(timestamp, true) { 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) { } else if let timestamp = timestamp, let text = self.timestampFormatter?(timestamp, false) {
return text timestampText = text
} }
return "" })
} .store(in: &disposeBag)
.removeDuplicates()
.assign(to: &$timestampText)
$timestampText $timestampText
.sink { [weak self] text in .sink { [weak self] text in
guard let _ = self else { return } guard let _ = self else { return }
@ -655,16 +655,12 @@ extension StatusView.ViewModel {
private func bindMenu(statusView: StatusView) { private func bindMenu(statusView: StatusView) {
let authorView = statusView.authorView let authorView = statusView.authorView
let publisherOne = Publishers.CombineLatest( let publisherOne = Publishers.CombineLatest3(
$authorName, $authorName,
$authorId,
$isMyself $isMyself
) )
let publishersTwo = Publishers.CombineLatest4(
$isMuting,
$isBlocking,
$isBookmark,
$isFollowed
)
let publishersThree = Publishers.CombineLatest( let publishersThree = Publishers.CombineLatest(
$translation, $translation,
$language $language
@ -672,15 +668,14 @@ extension StatusView.ViewModel {
Publishers.CombineLatest3( Publishers.CombineLatest3(
publisherOne.eraseToAnyPublisher(), publisherOne.eraseToAnyPublisher(),
publishersTwo.eraseToAnyPublisher(), $isBookmark,
publishersThree.eraseToAnyPublisher() publishersThree.eraseToAnyPublisher()
).eraseToAnyPublisher() ).eraseToAnyPublisher()
.sink { tupleOne, tupleTwo, tupleThree in .sink { tupleOne, isBookmark, tupleThree in
let (authorName, isMyself) = tupleOne let (authorName, authorId, isMyself) = tupleOne
let (isMuting, isBlocking, isBookmark, isFollowed) = tupleTwo
let (translatedFromLanguage, language) = tupleThree 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 statusView.authorView.menuButton.menu = nil
return return
} }
@ -689,21 +684,45 @@ extension StatusView.ViewModel {
let instance = authentication.instance(in: context.managedObjectContext) let instance = authentication.instance(in: context.managedObjectContext)
let isTranslationEnabled = instance?.isTranslationEnabled ?? false let isTranslationEnabled = instance?.isTranslationEnabled ?? false
let menuContext = StatusAuthorView.AuthorMenuContext( authorView.menuButton.menu = UIMenu(children: [
name: name, UIDeferredMenuElement({ menuElement in
isMuting: isMuting,
isBlocking: isBlocking, let domain = authContext.mastodonAuthenticationBox.domain
isMyself: isMyself,
isBookmarking: isBookmark, Task { @MainActor in
isFollowed: isFollowed, if let relationship = try? await Mastodon.API.Account.relationships(
isTranslationEnabled: isTranslationEnabled, session: .shared,
isTranslated: translatedFromLanguage != nil, domain: domain,
statusLanguage: language query: .init(ids: [authorId]),
) authorization: authContext.mastodonAuthenticationBox.userAuthorization
).singleOutput().value {
let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) guard let rel = relationship.first else { return }
authorView.menuButton.menu = menu DispatchQueue.main.async {
authorView.authorActions = actions
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 authorView.menuButton.showsMenuAsPrimaryAction = true
} }
.store(in: &disposeBag) .store(in: &disposeBag)

View File

@ -327,6 +327,9 @@ public final class StatusView: UIView {
setPollDisplay(isDisplay: false) setPollDisplay(isDisplay: false)
setFilterHintLabelDisplay(isDisplay: false) setFilterHintLabelDisplay(isDisplay: false)
setStatusCardControlDisplay(isDisplay: false) setStatusCardControlDisplay(isDisplay: false)
headerInfoLabel.text = nil
headerIconImageView.image = nil
} }
public override init(frame: CGRect) { public override init(frame: CGRect) {