Add displaying and voting on polls in statuses

This commit is contained in:
Shadowfacts 2021-04-28 19:00:17 -04:00
parent b0bd27db31
commit 1c36dfcc5f
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
14 changed files with 623 additions and 16 deletions

View File

@ -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<Poll> {
return Request<Poll>(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"
}
}
}

View File

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

View File

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

View File

@ -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 = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
D623A53E2635F6910095BD04 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; };
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; };
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; };
D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
@ -586,6 +595,7 @@
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UnknownNotificationTableViewCell.xib; sourceTree = "<group>"; };
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSegment.swift; sourceTree = "<group>"; };
D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationGroup.swift; sourceTree = "<group>"; };
D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D623A53B2635F4E20095BD04 /* Poll */ = {
isa = PBXGroup;
children = (
D623A53C2635F5590095BD04 /* StatusPollView.swift */,
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */,
D623A5402635FB3C0095BD04 /* PollOptionView.swift */,
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */,
);
path = Poll;
sourceTree = "<group>";
};
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 */,
);

View File

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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17510.1" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
@ -57,6 +57,7 @@
<attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollData" optional="YES" attributeType="Binary"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
@ -74,8 +75,8 @@
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="329"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="433"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="434"/>
</elements>
</model>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
@ -90,26 +89,30 @@
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z0g-HN-gS0" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="124.5" width="343" height="47.5"/>
<rect key="frame" x="0.0" y="124.5" width="343" height="0.0"/>
<string key="text">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.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="IF9-9U-Gk0" secondAttribute="width" multiplier="9:16" priority="999" id="5oh-eK-J5d"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TLv-Xu-tT1" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="132.5" width="343" height="39.5"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">
<rect key="frame" x="0.0" y="180" width="343" height="0.5"/>
<color key="backgroundColor" systemColor="opaqueSeparatorColor"/>
@ -221,6 +224,7 @@
<constraint firstItem="ejU-sO-Og5" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="biK-oQ-SLy"/>
<constraint firstItem="3Bg-XP-d13" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="iIq-gh-90O"/>
<constraint firstItem="3Fp-Nj-sVj" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="kfI-WN-ouW"/>
<constraint firstItem="TLv-Xu-tT1" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="v87-hd-fd4"/>
</constraints>
</stackView>
</subviews>
@ -244,6 +248,7 @@
<outlet property="favoriteAndReblogCountStackView" destination="HZv-qj-gi6" id="jC9-cA-dXg"/>
<outlet property="favoriteButton" destination="DhN-rJ-jdA" id="b2Q-ch-kSP"/>
<outlet property="moreButton" destination="Ujo-Ap-dmK" id="2ba-5w-HDx"/>
<outlet property="pollView" destination="TLv-Xu-tT1" id="hJX-YD-lNr"/>
<outlet property="profileDetailContainerView" destination="Cnd-Fj-B7l" id="wco-VB-VQx"/>
<outlet property="reblogButton" destination="GUG-f7-Hdy" id="WtT-Ph-DQm"/>
<outlet property="replyButton" destination="2cc-lE-AdG" id="My8-JV-Nho"/>

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -109,26 +108,30 @@
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="83" width="277" height="86.5"/>
<rect key="frame" x="0.0" y="83" width="277" height="82.5"/>
<string key="text">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.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU">
@ -254,6 +257,7 @@
<outlet property="favoriteButton" destination="x0t-TR-jJ4" id="guV-yz-Lm6"/>
<outlet property="moreButton" destination="982-J4-NGl" id="Pux-tL-aWe"/>
<outlet property="pinImageView" destination="wtt-8G-Ua1" id="mE8-oe-m1l"/>
<outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/>
<outlet property="reblogButton" destination="6tW-z8-Qh9" id="u2t-8D-kOn"/>
<outlet property="reblogLabel" destination="lDH-50-AJZ" id="uJf-Pt-cEP"/>
<outlet property="replyButton" destination="rKF-yF-KIa" id="rka-q1-o4a"/>

View File

@ -2,10 +2,26 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>poll votes count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%2$#@votes@</string>
<key>votes</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>u</string>
<key>one</key>
<string>1 vote</string>
<key>other</key>
<string>%u votes</string>
</dict>
</dict>
<key>trending hashtag info</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@accounts@, %#@posts@ recently</string>
<string>%1$#@accounts@, %2$#@posts@ recently</string>
<key>posts</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>