This commit is contained in:
Justin Mazzocchi 2020-10-24 19:31:44 -07:00
parent ff2f813280
commit 8ad01a6ecf
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
12 changed files with 434 additions and 5 deletions

View file

@ -201,6 +201,17 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func update(id: Status.Id, poll: Poll) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
let data = try StatusRecord.databaseJSONEncoder(for: StatusRecord.Columns.poll.name).encode(poll)
try StatusRecord.filter(StatusRecord.Columns.id == id)
.updateAll($0, StatusRecord.Columns.poll.set(to: data))
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
try list.save($0)

View file

@ -91,7 +91,6 @@
"status.show-more" = "Show More";
"status.show-less" = "Show Less";
"status.poll.vote" = "Vote";
"status.poll.participation-count" = "%ld people";
"status.poll.time-left" = "%@ left";
"status.poll.refresh" = "Refresh";
"status.poll.closed" = "Closed";

View file

@ -2,6 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>status.poll.participation-count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@people@</string>
<key>people</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>%ld person</string>
<key>other</key>
<string>%ld people</string>
</dict>
</dict>
<key>status.reblogs-count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View file

@ -0,0 +1,45 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum PollEndpoint {
case poll(id: Poll.Id)
case votes(id: Poll.Id, choices: [Int])
}
extension PollEndpoint: Endpoint {
public typealias ResultType = Poll
public var context: [String] {
defaultContext + ["polls"]
}
public var pathComponentsInContext: [String] {
switch self {
case let .poll(id):
return [id]
case let .votes(id, _):
return [id, "votes"]
}
}
public var jsonBody: [String: Any]? {
switch self {
case .poll:
return nil
case let .votes(_, choices):
return ["choices": choices]
}
}
public var method: HTTPMethod {
switch self {
case .poll:
return .get
case .votes:
return .post
}
}
}

View file

@ -26,6 +26,9 @@
D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */; };
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */; };
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */; };
D08B8D72254246E200B1EBEF /* PollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D71254246E200B1EBEF /* PollView.swift */; };
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D812544D80000B1EBEF /* PollOptionButton.swift */; };
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; };
@ -129,6 +132,9 @@
D08B8D5E2540DE3A00B1EBEF /* ZoomDismissalInteractionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomDismissalInteractionController.swift; sourceTree = "<group>"; };
D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomTransitionController.swift; sourceTree = "<group>"; };
D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimatableView.swift; sourceTree = "<group>"; };
D08B8D71254246E200B1EBEF /* PollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollView.swift; sourceTree = "<group>"; };
D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = "<group>"; };
D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
@ -333,6 +339,9 @@
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0FE1C8E253686F9003EF1EB /* PlayerView.swift */,
D08B8D812544D80000B1EBEF /* PollOptionButton.swift */,
D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */,
D08B8D71254246E200B1EBEF /* PollView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
D0B32F4F250B373600311912 /* RegistrationView.swift */,
@ -607,13 +616,16 @@
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D08B8D72254246E200B1EBEF /* PollView.swift in Sources */,
D0EA59402522AC8700804347 /* CardView.swift in Sources */,
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,

View file

@ -53,4 +53,20 @@ public extension StatusService {
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
func vote(selectedOptions: Set<Int>) -> AnyPublisher<Never, Error> {
guard let poll = status.displayStatus.poll else { return Empty().eraseToAnyPublisher() }
return mastodonAPIClient.request(PollEndpoint.votes(id: poll.id, choices: Array(selectedOptions)))
.flatMap { contentDatabase.update(id: status.displayStatus.id, poll: $0) }
.eraseToAnyPublisher()
}
func refreshPoll() -> AnyPublisher<Never, Error> {
guard let poll = status.displayStatus.poll else { return Empty().eraseToAnyPublisher() }
return mastodonAPIClient.request(PollEndpoint.poll(id: poll.id))
.flatMap { contentDatabase.update(id: status.displayStatus.id, poll: $0) }
.eraseToAnyPublisher()
}
}

View file

