506 lines
20 KiB
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 }
|
|
}
|
|
}
|