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

380 lines
14 KiB
Swift

//
// ComposeController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
import Pachyderm
public final class ComposeController: ViewController {
public typealias FetchAvatar = (URL) async -> UIImage?
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: 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 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)
}
}
}
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))
}
}
@ViewBuilder
private var postButton: some View {
if draft.hasContent {
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()
}
}