From f46150422a2b7be46305db7c56561f57e1d2befc Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 30 Jan 2025 17:23:13 -0500 Subject: [PATCH] Fix Post button enabling/disabling --- .../Controllers/ComposeController.swift | 2 +- .../Sources/ComposeUI/CoreData/Draft.swift | 5 +- .../Sources/ComposeUI/CoreData/Poll.swift | 2 +- .../Attachments/AttachmentsSection.swift | 2 +- .../Views/ComposeNavigationBarActions.swift | 219 ++++++++++++++++++ .../Sources/ComposeUI/Views/ComposeView.swift | 85 +------ .../ComposeUI/Views/DraftContentEditor.swift | 2 +- 7 files changed, 231 insertions(+), 86 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index ddfeb0d6..d782df5e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -83,7 +83,7 @@ public final class ComposeController: ViewController { } 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 { diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift index 3b49b3c8..62195679 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift @@ -71,9 +71,6 @@ public class Draft: NSManagedObject, Identifiable { extension Draft { public var hasContent: Bool { - (!text.isEmpty && text != initialText) || - (contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) || - attachments.count > 0 || - poll?.hasContent == true + !text.isEmpty && text != initialText } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift index fc1d8d3d..6926263f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Poll.swift @@ -41,6 +41,6 @@ public class Poll: NSManagedObject { extension Poll { public var hasContent: Bool { - pollOptions.allSatisfy { !$0.text.isEmpty } + pollOptions.contains { !$0.text.isEmpty } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift index 61151c70..60d3000a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift @@ -482,7 +482,7 @@ struct AddAttachmentConditionsModifier: ViewModifier { if instanceFeatures.mastodonAttachmentRestrictions { return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } - && draft.poll == nil + && !draft.pollEnabled } else { return true } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift new file mode 100644 index 00000000..c00b8ad6 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift @@ -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() } + } ?? [] + } + } +} + diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index d5ee53d8..06170c5d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -10,11 +10,16 @@ import SwiftUI struct ComposeView: View { @ObservedObject var draft: Draft let mastodonController: any ComposeMastodonContext - @State private var poster: PostService? = nil +// @State private var poster: PostService? = nil @FocusState private var focusedField: FocusableField? @EnvironmentObject private var controller: ComposeController @State private var isShowingDrafts = false + // TODO: replace this with an @State owned by this view + var poster: PostService? { + controller.poster + } + var body: some View { navigation .environmentObject(mastodonController.instanceFeatures) @@ -80,7 +85,7 @@ struct ComposeView: View { .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts) + ComposeNavigationBarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts, isPosting: poster != nil) #if os(visionOS) ToolbarItem(placement: .bottomOrnament) { 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 { case contentWarning case body diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift index 604f7122..68a9d7db 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift @@ -97,7 +97,7 @@ private struct TogglePollButton: View { var body: some View { 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()) .disabled(disabled)