Post polls

This commit is contained in:
Justin Mazzocchi 2021-01-10 16:06:20 -08:00
parent 377bf6aecc
commit 5c5677507b
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
9 changed files with 343 additions and 11 deletions

View file

@ -44,11 +44,13 @@
"camera-access.open-system-settings" = "Open system settings"; "camera-access.open-system-settings" = "Open system settings";
"cancel" = "Cancel"; "cancel" = "Cancel";
"compose.attachment.uploading" = "Uploading"; "compose.attachment.uploading" = "Uploading";
"compose.prompt" = "What's on your mind?"; "compose.browse" = "Browse";
"compose.mark-media-sensitive" = "Mark media as sensitive"; "compose.mark-media-sensitive" = "Mark media as sensitive";
"compose.photo-library" = "Photo Library"; "compose.photo-library" = "Photo Library";
"compose.poll.add-choice" = "Add a choice";
"compose.poll.allow-multiple-choices" = "Allow multiple choices";
"compose.prompt" = "What's on your mind?";
"compose.take-photo-or-video" = "Take Photo or Video"; "compose.take-photo-or-video" = "Take Photo or Video";
"compose.browse" = "Browse";
"error" = "Error"; "error" = "Error";
"favorites" = "Favorites"; "favorites" = "Favorites";
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue"; "registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";

View file

@ -23,6 +23,9 @@ public extension StatusEndpoint {
public let mediaIds: [Attachment.Id] public let mediaIds: [Attachment.Id]
public let visibility: Status.Visibility public let visibility: Status.Visibility
public let sensitive: Bool public let sensitive: Bool
public let pollOptions: [String]
public let pollExpiresIn: Int
public let pollMultipleChoice: Bool
public init( public init(
inReplyToId: Status.Id?, inReplyToId: Status.Id?,
@ -30,13 +33,19 @@ public extension StatusEndpoint {
spoilerText: String, spoilerText: String,
mediaIds: [Attachment.Id], mediaIds: [Attachment.Id],
visibility: Status.Visibility, visibility: Status.Visibility,
sensitive: Bool) { sensitive: Bool,
pollOptions: [String],
pollExpiresIn: Int,
pollMultipleChoice: Bool) {
self.inReplyToId = inReplyToId self.inReplyToId = inReplyToId
self.text = text self.text = text
self.spoilerText = spoilerText self.spoilerText = spoilerText
self.mediaIds = mediaIds self.mediaIds = mediaIds
self.visibility = visibility self.visibility = visibility
self.sensitive = sensitive self.sensitive = sensitive
self.pollOptions = pollOptions
self.pollExpiresIn = pollExpiresIn
self.pollMultipleChoice = pollMultipleChoice
} }
} }
} }
@ -64,6 +73,16 @@ extension StatusEndpoint.Components {
params["sensitive"] = true params["sensitive"] = true
} }
if !pollOptions.isEmpty {
var poll = [String: Any]()
poll["options"] = pollOptions
poll["expires_in"] = pollExpiresIn
poll["multiple"] = pollMultipleChoice
params["poll"] = poll
}
return params return params
} }
} }

View file

