From 24e573d9e2ef99ffc5cead2d6fc772f90419d19d Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 17 Apr 2024 16:36:03 +0200 Subject: [PATCH] Refactor Polls to not use Core Data (#1265) --- Mastodon/Diffable/Status/StatusSection.swift | 47 +------ ...er+NotificationTableViewCellDelegate.swift | 67 +++++++++ ...Provider+StatusTableViewCellDelegate.swift | 130 +++++------------- .../NotificationTableViewCellDelegate.swift | 10 ++ .../NotificationView/NotificationView.swift | 7 +- .../PollOptionView+Configuration.swift | 83 +++-------- .../StatusTableViewCellDelegate.swift | 1 + ...eadViewController+DataSourceProvider.swift | 2 + .../Entity/Mastodon/MastodonUser.swift | 4 +- .../CoreDataStack/Entity/Mastodon/Poll.swift | 38 ++--- .../Entity/Mastodon/PollOption.swift | 28 ++-- .../Entity/Mastodon/Status.swift | 6 +- .../DataController/FeedDataController.swift | 2 + .../CoreDataStack/Poll+Property.swift | 2 +- .../CoreDataStack/PollOption+Property.swift | 4 +- .../StatusDataController.swift | 2 + .../MastodonCore/Model/Poll/PollItem.swift | 2 +- .../Persistence/Persistence+Poll.swift | 26 ++-- .../Persistence/Persistence+PollOption.swift | 20 +-- .../Persistence/Persistence+Status.swift | 2 +- .../API/APIService+HashtagTimeline.swift | 14 -- .../Service/API/APIService+HomeTimeline.swift | 14 -- .../Service/API/APIService+Poll.swift | 55 +------- .../API/APIService+PublicTimeline.swift | 14 -- .../Service/API/APIService+Status.swift | 13 -- .../Entity/Mastodon+Entity+Poll.swift | 6 +- .../Sources/MastodonSDK/MastodonFeed.swift | 14 +- .../Sources/MastodonSDK/MastodonPoll.swift | 89 ++++++++++++ .../Sources/MastodonSDK/MastodonStatus.swift | 26 +++- .../ComposeContentViewModel.swift | 7 +- .../Content/StatusView+Configuration.swift | 68 +++------ .../View/Content/StatusView+ViewModel.swift | 27 ++++ .../MastodonUI/View/Content/StatusView.swift | 1 + 33 files changed, 399 insertions(+), 432 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/MastodonPoll.swift diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index 12fce16d8..1ad019c7f 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -158,54 +158,9 @@ extension StatusSection { }() cell.pollOptionView.viewModel.authContext = authContext - - managedObjectContext.performAndWait { - guard let option = record.object(in: managedObjectContext) else { - assertionFailure() - return - } - - cell.pollOptionView.configure(pollOption: option, status: statusView.viewModel.originalStatus) - - // trigger update if needs - let needsUpdatePoll: Bool = { - // check first option in poll to trigger update poll only once - guard - let poll = option.poll, - option.index == 0 - else { return false } - guard !poll.expired else { - return false - } + cell.pollOptionView.configure(pollOption: record) - let now = Date() - let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) - #if DEBUG - let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing - #else - let autoRefreshTimeInterval: TimeInterval = 30 - #endif - - guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { - return false - } - - return true - }() - - if needsUpdatePoll { - guard let poll = option.poll else { return } - let pollRecord: ManagedObjectRecord = .init(objectID: poll.objectID) - Task { [weak context] in - guard let context = context else { return } - _ = try await context.apiService.poll( - poll: pollRecord, - authenticationBox: authContext.mastodonAuthenticationBox - ) - } - } - } // end managedObjectContext.performAndWait return cell } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index 2e9e7ad50..d468962e1 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -523,3 +523,70 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut } // end Task } } + +// MARK: - poll +extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + pollTableView tableView: UITableView, + didSelectRowAt indexPath: IndexPath + ) { + guard let pollTableViewDiffableDataSource = notificationView.statusView.pollTableViewDiffableDataSource else { return } + guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } + + guard case let .option(pollOption) = pollItem else { + assertionFailure("only works for status data provider") + return + } + + let poll = pollOption.poll + + if !poll.multiple { + poll.options.forEach { $0.isSelected = false } + pollOption.isSelected = true + } else { + pollOption.isSelected.toggle() + } + } + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + pollVoteButtonPressed button: UIButton + ) { + guard let pollTableViewDiffableDataSource = notificationView.statusView.pollTableViewDiffableDataSource else { return } + guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return } + guard case let .option(firstPollOption) = firstPollItem else { return } + + notificationView.statusView.viewModel.isVoting = true + + Task { @MainActor in + let poll = firstPollOption.poll + + let choices = poll.options + .filter { $0.isSelected == true } + .compactMap { poll.options.firstIndex(of: $0) } + + do { + let newPoll = try await context.apiService.vote( + poll: poll.entity, + choices: choices, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + + guard let entity = poll.status?.entity else { return } + + let newStatus: MastodonStatus = .fromEntity(entity) + newStatus.poll = MastodonPoll(poll: newPoll, status: newStatus) + + self.update(status: newStatus, intent: .pollVote) + } catch { + notificationView.statusView.viewModel.isVoting = false + } + + } // end Task + } + +} diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 7d19b668e..1ad23a3aa 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -13,6 +13,7 @@ import MastodonUI import MastodonLocalization import MastodonAsset import LinkPresentation +import MastodonSDK // MARK: - header extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { @@ -263,66 +264,20 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte ) { guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return } guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } - - let managedObjectContext = context.managedObjectContext - - Task { - guard case let .option(pollOption) = pollItem else { - assertionFailure("only works for status data provider") - return - } - - var _poll: ManagedObjectRecord? - var _isMultiple: Bool? - var _choice: Int? - - try await managedObjectContext.performChanges { - guard let pollOption = pollOption.object(in: managedObjectContext) else { return } - guard let poll = pollOption.poll else { return } - _poll = .init(objectID: poll.objectID) - _isMultiple = poll.multiple - guard !poll.isVoting else { return } - - if !poll.multiple { - for option in poll.options where option != pollOption { - option.update(isSelected: false) - } - - // mark voting - poll.update(isVoting: true) - // set choice - _choice = Int(pollOption.index) - } - - pollOption.update(isSelected: !pollOption.isSelected) - poll.update(updatedAt: Date()) - } - - // Trigger vote API request for - guard let poll = _poll, - _isMultiple == false, - let choice = _choice - else { return } - - do { - _ = try await context.apiService.vote( - poll: poll, - choices: [choice], - authenticationBox: authContext.mastodonAuthenticationBox - ) - } catch { - // restore voting state - try await managedObjectContext.performChanges { - guard - let pollOption = pollOption.object(in: managedObjectContext), - let poll = pollOption.poll - else { return } - poll.update(isVoting: false) - } - } - - } // end Task + guard case let .option(pollOption) = pollItem else { + assertionFailure("only works for status data provider") + return + } + + let poll = pollOption.poll + + if !poll.multiple { + poll.options.forEach { $0.isSelected = false } + pollOption.isSelected = true + } else { + pollOption.isSelected.toggle() + } } func tableViewCell( @@ -333,46 +288,31 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return } guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return } guard case let .option(firstPollOption) = firstPollItem else { return } - - let managedObjectContext = context.managedObjectContext - - Task { - var _poll: ManagedObjectRecord? - var _choices: [Int]? - - try await managedObjectContext.performChanges { - guard let poll = firstPollOption.object(in: managedObjectContext)?.poll else { return } - _poll = .init(objectID: poll.objectID) - - guard poll.multiple else { return } - - // mark voting - poll.update(isVoting: true) - // set choice - _choices = poll.options - .filter { $0.isSelected } - .map { Int($0.index) } - - poll.update(updatedAt: Date()) - } - - // Trigger vote API request for - guard let poll = _poll, - let choices = _choices - else { return } - + + statusView.viewModel.isVoting = true + + Task { @MainActor in + let poll = firstPollOption.poll + + let choices = poll.options + .filter { $0.isSelected == true } + .compactMap { poll.options.firstIndex(of: $0) } + do { - _ = try await context.apiService.vote( - poll: poll, + let newPoll = try await context.apiService.vote( + poll: poll.entity, choices: choices, authenticationBox: authContext.mastodonAuthenticationBox - ) + ).value + + guard let entity = poll.status?.entity else { return } + + let newStatus: MastodonStatus = .fromEntity(entity) + newStatus.poll = MastodonPoll(poll: newPoll, status: newStatus) + + self.update(status: newStatus, intent: .pollVote) } catch { - // restore voting state - try await managedObjectContext.performChanges { - guard let poll = poll.object(in: managedObjectContext) else { return } - poll.update(isVoting: false) - } + statusView.viewModel.isVoting = false } } // end Task diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift index 7a603d5f0..b579be48c 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift @@ -30,6 +30,8 @@ protocol NotificationTableViewCellDelegate: AnyObject, AutoGenerateProtocolDeleg func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, pollVoteButtonPressed button: UIButton) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) @@ -70,6 +72,14 @@ extension NotificationViewDelegate where Self: NotificationViewContainerTableVie func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) { delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index) } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + delegate?.tableViewCell(self, notificationView: notificationView, pollTableView: tableView, didSelectRowAt: indexPath) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollVoteButtonPressed button: UIButton) { + delegate?.tableViewCell(self, notificationView: notificationView, pollVoteButtonPressed: button) + } func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView.swift index e4c55fe51..3d1d7605a 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView.swift @@ -25,7 +25,8 @@ public protocol NotificationViewDelegate: AnyObject { func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) - + func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollVoteButtonPressed button: UIButton) func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) @@ -547,11 +548,11 @@ extension NotificationView: StatusViewDelegate { } public func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - assertionFailure() + delegate?.notificationView(self, statusView: statusView, pollTableView: tableView, didSelectRowAt: indexPath) } public func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { - assertionFailure() + delegate?.notificationView(self, statusView: statusView, pollVoteButtonPressed: button) } public func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift index c815190be..c8a1b8588 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -14,92 +14,55 @@ import MastodonUI import MastodonSDK extension PollOptionView { - public func configure(pollOption option: PollOption, status: MastodonStatus?) { - guard let poll = option.poll else { - assertionFailure("PollOption to be configured is expected to be part of Poll with Status") - return - } - - viewModel.objects.insert(option) + public func configure(pollOption option: MastodonPollOption) { + let poll = option.poll + let status = option.poll.status // metaContent - option.publisher(for: \.title) + option.$title .map { title -> MetaContent? in return PlaintextMetaContent(string: title) } .assign(to: \.metaContent, on: viewModel) .store(in: &disposeBag) + // percentage Publishers.CombineLatest( - poll.publisher(for: \.votersCount), - option.publisher(for: \.votesCount) + poll.$votersCount, + option.$votesCount ) .map { pollVotersCount, optionVotesCount -> Double? in - guard pollVotersCount > 0, optionVotesCount >= 0 else { return 0 } + guard let pollVotersCount, pollVotersCount > 0, let optionVotesCount, optionVotesCount >= 0 else { return 0 } return Double(optionVotesCount) / Double(pollVotersCount) } .assign(to: \.percentage, on: viewModel) .store(in: &disposeBag) + // $isExpire - poll.publisher(for: \.expired) + poll.$expired .assign(to: \.isExpire, on: viewModel) .store(in: &disposeBag) + // isMultiple viewModel.isMultiple = poll.multiple - let optionIndex = option.index + let authContext = viewModel.authContext + let authorDomain = status?.entity.account.domain ?? "" let authorID = status?.entity.account.id ?? "" // isSelect, isPollVoted, isMyPoll - Publishers.CombineLatest4( - option.publisher(for: \.poll), - option.publisher(for: \.votedBy), - option.publisher(for: \.isSelected), - viewModel.$authContext - ) - .sink { [weak self] poll, optionVotedBy, isSelected, authContext in - guard let self = self, let poll = poll else { return } + let domain = authContext?.mastodonAuthenticationBox.domain ?? "" + let userID = authContext?.mastodonAuthenticationBox.userID ?? "" - let domain = authContext?.mastodonAuthenticationBox.domain ?? "" - let userID = authContext?.mastodonAuthenticationBox.userID ?? "" - - let options = poll.options - let pollVoteBy = poll.votedBy ?? Set() + let isMyPoll = authorDomain == domain + && authorID == userID - let isMyPoll = authorDomain == domain - && authorID == userID + self.viewModel.isSelect = option.isSelected + self.viewModel.isPollVoted = poll.voted == true + self.viewModel.isMyPoll = isMyPoll - let votedOptions = options.filter { option in - let votedBy = option.votedBy ?? Set() - return votedBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - let isRemoteVotedOption = votedOptions.contains(where: { $0.index == optionIndex }) - let isRemoteVotedPoll = pollVoteBy.contains(where: { $0.id == userID && $0.domain == domain }) - - let isLocalVotedOption = isSelected - - let isSelect: Bool? = { - if isLocalVotedOption { - return true - } else if !votedOptions.isEmpty { - return isRemoteVotedOption ? true : false - } else if isRemoteVotedPoll, votedOptions.isEmpty { - // the poll voted. But server not mark voted options - return nil - } else { - return false - } - }() - self.viewModel.isSelect = isSelect - self.viewModel.isPollVoted = isRemoteVotedPoll - self.viewModel.isMyPoll = isMyPoll - } - .store(in: &disposeBag) // appearance - checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in - return trailtCollection.userInterfaceStyle == .light ? .white : SystemTheme.tableViewCellSelectionBackgroundColor - }) - + checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor } } @@ -112,8 +75,6 @@ extension PollOptionView { // show left-hand-side dots, otherwise view looks "incomplete" viewModel.selectState = .off // appearance - checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in - return trailtCollection.userInterfaceStyle == .light ? .white : SystemTheme.tableViewCellSelectionBackgroundColor - }) + checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift index f2484e494..d00e85486 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -8,6 +8,7 @@ import UIKit import MetaTextKit import MastodonUI +import MastodonSDK // sourcery: protocolName = "StatusViewDelegate" // sourcery: replaceOf = "statusView(statusView" diff --git a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift index f8b972440..31b7492a4 100644 --- a/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Thread/ThreadViewController+DataSourceProvider.swift @@ -86,6 +86,8 @@ extension ThreadViewController: DataSourceProvider { viewModel.handleEdit(status) case .delete: break // this case has already been handled + case .pollVote: + viewModel.handleEdit(status) // technically the data changed so refresh it to reflect the new data } } } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index 7b9c0004e..5fa4a21e1 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -72,8 +72,8 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var reblogged: Set @NSManaged public private(set) var muted: Set @NSManaged public private(set) var bookmarked: Set - @NSManaged public private(set) var votePollOptions: Set - @NSManaged public private(set) var votePolls: Set + @NSManaged public private(set) var votePollOptions: Set + @NSManaged public private(set) var votePolls: Set // relationships @NSManaged public private(set) var following: Set @NSManaged public private(set) var followingBy: Set diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift index 6be59ea57..e76855aa9 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift @@ -8,7 +8,7 @@ import Foundation import CoreData -public final class Poll: NSManagedObject { +public final class PollLegacy: NSManagedObject { public typealias ID = String // sourcery: autoGenerateProperty @@ -41,20 +41,20 @@ public final class Poll: NSManagedObject { @NSManaged public private(set) var status: Status? // one-to-many relationship - @NSManaged public private(set) var options: Set + @NSManaged public private(set) var options: Set // many-to-many relationship @NSManaged public private(set) var votedBy: Set? } -extension Poll { +extension PollLegacy { @discardableResult public static func insert( into context: NSManagedObjectContext, property: Property - ) -> Poll { - let object: Poll = context.insertObject() + ) -> PollLegacy { + let object: PollLegacy = context.insertObject() object.configure(property: property) @@ -63,23 +63,23 @@ extension Poll { } -extension Poll: Managed { +extension PollLegacy: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)] + return [NSSortDescriptor(keyPath: \PollLegacy.createdAt, ascending: false)] } } -extension Poll { +extension PollLegacy { static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Poll.domain), domain) + return NSPredicate(format: "%K == %@", #keyPath(PollLegacy.domain), domain) } static func predicate(id: ID) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Poll.id), id) + return NSPredicate(format: "%K == %@", #keyPath(PollLegacy.id), id) } static func predicate(ids: [ID]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Poll.id), ids) + return NSPredicate(format: "%K IN %@", #keyPath(PollLegacy.id), ids) } public static func predicate(domain: String, id: ID) -> NSPredicate { @@ -205,7 +205,7 @@ extension Poll { //} // MARK: - AutoGenerateProperty -extension Poll: AutoGenerateProperty { +extension PollLegacy: AutoGenerateProperty { // sourcery:inline:Poll.AutoGenerateProperty // Generated using Sourcery @@ -268,7 +268,7 @@ extension Poll: AutoGenerateProperty { } // MARK: - AutoUpdatableObject -extension Poll: AutoUpdatableObject { +extension PollLegacy: AutoUpdatableObject { // sourcery:inline:Poll.AutoUpdatableObject // Generated using Sourcery @@ -308,25 +308,25 @@ extension Poll: AutoUpdatableObject { public func update(voted: Bool, by: MastodonUser) { if voted { if !(votedBy ?? Set()).contains(by) { - mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) + mutableSetValue(forKey: #keyPath(PollLegacy.votedBy)).add(by) } } else { if (votedBy ?? Set()).contains(by) { - mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) + mutableSetValue(forKey: #keyPath(PollLegacy.votedBy)).remove(by) } } } - public func attach(options: [PollOption]) { + public func attach(options: [PollOptionLegacy]) { for option in options { guard !self.options.contains(option) else { continue } - self.mutableSetValue(forKey: #keyPath(Poll.options)).add(option) + self.mutableSetValue(forKey: #keyPath(PollLegacy.options)).add(option) } } } -public extension Set { - func sortedByIndex() -> [PollOption] { +public extension Set { + func sortedByIndex() -> [PollOptionLegacy] { sorted(by: { lhs, rhs in lhs.index < rhs.index }) } } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift index 7c0ef9e3c..b61b4784c 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift @@ -8,7 +8,7 @@ import Foundation import CoreData -public final class PollOption: NSManagedObject { +public final class PollOptionLegacy: NSManagedObject { // sourcery: autoGenerateProperty @NSManaged public private(set) var index: Int64 @@ -28,21 +28,21 @@ public final class PollOption: NSManagedObject { // many-to-one relationship // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var poll: Poll? + @NSManaged public private(set) var poll: PollLegacy? // many-to-many relationship @NSManaged public private(set) var votedBy: Set? } -extension PollOption { +extension PollOptionLegacy { @discardableResult public static func insert( into context: NSManagedObjectContext, property: Property - ) -> PollOption { - let object: PollOption = context.insertObject() + ) -> PollOptionLegacy { + let object: PollOptionLegacy = context.insertObject() object.configure(property: property) @@ -51,9 +51,9 @@ extension PollOption { } -extension PollOption: Managed { +extension PollOptionLegacy: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)] + return [NSSortDescriptor(keyPath: \PollOptionLegacy.createdAt, ascending: false)] } } @@ -115,7 +115,7 @@ extension PollOption: Managed { // // MARK: - AutoGenerateProperty -extension PollOption: AutoGenerateProperty { +extension PollOptionLegacy: AutoGenerateProperty { // sourcery:inline:PollOption.AutoGenerateProperty // Generated using Sourcery @@ -126,7 +126,7 @@ extension PollOption: AutoGenerateProperty { public let votesCount: Int64 public let createdAt: Date public let updatedAt: Date - public let poll: Poll? + public let poll: PollLegacy? public init( index: Int64, @@ -134,7 +134,7 @@ extension PollOption: AutoGenerateProperty { votesCount: Int64, createdAt: Date, updatedAt: Date, - poll: Poll? + poll: PollLegacy? ) { self.index = index self.title = title @@ -164,7 +164,7 @@ extension PollOption: AutoGenerateProperty { } // MARK: - AutoUpdatableObject -extension PollOption: AutoUpdatableObject { +extension PollOptionLegacy: AutoUpdatableObject { // sourcery:inline:PollOption.AutoUpdatableObject // Generated using Sourcery @@ -189,7 +189,7 @@ extension PollOption: AutoUpdatableObject { self.isSelected = isSelected } } - public func update(poll: Poll?) { + public func update(poll: PollLegacy?) { if self.poll != poll { self.poll = poll } @@ -199,11 +199,11 @@ extension PollOption: AutoUpdatableObject { public func update(voted: Bool, by: MastodonUser) { if voted { if !(self.votedBy ?? Set()).contains(by) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) + self.mutableSetValue(forKey: #keyPath(PollOptionLegacy.votedBy)).add(by) } } else { if (self.votedBy ?? Set()).contains(by) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) + self.mutableSetValue(forKey: #keyPath(PollOptionLegacy.votedBy)).remove(by) } } } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 2cfa8c0f1..ba30aa0eb 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -78,7 +78,7 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var replyTo: Status? // sourcery: autoGenerateRelationship - @NSManaged public private(set) var poll: Poll? + @NSManaged public private(set) var poll: PollLegacy? // sourcery: autoGenerateRelationship @NSManaged public private(set) var card: Card? @@ -379,13 +379,13 @@ extension Status: AutoGenerateRelationship { public struct Relationship { public let application: Application? public let reblog: Status? - public let poll: Poll? + public let poll: PollLegacy? public let card: Card? public init( application: Application?, reblog: Status?, - poll: Poll?, + poll: PollLegacy?, card: Card? ) { self.application = application diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index 793e1d436..7ce0c6a20 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -49,6 +49,8 @@ final public class FeedDataController { updateReblogged(status, isReblogged) case let .toggleSensitive(isVisible): updateSensitive(status, isVisible) + case .pollVote: + updateEdited(status) // technically the data changed so refresh it to reflect the new data } } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Poll+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Poll+Property.swift index f703d8e53..4022b1176 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Poll+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Poll+Property.swift @@ -9,7 +9,7 @@ import Foundation import CoreDataStack import MastodonSDK -extension Poll.Property { +extension PollLegacy.Property { public init( entity: Mastodon.Entity.Poll, domain: String, diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift index 6de8343d4..592a056db 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift @@ -9,9 +9,9 @@ import Foundation import MastodonSDK import CoreDataStack -extension PollOption.Property { +extension PollOptionLegacy.Property { public init( - poll: Poll, + poll: PollLegacy, index: Int, entity: Mastodon.Entity.Poll.Option, networkDate: Date diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift index eb6001b42..95582e71e 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusDataController.swift @@ -53,6 +53,8 @@ public final class StatusDataController { updateReblogged(status, isReblogged) case let .toggleSensitive(isVisible): updateSensitive(status, isVisible) + case .pollVote: + updateEdited(status) // technically the data changed so refresh it to reflect the new data } } diff --git a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift index c0283cbd7..01eb06bcb 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift @@ -11,6 +11,6 @@ import CoreDataStack import MastodonSDK public enum PollItem: Hashable { - case option(record: ManagedObjectRecord) + case option(record: MastodonPollOption) case history(option: Mastodon.Entity.StatusEdit.Poll.Option) } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift index c0d6c813c..704869bf1 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift @@ -31,11 +31,11 @@ extension Persistence.Poll { } public struct PersistResult { - public let poll: Poll + public let poll: PollLegacy public let isNewInsertion: Bool public init( - poll: Poll, + poll: PollLegacy, isNewInsertion: Bool ) { self.poll = poll @@ -74,9 +74,9 @@ extension Persistence.Poll { public static func fetch( in managedObjectContext: NSManagedObjectContext, context: PersistContext - ) -> Poll? { - let request = Poll.sortedFetchRequest - request.predicate = Poll.predicate(domain: context.domain, id: context.entity.id) + ) -> PollLegacy? { + let request = PollLegacy.sortedFetchRequest + request.predicate = PollLegacy.predicate(domain: context.domain, id: context.entity.id) request.fetchLimit = 1 do { return try managedObjectContext.fetch(request).first @@ -90,13 +90,13 @@ extension Persistence.Poll { public static func create( in managedObjectContext: NSManagedObjectContext, context: PersistContext - ) -> Poll { - let property = Poll.Property( + ) -> PollLegacy { + let property = PollLegacy.Property( entity: context.entity, domain: context.domain, networkDate: context.networkDate ) - let poll = Poll.insert( + let poll = PollLegacy.insert( into: managedObjectContext, property: property ) @@ -106,11 +106,11 @@ extension Persistence.Poll { public static func merge( in managedObjectContext: NSManagedObjectContext, - poll: Poll, + poll: PollLegacy, context: PersistContext ) { guard context.networkDate > poll.updatedAt else { return } - let property = Poll.Property( + let property = PollLegacy.Property( entity: context.entity, domain: context.domain, networkDate: context.networkDate @@ -121,7 +121,7 @@ extension Persistence.Poll { public static func update( in managedObjectContext: NSManagedObjectContext, - poll: Poll, + poll: PollLegacy, context: PersistContext ) { let optionEntities = context.entity.options @@ -159,7 +159,7 @@ extension Persistence.Poll { option.update(poll: nil) managedObjectContext.delete(option) } - var attachableOptions = [PollOption]() + var attachableOptions = [PollOptionLegacy]() for (index, option) in context.entity.options.enumerated() { attachableOptions.append( Persistence.PollOption.create( @@ -180,7 +180,7 @@ extension Persistence.Poll { poll.update(updatedAt: context.networkDate) } - private static func needsPollOptionsUpdate(context: PersistContext, poll: Poll) -> Bool { + private static func needsPollOptionsUpdate(context: PersistContext, poll: PollLegacy) -> Bool { let entityPollOptions = context.entity.options.map { (title: $0.title, votes: $0.votesCount) } let pollOptions = poll.options.sortedByIndex().map { (title: $0.title, votes: Int($0.votesCount)) } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift index 322fd254f..e44ebe462 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift @@ -14,14 +14,14 @@ extension Persistence.PollOption { public struct PersistContext { public let index: Int - public let poll: Poll + public let poll: PollLegacy public let entity: Mastodon.Entity.Poll.Option public let me: MastodonUser? public let networkDate: Date public init( index: Int, - poll: Poll, + poll: PollLegacy, entity: Mastodon.Entity.Poll.Option, me: MastodonUser?, networkDate: Date @@ -35,11 +35,11 @@ extension Persistence.PollOption { } public struct PersistResult { - public let option: PollOption + public let option: PollOptionLegacy public let isNewInsertion: Bool public init( - option: PollOption, + option: PollOptionLegacy, isNewInsertion: Bool ) { self.option = option @@ -65,24 +65,24 @@ extension Persistence.PollOption { public static func create( in managedObjectContext: NSManagedObjectContext, context: PersistContext - ) -> PollOption { - let property = PollOption.Property( + ) -> PollOptionLegacy { + let property = PollOptionLegacy.Property( poll: context.poll, index: context.index, entity: context.entity, networkDate: context.networkDate ) - let option = PollOption.insert(into: managedObjectContext, property: property) + let option = PollOptionLegacy.insert(into: managedObjectContext, property: property) update(option: option, context: context) return option } public static func merge( - option: PollOption, + option: PollOptionLegacy, context: PersistContext ) { guard context.networkDate > option.updatedAt else { return } - let property = PollOption.Property( + let property = PollOptionLegacy.Property( poll: context.poll, index: context.index, entity: context.entity, @@ -93,7 +93,7 @@ extension Persistence.PollOption { } private static func update( - option: PollOption, + option: PollOptionLegacy, context: PersistContext ) { // Do nothing diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift index 79efbf78e..e648c9c8e 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift @@ -78,7 +78,7 @@ extension Persistence.Status { isNewInsertion: false ) } else { - let poll: Poll? = { + let poll: PollLegacy? = { guard let entity = context.entity.poll else { return nil } let result = Persistence.Poll.createOrMerge( in: managedObjectContext, diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift index 26ef9625f..4edd34bf3 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HashtagTimeline.swift @@ -41,20 +41,6 @@ extension APIService { hashtag: hashtag, authorization: authorization ).singleOutput() - - #warning("TODO: Remove this with IOS-181, IOS-182") - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - for entity in response.value { - guard let poll = entity.poll else { continue } - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) - ) - } - } return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift index 17544b4fe..2f9b67359 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift @@ -40,20 +40,6 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - #warning("TODO: Remove this with IOS-181, IOS-182") - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - for entity in response.value { - guard let poll = entity.poll else { continue } - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) - ) - } - } return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Poll.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Poll.swift index ef486448d..5384523b6 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Poll.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Poll.swift @@ -14,39 +14,18 @@ import MastodonSDK extension APIService { public func poll( - poll: ManagedObjectRecord, + poll: Mastodon.Entity.Poll, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { let authorization = authenticationBox.userAuthorization - let managedObjectContext = self.backgroundManagedObjectContext - let pollID: Poll.ID = try await managedObjectContext.perform { - guard let poll = poll.object(in: managedObjectContext) else { - throw APIError.implicit(.badRequest) - } - return poll.id - } - let response = try await Mastodon.API.Polls.poll( session: session, domain: authenticationBox.domain, - pollID: pollID, + pollID: poll.id, authorization: authorization ).singleOutput() - - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: Persistence.Poll.PersistContext( - domain: authenticationBox.domain, - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - } - + return response } @@ -55,41 +34,19 @@ extension APIService { extension APIService { public func vote( - poll: ManagedObjectRecord, + poll: Mastodon.Entity.Poll, choices: [Int], authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - let managedObjectContext = backgroundManagedObjectContext - let _pollID: Poll.ID? = try await managedObjectContext.perform { - guard let poll = poll.object(in: managedObjectContext) else { return nil } - return poll.id - } - - guard let pollID = _pollID else { - throw APIError.implicit(.badRequest) - } let response = try await Mastodon.API.Polls.vote( session: session, domain: authenticationBox.domain, - pollID: pollID, + pollID: poll.id, query: Mastodon.API.Polls.VoteQuery(choices: choices), authorization: authenticationBox.userAuthorization ).singleOutput() - - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: Persistence.Poll.PersistContext( - domain: authenticationBox.domain, - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - } - + return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift index ed36a57bc..9617c5233 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+PublicTimeline.swift @@ -26,20 +26,6 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - #warning("TODO: Remove this with IOS-181, IOS-182") - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - for entity in response.value { - guard let poll = entity.poll else { continue } - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) - ) - } - } return response } // end func diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift index a52d4506c..22f4b0d81 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status.swift @@ -26,19 +26,6 @@ extension APIService { statusID: statusID, authorization: authorization ).singleOutput() - - #warning("TODO: Remove this with IOS-181, IOS-182") - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - if let poll = response.value.poll { - _ = Persistence.Poll.createOrMerge( - in: managedObjectContext, - context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate) - ) - } - } return response } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift index c616b8b8f..7e93629c6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/2/24 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/poll/) - public struct Poll: Codable, Sendable { + public struct Poll: Codable, Sendable, Hashable { public typealias ID = String public let id: ID @@ -47,12 +47,12 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Poll { - public struct Option: Codable, Sendable { + public struct Option: Codable, Sendable, Hashable { public let title: String /// nil if results are not published yet public let votesCount: Int? public let emojis: [Mastodon.Entity.Emoji]? - + enum CodingKeys: String, CodingKey { case title case votesCount = "votes_count" diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index e512143fe..252dd1c84 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -71,17 +71,29 @@ extension MastodonFeed: Hashable { public static func == (lhs: MastodonFeed, rhs: MastodonFeed) -> Bool { lhs.id == rhs.id && lhs.status?.entity == rhs.status?.entity && + lhs.status?.poll == rhs.status?.poll && lhs.status?.reblog?.entity == rhs.status?.reblog?.entity && + lhs.status?.reblog?.poll == rhs.status?.reblog?.poll && lhs.status?.isSensitiveToggled == rhs.status?.isSensitiveToggled && - lhs.status?.reblog?.isSensitiveToggled == rhs.status?.reblog?.isSensitiveToggled + lhs.status?.reblog?.isSensitiveToggled == rhs.status?.reblog?.isSensitiveToggled && + lhs.status?.poll == rhs.status?.poll && + lhs.status?.reblog?.poll == rhs.status?.reblog?.poll && + lhs.status?.poll?.entity == rhs.status?.poll?.entity && + lhs.status?.reblog?.poll?.entity == rhs.status?.reblog?.poll?.entity } public func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(status?.entity) + hasher.combine(status?.poll) hasher.combine(status?.reblog?.entity) + hasher.combine(status?.reblog?.poll) hasher.combine(status?.isSensitiveToggled) hasher.combine(status?.reblog?.isSensitiveToggled) + hasher.combine(status?.poll) + hasher.combine(status?.reblog?.poll) + hasher.combine(status?.poll?.entity) + hasher.combine(status?.reblog?.poll?.entity) } } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonPoll.swift b/MastodonSDK/Sources/MastodonSDK/MastodonPoll.swift new file mode 100644 index 000000000..d2d59a909 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/MastodonPoll.swift @@ -0,0 +1,89 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +public final class MastodonPoll: ObservableObject, Hashable { + + @Published public var votersCount: Int? + @Published public var votesCount: Int + @Published public var options: [MastodonPollOption] = [] + @Published public var multiple: Bool + @Published public var expired: Bool + @Published public var expiresAt: Date? + @Published public var voted: Bool? + + public var id: String { + entity.id + } + + public let entity: Mastodon.Entity.Poll + public weak var status: MastodonStatus? + + public init(poll: Mastodon.Entity.Poll, status: MastodonStatus?) { + self.status = status + self.entity = poll + self.votersCount = poll.votersCount + self.votesCount = poll.votesCount + self.multiple = poll.multiple + self.expired = poll.expired + self.voted = poll.voted + self.expiresAt = poll.expiresAt + self.options = poll.options.map { $0.toMastodonPollOption(with: self) } + } + + public static func == (lhs: MastodonPoll, rhs: MastodonPoll) -> Bool { + lhs.entity == rhs.entity + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(entity) + } +} + +public extension Mastodon.Entity.Poll { + func toMastodonPoll(status: MastodonStatus?) -> MastodonPoll { + return .init(poll: self, status: status) + } +} + +public final class MastodonPollOption: ObservableObject, Hashable { + + public let poll: MastodonPoll + public let option: Mastodon.Entity.Poll.Option + @Published public var isSelected: Bool = false + @Published public var votesCount: Int? + @Published public var title: String + @Published public var voted: Bool? + public private(set) var optionIndex: Int? = nil + + public init(poll: MastodonPoll, option: Mastodon.Entity.Poll.Option, isSelected: Bool = false) { + self.poll = poll + self.option = option + self.isSelected = isSelected + self.votesCount = option.votesCount + self.title = option.title + self.optionIndex = poll.options.firstIndex(of: self) + + self.voted = { + guard let ownVotes = poll.entity.ownVotes else { return false } + guard let optionIndex else { return false } + return ownVotes.contains(optionIndex) + }() + } + + public static func == (lhs: MastodonPollOption, rhs: MastodonPollOption) -> Bool { + lhs.poll == rhs.poll && lhs.option == rhs.option && lhs.isSelected == rhs.isSelected + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(poll) + hasher.combine(option) + hasher.combine(isSelected) + } +} + +public extension Mastodon.Entity.Poll.Option { + func toMastodonPollOption(with poll: MastodonPoll) -> MastodonPollOption { + return .init(poll: poll, option: self) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift index c19283ba2..fb6af6765 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -16,10 +16,16 @@ public final class MastodonStatus: ObservableObject { @Published public var isSensitiveToggled: Bool = false + @Published public var poll: MastodonPoll? + init(entity: Mastodon.Entity.Status, isSensitiveToggled: Bool) { self.entity = entity self.isSensitiveToggled = isSensitiveToggled + if let poll = entity.poll { + self.poll = .init(poll: poll, status: self) + } + if let reblog = entity.reblog { self.reblog = MastodonStatus.fromEntity(reblog) } else { @@ -47,19 +53,30 @@ extension MastodonStatus { originalStatus = status return self } + + public func withPoll(_ poll: MastodonPoll?) -> MastodonStatus { + self.poll = poll + return self + } } extension MastodonStatus: Hashable { public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool { lhs.entity == rhs.entity && + lhs.poll == rhs.poll && + lhs.entity.poll == rhs.entity.poll && lhs.reblog?.entity == rhs.reblog?.entity && + lhs.reblog?.poll == rhs.reblog?.poll && + lhs.reblog?.entity.poll == rhs.reblog?.entity.poll && lhs.isSensitiveToggled == rhs.isSensitiveToggled && lhs.reblog?.isSensitiveToggled == rhs.reblog?.isSensitiveToggled } public func hash(into hasher: inout Hasher) { hasher.combine(entity) + hasher.combine(poll) hasher.combine(reblog?.entity) + hasher.combine(reblog?.poll) hasher.combine(isSensitiveToggled) hasher.combine(reblog?.isSensitiveToggled) } @@ -84,17 +101,16 @@ public extension MastodonStatus { case toggleSensitive(Bool) case delete case edit + case pollVote } } public extension MastodonStatus { - func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? { + func getPoll(in domain: String, authorization: Mastodon.API.OAuth.Authorization) async -> Mastodon.Entity.Poll? { guard let pollId = entity.poll?.id else { return nil } - return try? await context.perform { - let predicate = Poll.predicate(domain: domain, id: pollId) - return Poll.findOrFetch(in: context, matching: predicate) - } + let poll = try? await Mastodon.API.Polls.poll(session: .shared, domain: domain, pollID: pollId, authorization: authorization).singleOutput().value + return poll } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 325cbc955..f69ec0f86 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -281,13 +281,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { contentWarning = status.entity.spoilerText ?? "" } Task { @MainActor in - if let poll = await status.getPoll(in: context.managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) { + if let poll = await status.getPoll( + in: authContext.mastodonAuthenticationBox.domain, + authorization: authContext.mastodonAuthenticationBox.userAuthorization + ) { isPollActive = !poll.expired pollMultipleConfigurationOption = poll.multiple if let pollExpiresAt = poll.expiresAt { pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt) } - pollOptions = poll.options.sortedByIndex().map { + pollOptions = poll.options.map { let option = PollComposeItem.Option() option.text = $0.title return option diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index cd548dae1..c530ccd52 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -378,68 +378,44 @@ extension StatusView { private func configurePoll(status: MastodonStatus) { let status = status.reblog ?? status - guard - let context = viewModel.context?.managedObjectContext, - let domain = viewModel.authContext?.mastodonAuthenticationBox.domain, - let pollId = status.entity.poll?.id - else { + guard let poll = status.poll else { return } - let predicate = Poll.predicate(domain: domain, id: pollId) - guard let poll = Poll.findOrFetch(in: context, matching: predicate) else { return } - - viewModel.managedObjects.insert(poll) - - // pollItems - let options = poll.options.sorted(by: { $0.index < $1.index }) - let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) } + let options = poll.options + let items: [PollItem] = options.map { .option(record: $0) } self.viewModel.pollItems = items - - // isVoteButtonEnabled - poll.publisher(for: \.updatedAt) - .sink { [weak self] _ in - guard let self = self else { return } - let options = poll.options - let hasSelectedOption = options.contains(where: { $0.isSelected }) - self.viewModel.isVoteButtonEnabled = hasSelectedOption - } - .store(in: &disposeBag) - // isVotable + + let hasSelectedOption = options.contains(where: { $0.isSelected == true }) + viewModel.isVoteButtonEnabled = hasSelectedOption + Publishers.CombineLatest( - poll.publisher(for: \.votedBy), - poll.publisher(for: \.expired) + poll.$voted, + poll.$expired ) - .map { [weak viewModel] votedBy, expired in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - let domain = authContext.mastodonAuthenticationBox.domain - let userID = authContext.mastodonAuthenticationBox.userID - let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false - return !isVoted && !expired + .map { voted, expired in + return voted == false && expired == false } .assign(to: &viewModel.$isVotable) - - // votesCount - poll.publisher(for: \.votesCount) - .map { Int($0) } + + poll.$votesCount .assign(to: \.voteCount, on: viewModel) .store(in: &disposeBag) - // voterCount - poll.publisher(for: \.votersCount) - .map { Int($0) } + + poll.$votersCount .assign(to: \.voterCount, on: viewModel) .store(in: &disposeBag) - // expireAt - poll.publisher(for: \.expiresAt) + + poll.$expiresAt .assign(to: \.expireAt, on: viewModel) .store(in: &disposeBag) - // expired - poll.publisher(for: \.expired) + + poll.$expired .assign(to: \.expired, on: viewModel) .store(in: &disposeBag) - // isVoting - poll.publisher(for: \.isVoting) + + poll.$voted + .map { $0 == true } .assign(to: \.isVoting, on: viewModel) .store(in: &disposeBag) } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index d27711678..6ebc265de 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -457,8 +457,32 @@ extension StatusView.ViewModel { statusView.pollTableViewHeightLayoutConstraint.constant = CGFloat(items.count) * PollOptionTableViewCell.height statusView.setPollDisplay() + + items.forEach({ item in + guard case let PollItem.option(record) = item else { return } + record.$isSelected.receive(on: DispatchQueue.main).sink { [weak self] selected in + guard let self else { return } + if (selected) { + // as we have just selected an option, the vote button must be enabled + self.isVoteButtonEnabled = true + } else { + // figure out which buttons are currently selected + let records = pollItems.compactMap({ item -> MastodonPollOption? in + guard case let PollItem.option(record) = item else { return nil } + return record + }) + .filter({ $0.isSelected }) + + // only enable vote button if there are selected options + self.isVoteButtonEnabled = !records.isEmpty + } + statusView.pollTableView.reloadData() + } + .store(in: &self.disposeBag) + }) } .store(in: &disposeBag) + $isVotable .sink { isVotable in statusView.pollTableView.allowsSelection = isVotable @@ -508,14 +532,17 @@ extension StatusView.ViewModel { $isVotable, $isVoting ) + .receive(on: DispatchQueue.main) .sink { isVotable, isVoting in guard isVotable else { statusView.pollVoteButton.isHidden = true statusView.pollVoteActivityIndicatorView.isHidden = true + statusView.pollTableView.isUserInteractionEnabled = false return } statusView.pollVoteButton.isHidden = isVoting + statusView.pollTableView.isUserInteractionEnabled = !isVoting statusView.pollVoteActivityIndicatorView.isHidden = !isVoting statusView.pollVoteActivityIndicatorView.startAnimating() } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 5248208a1..79c4921b8 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -12,6 +12,7 @@ import Meta import MastodonAsset import MastodonCore import MastodonLocalization +import MastodonSDK public extension CGSize { static let authorAvatarButtonSize = CGSize(width: 46, height: 46)