258 lines
8.6 KiB
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()
|
|
// }
|
|
//}
|