From 1c36dfcc5f8bfadf55e0cb7c271b95e9774685bb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 28 Apr 2021 19:00:17 -0400 Subject: [PATCH] Add displaying and voting on polls in statuses --- Pachyderm/Model/Poll.swift | 55 ++++++ Pachyderm/Model/Status.swift | 2 + Pachyderm/Request/Parameter.swift | 4 + Tusker.xcodeproj/project.pbxproj | 28 +++ Tusker/CoreData/StatusMO.swift | 6 + .../Tusker.xcdatamodel/contents | 7 +- .../Views/Poll/PollOptionCheckboxView.swift | 74 ++++++++ Tusker/Views/Poll/PollOptionView.swift | 76 ++++++++ Tusker/Views/Poll/PollOptionsView.swift | 170 ++++++++++++++++++ Tusker/Views/Poll/StatusPollView.swift | 157 ++++++++++++++++ .../Status/BaseStatusTableViewCell.swift | 9 + .../ConversationMainStatusTableViewCell.xib | 17 +- .../Status/TimelineStatusTableViewCell.xib | 16 +- Tusker/en.lproj/Localizable.stringsdict | 18 +- 14 files changed, 623 insertions(+), 16 deletions(-) create mode 100644 Pachyderm/Model/Poll.swift create mode 100644 Tusker/Views/Poll/PollOptionCheckboxView.swift create mode 100644 Tusker/Views/Poll/PollOptionView.swift create mode 100644 Tusker/Views/Poll/PollOptionsView.swift create mode 100644 Tusker/Views/Poll/StatusPollView.swift diff --git a/Pachyderm/Model/Poll.swift b/Pachyderm/Model/Poll.swift new file mode 100644 index 00000000..5162ceb2 --- /dev/null +++ b/Pachyderm/Model/Poll.swift @@ -0,0 +1,55 @@ +// +// Poll.swift +// Pachyderm +// +// Created by Shadowfacts on 4/25/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import Foundation + +public final class Poll: Codable { + public let id: String + public let expiresAt: Date? + public let expired: Bool + public let multiple: Bool + public let votesCount: Int + public let votersCount: Int? + public let voted: Bool? + public let ownVotes: [Int]? + public let options: [Option] + public let emojis: [Emoji] + + public var effectiveExpired: Bool { + expired || (expiresAt != nil && expiresAt! < Date()) + } + + public static func vote(_ pollID: String, choices: [Int]) -> Request { + return Request(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil)) + } + + private enum CodingKeys: String, CodingKey { + case id + case expiresAt = "expires_at" + case expired + case multiple + case votesCount = "votes_count" + case votersCount = "voters_count" + case voted + case ownVotes = "own_votes" + case options + case emojis + } +} + +extension Poll { + public final class Option: Codable { + public let title: String + public let votesCount: Int? + + private enum CodingKeys: String, CodingKey { + case title + case votesCount = "votes_count" + } + } +} diff --git a/Pachyderm/Model/Status.swift b/Pachyderm/Model/Status.swift index 6e01199f..241d267f 100644 --- a/Pachyderm/Model/Status.swift +++ b/Pachyderm/Model/Status.swift @@ -37,6 +37,7 @@ public final class Status: /*StatusProtocol,*/ Decodable { public let pinned: Bool? public let bookmarked: Bool? public let card: Card? + public let poll: Poll? public var applicationName: String? { application?.name } @@ -132,6 +133,7 @@ public final class Status: /*StatusProtocol,*/ Decodable { case pinned case bookmarked case card + case poll } } diff --git a/Pachyderm/Request/Parameter.swift b/Pachyderm/Request/Parameter.swift index 3e6350c6..b5371b17 100644 --- a/Pachyderm/Request/Parameter.swift +++ b/Pachyderm/Request/Parameter.swift @@ -52,6 +52,10 @@ extension String { let name = "\(name)[]" return values.map { Parameter(name: name, value: $0) } } + + static func =>(name: String, values: [Int]) -> [Parameter] { + return name => values.map { $0.description } + } } extension Parameter: CustomStringConvertible { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 5d8f99c7..df765cd1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -85,6 +85,10 @@ D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; }; D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; }; D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; }; + D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; }; + D623A53F2635F6910095BD04 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53E2635F6910095BD04 /* Poll.swift */; }; + D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; }; + D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; }; D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; }; D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; }; D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; }; @@ -215,6 +219,7 @@ D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; }; D6969EA4240DD28D002843CE /* UnknownNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */; }; D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = D69CCBBE249E6EFD000AF167 /* CrashReporter */; }; + D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A3BC7723218E1300FD64D5 /* TimelineSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */; }; D6A3BC7923218E9200FD64D5 /* NotificationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */; }; D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; }; @@ -454,6 +459,10 @@ D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = ""; }; D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = ""; }; D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = ""; }; + D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = ""; }; + D623A53E2635F6910095BD04 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; + D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; + D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = ""; }; D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = ""; }; D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = ""; }; D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = ""; }; @@ -586,6 +595,7 @@ D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = ""; }; D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = ""; }; D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UnknownNotificationTableViewCell.xib; sourceTree = ""; }; + D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = ""; }; D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSegment.swift; sourceTree = ""; }; D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationGroup.swift; sourceTree = ""; }; D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = ""; }; @@ -837,6 +847,7 @@ D6109A062145756700432DC2 /* LoginSettings.swift */, D61099F22145688600432DC2 /* Mention.swift */, D61099F4214568C300432DC2 /* Notification.swift */, + D623A53E2635F6910095BD04 /* Poll.swift */, D61099F62145693500432DC2 /* PushSubscription.swift */, D6109A022145722C00432DC2 /* RegisteredApplication.swift */, D61099F82145698900432DC2 /* Relationship.swift */, @@ -892,6 +903,17 @@ path = "Instance Cell"; sourceTree = ""; }; + D623A53B2635F4E20095BD04 /* Poll */ = { + isa = PBXGroup; + children = ( + D623A53C2635F5590095BD04 /* StatusPollView.swift */, + D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */, + D623A5402635FB3C0095BD04 /* PollOptionView.swift */, + D623A542263634100095BD04 /* PollOptionCheckboxView.swift */, + ); + path = Poll; + sourceTree = ""; + }; D626494023C122C800612E6E /* Asset Picker */ = { isa = PBXGroup; children = ( @@ -1390,6 +1412,7 @@ D61959D0241E842400A37B8E /* Draft Cell */, D641C78A213DD926004B4513 /* Status */, D6C7D27B22B6EBE200071952 /* Attachments */, + D623A53B2635F4E20095BD04 /* Poll */, D641C78B213DD92F004B4513 /* Profile Header */, D641C78C213DD937004B4513 /* Notifications */, D6A3BC872321F78000FD64D5 /* Account Cell */, @@ -1870,6 +1893,7 @@ D61099F5214568C300432DC2 /* Notification.swift in Sources */, D61099EF214566C000432DC2 /* Instance.swift in Sources */, D61099D22144B2E600432DC2 /* Body.swift in Sources */, + D623A53F2635F6910095BD04 /* Poll.swift in Sources */, D63569E023908A8D003DD353 /* StatusState.swift in Sources */, D6109A0121456B0800432DC2 /* Hashtag.swift in Sources */, D61099FD21456A1D00432DC2 /* SearchResults.swift in Sources */, @@ -1982,7 +2006,9 @@ D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, + D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, + D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, @@ -1993,6 +2019,7 @@ D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, + D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, @@ -2105,6 +2132,7 @@ D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */, D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, + D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, ); diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index f1c8b7c4..7fe82357 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -42,6 +42,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol { @NSManaged public var uri: String // todo: are both uri and url necessary? @NSManaged public var url: URL? @NSManaged private var visibilityString: String + @NSManaged private var pollData: Data? @NSManaged public var account: AccountMO @NSManaged public var reblog: StatusMO? @@ -60,6 +61,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol { @LazilyDecoding(from: \StatusMO.cardData, fallback: nil) public var card: Card? + @LazilyDecoding(from: \StatusMO.pollData, fallback: nil) + public var poll: Poll? + public var pinned: Bool? { pinnedInternal } public var bookmarked: Bool? { bookmarkedInternal } @@ -129,6 +133,8 @@ extension StatusMO { self.uri = status.uri self.url = status.url self.visibility = status.visibility + self.poll = status.poll + if let existing = container.account(for: status.account.id, in: context) { existing.updateFrom(apiAccount: status.account, container: container) self.account = existing diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 0da7d6de..db91c526 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -57,6 +57,7 @@ + @@ -74,8 +75,8 @@ - + - + \ No newline at end of file diff --git a/Tusker/Views/Poll/PollOptionCheckboxView.swift b/Tusker/Views/Poll/PollOptionCheckboxView.swift new file mode 100644 index 00000000..7f940cce --- /dev/null +++ b/Tusker/Views/Poll/PollOptionCheckboxView.swift @@ -0,0 +1,74 @@ +// +// PollOptionCheckboxView.swift +// Tusker +// +// Created by Shadowfacts on 4/25/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit + +class PollOptionCheckboxView: UIView { + + var isChecked: Bool = false { + didSet { + updateStyle() + } + } + var readOnly: Bool = true { + didSet { + updateStyle() + } + } + var voted: Bool = false { + didSet { + updateStyle() + } + } + + private let imageView: UIImageView + + init(multiple: Bool) { + imageView = UIImageView(image: UIImage(systemName: "checkmark")!) + + super.init(frame: .zero) + + let size: CGFloat = 20 + layer.cornerRadius = (multiple ? 0.1 : 0.5) * size + layer.borderWidth = 2 + + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.isHidden = true + addSubview(imageView) + + updateStyle() + + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalTo: heightAnchor), + widthAnchor.constraint(equalToConstant: size), + + imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3), + imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3), + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateStyle() { + imageView.isHidden = !isChecked + if voted || readOnly { + layer.borderColor = UIColor.clear.cgColor + } else if isChecked { + layer.borderColor = tintColor.cgColor + } else { + layer.borderColor = UIColor.gray.cgColor + } + backgroundColor = isChecked && !voted ? tintColor : .clear + imageView.tintColor = voted ? .black : .white + } + +} diff --git a/Tusker/Views/Poll/PollOptionView.swift b/Tusker/Views/Poll/PollOptionView.swift new file mode 100644 index 00000000..ed2b9118 --- /dev/null +++ b/Tusker/Views/Poll/PollOptionView.swift @@ -0,0 +1,76 @@ +// +// PollOptionView.swift +// Tusker +// +// Created by Shadowfacts on 4/25/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class PollOptionView: UIView { + + private let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25) + + let checkbox: PollOptionCheckboxView + + init(poll: Poll, option: Poll.Option) { + checkbox = PollOptionCheckboxView(multiple: poll.multiple) + + super.init(frame: .zero) + + let minHeight: CGFloat = 35 + layer.cornerRadius = 0.1 * minHeight + backgroundColor = unselectedBackgroundColor + + checkbox.translatesAutoresizingMaskIntoConstraints = false + addSubview(checkbox) + + let label = EmojiLabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.text = option.title + label.setEmojis(poll.emojis, identifier: poll.id) + addSubview(label) + + if (poll.voted ?? false) || poll.effectiveExpired, + let optionVotes = option.votesCount { + let fillView = UIView() + fillView.translatesAutoresizingMaskIntoConstraints = false + fillView.backgroundColor = tintColor.withAlphaComponent(0.6) + fillView.layer.zPosition = -1 + fillView.layer.cornerRadius = layer.cornerRadius + addSubview(fillView) + + NSLayoutConstraint.activate([ + fillView.leadingAnchor.constraint(equalTo: leadingAnchor), + fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: CGFloat(optionVotes) / CGFloat(poll.votesCount)), + fillView.topAnchor.constraint(equalTo: topAnchor), + fillView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight) + // on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts + // this keeps autolayout from complaining + minHeightConstraint.priority = .required - 1 + + NSLayoutConstraint.activate([ + minHeightConstraint, + + checkbox.centerYAnchor.constraint(equalTo: centerYAnchor), + checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), + label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Tusker/Views/Poll/PollOptionsView.swift b/Tusker/Views/Poll/PollOptionsView.swift new file mode 100644 index 00000000..d6442f18 --- /dev/null +++ b/Tusker/Views/Poll/PollOptionsView.swift @@ -0,0 +1,170 @@ +// +// PollOptionsView.swift +// Tusker +// +// Created by Shadowfacts on 4/26/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class PollOptionsView: UIControl { + + var checkedOptionIndices: [Int] { + options.enumerated().filter { $0.element.checkbox.isChecked }.map(\.offset) + } + var checkedOptionsChanged: (() -> Void)? + + private let stack: UIStackView + private var options: [PollOptionView] = [] + + private var poll: Poll! + private var animator: UIViewPropertyAnimator! + private var currentSelectedOptionIndex: Int? + + private let animationDuration: TimeInterval = 0.1 + private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) + + override var isEnabled: Bool { + didSet { + options.forEach { $0.checkbox.readOnly = !isEnabled } + } + } + + override init(frame: CGRect) { + stack = UIStackView() + + super.init(frame: frame) + + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 4 + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateUI(poll: Poll) { + self.poll = poll + + options.forEach { $0.removeFromSuperview() } + + options = poll.options.enumerated().map { (index, opt) in + let optionView = PollOptionView(poll: poll, option: opt) + optionView.checkbox.readOnly = !isEnabled + optionView.checkbox.isChecked = poll.ownVotes?.contains(index) ?? false + optionView.checkbox.voted = poll.voted ?? false + stack.addArrangedSubview(optionView) + return optionView + } + } + + private func selectOption(_ option: PollOptionView) { + if poll.multiple { + option.checkbox.isChecked.toggle() + } else { + for opt in options { + if opt === option { + opt.checkbox.isChecked = true + } else { + opt.checkbox.isChecked = false + } + } + } + + checkedOptionsChanged?() + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // don't let subviews receive touch events + return self + } + + // MARK: - UIControl + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + guard isEnabled else { + return false + } + + for (index, view) in options.enumerated() { + if view.point(inside: touch.location(in: view), with: event) { + currentSelectedOptionIndex = index + + animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { + view.transform = self.scaledTransform + } + animator.startAnimation() + + return true + } + } + + return false + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + var newIndex: Int? = nil + for (index, view) in options.enumerated() { + if view.point(inside: touch.location(in: view), with: event) { + newIndex = index + break + } + } + + if newIndex != currentSelectedOptionIndex { + currentSelectedOptionIndex = newIndex + + UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) { + for (index, view) in self.options.enumerated() { + view.transform = index == newIndex ? self.scaledTransform : .identity + } + } + } + + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + + func selectOption() { + guard let index = currentSelectedOptionIndex else { return } + let option = options[index] + animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { + option.transform = .identity + self.selectOption(option) + } + animator.startAnimation() + } + + if animator.isRunning { + animator.addCompletion { (_) in + selectOption() + } + } else { + selectOption() + } + } + + override func cancelTracking(with event: UIEvent?) { + super.cancelTracking(with: event) + UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) { + for view in self.options { + view.transform = .identity + } + } + + } + +} diff --git a/Tusker/Views/Poll/StatusPollView.swift b/Tusker/Views/Poll/StatusPollView.swift new file mode 100644 index 00000000..e70ccecc --- /dev/null +++ b/Tusker/Views/Poll/StatusPollView.swift @@ -0,0 +1,157 @@ +// +// StatusPollView.swift +// Tusker +// +// Created by Shadowfacts on 4/25/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class StatusPollView: UIView { + + private static let formatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.includesTimeRemainingPhrase = true + f.maximumUnitCount = 1 + f.unitsStyle = .full + f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] + return f + }() + + weak var mastodonController: MastodonController! + + private var statusID: String! + private var poll: Poll! + + private var optionsView: PollOptionsView! + private var voteButton: UIButton! + private var infoLabel: UILabel! + private var options: [PollOptionView] = [] + + private var canVote = true + private var animator: UIViewPropertyAnimator! + private var currentSelectedOptionIndex: Int! + + override func awakeFromNib() { + super.awakeFromNib() + + backgroundColor = .clear + + optionsView = PollOptionsView(frame: .zero) + optionsView.translatesAutoresizingMaskIntoConstraints = false + optionsView.checkedOptionsChanged = self.checkedOptionsChanged + addSubview(optionsView) + + infoLabel = UILabel() + infoLabel.translatesAutoresizingMaskIntoConstraints = false + infoLabel.textColor = .secondaryLabel + infoLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .callout), size: 0) + infoLabel.adjustsFontSizeToFitWidth = true + addSubview(infoLabel) + + voteButton = UIButton(type: .system) + voteButton.translatesAutoresizingMaskIntoConstraints = false + voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside) + voteButton.setTitle("Vote", for: .normal) + voteButton.setTitleColor(.secondaryLabel, for: .disabled) + voteButton.titleLabel!.font = infoLabel.font + addSubview(voteButton) + + NSLayoutConstraint.activate([ + optionsView.leadingAnchor.constraint(equalTo: leadingAnchor), + optionsView.trailingAnchor.constraint(equalTo: trailingAnchor), + optionsView.topAnchor.constraint(equalTo: topAnchor), + + infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor), + infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: voteButton.leadingAnchor, constant: -8), + + voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), + voteButton.trailingAnchor.constraint(equalTo: trailingAnchor), + voteButton.topAnchor.constraint(equalTo: optionsView.bottomAnchor), + voteButton.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + func updateUI(status: StatusMO, poll: Poll) { + self.statusID = status.id + self.poll = poll + + options.forEach { $0.removeFromSuperview() } + + // poll.voted is nil if there is no user (e.g., public timeline), in which case the poll also cannot be voted upon + if (poll.voted ?? true) || poll.expired || status.account.id == mastodonController.account.id { + canVote = false + } else { + canVote = true + } + + optionsView.isEnabled = canVote + optionsView.updateUI(poll: poll) + + var expired = false + let expiryText: String? + if let expiresAt = poll.expiresAt { + if expiresAt > Date() { + expiryText = StatusPollView.formatter.string(from: Date(), to: expiresAt) + } else { + expired = true + expiryText = nil + } + } else { + expiryText = "Does not expire" + } + + let format = NSLocalizedString("poll votes count", comment: "poll total votes count") + infoLabel.text = String.localizedStringWithFormat(format, poll.votesCount) + if let expiryText = expiryText { + infoLabel.text! += ", \(expiryText)" + } + + if expired { + voteButton.setTitle("Expired", for: .disabled) + } else if poll.voted ?? false { + voteButton.setTitle("Voted", for: .disabled) + } else if poll.multiple { + voteButton.setTitle("Select multiple", for: .disabled) + } else { + voteButton.setTitle("Select one", for: .disabled) + } + voteButton.isEnabled = false + } + + private func checkedOptionsChanged() { + voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0 + } + + @objc private func votePressed() { + optionsView.isEnabled = false + voteButton.isEnabled = false + voteButton.setTitle("Voted", for: .disabled) + + let request = Poll.vote(poll.id, choices: optionsView.checkedOptionIndices) + mastodonController.run(request) { (response) in + switch response { + case let .failure(error): + fatalError("error voting in poll: \(error)") + + case let .success(poll, _): + let container = self.mastodonController.persistentContainer + DispatchQueue.main.async { + guard let status = container.status(for: self.statusID) else { + return + } + status.poll = poll + if container.viewContext.hasChanges { + try! container.viewContext.save() + } + container.statusSubject.send(status.id) + } + } + } + } + +} diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index 72e7788a..4f26ebd4 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -34,6 +34,7 @@ class BaseStatusTableViewCell: UITableViewCell { @IBOutlet weak var contentTextView: StatusContentTextView! @IBOutlet weak var cardView: StatusCardView! @IBOutlet weak var attachmentsView: AttachmentsContainerView! + @IBOutlet weak var pollView: StatusPollView! @IBOutlet weak var replyButton: UIButton! @IBOutlet weak var favoriteButton: UIButton! @IBOutlet weak var reblogButton: UIButton! @@ -213,6 +214,14 @@ class BaseStatusTableViewCell: UITableViewCell { // keep menu in sync with changed states e.g. bookmarked, muted moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton)) + + if let poll = status.poll { + pollView.isHidden = false + pollView.mastodonController = mastodonController + pollView.updateUI(status: status, poll: poll) + } else { + pollView.isHidden = true + } } func updateUI(account: AccountMO) { diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib b/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib index 72f69fbb..b91edc52 100644 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib +++ b/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -90,26 +89,30 @@ - + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + @@ -221,6 +224,7 @@ + @@ -244,6 +248,7 @@ + diff --git a/Tusker/Views/Status/TimelineStatusTableViewCell.xib b/Tusker/Views/Status/TimelineStatusTableViewCell.xib index bf1b455a..084d6955 100644 --- a/Tusker/Views/Status/TimelineStatusTableViewCell.xib +++ b/Tusker/Views/Status/TimelineStatusTableViewCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -109,26 +108,30 @@ - + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + @@ -254,6 +257,7 @@ + diff --git a/Tusker/en.lproj/Localizable.stringsdict b/Tusker/en.lproj/Localizable.stringsdict index 12dbedae..e01bcd8a 100644 --- a/Tusker/en.lproj/Localizable.stringsdict +++ b/Tusker/en.lproj/Localizable.stringsdict @@ -2,10 +2,26 @@ + poll votes count + + NSStringLocalizedFormatKey + %2$#@votes@ + votes + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + u + one + 1 vote + other + %u votes + + trending hashtag info NSStringLocalizedFormatKey - %#@accounts@, %#@posts@ recently + %1$#@accounts@, %2$#@posts@ recently posts NSStringFormatSpecTypeKey