357 lines
13 KiB
Swift
357 lines
13 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 {
|
|
@EnvironmentObject var mastodonController: MastodonController
|
|
@EnvironmentObject var uiState: ComposeUIState
|
|
@EnvironmentObject var draft: Draft
|
|
|
|
@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.instance))
|
|
}
|
|
|
|
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) {
|
|
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) {
|
|
DraftsView(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)
|
|
}
|
|
|
|
header
|
|
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
|
|
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)
|
|
}
|
|
|
|
MainComposeTextView(
|
|
draft: draft,
|
|
becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder
|
|
)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
|
|
if let poll = draft.poll {
|
|
ComposePollView(draft: draft, poll: poll)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
|
|
ComposeAttachmentsList(
|
|
draft: draft
|
|
)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
|
}
|
|
.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))
|
|
}
|
|
}
|
|
|
|
private var postButton: some View {
|
|
Button {
|
|
Task {
|
|
await self.postStatus()
|
|
}
|
|
} label: {
|
|
Text("Post")
|
|
}
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
.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
|
|
}
|
|
}
|
|
|
|
private 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()
|
|
// }
|
|
//}
|