From 8ad01a6ecfc1bcb41990fb072815b4556093b1f3 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sat, 24 Oct 2020 19:31:44 -0700 Subject: [PATCH] Polls --- DB/Sources/DB/Content/ContentDatabase.swift | 11 ++ Localizations/Localizable.strings | 1 - Localizations/Localizable.stringsdict | 16 ++ .../MastodonAPI/Endpoints/PollEndpoint.swift | 45 +++++ Metatext.xcodeproj/project.pbxproj | 12 ++ .../ServiceLayer/Services/StatusService.swift | 16 ++ .../ViewModels/CollectionItemsViewModel.swift | 2 +- .../Sources/ViewModels/StatusViewModel.swift | 43 ++++- Views/PollOptionButton.swift | 46 ++++++ Views/PollResultView.swift | 85 ++++++++++ Views/PollView.swift | 156 ++++++++++++++++++ Views/Status/StatusView.swift | 6 + 12 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 MastodonAPI/Sources/MastodonAPI/Endpoints/PollEndpoint.swift create mode 100644 Views/PollOptionButton.swift create mode 100644 Views/PollResultView.swift create mode 100644 Views/PollView.swift diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 9082f1b..6b103a2 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -201,6 +201,17 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func update(id: Status.Id, poll: Poll) -> AnyPublisher { + 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 { databaseWriter.writePublisher { try list.save($0) diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 263c8cd..c00498b 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -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"; diff --git a/Localizations/Localizable.stringsdict b/Localizations/Localizable.stringsdict index e876088..d3f6869 100644 --- a/Localizations/Localizable.stringsdict +++ b/Localizations/Localizable.stringsdict @@ -2,6 +2,22 @@ + status.poll.participation-count + + NSStringLocalizedFormatKey + %#@people@ + people + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld person + other + %ld people + + status.reblogs-count NSStringLocalizedFormatKey diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/PollEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/PollEndpoint.swift new file mode 100644 index 0000000..2987ef2 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/PollEndpoint.swift @@ -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 + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index d06dbca..e3edaf3 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -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 = ""; }; D08B8D5F2540DE3A00B1EBEF /* ZoomTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomTransitionController.swift; sourceTree = ""; }; D08B8D662540DEB200B1EBEF /* ZoomAnimatableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomAnimatableView.swift; sourceTree = ""; }; + D08B8D71254246E200B1EBEF /* PollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollView.swift; sourceTree = ""; }; + D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = ""; }; + D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift index 133f7b2..730b5fb 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift @@ -53,4 +53,20 @@ public extension StatusService { mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func vote(selectedOptions: Set) -> AnyPublisher { + 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 { + 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() + } } diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 6d82e0e..92f2151 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -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 diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index 31a1375..a16f39d 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -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() public var configuration = CollectionItem.StatusConfiguration.default public let events: AnyPublisher, 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 { 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 { diff --git a/Views/PollOptionButton.swift b/Views/PollOptionButton.swift new file mode 100644 index 0000000..d1ef912 --- /dev/null +++ b/Views/PollOptionButton.swift @@ -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) +} diff --git a/Views/PollResultView.swift b/Views/PollResultView.swift new file mode 100644 index 0000000..d30d7c6 --- /dev/null +++ b/Views/PollResultView.swift @@ -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 + }() +} diff --git a/Views/PollView.swift b/Views/PollView.swift new file mode 100644 index 0000000..e9155df --- /dev/null +++ b/Views/PollView.swift @@ -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) + ]) + } +} diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index 556dee0..6dfa7ad 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -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