// // ComposeController.swift // ComposeUI // // Created by Shadowfacts on 3/4/23. // import SwiftUI import Combine import Pachyderm import TuskerComponents import MatchedGeometryPresentation import CoreData public final class ComposeController: ViewController { public typealias FetchAttachment = (URL) async -> UIImage? 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 fetchAttachment: FetchAttachment 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 public var deleteDraftOnDisappear = 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() @Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)? let scrollToAttachment = PassthroughSubject() @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 @Published var hasChangedLanguageSelection = false private var isDisappearing = false private var userConfirmedDelete = 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.editedStatusID != nil || (draft.hasContent && charactersRemaining >= 0 && !isPosting && attachmentsListController.isValid && isPollValid) } private var isPollValid: Bool { draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty } } public var navigationTitle: String { if let id = draft.inReplyToID, let status = fetchStatus(id) { return "Reply to @\(status.account.acct)" } else if draft.editedStatusID != nil { return "Edit Post" } else { return "New Post" } } public init( draft: Draft, config: ComposeUIConfig, mastodonController: ComposeMastodonContext, fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchAttachment: @escaping FetchAttachment, 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.fetchAttachment = fetchAttachment 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) if #available(iOS 16.0, *) { NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) } NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext) } public var view: some View { ComposeView(poster: poster) .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) .environmentObject(draft) .environmentObject(mastodonController.instanceFeatures) .environment(\.composeUIConfig, config) } @MainActor @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set, deleted.contains(where: { $0.objectID == self.draft.objectID }), !isDisappearing { self.config.dismiss(.cancel) } } 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 draft.hasContent { isShowingSaveDraftSheet = true } else { deleteDraftOnDisappear = true config.dismiss(.cancel) } } @MainActor func cancel(deleteDraft: Bool) { deleteDraftOnDisappear = true userConfirmedDelete = deleteDraft config.dismiss(.cancel) } func postStatus() { guard !isPosting, draft.editedStatusID != nil || 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() deleteDraftOnDisappear = true 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) { let oldDraft = self.draft self.draft = newDraft if !oldDraft.hasContent { DraftsPersistentContainer.shared.viewContext.delete(oldDraft) } DraftsPersistentContainer.shared.save() } func onDisappear() { isDisappearing = true if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) { DraftsPersistentContainer.shared.viewContext.delete(draft) } DraftsPersistentContainer.shared.save() } func toggleContentWarning() { draft.contentWarningEnabled.toggle() if draft.contentWarningEnabled { contentWarningBecomeFirstResponder = true } } @available(iOS 16.0, *) @objc private func currentInputModeChanged() { guard let mode = currentInput?.textInputMode, let code = LanguagePicker.codeFromInputMode(mode), !hasChangedLanguageSelection && !draft.hasContent else { return } draft.language = code.identifier } 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 { NavigationView { navRoot } .navigationViewStyle(.stack) } private var navRoot: 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) ScrollViewReader { proxy in mainList .onReceive(controller.scrollToAttachment) { id in proxy.scrollTo(id, anchor: .center) } } 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) }) .matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in let id = controller.focusedAttachment?.0.id // this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID? return id.map { Optional.some($0) } }, set: { if $0 == nil { controller.focusedAttachment = nil } else { fatalError() } }), backgroundColor: .black) { ControllerView(controller: { FocusedAttachmentController( parent: controller, attachment: controller.focusedAttachment!.0, thumbnailController: controller.focusedAttachment!.1 ) }) } .onDisappear(perform: controller.onDisappear) .navigationTitle(controller.navigationTitle) .navigationBarTitleDisplayMode(.inline) } private var mainList: some View { List { if let id = draft.inReplyToID, let status = controller.fetchStatus(id) { ReplyStatusView( status: status, rowTopInset: 8, globalFrameOutsideList: globalFrameOutsideList ) // i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing .id(id) .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) { // edit drafts can't be saved if draft.editedStatusID == nil { Button(action: { controller.cancel(deleteDraft: false) }) { Text("Save Draft") } Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { Text("Delete Draft") } } else { Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { Text("Cancel Edit") } } } } @ViewBuilder private var postButton: some View { if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts { Button(action: controller.postStatus) { Text(draft.editedStatusID == nil ? "Post" : "Edit") } .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() } } private struct ComposeUIConfigEnvironmentKey: EnvironmentKey { static let defaultValue = ComposeUIConfig() } extension EnvironmentValues { var composeUIConfig: ComposeUIConfig { get { self[ComposeUIConfigEnvironmentKey.self] } set { self[ComposeUIConfigEnvironmentKey.self] = newValue } } }