feat: [WIP] make the vote poll logic works

This commit is contained in:
CMK 2021-03-04 18:53:29 +08:00
parent 028f3a9404
commit 06aac878c8
13 changed files with 318 additions and 16 deletions

View File

@ -62,7 +62,7 @@ extension PollOption {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by)
}
} else {
if !(self.votedBy ?? Set()).contains(by) {
if (self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by)
}
}

View File

@ -27,13 +27,20 @@
"ERR_INCLUSION": "is not a supported value"
},
"alerts": {
"common": {
"please_try_again": "Please try again.",
"please_try_again_later": "Please try again later."
},
"sign_up_failure": {
"title": "Sign Up Failure"
},
"server_error": {
"title": "Server Error"
},
"vote_failure": {
"title": "Vote Failure",
"poll_expired": "The poll has expired"
}
},
"controls": {
"actions": {
@ -125,7 +132,7 @@
"prompt_eight_characters": "Eight characters"
},
"invite": {
"registration_user_invite_request": "Why do you want to join?"
"registration_user_invite_request": "Why do you want to join?"
}
},
"success": "Success",
@ -165,4 +172,4 @@
"title": "Public"
}
}
}
}

View File

@ -284,13 +284,13 @@ extension StatusSection {
.map { option -> PollItem in
let attribute: PollItem.Attribute = {
let selectState: PollItem.Attribute.SelectState = {
if isPollVoted {
guard !votedOptions.isEmpty else {
return .none
}
// make isPollVoted check later to make only local change possible
if !votedOptions.isEmpty {
return votedOptions.contains(option) ? .on : .off
} else if poll.expired {
return .none
} else if isPollVoted, votedOptions.isEmpty {
return .none
} else {
return .off
}
@ -302,6 +302,8 @@ extension StatusSection {
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
}()
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option)
// let voted = true
// let percentage: Double = Double.random(in: 0..<1)
return .reveal(voted: voted, percentage: percentage)
}()
return PollItem.Attribute(selectState: selectState, voteState: voteState)

View File

@ -13,6 +13,12 @@ internal enum L10n {
internal enum Common {
internal enum Alerts {
internal enum Common {
/// Please try again.
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
/// Please try again later.
internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater")
}
internal enum ServerError {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
@ -21,6 +27,12 @@ internal enum L10n {
/// Sign Up Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
}
internal enum VoteFailure {
/// The poll has expired
internal static let pollExpired = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollExpired")
/// Vote Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title")
}
}
internal enum Controls {
internal enum Actions {

View File

@ -39,6 +39,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
// MARK: - MosciaImageViewContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
@ -69,3 +70,37 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
}
// MARK: - PollTableView
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) {
guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return }
guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .opion(objectID, attribute) = item else { return }
guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return }
if option.poll.multiple {
var choices: [Int] = []
} else {
context.apiService.vote(
pollObjectID: option.poll.objectID,
mastodonUserObjectID: activeMastodonAuthentication.user.objectID,
choices: [option.index.intValue]
)
.receive(on: DispatchQueue.main)
.sink { completion in
} receiveValue: { pollID in
}
.store(in: &context.disposeBag)
}
}
}

View File

@ -33,7 +33,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
return nil
}
let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt)
guard timeIntervalSinceUpdate > 60 else {
#if DEBUG
let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing
#else
let autoRefreshTimeInterval: TimeInterval = 60
#endif
guard timeIntervalSinceUpdate > autoRefreshTimeInterval else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate)
return nil
}

View File

@ -1,5 +1,9 @@
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
"Common.Controls.Actions.Add" = "Add";
"Common.Controls.Actions.Back" = "Back";
"Common.Controls.Actions.Cancel" = "Cancel";

View File

@ -51,7 +51,9 @@ extension VoteProgressStripView {
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
guard let self = self else { return }
self.updateLayerPath()
UIView.animate(withDuration: 0.33) {
self.updateLayerPath()
}
}
.store(in: &disposeBag)
}

View File

@ -13,11 +13,15 @@ import CoreData
import CoreDataStack
protocol StatusTableViewCellDelegate: class {
var context: AppContext! { get}
var managedObjectContext: NSManagedObjectContext { get }
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
}
final class StatusTableViewCell: UITableViewCell {
@ -110,6 +114,44 @@ extension StatusTableViewCell: UITableViewDelegate {
return true
}
}
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
guard let context = delegate?.context else { return nil }
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
guard let item = diffableDataSource.itemIdentifier(for: indexPath),
case let .opion(objectID, _) = item,
let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else {
return nil
}
let poll = option.poll
// disallow select when: poll expired OR user voted remote OR user voted local
let userID = activeMastodonAuthenticationBox.userID
let didVotedRemote = (option.poll.votedBy ?? Set()).contains(where: { $0.id == userID })
let votedOptions = poll.options.filter { option in
(option.votedBy ?? Set()).map { $0.id }.contains(userID)
}
let didVotedLocal = !votedOptions.isEmpty
guard !option.poll.expired, !didVotedRemote, !didVotedLocal else {
return nil
}
return indexPath
} else {
return indexPath
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if tableView === statusView.pollTableView {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath)
}
}
}
// MARK: - StatusViewDelegate

View File

