From 1be3cb77b675ef57411775c580da00abf1f82b1e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 6 Feb 2025 23:58:23 -0500 Subject: [PATCH] Re-add more missing stuff --- .../Sources/ComposeUI/ComposeUIConfig.swift | 9 +- .../Controllers/ComposeController.swift | 10 -- .../Sources/ComposeUI/CoreData/Draft.swift | 4 + .../ComposeUI/CoreData/DraftAttachment.swift | 6 +- .../AttachmentCollectionViewCell.swift | 100 +++++++++++++++++- .../Sources/ComposeUI/Views/ComposeView.swift | 14 +++ .../ComposeUI/Views/NewMainTextView.swift | 65 +++++++++++- .../Compose/ComposeHostingController.swift | 30 +++++- 8 files changed, 217 insertions(+), 21 deletions(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index 0a4c62f3..ecf21d5c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -47,5 +47,12 @@ public struct ComposeUIConfig { } } -extension ComposeUIConfig { +private struct ComposeUIConfigEnvironmentKey: EnvironmentKey { + static let defaultValue = ComposeUIConfig() +} +extension EnvironmentValues { + var composeUIConfig: ComposeUIConfig { + get { self[ComposeUIConfigEnvironmentKey.self] } + set { self[ComposeUIConfigEnvironmentKey.self] = newValue } + } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 797faba3..6545a51a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -513,13 +513,3 @@ private struct GlobalFrameOutsideListPrefKey: PreferenceKey { value = nextValue() } } - -private struct ComposeUIConfigEnvironmentKey: EnvironmentKey { - static let defaultValue = ComposeUIConfig() -} -extension EnvironmentValues { - var composeUIConfig: ComposeUIConfig { - get { self[ComposeUIConfigEnvironmentKey.self] } - set { self[ComposeUIConfigEnvironmentKey.self] = newValue } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift index 6c35b9cf..6d2ee733 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift @@ -67,6 +67,10 @@ public class Draft: NSManagedObject, Identifiable { lastModified = Date() } + public func addAttachment(_ attachment: DraftAttachment) { + attachments.add(attachment) + } + } extension Draft { diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift index d71fb082..e0d3f583 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -32,7 +32,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable { @NSManaged internal var fileType: String? @NSManaged public var id: UUID! - @NSManaged internal var draft: Draft + @NSManaged public var draft: Draft public var drawing: PKDrawing? { get { @@ -89,7 +89,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable { } extension DraftAttachment { - var type: AttachmentType { + public var type: AttachmentType { if let editedAttachmentKind { switch editedAttachmentKind { case .image: @@ -129,7 +129,7 @@ extension DraftAttachment { } } - enum AttachmentType { + public enum AttachmentType { case image, video, unknown } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift index ec044c21..2394fb04 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift @@ -7,9 +7,12 @@ import UIKit import SwiftUI +import InstanceFeatures +import Vision struct AttachmentCollectionViewCellView: View { let attachment: DraftAttachment? + @State private var recognizingText = false var body: some View { if let attachment { @@ -19,18 +22,111 @@ struct AttachmentCollectionViewCellView: View { RoundedSquare(cornerRadius: 5) .fill(.quaternary) } - .overlay(alignment: .bottom) { - AttachmentDescriptionLabel(attachment: attachment) + .overlay(alignment: .bottomLeading) { + if recognizingText { + ProgressView() + .progressViewStyle(.circular) + } else { + AttachmentDescriptionLabel(attachment: attachment) + } } .overlay(alignment: .topTrailing) { AttachmentRemoveButton(attachment: attachment) } .clipShape(RoundedSquare(cornerRadius: 5)) + // TODO: context menu preview? + .contextMenu { + if attachment.drawingData != nil { + EditDrawingButton(attachment: attachment) + } else if attachment.type == .image { + RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText) + } + } } } } +private struct RecognizeTextButton: View { + let attachment: DraftAttachment + @Binding var recognizingText: Bool + @State private var error: (any Error)? + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + var body: some View { + Button(action: self.recognizeText) { + Label("Recognize Text", systemImage: "doc.text.viewfinder") + } + .alertWithData("Text Recognition Failed", data: $error) { _ in + Button("OK") {} + } message: { error in + Text(error.localizedDescription) + } + } + + private func recognizeText() { + recognizingText = true + attachment.getData(features: instanceFeatures) { result in + switch result { + case .failure(let error): + self.recognizingText = false + self.error = error + case .success(let (data, _)): + let handler = VNImageRequestHandler(data: data) + let request = VNRecognizeTextRequest { request, error in + DispatchQueue.main.async { + if let results = request.results as? [VNRecognizedTextObservation] { + var text = "" + for observation in results { + let result = observation.topCandidates(1).first! + text.append(result.string) + text.append("\n") + } + self.attachment.attachmentDescription = text + } + self.recognizingText = false + } + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch let error as NSError where error.code == 1 { + // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. + return + } catch { + DispatchQueue.main.async { + self.recognizingText = false + self.error = error + } + } + } + } + } + } +} + +private struct EditDrawingButton: View { + let attachment: DraftAttachment + @Environment(\.composeUIConfig.presentDrawing) private var presentDrawing + + var body: some View { + Button(action: self.editDrawing) { + Label("Edit Drawing", systemImage: "hand.draw") + } + } + + private func editDrawing() { + guard let drawing = attachment.drawing else { + return + } + presentDrawing?(drawing) { drawing in + self.attachment.drawing = drawing + } + } +} + private struct AttachmentRemoveButton: View { let attachment: DraftAttachment diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index a2b926c8..4209b774 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreData import Pachyderm +import TuskerComponents // State owned by the compose UI but that needs to be accessible from outside. public final class ComposeViewState: ObservableObject { @@ -51,6 +52,7 @@ public struct ComposeView: View { ) .environment(\.composeUIConfig, config) .environment(\.currentAccount, currentAccount) + .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) } private func setDraft(_ draft: Draft) { @@ -64,6 +66,13 @@ public struct ComposeView: View { } DraftsPersistentContainer.shared.save() } + + private func managedObjectsDidChange(_ notification: Foundation.Notification) { + if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set, + deleted.contains(where: { $0.objectID == state.draft.objectID }) { + config.dismiss(.cancel) + } + } } // TODO: see if this can be broken up further @@ -93,6 +102,11 @@ private struct ComposeViewBody: View { } ) } + .alertWithData("Error Posting", data: $postError, actions: { _ in + Button("OK") {} + }, message: { error in + Text(error.localizedDescription) + }) .onDisappear(perform: self.deleteOrSaveDraft) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift index c1efc81b..742479e6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import Pachyderm +import TuskerPreferences struct NewMainTextView: View { static var minHeight: CGFloat { 150 } @@ -37,8 +39,9 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { @Environment(\.composeUIConfig.fillColor) private var fillColor @Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard @Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning + @PreferenceObserving(\.$statusContentType) private var statusContentType - func makeUIView(context: Context) -> UITextView { + func makeUIView(context: Context) -> WrappedTextView { // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary let view = if #available(iOS 16.0, *) { WrappedTextView(usingTextLayoutManager: true) @@ -79,6 +82,8 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { uiView.isEditable = isEnabled uiView.keyboardType = useTwitterKeyboard ? .twitter : .default + + uiView.contentType = statusContentType // #if !os(visionOS) // uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground // #endif @@ -247,6 +252,35 @@ extension WrappedTextViewCoordinator: UITextViewDelegate { func textViewDidChangeSelection(_ textView: UITextView) { } + + func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + guard let textView = textView as? WrappedTextView else { + return nil + } + var actions = suggestedActions + if textView.contentType != .plain, + let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { + if range.length > 0 { + let formatMenu = suggestedActions[index] as! UIMenu + let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in + return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { _ in + // TODO + } + }) + actions[index] = newFormatMenu + } else { + actions.remove(at: index) + } + } + // TODO +// if range.length == 0 { +// actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in +// self?.controller.shouldEmojiAutocompletionBeginExpanded = true +// self?.beginAutocompletingEmoji() +// })) +// } + return UIMenu(children: actions) + } } //extension WrappedTextViewCoordinator: ComposeInput { @@ -269,6 +303,35 @@ extension WrappedTextViewCoordinator: UIDropInteractionDelegate { } private final class WrappedTextView: UITextView { + var contentType: StatusContentType = .plain + + private static var formattingActions: [Selector] { + [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if Self.formattingActions.contains(action) { + return contentType != .plain + } + return super.canPerformAction(action, withSender: sender) + } + + override func toggleBoldface(_ sender: Any?) { + // TODO + } + + override func toggleItalics(_ sender: Any?) { + // TODO + } + + override func validate(_ command: UICommand) { + super.validate(command) + + if Self.formattingActions.contains(command.action), + contentType != .plain { + command.attributes.remove(.disabled) + } + } } private extension NSAttributedString.Key { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index a6d51583..1841295b 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -120,13 +120,35 @@ class ComposeHostingController: UIHostingController Bool { -// return controller.canPaste(itemProviders: itemProviders) - return false - // TODO: pasting + guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else { + return false + } + if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + let existing = state.draft.draftAttachments + if existing.allSatisfy({ $0.type == .image }) { + // if providers are videos, this technically allows invalid video/image combinations + return itemProviders.count + existing.count <= 4 + } else { + return false + } + } else { + return true + } } override func paste(itemProviders: [NSItemProvider]) { -// controller.paste(itemProviders: itemProviders) + 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 { + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = self.state.draft + self.state.draft.addAttachment(attachment) + } + } + } } private func dismiss(mode: DismissMode) {