410 lines
16 KiB
Swift
410 lines
16 KiB
Swift
//
|
|
// ComposeController.swift
|
|
// ComposeUI
|
|
//
|
|
// Created by Shadowfacts on 3/4/23.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Combine
|
|
import Pachyderm
|
|
import TuskerComponents
|
|
|
|
public final class ComposeController: ViewController {
|
|
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
|
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
|
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
|
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
|
public typealias EmojiImageView = (Emoji) -> AnyView
|
|
|
|
@Published public private(set) var draft: Draft
|
|
@Published public var config: ComposeUIConfig
|
|
@Published public var mastodonController: ComposeMastodonContext
|
|
let fetchAvatar: AvatarImageView.FetchAvatar
|
|
let fetchStatus: FetchStatus
|
|
let displayNameLabel: DisplayNameLabel
|
|
let currentAccountContainerView: CurrentAccountContainerView
|
|
let replyContentView: ReplyContentView
|
|
let emojiImageView: EmojiImageView
|
|
|
|
@Published public var currentAccount: (any AccountProtocol)?
|
|
@Published public var showToolbar = true
|
|
|
|
@Published var autocompleteController: AutocompleteController!
|
|
@Published var toolbarController: ToolbarController!
|
|
@Published var attachmentsListController: AttachmentsListController!
|
|
|
|
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
|
// updates when it changes, because changes to it may alter postButtonEnabled
|
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
|
@Published var contentWarningBecomeFirstResponder = false
|
|
@Published var mainComposeTextViewBecomeFirstResponder = false
|
|
@Published var currentInput: (any ComposeInput)? = nil
|
|
@Published var shouldEmojiAutocompletionBeginExpanded = false
|
|
@Published var isShowingSaveDraftSheet = false
|
|
@Published var isShowingDraftsList = false
|
|
@Published var poster: PostService?
|
|
@Published var postError: PostService.Error?
|
|
@Published public private(set) var didPostSuccessfully = false
|
|
|
|
var isPosting: Bool {
|
|
poster != nil
|
|
}
|
|
|
|
var charactersRemaining: Int {
|
|
let instanceFeatures = mastodonController.instanceFeatures
|
|
let limit = instanceFeatures.maxStatusChars
|
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
|
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
|
}
|
|
|
|
var postButtonEnabled: Bool {
|
|
draft.hasContent
|
|
&& charactersRemaining >= 0
|
|
&& !isPosting
|
|
&& attachmentsListController.isValid
|
|
&& isPollValid
|
|
}
|
|
|
|
private var isPollValid: Bool {
|
|
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
|
}
|
|
|
|
public init(
|
|
draft: Draft,
|
|
config: ComposeUIConfig,
|
|
mastodonController: ComposeMastodonContext,
|
|
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
|
fetchStatus: @escaping FetchStatus,
|
|
displayNameLabel: @escaping DisplayNameLabel,
|
|
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
|
replyContentView: @escaping ReplyContentView,
|
|
emojiImageView: @escaping EmojiImageView
|
|
) {
|
|
self.draft = draft
|
|
self.config = config
|
|
self.mastodonController = mastodonController
|
|
self.fetchAvatar = fetchAvatar
|
|
self.fetchStatus = fetchStatus
|
|
self.displayNameLabel = displayNameLabel
|
|
self.currentAccountContainerView = currentAccountContainerView
|
|
self.replyContentView = replyContentView
|
|
self.emojiImageView = emojiImageView
|
|
|
|
self.autocompleteController = AutocompleteController(parent: self)
|
|
self.toolbarController = ToolbarController(parent: self)
|
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
|
}
|
|
|
|
public var view: some View {
|
|
ComposeView(poster: poster)
|
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
|
.environmentObject(draft)
|
|
.environmentObject(mastodonController.instanceFeatures)
|
|
}
|
|
|
|
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
|
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
|
return false
|
|
}
|
|
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
|
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
|
|
// if providers are videos, this technically allows invalid video/image combinations
|
|
return itemProviders.count + draft.attachments.count <= 4
|
|
} else {
|
|
return false
|
|
}
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
public func paste(itemProviders: [NSItemProvider]) {
|
|
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
|
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
|
guard let attachment = object as? DraftAttachment else { return }
|
|
DispatchQueue.main.async {
|
|
guard self.attachmentsListController.canAddAttachment else { return }
|
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
|
attachment.draft = self.draft
|
|
self.draft.attachments.add(attachment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func cancel() {
|
|
if config.automaticallySaveDrafts {
|
|
config.dismiss(.cancel)
|
|
} else {
|
|
if draft.hasContent {
|
|
isShowingSaveDraftSheet = true
|
|
} else {
|
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
|
config.dismiss(.cancel)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func cancel(deleteDraft: Bool) {
|
|
if deleteDraft {
|
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
|
}
|
|
config.dismiss(.cancel)
|
|
}
|
|
|
|
func postStatus() {
|
|
guard !isPosting,
|
|
draft.hasContent else {
|
|
return
|
|
}
|
|
|
|
Task { @MainActor in
|
|
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
|
|
self.poster = poster
|
|
|
|
// try to resign the first responder, if there is one.
|
|
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
|
|
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
|
|
do {
|
|
try await poster.post()
|
|
|
|
didPostSuccessfully = true
|
|
|
|
// wait .25 seconds so the user can see the progress bar has completed
|
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
|
|
// don't unset the poster, so the ui remains disabled while dismissing
|
|
|
|
config.dismiss(.post)
|
|
|
|
} catch let error as PostService.Error {
|
|
self.postError = error
|
|
self.poster = nil
|
|
} catch {
|
|
fatalError("unreachable")
|
|
}
|
|
}
|
|
}
|
|
|
|
func showDrafts() {
|
|
isShowingDraftsList = true
|
|
}
|
|
|
|
func selectDraft(_ newDraft: Draft) {
|
|
if !self.draft.hasContent {
|
|
DraftsPersistentContainer.shared.viewContext.delete(self.draft)
|
|
}
|
|
DraftsPersistentContainer.shared.save()
|
|
|
|
self.draft = newDraft
|
|
}
|
|
|
|
func onDisappear() {
|
|
if !draft.hasContent || didPostSuccessfully {
|
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
|
}
|
|
DraftsPersistentContainer.shared.save()
|
|
}
|
|
|
|
func toggleContentWarning() {
|
|
draft.contentWarningEnabled.toggle()
|
|
if draft.contentWarningEnabled {
|
|
contentWarningBecomeFirstResponder = true
|
|
}
|
|
}
|
|
|
|
struct ComposeView: View {
|
|
@OptionalObservedObject var poster: PostService?
|
|
@EnvironmentObject var controller: ComposeController
|
|
@EnvironmentObject var draft: Draft
|
|
@StateObject private var keyboardReader = KeyboardReader()
|
|
@State private var globalFrameOutsideList = CGRect.zero
|
|
|
|
init(poster: PostService?) {
|
|
self.poster = poster
|
|
}
|
|
|
|
var config: ComposeUIConfig {
|
|
controller.config
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
|
config.backgroundColor
|
|
.edgesIgnoringSafeArea(.all)
|
|
|
|
mainList
|
|
|
|
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 controller.showToolbar {
|
|
VStack(spacing: 0) {
|
|
ControllerView(controller: { controller.autocompleteController })
|
|
.transition(.move(edge: .bottom))
|
|
.animation(.default, value: controller.currentInput?.autocompleteState)
|
|
|
|
ControllerView(controller: { controller.toolbarController })
|
|
}
|
|
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
|
.padding(.bottom, keyboardInset)
|
|
.transition(.move(edge: .bottom))
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
|
}
|
|
.background(GeometryReader { proxy in
|
|
Color.clear
|
|
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
|
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
|
|
globalFrameOutsideList = newValue
|
|
}
|
|
})
|
|
.sheet(isPresented: $controller.isShowingDraftsList) {
|
|
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
|
|
}
|
|
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
|
|
Button("OK") {}
|
|
}, message: { error in
|
|
Text(error.localizedDescription)
|
|
})
|
|
.onDisappear(perform: controller.onDisappear)
|
|
.navigationTitle(navTitle)
|
|
}
|
|
|
|
private var navTitle: String {
|
|
if let id = draft.inReplyToID,
|
|
let status = controller.fetchStatus(id) {
|
|
return "Reply to @\(status.account.acct)"
|
|
} else {
|
|
return "New Post"
|
|
}
|
|
}
|
|
|
|
private var mainList: some View {
|
|
List {
|
|
if let id = draft.inReplyToID,
|
|
let status = controller.fetchStatus(id) {
|
|
ReplyStatusView(
|
|
status: status,
|
|
rowTopInset: 8,
|
|
globalFrameOutsideList: globalFrameOutsideList
|
|
)
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(config.backgroundColor)
|
|
}
|
|
|
|
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
|
|
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(config.backgroundColor)
|
|
|
|
if draft.contentWarningEnabled {
|
|
EmojiTextField(
|
|
text: $draft.contentWarning,
|
|
placeholder: "Write your warning here",
|
|
maxLength: nil,
|
|
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
|
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
|
)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(config.backgroundColor)
|
|
}
|
|
|
|
MainTextView()
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(config.backgroundColor)
|
|
|
|
if let poll = draft.poll {
|
|
ControllerView(controller: { PollController(parent: controller, poll: poll) })
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(config.backgroundColor)
|
|
}
|
|
|
|
ControllerView(controller: { controller.attachmentsListController })
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
|
.listRowBackground(config.backgroundColor)
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
|
.disabled(controller.isPosting)
|
|
}
|
|
|
|
private var cancelButton: some View {
|
|
Button(action: controller.cancel) {
|
|
Text("Cancel")
|
|
// otherwise all Buttons in the nav bar are made semibold
|
|
.font(.system(size: 17, weight: .regular))
|
|
}
|
|
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
|
Button(action: { controller.cancel(deleteDraft: false) }) {
|
|
Text("Save Draft")
|
|
}
|
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
|
Text("Delete Draft")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var postButton: some View {
|
|
if draft.hasContent || !controller.config.allowSwitchingDrafts {
|
|
Button(action: controller.postStatus) {
|
|
Text("Post")
|
|
}
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
.disabled(!controller.postButtonEnabled)
|
|
} else {
|
|
Button(action: controller.showDrafts) {
|
|
Text("Drafts")
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS, obsoleted: 16.0)
|
|
private var keyboardInset: CGFloat {
|
|
if #unavailable(iOS 16.0),
|
|
UIDevice.current.userInterfaceIdiom == .pad,
|
|
keyboardReader.isVisible {
|
|
return ToolbarController.height
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|