Tusker/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift

506 lines
20 KiB
Swift

//
// 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 {
didSet {
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
}
}
@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<UUID>()
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
let scrollToAttachment = PassthroughSubject<UUID, Never>()
@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
public 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
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
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<NSManagedObject>,
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))
}
.disabled(controller.isPosting)
.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 }
}
}