@ -114,7 +114,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
switch item {
case let .status(status, configuration):
var viewModel: StatusViewModel
let viewModel: StatusViewModel
if let cachedViewModel = cachedViewModel as? StatusViewModel {
viewModel = cachedViewModel

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon
import ServiceLayer
public struct StatusViewModel: CollectionItemViewModel {
public final class StatusViewModel: CollectionItemViewModel, ObservableObject {
public let content: NSAttributedString
public let contentEmoji: [Emoji]
public let displayName: String
@ -15,8 +15,8 @@ public struct StatusViewModel: CollectionItemViewModel {
public let rebloggedByDisplayName: String
public let rebloggedByDisplayNameEmoji: [Emoji]
public let attachmentViewModels: [AttachmentViewModel]
public let pollOptionTitles: [String]
public let pollEmoji: [Emoji]
@Published public var pollOptionSelections = Set<Int>()
public var configuration = CollectionItem.StatusConfiguration.default
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
@ -41,7 +41,6 @@ public struct StatusViewModel: CollectionItemViewModel {
rebloggedByDisplayNameEmoji = statusService.status.account.emojis
attachmentViewModels = statusService.status.displayStatus.mediaAttachments
.map { AttachmentViewModel(attachment: $0, status: statusService.status, identification: identification) }
pollOptionTitles = statusService.status.displayStatus.poll?.options.map { $0.title } ?? []
pollEmoji = statusService.status.displayStatus.poll?.emojis ?? []
events = eventsSubject.eraseToAnyPublisher()
}
@ -112,6 +111,30 @@ public extension StatusViewModel {
var sharingURL: URL? { statusService.status.displayStatus.url }
var isPollExpired: Bool { statusService.status.displayStatus.poll?.expired ?? true }
var hasVotedInPoll: Bool { statusService.status.displayStatus.poll?.voted ?? false }
var isPollMultipleSelection: Bool { statusService.status.displayStatus.poll?.multiple ?? false }
var pollOptions: [Poll.Option] { statusService.status.displayStatus.poll?.options ?? [] }
var pollVotersCount: Int {
guard let poll = statusService.status.displayStatus.poll else { return 0 }
return poll.votersCount ?? poll.votesCount
}
var pollOwnVotes: Set<Int> { Set(statusService.status.displayStatus.poll?.ownVotes ?? []) }
var pollTimeLeft: String? {
guard let expiresAt = statusService.status.displayStatus.poll?.expiresAt,
expiresAt > Date()
else { return nil }
return expiresAt.fullUnitTimeUntil
}
var cardViewModel: CardViewModel? {
if let card = statusService.status.displayStatus.card {
return CardViewModel(card: card)
@ -191,6 +214,20 @@ public extension StatusViewModel {
eventsSubject.send(Just(.share(url)).setFailureType(to: Error.self).eraseToAnyPublisher())
}
func vote() {
eventsSubject.send(
statusService.vote(selectedOptions: pollOptionSelections)
.map { _ in .ignorableOutput }
.eraseToAnyPublisher())
}
func refreshPoll() {
eventsSubject.send(
statusService.refreshPoll()
.map { _ in .ignorableOutput }
.eraseToAnyPublisher())
}
}
private extension StatusViewModel {

View file

@ -0,0 +1,46 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Mastodon
import UIKit
class PollOptionButton: UIButton {
init(title: String, emoji: [Emoji], multipleSelection: Bool) {
super.init(frame: .zero)
titleLabel?.font = .preferredFont(forTextStyle: .callout)
titleLabel?.adjustsFontForContentSizeCategory = true
titleLabel?.numberOfLines = 0
titleLabel?.lineBreakMode = .byWordWrapping
contentHorizontalAlignment = .leading
titleEdgeInsets = Self.titleEdgeInsets
let attributedTitle = NSMutableAttributedString(string: title)
attributedTitle.insert(emoji: emoji, view: titleLabel!)
attributedTitle.resizeAttachments(toLineHeight: titleLabel!.font.lineHeight)
setAttributedTitle(attributedTitle, for: .normal)
setImage(
UIImage(
systemName: multipleSelection ? "square" : "circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
setImage(
UIImage(
systemName: multipleSelection ? "checkmark.square" : "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .selected)
setContentCompressionResistancePriority(.required, for: .vertical)
heightAnchor.constraint(equalTo: titleLabel!.heightAnchor).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension PollOptionButton {
static let titleEdgeInsets = UIEdgeInsets(top: 0, left: .compactSpacing, bottom: 0, right: .compactSpacing)
}

View file

@ -0,0 +1,85 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Mastodon
import UIKit
class PollResultView: UIView {
private let verticalStackView = UIStackView()
private let horizontalStackView = UIStackView()
private let titleLabel = UILabel()
private let percentLabel = UILabel()
private let percentView = UIProgressView()
init(option: Poll.Option, emoji: [Emoji], selected: Bool, multipleSelection: Bool, votersCount: Int) {
super.init(frame: .zero)
addSubview(verticalStackView)
verticalStackView.translatesAutoresizingMaskIntoConstraints = false
verticalStackView.axis = .vertical
verticalStackView.spacing = .compactSpacing
verticalStackView.addArrangedSubview(horizontalStackView)
horizontalStackView.spacing = .compactSpacing
verticalStackView.addArrangedSubview(percentView)
if selected {
let imageView = UIImageView(
image: UIImage(
systemName: multipleSelection ? "checkmark.square" : "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)))
imageView.setContentHuggingPriority(.required, for: .horizontal)
horizontalStackView.addArrangedSubview(imageView)
}
horizontalStackView.addArrangedSubview(titleLabel)
titleLabel.font = .preferredFont(forTextStyle: .callout)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0
horizontalStackView.addArrangedSubview(percentLabel)
percentLabel.font = .preferredFont(forTextStyle: .callout)
percentLabel.adjustsFontForContentSizeCategory = true
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
let attributedTitle = NSMutableAttributedString(string: option.title)
attributedTitle.insert(emoji: emoji, view: titleLabel)
attributedTitle.resizeAttachments(toLineHeight: titleLabel.font.lineHeight)
titleLabel.attributedText = attributedTitle
let percent: Float
if votersCount == 0 {
percent = 0
} else {
percent = Float(option.votesCount) / Float(votersCount)
}
percentLabel.text = Self.percentFormatter.string(from: NSNumber(value: percent))
percentView.progress = percent
NSLayoutConstraint.activate([
verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
verticalStackView.topAnchor.constraint(equalTo: topAnchor),
verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension PollResultView {
private static var percentFormatter: NumberFormatter = {
let percentageFormatter = NumberFormatter()
percentageFormatter.numberStyle = .percent
return percentageFormatter
}()
}

156
Views/PollView.swift Normal file
View file

@ -0,0 +1,156 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
final class PollView: UIView {
private let stackView = UIStackView()
private let voteButtonStackView = UIStackView()
private let bottomStackView = UIStackView()
private let voteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
private let refreshDividerLabel = UILabel()
private let votesCountLabel = UILabel()
private let votesCountDividerLabel = UILabel()
private let expiryLabel = UILabel()
private var selectionCancellable: AnyCancellable?
var viewModel: StatusViewModel? {
didSet {
for view in stackView.arrangedSubviews {
stackView.removeArrangedSubview(view)
view.removeFromSuperview()
}
guard let viewModel = viewModel else {
selectionCancellable = nil
return
}
if !viewModel.isPollExpired, !viewModel.hasVotedInPoll {
for (index, option) in viewModel.pollOptions.enumerated() {
let button = PollOptionButton(
title: option.title,
emoji: viewModel.pollEmoji,
multipleSelection: viewModel.isPollMultipleSelection)
button.addAction(
UIAction { _ in
if viewModel.pollOptionSelections.contains(index) {
viewModel.pollOptionSelections.remove(index)
} else if viewModel.isPollMultipleSelection {
viewModel.pollOptionSelections.insert(index)
} else {
viewModel.pollOptionSelections = [index]
}
},
for: .touchUpInside)
stackView.addArrangedSubview(button)
}
} else {
for (index, option) in viewModel.pollOptions.enumerated() {
let resultView = PollResultView(
option: option,
emoji: viewModel.pollEmoji,
selected: viewModel.pollOwnVotes.contains(index),
multipleSelection: viewModel.isPollMultipleSelection,
votersCount: viewModel.pollVotersCount)
stackView.addArrangedSubview(resultView)
}
}
if !viewModel.isPollExpired, !viewModel.hasVotedInPoll {
stackView.addArrangedSubview(voteButtonStackView)
selectionCancellable = viewModel.$pollOptionSelections.sink { [weak self] in
guard let self = self else { return }
for (index, view) in self.stackView.arrangedSubviews.enumerated() {
(view as? UIButton)?.isSelected = $0.contains(index)
}
self.voteButton.isEnabled = !$0.isEmpty
}
} else {
selectionCancellable = nil
}
stackView.addArrangedSubview(bottomStackView)
votesCountLabel.text = String.localizedStringWithFormat(
NSLocalizedString("status.poll.participation-count", comment: ""),
viewModel.pollVotersCount)
if !viewModel.isPollExpired, let pollTimeLeft = viewModel.pollTimeLeft {
expiryLabel.text = String.localizedStringWithFormat(
NSLocalizedString("status.poll.time-left", comment: ""),
pollTimeLeft)
refreshButton.isHidden = false
} else {
expiryLabel.text = NSLocalizedString("status.poll.closed", comment: "")
refreshButton.isHidden = true
}
refreshDividerLabel.isHidden = refreshButton.isHidden
}
}
override init(frame: CGRect) {
super.init(frame: frame)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension PollView {
func initialSetup() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .defaultSpacing
voteButtonStackView.addArrangedSubview(voteButton)
voteButtonStackView.addArrangedSubview(UIView())
voteButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
voteButton.titleLabel?.adjustsFontForContentSizeCategory = true
voteButton.setTitle(NSLocalizedString("status.poll.vote", comment: ""), for: .normal)
voteButton.addAction(UIAction { [weak self] _ in self?.viewModel?.vote() }, for: .touchUpInside)
bottomStackView.spacing = .compactSpacing
bottomStackView.addArrangedSubview(refreshButton)
refreshButton.titleLabel?.font = .preferredFont(forTextStyle: .caption1)
refreshButton.titleLabel?.adjustsFontForContentSizeCategory = true
refreshButton.setTitle(NSLocalizedString("status.poll.refresh", comment: ""), for: .normal)
refreshButton.addAction(UIAction { [weak self] _ in self?.viewModel?.refreshPoll() }, for: .touchUpInside)
for label in [refreshDividerLabel, votesCountLabel, votesCountDividerLabel, expiryLabel] {
bottomStackView.addArrangedSubview(label)
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
}
refreshDividerLabel.text = ""
votesCountDividerLabel.text = ""
bottomStackView.addArrangedSubview(UIView())
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}

View file

@ -16,6 +16,7 @@ final class StatusView: UIView {
let toggleShowContentButton = UIButton(type: .system)
let contentTextView = TouchFallthroughTextView()
let attachmentsView = StatusAttachmentsView()
let pollView = PollView()
let cardView = CardView()
let contextParentTimeLabel = UILabel()
let timeApplicationDividerLabel = UILabel()
@ -163,6 +164,8 @@ private extension StatusView {
mainStackView.addArrangedSubview(attachmentsView)
mainStackView.addArrangedSubview(pollView)
cardView.button.addAction(
UIAction { [weak self] _ in
guard
@ -407,6 +410,9 @@ private extension StatusView {
attachmentsView.isHidden = viewModel.attachmentViewModels.count == 0
attachmentsView.viewModel = viewModel
pollView.isHidden = viewModel.pollOptions.count == 0
pollView.viewModel = viewModel
cardView.viewModel = viewModel.cardViewModel
cardView.isHidden = viewModel.cardViewModel == nil