Polls (#70)
* Add poll options * Add the poll view * Disable adding attachments when showing polls * Update to post poll info * Wire up poll view * Remove debug code * Use VM for showing poll * Rename PollView to something better! * Move file location * Disable poll button if media is attached. * Don't refocus on delete option to avoid index out of range crash Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
parent
7d053592c9
commit
ba64015f18
|
@ -0,0 +1,36 @@
|
|||
import Foundation
|
||||
|
||||
public enum PollDuration: Int, CaseIterable {
|
||||
// rawValue == time in seconds; used for sending to the API
|
||||
case fiveMinutes = 300
|
||||
case halfAnHour = 1800
|
||||
case oneHour = 3600
|
||||
case sixHours = 21600
|
||||
case oneDay = 86400
|
||||
case threeDays = 259_200
|
||||
case sevenDays = 604_800
|
||||
|
||||
public var displayString: String {
|
||||
switch self {
|
||||
case .fiveMinutes: return "5 minutes"
|
||||
case .halfAnHour: return "30 minutes"
|
||||
case .oneHour: return "1 hour"
|
||||
case .sixHours: return "6 hours"
|
||||
case .oneDay: return "1 day"
|
||||
case .threeDays: return "3 days"
|
||||
case .sevenDays: return "7 days"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PollVotingFrequency: String, CaseIterable {
|
||||
case oneVote = "One Vote"
|
||||
case multipleVotes = "Multiple Votes"
|
||||
|
||||
public var canVoteMultipleTimes: Bool {
|
||||
switch self {
|
||||
case .multipleVotes: return true
|
||||
case .oneVote: return false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,7 +6,10 @@ public enum Statuses: Endpoint {
|
|||
inReplyTo: String?,
|
||||
mediaIds: [String]?,
|
||||
spoilerText: String?,
|
||||
visibility: Visibility)
|
||||
visibility: Visibility,
|
||||
pollOptions: [String],
|
||||
pollVotingFrequency: Bool?,
|
||||
pollDuration: Int?)
|
||||
case editStatus(id: String,
|
||||
status: String,
|
||||
mediaIds: [String]?,
|
||||
|
@ -60,7 +63,7 @@ public enum Statuses: Endpoint {
|
|||
|
||||
public func queryItems() -> [URLQueryItem]? {
|
||||
switch self {
|
||||
case let .postStatus(status, inReplyTo, mediaIds, spoilerText, visibility):
|
||||
case let .postStatus(status, inReplyTo, mediaIds, spoilerText, visibility, pollOptions, pollVotingFrequency, pollDuration):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status),
|
||||
.init(name: "visibility", value: visibility.rawValue)]
|
||||
if let inReplyTo {
|
||||
|
@ -74,6 +77,14 @@ public enum Statuses: Endpoint {
|
|||
if let spoilerText {
|
||||
params.append(.init(name: "spoiler_text", value: spoilerText))
|
||||
}
|
||||
if !pollOptions.isEmpty, let pollVotingFrequency, let pollDuration {
|
||||
for option in pollOptions {
|
||||
params.append(.init(name: "poll[options][]", value: option))
|
||||
}
|
||||
|
||||
params.append(.init(name: "poll[multiple]", value: pollVotingFrequency ? "true" : "false"))
|
||||
params.append(.init(name: "poll[expires_in]", value: "\(pollDuration)"))
|
||||
}
|
||||
return params
|
||||
case let .editStatus(_, status, mediaIds, spoilerText, visibility):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status),
|
||||
|
|
|
@ -23,6 +23,7 @@ struct StatusEditorAccessoryView: View {
|
|||
matching: .images) {
|
||||
Image(systemName: "photo.fill.on.rectangle.fill")
|
||||
}
|
||||
.disabled(viewModel.showPoll)
|
||||
|
||||
Button {
|
||||
viewModel.insertStatusText(text: " @")
|
||||
|
@ -35,6 +36,15 @@ struct StatusEditorAccessoryView: View {
|
|||
} label: {
|
||||
Image(systemName: "number")
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.showPoll.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chart.bar")
|
||||
}
|
||||
.disabled(viewModel.shouldDisablePollButton)
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import SwiftUI
|
||||
import DesignSystem
|
||||
import Env
|
||||
|
||||
struct StatusEditorPollView: View {
|
||||
enum FocusField: Hashable {
|
||||
case option(Int)
|
||||
}
|
||||
|
||||
@FocusState var focused: FocusField?
|
||||
|
||||
@State private var currentFocusIndex: Int = 0
|
||||
|
||||
@EnvironmentObject private var theme: Theme
|
||||
@EnvironmentObject private var currentInstance: CurrentInstance
|
||||
|
||||
@ObservedObject var viewModel: StatusEditorViewModel
|
||||
|
||||
@Binding var showPoll: Bool
|
||||
|
||||
var body: some View {
|
||||
let count = viewModel.pollOptions.count
|
||||
|
||||
VStack {
|
||||
ForEach(0..<count, id: \.self) { index in
|
||||
VStack {
|
||||
HStack(spacing: 16) {
|
||||
TextField("Option \(index + 1)", text: $viewModel.pollOptions[index])
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focused, equals: .option(index))
|
||||
.onTapGesture {
|
||||
if canAddMoreAt(index) {
|
||||
currentFocusIndex = index
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
if canAddMoreAt(index) {
|
||||
addChoice(at: index)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.pollOptions[index]) {
|
||||
let maxCharacters: Int = currentInstance.instance?.configuration.polls.maxCharactersPerOption ?? 50
|
||||
viewModel.pollOptions[index] = String($0.prefix(maxCharacters))
|
||||
}
|
||||
|
||||
if canAddMoreAt(index) {
|
||||
Button {
|
||||
addChoice(at: index)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
removeChoice(at: index)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
focused = .option(0)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Picker("Polling Frequency", selection: $viewModel.pollVotingFrequency) {
|
||||
ForEach(PollVotingFrequency.allCases, id: \.rawValue) {
|
||||
Text($0.rawValue)
|
||||
.tag($0)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1.0)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("Poll Duration", selection: $viewModel.pollDuration) {
|
||||
ForEach(PollDuration.allCases, id: \.rawValue) {
|
||||
Text($0.displayString)
|
||||
.tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6.0)
|
||||
.stroke(theme.secondaryBackgroundColor.opacity(0.6), lineWidth: 1)
|
||||
.background(theme.primaryBackgroundColor.opacity(0.3))
|
||||
)
|
||||
}
|
||||
|
||||
private func addChoice(at index: Int) {
|
||||
viewModel.pollOptions.append("")
|
||||
currentFocusIndex = index + 1
|
||||
moveFocus()
|
||||
}
|
||||
|
||||
private func removeChoice(at index: Int) {
|
||||
viewModel.pollOptions.remove(at: index)
|
||||
|
||||
if viewModel.pollOptions.count == 1 {
|
||||
viewModel.resetPollDefaults()
|
||||
|
||||
withAnimation {
|
||||
showPoll = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveFocus() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
focused = .option(currentFocusIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private func canAddMoreAt(_ index: Int) -> Bool {
|
||||
let count = viewModel.pollOptions.count
|
||||
let maxEntries: Int = currentInstance.instance?.configuration.polls.maxOptions ?? 4
|
||||
|
||||
return index == count - 1 && count < maxEntries
|
||||
}
|
||||
}
|
|
@ -49,6 +49,10 @@ public struct StatusEditorView: View {
|
|||
.padding(.horizontal, .layoutPadding)
|
||||
.disabled(true)
|
||||
}
|
||||
if viewModel.showPoll {
|
||||
StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import SwiftUI
|
||||
import DesignSystem
|
||||
import Env
|
||||
import Models
|
||||
import Network
|
||||
import PhotosUI
|
||||
|
@ -26,7 +27,12 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
checkEmbed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Published var showPoll: Bool = false
|
||||
@Published var pollVotingFrequency = PollVotingFrequency.oneVote
|
||||
@Published var pollDuration = PollDuration.oneDay
|
||||
@Published var pollOptions: [String] = ["", ""]
|
||||
|
||||
@Published var spoilerOn: Bool = false
|
||||
@Published var spoilerText: String = ""
|
||||
|
||||
|
@ -47,6 +53,10 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
var canPost: Bool {
|
||||
statusText.length > 0 || !selectedMedias.isEmpty
|
||||
}
|
||||
|
||||
var shouldDisablePollButton: Bool {
|
||||
showPoll || !selectedMedias.isEmpty
|
||||
}
|
||||
|
||||
@Published var visibility: Models.Visibility = .pub
|
||||
|
||||
|
@ -78,6 +88,10 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
statusText = string
|
||||
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
||||
}
|
||||
|
||||
private func getPollOptionsForAPI() -> [String] {
|
||||
pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
}
|
||||
|
||||
func postStatus() async -> Status? {
|
||||
guard let client else { return nil }
|
||||
|
@ -90,7 +104,10 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
inReplyTo: mode.replyToStatus?.id,
|
||||
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
|
||||
spoilerText: spoilerOn ? spoilerText : nil,
|
||||
visibility: visibility))
|
||||
visibility: visibility,
|
||||
pollOptions: getPollOptionsForAPI(),
|
||||
pollVotingFrequency: pollVotingFrequency.canVoteMultipleTimes,
|
||||
pollDuration: pollDuration.rawValue))
|
||||
case let .edit(status):
|
||||
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id,
|
||||
status: statusText.string,
|
||||
|
@ -194,6 +211,12 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
func resetPollDefaults() {
|
||||
pollOptions = ["", ""]
|
||||
pollDuration = .oneDay
|
||||
pollVotingFrequency = .oneVote
|
||||
}
|
||||
|
||||
private func checkEmbed() {
|
||||
if let url = embededStatusURL,
|
||||
|
|
Loading…
Reference in New Issue