forked from shadowfacts/Tusker
366 lines
14 KiB
Swift
366 lines
14 KiB
Swift
//
|
|
// 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 {
|
|
// the pre-iOS 14 API does not result in the correct pointer interactions for nav bar buttons, see FB8595468
|
|
if #available(iOS 14.0, *) {
|
|
mostOfTheBody.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
|
}
|
|
} else {
|
|
mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: 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 {
|
|
// on iOS 13, the transition causes SwiftUI to hang on the main thread when the view appears, so it's disabled
|
|
if #available(iOS 14.0, *) {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
if let state = uiState.autocompleteState {
|
|
ComposeAutocompleteView(autocompleteState: state)
|
|
}
|
|
}
|
|
.transition(.move(edge: .bottom))
|
|
.animation(.default)
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
if let state = uiState.autocompleteState {
|
|
ComposeAutocompleteView(autocompleteState: state)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
} 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}),
|
|
.destructive(Text("Delete Draft"), action: {
|
|
DraftsManager.shared.remove(draft)
|
|
uiState.isShowingSaveDraftSheet = false
|
|
uiState.delegate?.dismissCompose()
|
|
}),
|
|
.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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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<Attachment, Error>?]()
|
|
|
|
// 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()
|
|
// }
|
|
//}
|