// // 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 ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView @Published public private(set) var draft: Draft @Published public var config: ComposeUIConfig let mastodonController: ComposeMastodonContext let fetchAvatar: AvatarImageView.FetchAvatar let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel 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! @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: (any Error)? 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!.options.allSatisfy { !$0.text.isEmpty } } public init( draft: Draft, config: ComposeUIConfig, mastodonController: ComposeMastodonContext, fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchStatus: @escaping FetchStatus, displayNameLabel: @escaping DisplayNameLabel, 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.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) .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.attachments.allSatisfy({ $0.data.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 { self.draft.attachments.append(attachment) } } } } @MainActor func cancel() { if config.automaticallySaveDrafts { config.dismiss(.cancel) } else { if draft.hasContent { isShowingSaveDraftSheet = true } else { DraftsManager.shared.remove(draft) config.dismiss(.cancel) } } } @MainActor func cancel(deleteDraft: Bool) { if deleteDraft { DraftsManager.shared.remove(draft) } else { DraftsManager.save() } 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() // wait .25 seconds so the user can see the progress bar has completed try? await Task.sleep(nanoseconds: 250_000_000) config.dismiss(.post) // don't unset the poster, so the ui remains disabled while dismissing } catch let error as PostService.Error { self.postError = error self.poster = nil } catch { fatalError("unreachable") } } } func showDrafts() { isShowingDraftsList = true } func selectDraft(_ draft: Draft) { if !self.draft.hasContent { DraftsManager.shared.remove(self.draft) } DraftsManager.save() self.draft = draft } func onDisappear() { if !draft.hasContent { DraftsManager.shared.remove(draft) } DraftsManager.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() .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() } }