@ -46,6 +46,10 @@
D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936F325AA66A600754FDF /* UIView+Extensions.swift */; }; D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936F325AA66A600754FDF /* UIView+Extensions.swift */; };
D05936FF25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */; }; D05936FF25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */; };
D059370025AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */; }; D059370025AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */; };
D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373225AAEA7000754FDF /* CompositionPollView.swift */; };
D059373425AAEA7000754FDF /* CompositionPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373225AAEA7000754FDF /* CompositionPollView.swift */; };
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */; };
D059373F25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */; };
D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; };
@ -196,6 +200,8 @@
D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAttachmentView.swift; sourceTree = "<group>"; }; D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAttachmentView.swift; sourceTree = "<group>"; };
D05936F325AA66A600754FDF /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = "<group>"; }; D05936F325AA66A600754FDF /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = "<group>"; };
D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAttachmentsSensitiveView.swift; sourceTree = "<group>"; }; D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAttachmentsSensitiveView.swift; sourceTree = "<group>"; };
D059373225AAEA7000754FDF /* CompositionPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionPollView.swift; sourceTree = "<group>"; };
D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionPollOptionView.swift; sourceTree = "<group>"; };
D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = "<group>"; }; D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = "<group>"; };
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@ -450,6 +456,8 @@
D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */, D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */,
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */, D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */,
D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */, D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */,
D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */,
D059373225AAEA7000754FDF /* CompositionPollView.swift */,
D08E52ED257D757100FA2C5F /* CompositionView.swift */, D08E52ED257D757100FA2C5F /* CompositionView.swift */,
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */, D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
@ -809,6 +817,7 @@
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */, D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */, D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */, D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
@ -824,6 +833,7 @@
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */, D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */, D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,
D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */, D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */,
D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */, D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */,
D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */, D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */,
@ -870,6 +880,7 @@
files = ( files = (
D036EBC7259FE2B700EC1CFC /* KingfisherOptionsInfo+Extensions.swift in Sources */, D036EBC7259FE2B700EC1CFC /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */, D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
D059373425AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */, D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */,
D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */, D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
@ -881,6 +892,7 @@
D015B14425A812F6006D88A8 /* PlayerCache.swift in Sources */, D015B14425A812F6006D88A8 /* PlayerCache.swift in Sources */,
D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */, D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */,
D015B13F25A812EC006D88A8 /* PlayerView.swift in Sources */, D015B13F25A812EC006D88A8 /* PlayerView.swift in Sources */,
D059373F25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,
D015B13A25A812E6006D88A8 /* AttachmentView.swift in Sources */, D015B13A25A812E6006D88A8 /* AttachmentView.swift in Sources */,
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */, D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */,
D036EBC2259FE2AD00EC1CFC /* UIVIewController+Extensions.swift in Sources */, D036EBC2259FE2AD00EC1CFC /* UIVIewController+Extensions.swift in Sources */,

View file

@ -72,7 +72,6 @@ final class EditAttachmentViewController: UIViewController {
stackView.addArrangedSubview(remainingCharactersLabel) stackView.addArrangedSubview(remainingCharactersLabel)
remainingCharactersLabel.adjustsFontForContentSizeCategory = true remainingCharactersLabel.adjustsFontForContentSizeCategory = true
remainingCharactersLabel.font = .preferredFont(forTextStyle: .subheadline) remainingCharactersLabel.font = .preferredFont(forTextStyle: .subheadline)
remainingCharactersLabel.text = "1500"
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),

View file

@ -12,6 +12,11 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
@Published public var contentWarning = "" @Published public var contentWarning = ""
@Published public var displayContentWarning = false @Published public var displayContentWarning = false
@Published public var sensitive = false @Published public var sensitive = false
@Published public var displayPoll = false
@Published public var pollMultipleChoice = false
@Published public var pollHideTotals = false
@Published public var pollExpiresIn = PollExpiry.oneDay
@Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")]
@Published public private(set) var attachmentViewModels = [AttachmentViewModel]() @Published public private(set) var attachmentViewModels = [AttachmentViewModel]()
@Published public private(set) var attachmentUpload: AttachmentUpload? @Published public private(set) var attachmentUpload: AttachmentUpload?
@Published public private(set) var isPostable = false @Published public private(set) var isPostable = false
@ -34,8 +39,8 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
} }
.assign(to: &$isPostable) .assign(to: &$isPostable)
$attachmentViewModels $attachmentViewModels
.combineLatest($attachmentUpload) .combineLatest($attachmentUpload, $displayPoll)
.map { $0.count < Self.maxAttachmentCount && $1 == nil } .map { $0.count < Self.maxAttachmentCount && $1 == nil && !$2 }
.assign(to: &$canAddAttachment) .assign(to: &$canAddAttachment)
$attachmentViewModels.map(\.isEmpty).assign(to: &$canAddNonImageAttachment) $attachmentViewModels.map(\.isEmpty).assign(to: &$canAddNonImageAttachment)
$text.map { $text.map {
@ -60,12 +65,35 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
public extension CompositionViewModel { public extension CompositionViewModel {
static let maxCharacters = 500 static let maxCharacters = 500
static let minPollOptionCount = 2
static let maxPollOptionCount = 4
enum Event { enum Event {
case editAttachment(AttachmentViewModel, CompositionViewModel) case editAttachment(AttachmentViewModel, CompositionViewModel)
case updateAttachment(AnyPublisher<Never, Error>) case updateAttachment(AnyPublisher<Never, Error>)
} }
enum PollExpiry: Int, CaseIterable {
case fiveMinutes = 300
case thirtyMinutes = 1800
case oneHour = 3600
case sixHours = 21600
case oneDay = 86400
case threeDays = 259200
case sevenDays = 604800
}
class PollOption: ObservableObject {
public let id = Id()
@Published public var text: String
@Published public private(set) var remainingCharacters = CompositionViewModel.maxCharacters
public init(text: String) {
self.text = text
$text.map { Self.maxCharacters - $0.count }.assign(to: &$remainingCharacters)
}
}
typealias Id = UUID typealias Id = UUID
func components(inReplyToId: Status.Id?, visibility: Status.Visibility) -> StatusComponents { func components(inReplyToId: Status.Id?, visibility: Status.Visibility) -> StatusComponents {
@ -75,7 +103,18 @@ public extension CompositionViewModel {
spoilerText: displayContentWarning ? contentWarning : "", spoilerText: displayContentWarning ? contentWarning : "",
mediaIds: attachmentViewModels.map(\.attachment.id), mediaIds: attachmentViewModels.map(\.attachment.id),
visibility: visibility, visibility: visibility,
sensitive: sensitive) sensitive: sensitive,
pollOptions: displayPoll ? pollOptions.map(\.text) : [],
pollExpiresIn: pollExpiresIn.rawValue,
pollMultipleChoice: pollMultipleChoice)
}
func addPollOption() {
pollOptions.append(PollOption(text: ""))
}
func remove(pollOption: PollOption) {
pollOptions.removeAll { $0 === pollOption }
} }
func cancelUpload() { func cancelUpload() {
@ -100,6 +139,12 @@ public extension CompositionViewModel {
} }
} }
public extension CompositionViewModel.PollOption {
static let maxCharacters = 25
typealias Id = UUID
}
extension CompositionViewModel { extension CompositionViewModel {
func attach(itemProvider: NSItemProvider, parentViewModel: NewStatusViewModel) { func attach(itemProvider: NSItemProvider, parentViewModel: NewStatusViewModel) {
attachmentUploadCancellable = MediaProcessingService.dataAndMimeType(itemProvider: itemProvider) attachmentUploadCancellable = MediaProcessingService.dataAndMimeType(itemProvider: itemProvider)

View file

@ -83,7 +83,7 @@ private extension CompositionInputAccessoryView {
attachmentButton.showsMenuAsPrimaryAction = true attachmentButton.showsMenuAsPrimaryAction = true
attachmentButton.menu = UIMenu(children: attachmentActions) attachmentButton.menu = UIMenu(children: attachmentActions)
let pollButton = UIButton() let pollButton = UIButton(primaryAction: UIAction { [weak self] _ in self?.viewModel.displayPoll.toggle() })
stackView.addArrangedSubview(pollButton) stackView.addArrangedSubview(pollButton)
pollButton.setImage( pollButton.setImage(
@ -134,6 +134,11 @@ private extension CompositionInputAccessoryView {
.sink { attachmentButton.isEnabled = $0 } .sink { attachmentButton.isEnabled = $0 }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$attachmentViewModels
.combineLatest(viewModel.$attachmentUpload)
.sink { pollButton.isEnabled = $0.isEmpty && $1 == nil }
.store(in: &cancellables)
viewModel.$remainingCharacters.sink { viewModel.$remainingCharacters.sink {
charactersLabel.text = String($0) charactersLabel.text = String($0)
charactersLabel.textColor = $0 < 0 ? .systemRed : .label charactersLabel.textColor = $0 < 0 ? .systemRed : .label

View file

@ -0,0 +1,87 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
final class CompositionPollOptionView: UIView {
let option: CompositionViewModel.PollOption
let removeButton = UIButton(type: .close)
private let viewModel: CompositionViewModel
private let compositionInputAccessoryView: CompositionInputAccessoryView
private var cancellables = Set<AnyCancellable>()
init(viewModel: CompositionViewModel,
option: CompositionViewModel.PollOption,
inputAccessoryView: CompositionInputAccessoryView) {
self.viewModel = viewModel
self.option = option
self.compositionInputAccessoryView = inputAccessoryView
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension CompositionPollOptionView {
// swiftlint:disable:next function_body_length
func initialSetup() {
let stackView = UIStackView()
let textField = UITextField()
let remainingCharactersLabel = UILabel()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .defaultSpacing
stackView.addArrangedSubview(textField)
textField.borderStyle = .roundedRect
textField.adjustsFontForContentSizeCategory = true
textField.font = .preferredFont(forTextStyle: .body)
textField.inputAccessoryView = compositionInputAccessoryView
textField.addAction(
UIAction { [weak self] _ in
self?.option.text = textField.text ?? "" },
for: .editingChanged)
stackView.addArrangedSubview(remainingCharactersLabel)
remainingCharactersLabel.adjustsFontForContentSizeCategory = true
remainingCharactersLabel.font = .preferredFont(forTextStyle: .callout)
remainingCharactersLabel.setContentHuggingPriority(.required, for: .horizontal)
stackView.addArrangedSubview(removeButton)
removeButton.showsMenuAsPrimaryAction = true
removeButton.menu = UIMenu(
children: [
UIAction(
title: NSLocalizedString("remove", comment: ""),
image: UIImage(systemName: "trash"),
attributes: .destructive) { [weak self] _ in
guard let self = self else { return }
self.viewModel.remove(pollOption: self.option)
}])
removeButton.setContentHuggingPriority(.required, for: .horizontal)
removeButton.setContentHuggingPriority(.required, for: .vertical)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
option.$remainingCharacters
.sink {
remainingCharactersLabel.text = String($0)
remainingCharactersLabel.textColor = $0 < 0 ? .systemRed : .label
}
.store(in: &cancellables)
}
}

View file

@ -0,0 +1,146 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
final class CompositionPollView: UIView {
private let viewModel: CompositionViewModel
private let compositionInputAccessoryView: CompositionInputAccessoryView
private let stackView = UIStackView()
private var cancellables = Set<AnyCancellable>()
init(viewModel: CompositionViewModel, inputAccessoryView: CompositionInputAccessoryView) {
self.viewModel = viewModel
self.compositionInputAccessoryView = inputAccessoryView
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension CompositionPollView {
static let dateComponentsFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
return formatter
}()
static func format(expiry: CompositionViewModel.PollExpiry) -> String? {
dateComponentsFormatter.string(from: TimeInterval(expiry.rawValue))
}
var pollOptionViews: [CompositionPollOptionView] {
stackView.arrangedSubviews.compactMap({ $0 as? CompositionPollOptionView })
}
// swiftlint:disable:next function_body_length
func initialSetup() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .defaultSpacing
let buttonsStackView = UIStackView()
stackView.addArrangedSubview(buttonsStackView)
buttonsStackView.distribution = .fillEqually
let addChoiceButton = UIButton(primaryAction: UIAction { [weak self] _ in self?.viewModel.addPollOption() })
buttonsStackView.addArrangedSubview(addChoiceButton)
addChoiceButton.setImage(
UIImage(systemName: "plus",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
addChoiceButton.setTitle(NSLocalizedString("compose.poll.add-choice", comment: ""), for: .normal)
let expiresInButton = UIButton(type: .system)
buttonsStackView.addArrangedSubview(expiresInButton)
expiresInButton.setImage(
UIImage(systemName: "clock",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
expiresInButton.showsMenuAsPrimaryAction = true
expiresInButton.menu = UIMenu(children: CompositionViewModel.PollExpiry.allCases.map { expiry in
UIAction(title: Self.format(expiry: expiry) ?? "") { [weak self] _ in
self?.viewModel.pollExpiresIn = expiry
}
})
let switchStackView = UIStackView()
switchStackView.spacing = .defaultSpacing
stackView.addArrangedSubview(switchStackView)
let allowMultipleLabel = UILabel()
switchStackView.addArrangedSubview(allowMultipleLabel)
allowMultipleLabel.adjustsFontForContentSizeCategory = true
allowMultipleLabel.font = .preferredFont(forTextStyle: .callout)
allowMultipleLabel.textColor = .secondaryLabel
allowMultipleLabel.text = NSLocalizedString("compose.poll.allow-multiple-choices", comment: "")
allowMultipleLabel.textAlignment = .right
let allowMultipleSwitch = UISwitch()
switchStackView.addArrangedSubview(allowMultipleSwitch)
allowMultipleSwitch.addAction(
UIAction { [weak self] _ in
self?.viewModel.sensitive = allowMultipleSwitch.isOn
},
for: .valueChanged)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
buttonsStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension)
])
viewModel.$pollOptions.sink { [weak self] in
guard let self = self else { return }
addChoiceButton.isEnabled = $0.count < CompositionViewModel.maxPollOptionCount
for (index, option) in $0.enumerated() {
if !self.pollOptionViews.contains(where: { $0.option === option }) {
let optionView = CompositionPollOptionView(
viewModel: self.viewModel,
option: option,
inputAccessoryView: self.compositionInputAccessoryView)
self.stackView.insertArrangedSubview(optionView, at: index)
}
}
for (index, optionView) in self.pollOptionViews.enumerated() {
optionView.removeButton.isHidden = index < CompositionViewModel.minPollOptionCount
if !$0.contains(where: { $0 === optionView.option }) {
optionView.removeFromSuperview()
}
}
}
.store(in: &cancellables)
viewModel.$pollExpiresIn
.sink { expiresInButton.setTitle(Self.format(expiry: $0), for: .normal) }
.store(in: &cancellables)
viewModel.$pollMultipleChoice
.sink { allowMultipleSwitch.isEnabled = !$0 }
.store(in: &cancellables)
}
}

View file

@ -13,8 +13,10 @@ final class CompositionView: UIView {
let removeButton = UIButton(type: .close) let removeButton = UIButton(type: .close)
let inReplyToView = UIView() let inReplyToView = UIView()
let hasReplyFollowingView = UIView() let hasReplyFollowingView = UIView()
let compositionInputAccessoryView: CompositionInputAccessoryView
let attachmentsView = AttachmentsView() let attachmentsView = AttachmentsView()
let attachmentUploadView: AttachmentUploadView let attachmentUploadView: AttachmentUploadView
let pollView: CompositionPollView
let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView
private let viewModel: CompositionViewModel private let viewModel: CompositionViewModel
@ -25,8 +27,12 @@ final class CompositionView: UIView {
self.viewModel = viewModel self.viewModel = viewModel
self.parentViewModel = parentViewModel self.parentViewModel = parentViewModel
compositionInputAccessoryView = CompositionInputAccessoryView(
viewModel: viewModel,
parentViewModel: parentViewModel)
attachmentUploadView = AttachmentUploadView(viewModel: viewModel) attachmentUploadView = AttachmentUploadView(viewModel: viewModel)
markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel) markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel)
pollView = CompositionPollView(viewModel: viewModel, inputAccessoryView: compositionInputAccessoryView)
super.init(frame: .zero) super.init(frame: .zero)
@ -63,7 +69,6 @@ private extension CompositionView {
avatarImageView.setContentHuggingPriority(.required, for: .horizontal) avatarImageView.setContentHuggingPriority(.required, for: .horizontal)
let stackView = UIStackView() let stackView = UIStackView()
let inputAccessoryView = CompositionInputAccessoryView(viewModel: viewModel, parentViewModel: parentViewModel)
addSubview(stackView) addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false stackView.translatesAutoresizingMaskIntoConstraints = false
@ -75,7 +80,7 @@ private extension CompositionView {
spoilerTextField.adjustsFontForContentSizeCategory = true spoilerTextField.adjustsFontForContentSizeCategory = true
spoilerTextField.font = .preferredFont(forTextStyle: .body) spoilerTextField.font = .preferredFont(forTextStyle: .body)
spoilerTextField.placeholder = NSLocalizedString("status.spoiler-text-placeholder", comment: "") spoilerTextField.placeholder = NSLocalizedString("status.spoiler-text-placeholder", comment: "")
spoilerTextField.inputAccessoryView = inputAccessoryView spoilerTextField.inputAccessoryView = compositionInputAccessoryView
spoilerTextField.addAction( spoilerTextField.addAction(
UIAction { [weak self] _ in UIAction { [weak self] _ in
guard let self = self, let text = self.spoilerTextField.text else { return } guard let self = self, let text = self.spoilerTextField.text else { return }
@ -92,7 +97,7 @@ private extension CompositionView {
textView.font = textViewFont textView.font = textViewFont
textView.textContainerInset = .zero textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0 textView.textContainer.lineFragmentPadding = 0
textView.inputAccessoryView = inputAccessoryView textView.inputAccessoryView = compositionInputAccessoryView
textView.inputAccessoryView?.sizeToFit() textView.inputAccessoryView?.sizeToFit()
textView.delegate = self textView.delegate = self
@ -109,6 +114,8 @@ private extension CompositionView {
attachmentUploadView.isHidden = true attachmentUploadView.isHidden = true
stackView.addArrangedSubview(markAttachmentsSensitiveView) stackView.addArrangedSubview(markAttachmentsSensitiveView)
markAttachmentsSensitiveView.isHidden = true markAttachmentsSensitiveView.isHidden = true
stackView.addArrangedSubview(pollView)
pollView.isHidden = true
addSubview(removeButton) addSubview(removeButton)
removeButton.translatesAutoresizingMaskIntoConstraints = false removeButton.translatesAutoresizingMaskIntoConstraints = false
@ -172,6 +179,16 @@ private extension CompositionView {
} }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$displayPoll
.sink { [weak self] in
if !$0 {
self?.textView.becomeFirstResponder()
}
self?.pollView.isHidden = !$0
}
.store(in: &cancellables)
let guide = UIDevice.current.userInterfaceIdiom == .pad ? readableContentGuide : layoutMarginsGuide let guide = UIDevice.current.userInterfaceIdiom == .pad ? readableContentGuide : layoutMarginsGuide
let constraints = [ let constraints = [
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension), avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),