// // ComposeView.swift // Tusker // // Created by Shadowfacts on 8/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Pachyderm import Combine struct ComposeView: View { @ObservedObject var draft: Draft @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState @State private var isPosting = false @State private var postProgress: Double = 0 @State private var postTotalProgress: Double = 0 @State private var isShowingPostErrorAlert = false @State private var postError: PostError? private let stackPadding: CGFloat = 8 init(draft: Draft) { self.draft = draft } var charactersRemaining: Int { let limit = mastodonController.instance?.maxStatusCharacters ?? 500 let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 return limit - (cwCount + CharacterCounter.count(text: draft.text)) } 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 } 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) } } // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 WrappedProgressView(value: postProgress, total: postTotalProgress) autocompleteSuggestions } .onAppear(perform: self.didAppear) .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) } 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 { ComposeContentWarningTextField(text: $draft.contentWarning) } MainComposeTextView( draft: draft, placeholder: Text("What's on your mind?") ) ComposeAttachmentsList( draft: draft ) // the list rows provide their own padding, so we cancel out the extra spacing from the VStack .padding([.top, .bottom], -8) } .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(action: self.postStatus) { Text("Post") } .disabled(!postButtonEnabled) } private func didAppear() { let proxy = UIScrollView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) proxy.keyboardDismissMode = .interactive } 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() { guard draft.hasContent else { return } isPosting = true // save before posting, so if a crash occurs during network request, the status won't be lost DraftsManager.save() let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil let sensitive = contentWarning != nil // 2 steps (request data, then upload) for each attachment postTotalProgress = Double(2 + (draft.attachments.count * 2)) postProgress = 1 uploadAttachments { (result) in switch result { case let .failure(error): self.isShowingPostErrorAlert = true self.postError = error self.postProgress = 0 self.postTotalProgress = 0 self.isPosting = false case let .success(uploadedAttachments): let request = Client.createStatus(text: draft.textForPosting, contentType: Preferences.shared.statusContentType, inReplyTo: draft.inReplyToID, media: uploadedAttachments, sensitive: sensitive, spoilerText: contentWarning, visibility: draft.visibility, language: nil) self.mastodonController.run(request) { (response) in switch response { case let .failure(error): self.isShowingPostErrorAlert = true self.postError = error case .success(_, _): self.postProgress += 1 DraftsManager.shared.remove(self.draft) // wait .25 seconds so the user can see the progress bar has completed DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { self.uiState.delegate?.dismissCompose(mode: .post) } } } } } } private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) { let group = DispatchGroup() var attachmentDatas = [(Data, String)?]() for (index, compAttachment) in draft.attachments.enumerated() { group.enter() attachmentDatas.append(nil) compAttachment.data.getData { (data, mimeType) in postProgress += 1 attachmentDatas[index] = (data, mimeType) group.leave() } } group.notify(queue: .global(qos: .userInitiated)) { var anyFailed = false var uploadedAttachments = [Result?]() // Mastodon does not respect the order of the `media_ids` parameter in the create post request, // it determines attachment order by which was uploaded first. Since the upload attachment request // does not include any timestamp data, and requests may arrive at the server out-of-order, // attachments need to be uploaded serially in order to ensure the order of attachments in the // posted status reflects order the user set. // Pleroma does respect the order of the `media_ids` parameter. for (index, (data, mimeType)) in attachmentDatas.map(\.unsafelyUnwrapped).enumerated() { group.enter() let compAttachment = draft.attachments[index] let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) self.mastodonController.run(request) { (response) in switch response { case let .failure(error): uploadedAttachments.append(.failure(error)) anyFailed = true case let .success(attachment, _): self.postProgress += 1 uploadedAttachments.append(.success(attachment)) } group.leave() } group.wait() } if anyFailed { let errors = uploadedAttachments.map { (result) -> Error? in if case let .failure(error) = result { return error } else { return nil } } completion(.failure(AttachmentUploadError(errors: errors))) } else { let uploadedAttachments = uploadedAttachments.map { try! $0!.get() } completion(.success(uploadedAttachments)) } } } } fileprivate protocol PostError: LocalizedError {} extension PostError { var localizedDescription: String { if let self = self as? Client.Error { return self.localizedDescription } else if let self = self as? AttachmentUploadError { return self.localizedDescription } else { return "Unknown Error" } } } extension Client.Error: PostError {} fileprivate struct AttachmentUploadError: PostError { let errors: [Error?] var localizedDescription: String { return errors.enumerated().compactMap { (index, error) -> String? in guard let error = error else { return nil } let description: String // need to downcast to use more specific localizedDescription impl from Pachyderm if let error = error as? Client.Error { description = error.localizedDescription } else { description = error.localizedDescription } return "Attachment \(index + 1): \(description)" }.joined(separator: ",\n") } } //struct ComposeView_Previews: PreviewProvider { // static var previews: some View { // ComposeView() // } //}