// // ComposeView.swift // Tusker // // Created by Shadowfacts on 8/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Pachyderm import Combine import ComposeUI @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 { @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var draft: OldDraft @State private var globalFrameOutsideList: CGRect = .zero @State private var contentWarningBecomeFirstResponder = false @State private var mainComposeTextViewBecomeFirstResponder = false @StateObject private var keyboardReader = KeyboardReader() @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 private 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.instanceFeatures)) } private var requiresAttachmentDescriptions: Bool { guard Preferences.shared.requireAttachmentDescriptions else { return false } let attachmentIds = draft.attachments.map(\.id) return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } } private var validAttachmentCombination: Bool { if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return true } else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 { return false } else if draft.attachments.count > 4 { return false } return true } private var postButtonEnabled: Bool { draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && validAttachmentCombination && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) } var body: some View { ZStack(alignment: .top) { // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed Color.appBackground .edgesIgnoringSafeArea(.all) mainList .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) } } .safeAreaInset(edge: .bottom, spacing: 0) { if !uiState.isDucking { VStack(spacing: 0) { autocompleteSuggestions .transition(.move(edge: .bottom)) .animation(.default, value: uiState.autocompleteState) ComposeToolbar(draft: draft) } // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this .padding(.bottom, keyboardInset) .transition(.move(edge: .bottom)) } } .background(GeometryReader { proxy in Color.clear .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global)) .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in globalFrameOutsideList = frame } }) .sheet(isPresented: $uiState.isShowingDraftsList) { DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController) } .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) .alert(isPresented: $isShowingPostErrorAlert) { Alert( title: Text("Error Posting Status"), message: Text(postError?.localizedDescription ?? ""), dismissButton: .default(Text("OK")) ) } .toolbar { ToolbarItem(placement: .cancellationAction) { cancelButton } ToolbarItem(placement: .confirmationAction) { postButton } } } @available(iOS, obsoleted: 16.0) private var keyboardInset: CGFloat { if #unavailable(iOS 16.0), UIDevice.current.userInterfaceIdiom == .pad, keyboardReader.isVisible { return 44 } else { return 0 } } @ViewBuilder private var autocompleteSuggestions: some View { if let state = uiState.autocompleteState { ComposeAutocompleteView(autocompleteState: state) } } private var mainList: some View { List { if let id = draft.inReplyToID, let status = mastodonController.persistentContainer.status(for: id) { ComposeReplyView( status: status, rowTopInset: 8, globalFrameOutsideList: globalFrameOutsideList ) .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(Color.appBackground) } header .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(Color.appBackground) if uiState.draft.contentWarningEnabled { ComposeEmojiTextField( text: $uiState.draft.contentWarning, placeholder: "Write your warning here", becomeFirstResponder: $contentWarningBecomeFirstResponder, focusNextView: $mainComposeTextViewBecomeFirstResponder ) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(Color.appBackground) } MainComposeTextView( draft: draft, becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder ) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(Color.appBackground) if let poll = draft.poll { ComposePollView(draft: draft, poll: poll) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .listRowBackground(Color.appBackground) } ComposeAttachmentsList( draft: draft ) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) .listRowBackground(Color.appBackground) } .animation(.default, value: draft.poll?.options.count) .scrollDismissesKeyboardInteractivelyIfAvailable() .listStyle(.plain) .disabled(isPosting) .onChange(of: draft.contentWarningEnabled) { newValue in if newValue { contentWarningBecomeFirstResponder = true } } } private var header: some View { HStack(alignment: .top) { ComposeCurrentAccount() .accessibilitySortPriority(1) 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")) // this should come first, so VO users can back to it from the main compose text view .accessibilitySortPriority(0) }.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)) } } @ViewBuilder private var postButton: some View { if draft.hasContent { Button { Task { await self.postStatus() } } label: { Text("Post") } .keyboardShortcut(.return, modifiers: .command) .disabled(!postButtonEnabled) } else { Button { uiState.isShowingDraftsList = true } label: { Text("Drafts") } } } 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 { OldDraftsManager.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: { OldDraftsManager.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 } } extension View { @available(iOS, obsoleted: 16.0) @ViewBuilder func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { if #available(iOS 16.0, *) { self.scrollDismissesKeyboard(.interactively) } else { self } } } private struct GlobalFrameOutsideListPrefKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } @available(iOS, obsoleted: 16.0) private class KeyboardReader: ObservableObject { @Published var isVisible = false init() { NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil) } @objc func willShow(_ notification: Foundation.Notification) { // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect isVisible = endFrame.height > 72 } @objc func willHide() { isVisible = false } } //struct ComposeView_Previews: PreviewProvider { // static var previews: some View { // ComposeView() // } //}