Tusker/Tusker/Screens/Compose/ComposeView.swift

258 lines
8.6 KiB
Swift

//
// 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<T: ObservableObject>: 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)
}
}
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)
}
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))))
.animation(.default)
}
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
}
}
//struct ComposeView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeView()
// }
//}