// // ComposeView.swift // Tusker // // Created by Shadowfacts on 8/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Pachyderm import Combine @propertyWrapper struct OptionalStateObject: DynamicProperty { private class Republisher: ObservableObject { var cancellable: AnyCancellable? var wrapped: T? { didSet { cancellable?.cancel() cancellable = wrapped?.objectWillChange .receive(on: RunLoop.main) .sink { [unowned self] _ in self.objectWillChange.send() } } } } @StateObject private var republisher = Republisher() @State private var object: T? var wrappedValue: T? { get { object } nonmutating set { object = newValue } } func update() { republisher.wrapped = wrappedValue } } struct ComposeView: View { @ObservedObject var draft: Draft @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState @OptionalStateObject private var poster: PostService? @State private var isShowingPostErrorAlert = false @State private var postError: PostService.Error? private var isPosting: Bool { poster != nil } private let stackPadding: CGFloat = 8 init(draft: Draft) { self.draft = draft } var charactersRemaining: Int { let limit = mastodonController.instanceFeatures.maxStatusChars let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance)) } var requiresAttachmentDescriptions: Bool { guard Preferences.shared.requireAttachmentDescriptions else { return false } let attachmentIds = draft.attachments.map(\.id) return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } } var postButtonEnabled: Bool { draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) } var body: some View { mostOfTheBody.toolbar { ToolbarItem(placement: .cancellationAction) { cancelButton } ToolbarItem(placement: .confirmationAction) { postButton } } } var mostOfTheBody: some View { ZStack(alignment: .top) { GeometryReader { (outer) in ScrollView(.vertical) { mainStack(outerMinY: outer.frame(in: .global).minY) } .scrollDismissesKeyboardInteractivelyIfAvailable() } if let poster = poster { // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) } autocompleteSuggestions } .navigationBarTitle("Compose") .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) .alert(isPresented: $isShowingPostErrorAlert) { Alert( title: Text("Error Posting Status"), message: Text(postError?.localizedDescription ?? ""), dismissButton: .default(Text("OK")) ) } } @ViewBuilder var autocompleteSuggestions: some View { VStack(spacing: 0) { Spacer() if let state = uiState.autocompleteState { ComposeAutocompleteView(autocompleteState: state) } } .transition(.move(edge: .bottom)) .animation(.default, value: uiState.autocompleteState) } func mainStack(outerMinY: CGFloat) -> some View { VStack(alignment: .leading, spacing: 8) { if let id = draft.inReplyToID, let status = mastodonController.persistentContainer.status(for: id) { ComposeReplyView( status: status, stackPadding: stackPadding, outerMinY: outerMinY ) } header if draft.contentWarningEnabled { ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here") } MainComposeTextView( draft: draft, placeholder: Text("What's on your mind?") ) if let poll = draft.poll { ComposePollView(draft: draft, poll: poll) .transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing)))) } ComposeAttachmentsList( draft: draft ) // the list rows provide their own padding, so we cancel out the extra spacing from the VStack .padding([.top, .bottom], -8) } .disabled(isPosting) .padding(stackPadding) .padding(.bottom, uiState.autocompleteState != nil ? 46 : nil) } private var header: some View { HStack(alignment: .top) { ComposeCurrentAccount() Spacer() Text(verbatim: charactersRemaining.description) .foregroundColor(charactersRemaining < 0 ? .red : .secondary) .font(Font.body.monospacedDigit()) .accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining")) }.frame(height: 50) } private var cancelButton: some View { Button(action: self.cancel) { Text("Cancel") // otherwise all Buttons in the nav bar are made semibold .font(.system(size: 17, weight: .regular)) } } private var postButton: some View { Button { Task { await self.postStatus() } } label: { Text("Post") } .disabled(!postButtonEnabled) } private func cancel() { if Preferences.shared.automaticallySaveDrafts { // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear uiState.delegate?.dismissCompose(mode: .cancel) } else { // if the draft doesn't have content, it doesn't need to be saved if draft.hasContent { uiState.isShowingSaveDraftSheet = true } else { DraftsManager.shared.remove(draft) uiState.delegate?.dismissCompose(mode: .cancel) } } } private func saveAndCloseSheet() -> ActionSheet { ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [ .default(Text("Save Draft"), action: { // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear uiState.isShowingSaveDraftSheet = false uiState.delegate?.dismissCompose(mode: .cancel) }), .destructive(Text("Delete Draft"), action: { DraftsManager.shared.remove(draft) uiState.isShowingSaveDraftSheet = false uiState.delegate?.dismissCompose(mode: .cancel) }), .cancel(), ]) } private func postStatus() async { guard !isPosting, draft.hasContent else { return } let poster = PostService(mastodonController: mastodonController, draft: draft) self.poster = poster do { try await poster.post() // wait .25 seconds so the user can see the progress bar has completed try? await Task.sleep(nanoseconds: 250_000_000) uiState.delegate?.dismissCompose(mode: .post) } catch let error as PostService.Error { self.isShowingPostErrorAlert = true self.postError = error } catch { fatalError("Unreachable") } self.poster = nil } } private extension View { @available(iOS, obsoleted: 16.0) @ViewBuilder func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { if #available(iOS 16.0, *) { self.scrollDismissesKeyboard(.interactively) } else { self } } } //struct ComposeView_Previews: PreviewProvider { // static var previews: some View { // ComposeView() // } //}