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 */; };
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 = "<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>"; };
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>"; };
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; };
@ -2690,6 +2692,7 @@
DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */,
DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */,
DB0FCB7F27968F70006C02E2 /* MastodonStatusThreadViewModel.swift */,
2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */,
);
path = Thread;
sourceTree = "<group>";
@ -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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ final class ListBatchFetchViewModel {
var disposeBag = Set<AnyCancellable>()
// 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()

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 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<AnyCancellable>()
// 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 {
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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