@ -21,6 +21,8 @@ extension APIService {
case badResponse
case requestThrottle
case voteExpiredPoll
// Server API error
case mastodonAPIError(Mastodon.API.Error)
}
@ -44,6 +46,7 @@ extension APIService.APIError: LocalizedError {
case .badRequest: return "Bad Request"
case .badResponse: return "Bad Response"
case .requestThrottle: return "Request Throttled"
case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.title
case .mastodonAPIError(let error):
guard let responseError = error.mastodonError else {
guard error.httpResponseStatus != .ok else {
@ -62,6 +65,7 @@ extension APIService.APIError: LocalizedError {
case .badRequest: return "Request invalid."
case .badResponse: return "Response invalid."
case .requestThrottle: return "Request too frequency."
case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.pollExpired
case .mastodonAPIError(let error):
guard let responseError = error.mastodonError else {
return nil
@ -73,9 +77,10 @@ extension APIService.APIError: LocalizedError {
var helpAnchor: String? {
switch errorReason {
case .authenticationMissing: return "Please request after authenticated."
case .badRequest: return "Please try again."
case .badResponse: return "Please try again."
case .requestThrottle: return "Please try again later."
case .badRequest: return L10n.Common.Alerts.Common.pleaseTryAgain
case .badResponse: return L10n.Common.Alerts.Common.pleaseTryAgain
case .requestThrottle: return L10n.Common.Alerts.Common.pleaseTryAgainLater
case .voteExpiredPoll: return nil
case .mastodonAPIError(let error):
guard let responseError = error.mastodonError else {
return nil

View File

@ -69,3 +69,127 @@ extension APIService {
}
}
extension APIService {
/// vote local
/// # Note
/// Not mark the poll voted so that view model could know when to reveal the results
func vote(
pollObjectID: NSManagedObjectID,
mastodonUserObjectID: NSManagedObjectID,
choices: [Int]
) -> AnyPublisher<Mastodon.Entity.Poll.ID, Error> {
var _targetPollID: Mastodon.Entity.Poll.ID?
var isPollExpired = false
var didVotedLocal = false
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let poll = managedObjectContext.object(with: pollObjectID) as! Poll
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
_targetPollID = poll.id
if let expiresAt = poll.expiresAt, Date().timeIntervalSince(expiresAt) > 0 {
isPollExpired = true
poll.update(expired: true)
return
}
let options = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue })
let votedOptions = poll.options.filter { option in
(option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id)
}
guard votedOptions.isEmpty else {
// if did voted. Do not allow vote again
didVotedLocal = true
return
}
for option in options {
let voted = choices.contains(option.index.intValue)
option.update(voted: voted, by: mastodonUser)
option.didUpdate(at: option.updatedAt) // trigger update without change anything
}
poll.didUpdate(at: poll.updatedAt) // trigger update without change anything
}
.tryMap { result in
guard !isPollExpired else {
throw APIError.explicit(APIError.ErrorReason.voteExpiredPoll)
}
guard !didVotedLocal else {
throw APIError.implicit(APIError.ErrorReason.badRequest)
}
switch result {
case .success:
guard let targetPollID = _targetPollID else {
throw APIError.implicit(.badRequest)
}
return targetPollID
case .failure(let error):
assertionFailure(error.localizedDescription)
throw error
}
}
.eraseToAnyPublisher()
}
// send vote request to remote
func vote(
domain: String,
pollID: Mastodon.Entity.Poll.ID,
pollObjectID: NSManagedObjectID,
choices: [Int],
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
let query = Mastodon.API.Polls.VoteQuery(choices: choices)
return Mastodon.API.Polls.vote(
session: session,
domain: domain,
pollID: pollID,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in
let entity = response.value
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let _requestMastodonUser: MastodonUser? = {
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
guard let requestMastodonUser = _requestMastodonUser else {
assertionFailure()
return
}
guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return }
APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Poll> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -9,6 +9,7 @@ import Combine
import Foundation
extension Mastodon.API.Favorites {
static func favoritesStatusesEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites")
}
@ -34,6 +35,8 @@ extension Mastodon.API.Favorites {
///
/// Add a status to your favourites list / Remove a status from your favourites list
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
@ -60,6 +63,8 @@ extension Mastodon.API.Favorites {
///
/// View who favourited a given status.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
@ -85,6 +90,8 @@ extension Mastodon.API.Favorites {
///
/// Using this endpoint to view the favourited list for user
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
@ -104,9 +111,11 @@ extension Mastodon.API.Favorites {
}
.eraseToAnyPublisher()
}
}
public extension Mastodon.API.Favorites {
enum FavoriteKind {
case create
case destroy
@ -144,4 +153,5 @@ public extension Mastodon.API.Favorites {
return items
}
}
}

View File

@ -14,11 +14,18 @@ extension Mastodon.API.Polls {
let pathComponent = "polls/" + pollID
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
static func votePollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL {
let pathComponent = "polls/" + pollID + "/votes"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// View a poll
///
/// Using this endpoint to view the poll of status
///
/// - Since: 2.8.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
@ -28,7 +35,7 @@ extension Mastodon.API.Polls {
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - pollID: id for poll
/// - authorization: User token. Could be nil if status is public
/// - Returns: `AnyPublisher` contains `Server` nested in the response
/// - Returns: `AnyPublisher` contains `Poll` nested in the response
public static func poll(
session: URLSession,
domain: String,
@ -48,4 +55,51 @@ extension Mastodon.API.Polls {
.eraseToAnyPublisher()
}
/// Vote on a poll
///
/// Using this endpoint to vote an option of poll
///
/// - Since: 2.8.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/4
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/polls/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - pollID: id for poll
/// - query: `VoteQuery`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Poll` nested in the response
public static func vote(
session: URLSession,
domain: String,
pollID: Mastodon.Entity.Poll.ID,
query: VoteQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> {
let request = Mastodon.API.post(
url: votePollEndpointURL(domain: domain, pollID: pollID),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Polls {
public struct VoteQuery: Codable, PostQuery {
public let choices: [Int]
public init(choices: [Int]) {
self.choices = choices
}
}
}