Fix Post button enabling/disabling

This commit is contained in:
Shadowfacts 2025-01-30 17:23:13 -05:00
parent 221ea05629
commit f46150422a
7 changed files with 231 additions and 86 deletions

View File

@ -83,7 +83,7 @@ public final class ComposeController: ViewController {
} }
private var isPollValid: Bool { private var isPollValid: Bool {
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty } !draft.pollEnabled || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
} }
public var navigationTitle: String { public var navigationTitle: String {

View File

@ -71,9 +71,6 @@ public class Draft: NSManagedObject, Identifiable {
extension Draft { extension Draft {
public var hasContent: Bool { public var hasContent: Bool {
(!text.isEmpty && text != initialText) || !text.isEmpty && text != initialText
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
attachments.count > 0 ||
poll?.hasContent == true
} }
} }

View File

@ -41,6 +41,6 @@ public class Poll: NSManagedObject {
extension Poll { extension Poll {
public var hasContent: Bool { public var hasContent: Bool {
pollOptions.allSatisfy { !$0.text.isEmpty } pollOptions.contains { !$0.text.isEmpty }
} }
} }

View File

@ -482,7 +482,7 @@ struct AddAttachmentConditionsModifier: ViewModifier {
if instanceFeatures.mastodonAttachmentRestrictions { if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image } && draft.draftAttachments.allSatisfy { $0.type == .image }
&& draft.poll == nil && !draft.pollEnabled
} else { } else {
return true return true
} }

View File

@ -0,0 +1,219 @@
//
// ComposeNavigationBarActions.swift
// ComposeUI
//
// Created by Shadowfacts on 1/30/25.
//
import SwiftUI
import Combine
import InstanceFeatures
struct ComposeNavigationBarActions: ToolbarContent {
@ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment from the containing view.
let controller: ComposeController
@Binding var isShowingDrafts: Bool
let isPosting: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { DraftsButton(isShowingDrafts: $isShowingDrafts) }
ToolbarItem(placement: .confirmationAction) { PostButton(draft: draft, isPosting: isPosting) }
#else
ToolbarItem(placement: .confirmationAction) { PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting) }
#endif
}
}
private struct ToolbarCancelButton: View {
let draft: Draft
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(role: .cancel, action: controller.cancel) {
Text("Cancel")
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
}
#if !targetEnvironment(macCatalyst)
private struct PostOrDraftsButton: View {
@DraftObserving var draft: Draft
@Binding var isShowingDrafts: Bool
let isPosting: Bool
@Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts
var body: some View {
if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts {
PostButton(draft: draft, isPosting: isPosting)
} else {
DraftsButton(isShowingDrafts: $isShowingDrafts)
}
}
private var draftIsEmpty: Bool {
draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && !draft.pollEnabled
}
}
#endif
private struct PostButton: View {
@DraftObserving var draft: Draft
let isPosting: Bool
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!draftValid)
.disabled(isPosting)
}
private var hasCharactersRemaining: Bool {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
let bodyCount = CharacterCounter.count(text: draft.text, for: instanceFeatures)
let remaining = limit - (cwCount + bodyCount)
return remaining >= 0
}
private var attachmentsCombinationValid: Bool {
if !instanceFeatures.mastodonAttachmentRestrictions {
true
} else if draft.attachments.count > 1,
draft.draftAttachments.contains(where: { $0.type == .video }) {
false
} else if draft.attachments.count > 4 {
false
} else {
true
}
}
private var attachmentsValid: Bool {
(!requireAttachmentDescriptions || draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty })
&& attachmentsCombinationValid
}
private var pollValid: Bool {
!draft.pollEnabled || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
}
private var draftValid: Bool {
draft.editedStatusID != nil ||
((draft.hasContent || draft.attachments.count > 0)
&& hasCharactersRemaining
&& attachmentsValid
&& pollValid)
}
}
private struct DraftsButton: View {
@Binding var isShowingDrafts: Bool
var body: some View {
Button {
isShowingDrafts = true
} label: {
Text("Drafts")
}
}
}
// This property wrapper lets a View observe all of the following:
// 1. The Draft itself
// 2. The Draft's Poll (if it has one)
// 3. Each of the Poll's PollOptions (if there is a Poll)
// 4. Each of the Draft's DraftAttachments
@propertyWrapper
private struct DraftObserving: DynamicProperty {
let wrappedValue: Draft
@StateObject private var observer = Observer()
init(wrappedValue: Draft) {
self.wrappedValue = wrappedValue
}
func update() {
observer.update(draft: wrappedValue)
}
private class Observer: ObservableObject {
private var draft: Draft?
private var cancellable: AnyCancellable?
private var draftPollObservation: NSKeyValueObservation?
private var pollOptionsObservation: NSKeyValueObservation?
private var pollOptionsCancellables: [AnyCancellable] = []
private var draftAttachmentsObservation: NSKeyValueObservation?
private var draftAttachmentsCancellables: [AnyCancellable] = []
func update(draft: Draft) {
guard draft !== self.draft else {
return
}
self.draft = draft
cancellable = draft.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
draftPollObservation = draft.observe(\.poll) { [unowned self] _, _ in
objectWillChange.send()
self.pollChanged()
}
pollChanged()
draftAttachmentsObservation = draft.observe(\.attachments) { [unowned self] _, _ in
objectWillChange.send()
self.draftAttachmentsChanged()
}
draftAttachmentsChanged()
}
private func pollChanged() {
pollOptionsObservation = (draft?.poll).map {
$0.observe(\.options) { [unowned self] _, _ in
objectWillChange.send()
self.pollOptionsChanged()
}
}
pollOptionsChanged()
}
private func pollOptionsChanged() {
pollOptionsCancellables = draft?.poll?.pollOptions.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
private func draftAttachmentsChanged() {
draftAttachmentsCancellables = draft?.draftAttachments.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
}
}

View File

@ -10,11 +10,16 @@ import SwiftUI
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@State private var poster: PostService? = nil // @State private var poster: PostService? = nil
@FocusState private var focusedField: FocusableField? @FocusState private var focusedField: FocusableField?
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
@State private var isShowingDrafts = false @State private var isShowingDrafts = false
// TODO: replace this with an @State owned by this view
var poster: PostService? {
controller.poster
}
var body: some View { var body: some View {
navigation navigation
.environmentObject(mastodonController.instanceFeatures) .environmentObject(mastodonController.instanceFeatures)
@ -80,7 +85,7 @@ struct ComposeView: View {
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts) ComposeNavigationBarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts, isPosting: poster != nil)
#if os(visionOS) #if os(visionOS)
ToolbarItem(placement: .bottomOrnament) { ToolbarItem(placement: .bottomOrnament) {
toolbarView toolbarView
@ -143,82 +148,6 @@ public struct NavigationTitlePreferenceKey: PreferenceKey {
} }
} }
private struct ToolbarActions: ToolbarContent {
@ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment from the containing view.
let controller: ComposeController
@Binding var isShowingDrafts: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
}
private var draftsButton: some View {
Button {
isShowingDrafts = true
} label: {
Text("Drafts")
}
}
private var postButton: some View {
// TODO: don't use the controller for this
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
#if !targetEnvironment(macCatalyst)
@ViewBuilder
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
#endif
}
private struct ToolbarCancelButton: View {
let draft: Draft
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(action: controller.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
}
enum FocusableField: Hashable { enum FocusableField: Hashable {
case contentWarning case contentWarning
case body case body

View File

@ -97,7 +97,7 @@ private struct TogglePollButton: View {
var body: some View { var body: some View {
Button(action: togglePoll) { Button(action: togglePoll) {
Image(systemName: draft.poll == nil ? "chart.bar.doc.horizontal" : "chart.bar.horizontal.page.fill") Image(systemName: draft.pollEnabled ? "chart.bar.doc.horizontal.fill" : "chart.bar.horizontal.page")
} }
.buttonStyle(LanguageButtonStyle()) .buttonStyle(LanguageButtonStyle())
.disabled(disabled) .disabled(disabled)