Compare commits
5 Commits
96f5ea8af1
...
56a408355d
Author | SHA1 | Date | |
---|---|---|---|
56a408355d | |||
1d81510899 | |||
1be3cb77b6 | |||
042110ec5e | |||
a2ffe1bbf1 |
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,223 +0,0 @@
|
|||||||
//
|
|
||||||
// AttachmentRowController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/12/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import TuskerComponents
|
|
||||||
import Vision
|
|
||||||
import MatchedGeometryPresentation
|
|
||||||
|
|
||||||
class AttachmentRowController: ViewController {
|
|
||||||
let parent: ComposeController
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
|
||||||
@Published var textRecognitionError: Error?
|
|
||||||
@Published var focusAttachmentOnTextEditorUnfocus = false
|
|
||||||
|
|
||||||
let thumbnailController: AttachmentThumbnailController
|
|
||||||
|
|
||||||
private var descriptionObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
|
||||||
self.parent = parent
|
|
||||||
self.attachment = attachment
|
|
||||||
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
|
|
||||||
|
|
||||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
|
||||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
|
||||||
if attachment.faultingState == 0 {
|
|
||||||
self.updateAttachmentDescriptionState()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAttachmentDescriptionState() {
|
|
||||||
if attachment.attachmentDescription.isEmpty {
|
|
||||||
parent.attachmentsMissingDescriptions.insert(attachment.id)
|
|
||||||
} else {
|
|
||||||
parent.attachmentsMissingDescriptions.remove(attachment.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AttachmentView(attachment: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeAttachment() {
|
|
||||||
withAnimation {
|
|
||||||
var newAttachments = parent.draft.draftAttachments
|
|
||||||
newAttachments.removeAll(where: { $0.id == attachment.id })
|
|
||||||
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func editDrawing() {
|
|
||||||
guard case .drawing(let drawing) = attachment.data else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parent.config.presentDrawing?(drawing) { newDrawing in
|
|
||||||
self.attachment.drawing = newDrawing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func focusAttachment() {
|
|
||||||
focusAttachmentOnTextEditorUnfocus = false
|
|
||||||
parent.focusedAttachment = (attachment, thumbnailController)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recognizeText() {
|
|
||||||
descriptionMode = .recognizingText
|
|
||||||
|
|
||||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let data: Data
|
|
||||||
switch result {
|
|
||||||
case .success((let d, _)):
|
|
||||||
data = d
|
|
||||||
case .failure(let error):
|
|
||||||
self.descriptionMode = .allowEntry
|
|
||||||
self.textRecognitionError = error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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.descriptionMode = .allowEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.descriptionMode = .allowEntry
|
|
||||||
self.textRecognitionError = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentView: View {
|
|
||||||
@ObservedObject private var attachment: DraftAttachment
|
|
||||||
@EnvironmentObject private var controller: AttachmentRowController
|
|
||||||
@FocusState private var textEditorFocused: Bool
|
|
||||||
|
|
||||||
init(attachment: DraftAttachment) {
|
|
||||||
self.attachment = attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
|
|
||||||
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
|
|
||||||
.overlay {
|
|
||||||
thumbnailFocusedOverlay
|
|
||||||
}
|
|
||||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
|
||||||
.onTapGesture {
|
|
||||||
textEditorFocused = false
|
|
||||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
|
||||||
controller.focusAttachmentOnTextEditorUnfocus = true
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
if attachment.drawingData != nil {
|
|
||||||
Button(action: controller.editDrawing) {
|
|
||||||
Label("Edit Drawing", systemImage: "hand.draw")
|
|
||||||
}
|
|
||||||
} else if attachment.type == .image {
|
|
||||||
Button(action: controller.recognizeText) {
|
|
||||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive, action: controller.removeAttachment) {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
} previewIfAvailable: {
|
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
|
||||||
}
|
|
||||||
|
|
||||||
switch controller.descriptionMode {
|
|
||||||
case .allowEntry:
|
|
||||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
|
||||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
|
||||||
.focused($textEditorFocused)
|
|
||||||
|
|
||||||
case .recognizingText:
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
|
|
||||||
Button("OK") {}
|
|
||||||
} message: { error in
|
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: textEditorFocused) {
|
|
||||||
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
|
|
||||||
controller.focusAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: textEditorFocused) { newValue in
|
|
||||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
|
||||||
controller.focusAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var thumbnailSize: CGFloat {
|
|
||||||
#if os(visionOS)
|
|
||||||
120
|
|
||||||
#else
|
|
||||||
80
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var thumbnailFocusedOverlay: some View {
|
|
||||||
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.black.opacity(0.35))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
|
|
||||||
.opacity(textEditorFocused ? 1 : 0)
|
|
||||||
.animation(.linear(duration: 0.1), value: textEditorFocused)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AttachmentRowController {
|
|
||||||
enum DescriptionMode {
|
|
||||||
case allowEntry, recognizingText
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,201 +0,0 @@
|
|||||||
//
|
|
||||||
// AttachmentThumbnailController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/10/21.
|
|
||||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Photos
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AttachmentThumbnailController: ViewController {
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
@Published private var image: UIImage?
|
|
||||||
@Published private var gifController: GIFController?
|
|
||||||
@Published private var fullSize: Bool = false
|
|
||||||
|
|
||||||
init(attachment: DraftAttachment, parent: ComposeController) {
|
|
||||||
self.attachment = attachment
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadImageIfNecessary(fullSize: Bool) {
|
|
||||||
if (gifController != nil) || (image != nil && self.fullSize) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.fullSize = fullSize
|
|
||||||
|
|
||||||
switch attachment.data {
|
|
||||||
case .editing(_, let kind, let url):
|
|
||||||
switch kind {
|
|
||||||
case .image:
|
|
||||||
Task { @MainActor in
|
|
||||||
self.image = await parent.fetchAttachment(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .video, .gifv:
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
|
||||||
imageGenerator.appliesPreferredTrackTransform = true
|
|
||||||
#if os(visionOS)
|
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
||||||
#else
|
|
||||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
|
||||||
self.image = UIImage(cgImage: cgImage)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
case .audio, .unknown:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case .asset(let id):
|
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
|
||||||
if isGIF {
|
|
||||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
|
||||||
guard let data else { return }
|
|
||||||
if typeIdentifier == UTType.gif.identifier {
|
|
||||||
self.gifController = GIFController(gifData: data)
|
|
||||||
} else {
|
|
||||||
let image = UIImage(data: data)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let size: CGSize
|
|
||||||
if fullSize {
|
|
||||||
size = PHImageManagerMaximumSize
|
|
||||||
} else {
|
|
||||||
// currently only used as thumbnail in ComposeAttachmentRow
|
|
||||||
size = CGSize(width: 80, height: 80)
|
|
||||||
}
|
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .drawing(let drawing):
|
|
||||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
|
||||||
|
|
||||||
case .file(let url, let type):
|
|
||||||
if type.conforms(to: .movie) {
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
|
||||||
imageGenerator.appliesPreferredTrackTransform = true
|
|
||||||
#if os(visionOS)
|
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
||||||
#else
|
|
||||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
|
||||||
self.image = UIImage(cgImage: cgImage)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} else if let data = try? Data(contentsOf: url) {
|
|
||||||
if type == .gif {
|
|
||||||
self.gifController = GIFController(gifData: data)
|
|
||||||
} else if type.conforms(to: .image),
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
|
||||||
// crashing share extension. see FB12186346
|
|
||||||
// if fullSize {
|
|
||||||
image.prepareForDisplay { prepared in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// } else {
|
|
||||||
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.image = prepared
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .none:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some SwiftUI.View {
|
|
||||||
View()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct View: SwiftUI.View {
|
|
||||||
@EnvironmentObject private var controller: AttachmentThumbnailController
|
|
||||||
@Environment(\.attachmentThumbnailConfiguration) private var config
|
|
||||||
|
|
||||||
var body: some SwiftUI.View {
|
|
||||||
content
|
|
||||||
.onAppear {
|
|
||||||
controller.loadImageIfNecessary(fullSize: config.fullSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var content: some SwiftUI.View {
|
|
||||||
if let gifController = controller.gifController {
|
|
||||||
GIFViewWrapper(controller: gifController)
|
|
||||||
} else if let image = controller.image {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentThumbnailConfiguration {
|
|
||||||
let aspectRatio: CGFloat?
|
|
||||||
let contentMode: ContentMode
|
|
||||||
let fullSize: Bool
|
|
||||||
|
|
||||||
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
|
|
||||||
self.aspectRatio = aspectRatio
|
|
||||||
self.contentMode = contentMode
|
|
||||||
self.fullSize = fullSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue = AttachmentThumbnailConfiguration()
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
|
|
||||||
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
|
|
||||||
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GIFViewWrapper: UIViewRepresentable {
|
|
||||||
typealias UIViewType = GIFImageView
|
|
||||||
|
|
||||||
@State var controller: GIFController
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> GIFImageView {
|
|
||||||
let view = GIFImageView()
|
|
||||||
controller.attach(to: view)
|
|
||||||
controller.startAnimating()
|
|
||||||
view.contentMode = .scaleAspectFit
|
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,262 +0,0 @@
|
|||||||
//
|
|
||||||
// AttachmentsListController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/8/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import PhotosUI
|
|
||||||
import PencilKit
|
|
||||||
|
|
||||||
class AttachmentsListController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
var draft: Draft { parent.draft }
|
|
||||||
|
|
||||||
var isValid: Bool {
|
|
||||||
!requiresAttachmentDescriptions && validAttachmentCombination
|
|
||||||
}
|
|
||||||
|
|
||||||
private var requiresAttachmentDescriptions: Bool {
|
|
||||||
if parent.config.requireAttachmentDescriptions {
|
|
||||||
if draft.attachments.count == 0 {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return !parent.attachmentsMissingDescriptions.isEmpty
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var validAttachmentCombination: Bool {
|
|
||||||
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
|
||||||
return true
|
|
||||||
} else if draft.attachments.count > 1,
|
|
||||||
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
|
||||||
return false
|
|
||||||
} else if draft.attachments.count > 4 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
init(parent: ComposeController) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
var canAddAttachment: Bool {
|
|
||||||
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
|
||||||
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canAddPoll: Bool {
|
|
||||||
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return draft.attachments.count == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AttachmentsList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
|
||||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
|
||||||
// results in the order switching back to the previous order and then to the correct one
|
|
||||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
|
||||||
var array = draft.draftAttachments
|
|
||||||
array.move(fromOffsets: source, toOffset: destination)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteAttachments(at indices: IndexSet) {
|
|
||||||
var array = draft.draftAttachments
|
|
||||||
array.remove(atOffsets: indices)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func insertAttachments(at offset: Int, 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 { [weak self] in
|
|
||||||
guard let self,
|
|
||||||
self.canAddAttachment else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
|
||||||
attachment.draft = self.draft
|
|
||||||
self.draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addImage() {
|
|
||||||
parent.deleteDraftOnDisappear = false
|
|
||||||
parent.config.presentAssetPicker?({ results in
|
|
||||||
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addDrawing() {
|
|
||||||
parent.deleteDraftOnDisappear = false
|
|
||||||
parent.config.presentDrawing?(PKDrawing()) { drawing in
|
|
||||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
|
||||||
attachment.id = UUID()
|
|
||||||
attachment.drawing = drawing
|
|
||||||
attachment.draft = self.draft
|
|
||||||
self.draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func togglePoll() {
|
|
||||||
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
||||||
|
|
||||||
withAnimation {
|
|
||||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentsList: View {
|
|
||||||
private let cellHeight: CGFloat = 80
|
|
||||||
private let cellPadding: CGFloat = 12
|
|
||||||
|
|
||||||
@EnvironmentObject private var controller: AttachmentsListController
|
|
||||||
@EnvironmentObject private var draft: Draft
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
attachmentsList
|
|
||||||
|
|
||||||
Group {
|
|
||||||
if controller.parent.config.presentAssetPicker != nil {
|
|
||||||
addImageButton
|
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
if controller.parent.config.presentDrawing != nil {
|
|
||||||
addDrawingButton
|
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePollButton
|
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
|
||||||
}
|
|
||||||
#if os(visionOS)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.labelStyle(AttachmentButtonLabelStyle())
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var attachmentsList: some View {
|
|
||||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
|
||||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
|
||||||
.id(attachment.id)
|
|
||||||
}
|
|
||||||
.onMove(perform: controller.moveAttachments)
|
|
||||||
.onDelete(perform: controller.deleteAttachments)
|
|
||||||
.conditionally(controller.canAddAttachment) {
|
|
||||||
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
|
|
||||||
controller.insertAttachments(at: offset, itemProviders: providers)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// only sort of works, see #240
|
|
||||||
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
|
|
||||||
controller.insertAttachments(at: 0, itemProviders: providers)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var addImageButton: some View {
|
|
||||||
Button(action: controller.addImage) {
|
|
||||||
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
|
|
||||||
}
|
|
||||||
.disabled(!controller.canAddAttachment)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.frame(height: cellHeight / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var addDrawingButton: some View {
|
|
||||||
Button(action: controller.addDrawing) {
|
|
||||||
Label("Draw something", systemImage: "hand.draw")
|
|
||||||
}
|
|
||||||
.disabled(!controller.canAddAttachment)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.frame(height: cellHeight / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var togglePollButton: some View {
|
|
||||||
Button(action: controller.togglePoll) {
|
|
||||||
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
|
|
||||||
}
|
|
||||||
.disabled(!controller.canAddPoll)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.frame(height: cellHeight / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
|
|
||||||
if condition {
|
|
||||||
body(self)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
|
|
||||||
} else {
|
|
||||||
self.popover(isPresented: isPresented, content: content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func withSheetDetentsIfAvailable() -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
@ViewBuilder let view: () -> V
|
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) var sizeClass
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if sizeClass == .compact {
|
|
||||||
content.sheet(isPresented: $isPresented, content: view)
|
|
||||||
} else {
|
|
||||||
content.popover(isPresented: $isPresented, content: view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
|
||||||
struct AttachmentButtonLabelStyle: LabelStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,83 +0,0 @@
|
|||||||
//
|
|
||||||
// AutocompleteController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/25/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
class AutocompleteController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
|
|
||||||
@Published var mode: Mode?
|
|
||||||
|
|
||||||
init(parent: ComposeController) {
|
|
||||||
self.parent = parent
|
|
||||||
|
|
||||||
parent.$currentInput
|
|
||||||
.compactMap { $0 }
|
|
||||||
.flatMap { $0.autocompleteStatePublisher }
|
|
||||||
.map {
|
|
||||||
switch $0 {
|
|
||||||
case .mention(_):
|
|
||||||
return Mode.mention
|
|
||||||
case .emoji(_):
|
|
||||||
return Mode.emoji
|
|
||||||
case .hashtag(_):
|
|
||||||
return Mode.hashtag
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.assign(to: &$mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AutocompleteView()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AutocompleteView: View {
|
|
||||||
@EnvironmentObject private var parent: ComposeController
|
|
||||||
@EnvironmentObject private var controller: AutocompleteController
|
|
||||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let mode = controller.mode {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Divider()
|
|
||||||
suggestionsView(mode: mode)
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func suggestionsView(mode: Mode) -> some View {
|
|
||||||
switch mode {
|
|
||||||
case .mention:
|
|
||||||
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
|
|
||||||
case .emoji:
|
|
||||||
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
|
|
||||||
case .hashtag:
|
|
||||||
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
|
||||||
Color(white: colorScheme == .light ? 0.98 : 0.15)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var borderColor: Color {
|
|
||||||
Color(white: colorScheme == .light ? 0.85 : 0.25)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Mode {
|
|
||||||
case mention
|
|
||||||
case emoji
|
|
||||||
case hashtag
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,188 +0,0 @@
|
|||||||
//
|
|
||||||
// AutocompleteEmojisController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/26/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import Combine
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AutocompleteEmojisController: ViewController {
|
|
||||||
unowned let composeController: ComposeController
|
|
||||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
|
||||||
|
|
||||||
private var stateCancellable: AnyCancellable?
|
|
||||||
private var searchTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
@Published var expanded = false
|
|
||||||
@Published var emojis: [Emoji] = []
|
|
||||||
@Published var emojisBySection: [String: [Emoji]] = [:]
|
|
||||||
|
|
||||||
init(composeController: ComposeController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
|
|
||||||
stateCancellable = composeController.$currentInput
|
|
||||||
.compactMap { $0 }
|
|
||||||
.flatMap { $0.autocompleteStatePublisher }
|
|
||||||
.compactMap {
|
|
||||||
if case .emoji(let s) = $0 {
|
|
||||||
return s
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.removeDuplicates()
|
|
||||||
.sink { [unowned self] query in
|
|
||||||
self.searchTask?.cancel()
|
|
||||||
self.searchTask = Task { [weak self] in
|
|
||||||
await self?.queryChanged(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func queryChanged(_ query: String) async {
|
|
||||||
var emojis = await composeController.mastodonController.getCustomEmojis()
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !query.isEmpty {
|
|
||||||
emojis =
|
|
||||||
emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
|
||||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
|
||||||
}
|
|
||||||
.filter(\.1.matched)
|
|
||||||
.sorted { $0.1.score > $1.1.score }
|
|
||||||
.map(\.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var shortcodes = Set<String>()
|
|
||||||
var newEmojis = [Emoji]()
|
|
||||||
var newEmojisBySection = [String: [Emoji]]()
|
|
||||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
|
||||||
newEmojis.append(emoji)
|
|
||||||
shortcodes.insert(emoji.shortcode)
|
|
||||||
|
|
||||||
let category = emoji.category ?? ""
|
|
||||||
if newEmojisBySection.keys.contains(category) {
|
|
||||||
newEmojisBySection[category]!.append(emoji)
|
|
||||||
} else {
|
|
||||||
newEmojisBySection[category] = [emoji]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.emojis = newEmojis
|
|
||||||
self.emojisBySection = newEmojisBySection
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toggleExpanded() {
|
|
||||||
withAnimation {
|
|
||||||
expanded.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func autocomplete(with emoji: Emoji) {
|
|
||||||
guard let input = composeController.currentInput else { return }
|
|
||||||
input.autocomplete(with: ":\(emoji.shortcode):")
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AutocompleteEmojisView()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AutocompleteEmojisView: View {
|
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
|
||||||
@EnvironmentObject private var controller: AutocompleteEmojisController
|
|
||||||
@ScaledMetric private var emojiSize = 30
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
|
||||||
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
|
|
||||||
emojiList
|
|
||||||
.transition(.move(edge: .bottom))
|
|
||||||
|
|
||||||
toggleExpandedButton
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
.padding(.top, controller.expanded ? 8 : 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var emojiList: some View {
|
|
||||||
if controller.expanded {
|
|
||||||
verticalGrid
|
|
||||||
.frame(height: 150)
|
|
||||||
} else {
|
|
||||||
horizontalScrollView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var verticalGrid: some View {
|
|
||||||
ScrollView {
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
|
||||||
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
|
|
||||||
Section {
|
|
||||||
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
|
|
||||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
|
||||||
composeController.emojiImageView(emoji)
|
|
||||||
.frame(height: emojiSize)
|
|
||||||
}
|
|
||||||
.accessibilityLabel(emoji.shortcode)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
if !section.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(section)
|
|
||||||
.font(.caption)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.all, 8)
|
|
||||||
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
|
|
||||||
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var horizontalScrollView: some View {
|
|
||||||
ScrollView(.horizontal) {
|
|
||||||
LazyHStack(spacing: 8) {
|
|
||||||
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
|
||||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
composeController.emojiImageView(emoji)
|
|
||||||
.frame(height: emojiSize)
|
|
||||||
Text(verbatim: ":\(emoji.shortcode):")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityLabel(emoji.shortcode)
|
|
||||||
.frame(height: emojiSize)
|
|
||||||
}
|
|
||||||
.animation(.linear(duration: 0.2), value: controller.emojis)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.frame(height: emojiSize + 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var toggleExpandedButton: some View {
|
|
||||||
Button(action: controller.toggleExpanded) {
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.rotationEffect(controller.expanded ? .zero : .degrees(180))
|
|
||||||
}
|
|
||||||
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,125 +0,0 @@
|
|||||||
//
|
|
||||||
// AutocompleteHashtagsController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/1/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AutocompleteHashtagsController: ViewController {
|
|
||||||
unowned let composeController: ComposeController
|
|
||||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
|
||||||
|
|
||||||
private var stateCancellable: AnyCancellable?
|
|
||||||
private var searchTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
@Published var hashtags: [Hashtag] = []
|
|
||||||
|
|
||||||
init(composeController: ComposeController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
|
|
||||||
stateCancellable = composeController.$currentInput
|
|
||||||
.compactMap { $0 }
|
|
||||||
.flatMap { $0.autocompleteStatePublisher }
|
|
||||||
.compactMap {
|
|
||||||
if case .hashtag(let s) = $0 {
|
|
||||||
return s
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
|
||||||
.sink { [unowned self] query in
|
|
||||||
self.searchTask?.cancel()
|
|
||||||
self.searchTask = Task { [weak self] in
|
|
||||||
await self?.queryChanged(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func queryChanged(_ query: String) async {
|
|
||||||
guard !query.isEmpty else {
|
|
||||||
hashtags = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let localHashtags = mastodonController.searchCachedHashtags(query: query)
|
|
||||||
|
|
||||||
var onlyLocalTagsTask: Task<Void, any Error>?
|
|
||||||
if !localHashtags.isEmpty {
|
|
||||||
onlyLocalTagsTask = Task {
|
|
||||||
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
|
|
||||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
|
||||||
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
|
|
||||||
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
|
|
||||||
|
|
||||||
let trends = await trendingTags ?? []
|
|
||||||
let search = await searchResults ?? []
|
|
||||||
|
|
||||||
onlyLocalTagsTask?.cancel()
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
|
|
||||||
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
|
|
||||||
var addedHashtags = Set<String>()
|
|
||||||
var hashtags = [(Hashtag, Int)]()
|
|
||||||
for group in [searchResults, trendingTags, localHashtags] {
|
|
||||||
for tag in group where !addedHashtags.contains(tag.name) {
|
|
||||||
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
|
|
||||||
if matched {
|
|
||||||
hashtags.append((tag, score))
|
|
||||||
addedHashtags.insert(tag.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.hashtags = hashtags
|
|
||||||
.sorted { $0.1 > $1.1 }
|
|
||||||
.map(\.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func autocomplete(with hashtag: Hashtag) {
|
|
||||||
guard let currentInput = composeController.currentInput else { return }
|
|
||||||
currentInput.autocomplete(with: "#\(hashtag.name)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AutocompleteHashtagsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AutocompleteHashtagsView: View {
|
|
||||||
@EnvironmentObject private var controller: AutocompleteHashtagsController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(.horizontal) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(controller.hashtags, id: \.name) { hashtag in
|
|
||||||
Button(action: { controller.autocomplete(with: hashtag) }) {
|
|
||||||
Text(verbatim: "#\(hashtag.name)")
|
|
||||||
.foregroundColor(Color(uiColor: .label))
|
|
||||||
}
|
|
||||||
.frame(height: 30)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.animation(.linear(duration: 0.2), value: controller.hashtags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -1,179 +0,0 @@
|
|||||||
//
|
|
||||||
// AutocompleteMentionsController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/25/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AutocompleteMentionsController: ViewController {
|
|
||||||
|
|
||||||
unowned let composeController: ComposeController
|
|
||||||
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
|
||||||
|
|
||||||
private var stateCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
@Published private var accounts: [AnyAccount] = []
|
|
||||||
private var searchTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(composeController: ComposeController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
|
|
||||||
stateCancellable = composeController.$currentInput
|
|
||||||
.compactMap { $0 }
|
|
||||||
.flatMap { $0.autocompleteStatePublisher }
|
|
||||||
.compactMap {
|
|
||||||
if case .mention(let s) = $0 {
|
|
||||||
return s
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
|
||||||
.sink { [unowned self] query in
|
|
||||||
self.searchTask?.cancel()
|
|
||||||
// weak in case the autocomplete controller is dealloc'd racing with the task starting
|
|
||||||
self.searchTask = Task { [weak self] in
|
|
||||||
await self?.queryChanged(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func queryChanged(_ query: String) async {
|
|
||||||
guard !query.isEmpty else {
|
|
||||||
accounts = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let localSearchTask = Task {
|
|
||||||
// we only want to search locally if the search API call takes more than .25sec or it fails
|
|
||||||
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
|
||||||
|
|
||||||
let results = self.mastodonController.searchCachedAccounts(query: query)
|
|
||||||
try Task.checkCancellation()
|
|
||||||
|
|
||||||
if !results.isEmpty {
|
|
||||||
self.loadAccounts(results.map { .init(value: $0) }, query: query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
|
|
||||||
guard let accounts,
|
|
||||||
!Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
localSearchTask.cancel()
|
|
||||||
|
|
||||||
loadAccounts(accounts.map { .init(value: $0) }, query: query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
|
|
||||||
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
|
|
||||||
let ignoreDomain = !query.contains("@")
|
|
||||||
|
|
||||||
self.accounts =
|
|
||||||
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
|
||||||
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
|
||||||
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
.filter(\.1.matched)
|
|
||||||
.map { (account, res) -> (AnyAccount, Int) in
|
|
||||||
// give higher weight to accounts that the user follows or is followed by
|
|
||||||
var score = res.score
|
|
||||||
if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
|
|
||||||
if relationship.following {
|
|
||||||
score += 3
|
|
||||||
}
|
|
||||||
if relationship.followedBy {
|
|
||||||
score += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (account, score)
|
|
||||||
}
|
|
||||||
.sorted { $0.1 > $1.1 }
|
|
||||||
.map(\.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func autocomplete(with account: AnyAccount) {
|
|
||||||
guard let input = composeController.currentInput else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.autocomplete(with: "@\(account.value.acct)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
AutocompleteMentionsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AutocompleteMentionsView: View {
|
|
||||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(.horizontal) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(controller.accounts) { account in
|
|
||||||
AutocompleteMentionButton(account: account)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.animation(.linear(duration: 0.2), value: controller.accounts)
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
controller.searchTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AutocompleteMentionButton: View {
|
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
|
||||||
@EnvironmentObject private var controller: AutocompleteMentionsController
|
|
||||||
let account: AnyAccount
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: { controller.autocomplete(with: account) }) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
AvatarImageView(
|
|
||||||
url: account.value.avatar,
|
|
||||||
size: 30,
|
|
||||||
style: composeController.config.avatarStyle,
|
|
||||||
fetchAvatar: composeController.fetchAvatar
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Text(verbatim: "@\(account.value.acct)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 30)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct AnyAccount: Equatable, Identifiable {
|
|
||||||
let value: any AccountProtocol
|
|
||||||
|
|
||||||
var id: String { value.id }
|
|
||||||
|
|
||||||
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
|
||||||
return lhs.value.id == rhs.value.id
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,525 +0,0 @@
|
|||||||
//
|
|
||||||
// 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.pollEnabled || 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 {
|
|
||||||
oldDraft.lastModified = Date()
|
|
||||||
} else 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)
|
|
||||||
} else {
|
|
||||||
draft.lastModified = Date()
|
|
||||||
}
|
|
||||||
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
|
|
||||||
#if !os(visionOS)
|
|
||||||
@StateObject private var keyboardReader = KeyboardReader()
|
|
||||||
#endif
|
|
||||||
@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)
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
ControllerView(controller: { controller.toolbarController })
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if !os(visionOS)
|
|
||||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
|
||||||
.padding(.bottom, keyboardInset)
|
|
||||||
#endif
|
|
||||||
.transition(.move(edge: .bottom))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
|
||||||
#else
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
|
||||||
#endif
|
|
||||||
#if os(visionOS)
|
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
|
||||||
ControllerView(controller: { controller.toolbarController })
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
#if !os(visionOS)
|
|
||||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
|
||||||
#endif
|
|
||||||
.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 postOrDraftsButton: some View {
|
|
||||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
|
||||||
postButton
|
|
||||||
} else {
|
|
||||||
draftsButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var draftsButton: some View {
|
|
||||||
Button(action: controller.showDrafts) {
|
|
||||||
Text("Drafts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var postButton: some View {
|
|
||||||
Button(action: controller.postStatus) {
|
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
|
||||||
.disabled(!controller.postButtonEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,174 +0,0 @@
|
|||||||
//
|
|
||||||
// DraftsController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/7/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import TuskerComponents
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
class DraftsController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
|
|
||||||
@Published var draftForDifferentReply: Draft?
|
|
||||||
|
|
||||||
init(parent: ComposeController, isPresented: Binding<Bool>) {
|
|
||||||
self.parent = parent
|
|
||||||
self._isPresented = isPresented
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
DraftsRepresentable()
|
|
||||||
}
|
|
||||||
|
|
||||||
func maybeSelectDraft(_ draft: Draft) {
|
|
||||||
if draft.inReplyToID != parent.draft.inReplyToID,
|
|
||||||
parent.draft.hasContent {
|
|
||||||
draftForDifferentReply = draft
|
|
||||||
} else {
|
|
||||||
confirmSelectDraft(draft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelSelectingDraft() {
|
|
||||||
draftForDifferentReply = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmSelectDraft(_ draft: Draft) {
|
|
||||||
parent.selectDraft(draft)
|
|
||||||
closeDrafts()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteDraft(_ draft: Draft) {
|
|
||||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeDrafts() {
|
|
||||||
isPresented = false
|
|
||||||
DraftsPersistentContainer.shared.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
|
||||||
typealias UIViewControllerType = UIHostingController<DraftsView>
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
|
|
||||||
return UIHostingController(rootView: DraftsView())
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DraftsView: View {
|
|
||||||
@EnvironmentObject private var controller: DraftsController
|
|
||||||
@EnvironmentObject private var currentDraft: Draft
|
|
||||||
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
List {
|
|
||||||
ForEach(drafts) { draft in
|
|
||||||
Button(action: { controller.maybeSelectDraft(draft) }) {
|
|
||||||
DraftRow(draft: draft)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
|
|
||||||
Label("Delete Draft", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
|
|
||||||
view.onDrag { activity }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.onDelete { indices in
|
|
||||||
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.navigationTitle("Drafts")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
|
|
||||||
Button(role: .cancel, action: controller.cancelSelectingDraft) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
Button(action: { controller.confirmSelectDraft(draft) }) {
|
|
||||||
Text("Restore Draft")
|
|
||||||
}
|
|
||||||
} message: { _ in
|
|
||||||
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cancelButton: some View {
|
|
||||||
Button(action: controller.closeDrafts) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DraftRow: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject private var controller: DraftsController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
if draft.editedStatusID != nil {
|
|
||||||
// shouldn't happen unless the app crashed/was killed during an edit
|
|
||||||
Text("Edit")
|
|
||||||
.font(.body.bold())
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
if draft.contentWarningEnabled {
|
|
||||||
Text(draft.contentWarning)
|
|
||||||
.font(.body.bold())
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(draft.text)
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(draft.draftAttachments) { attachment in
|
|
||||||
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
||||||
.frame(height: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if let lastModified = draft.lastModified {
|
|
||||||
Text(lastModified.formatted(.abbreviatedTimeAgo))
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
|
|
||||||
if let value {
|
|
||||||
modify(self, value)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,122 +0,0 @@
|
|||||||
//
|
|
||||||
// FocusedAttachmentController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/29/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import MatchedGeometryPresentation
|
|
||||||
import AVKit
|
|
||||||
|
|
||||||
class FocusedAttachmentController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
let thumbnailController: AttachmentThumbnailController
|
|
||||||
private let player: AVPlayer?
|
|
||||||
|
|
||||||
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
|
|
||||||
self.parent = parent
|
|
||||||
self.attachment = attachment
|
|
||||||
self.thumbnailController = thumbnailController
|
|
||||||
|
|
||||||
if case let .file(url, type) = attachment.data,
|
|
||||||
type.conforms(to: .movie) {
|
|
||||||
self.player = AVPlayer(url: url)
|
|
||||||
self.player!.isMuted = true
|
|
||||||
} else {
|
|
||||||
self.player = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
FocusedAttachmentView(attachment: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FocusedAttachmentView: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
@EnvironmentObject private var controller: FocusedAttachmentController
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@FocusState private var textEditorFocused: Bool
|
|
||||||
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
if let player = controller.player {
|
|
||||||
VideoPlayer(player: player)
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
.onAppear {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
} else if #available(iOS 16.0, *) {
|
|
||||||
ZoomableScrollView {
|
|
||||||
attachmentView
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
attachmentView
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
FocusedAttachmentDescriptionView(attachment: attachment)
|
|
||||||
.environment(\.colorScheme, .dark)
|
|
||||||
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
|
|
||||||
.frame(height: 150)
|
|
||||||
.focused($textEditorFocused)
|
|
||||||
}
|
|
||||||
.background(.black)
|
|
||||||
.overlay(alignment: .topLeading, content: {
|
|
||||||
Button {
|
|
||||||
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
|
|
||||||
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
|
|
||||||
if textEditorFocused {
|
|
||||||
matchedGeomState.mode = .dismissing
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
|
|
||||||
}
|
|
||||||
.buttonStyle(DismissFocusedAttachmentButtonStyle())
|
|
||||||
.padding([.top, .leading], 4)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private var attachmentView: some View {
|
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
|
||||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(.black.opacity(0.5))
|
|
||||||
|
|
||||||
configuration.label
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.imageScale(.large)
|
|
||||||
}
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentDescriptionTextViewID: Hashable {
|
|
||||||
let attachmentID: UUID!
|
|
||||||
|
|
||||||
init(_ attachment: DraftAttachment) {
|
|
||||||
self.attachmentID = attachment.id
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(attachmentID)
|
|
||||||
hasher.combine("descriptionTextView")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
|||||||
//
|
|
||||||
// PollController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/25/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class PollController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
var draft: Draft { parent.draft }
|
|
||||||
let poll: Poll
|
|
||||||
|
|
||||||
@Published var duration: Duration
|
|
||||||
|
|
||||||
init(parent: ComposeController, poll: Poll) {
|
|
||||||
self.parent = parent
|
|
||||||
self.poll = poll
|
|
||||||
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
PollView()
|
|
||||||
.environmentObject(poll)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removePoll() {
|
|
||||||
withAnimation {
|
|
||||||
draft.poll = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
|
||||||
// see AttachmentsListController.moveAttachments
|
|
||||||
var array = poll.pollOptions
|
|
||||||
array.move(fromOffsets: indices, toOffset: newIndex)
|
|
||||||
poll.options = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeOption(_ option: PollOption) {
|
|
||||||
var array = poll.pollOptions
|
|
||||||
array.remove(at: poll.options.index(of: option))
|
|
||||||
poll.options = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canAddOption: Bool {
|
|
||||||
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
|
|
||||||
return poll.options.count < max
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addOption() {
|
|
||||||
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
|
||||||
option.poll = poll
|
|
||||||
poll.options.add(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PollView: View {
|
|
||||||
@EnvironmentObject private var controller: PollController
|
|
||||||
@EnvironmentObject private var poll: Poll
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Text("Poll")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: controller.removePoll) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.imageScale(.small)
|
|
||||||
.padding(4)
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Remove poll")
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accentColor(buttonForegroundColor)
|
|
||||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
|
||||||
.hoverEffect()
|
|
||||||
}
|
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach($poll.pollOptions) { $option in
|
|
||||||
PollOptionView(option: option, remove: { controller.removeOption(option) })
|
|
||||||
.frame(height: 36)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onMove(perform: controller.moveOptions)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollDisabledIfAvailable(true)
|
|
||||||
.frame(height: 44 * CGFloat(poll.options.count))
|
|
||||||
|
|
||||||
Button(action: controller.addOption) {
|
|
||||||
Label {
|
|
||||||
Text("Add Option")
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(!controller.canAddOption)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
MenuPicker(selection: $poll.multiple, options: [
|
|
||||||
.init(value: true, title: "Allow multiple"),
|
|
||||||
.init(value: false, title: "Single choice"),
|
|
||||||
])
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
|
|
||||||
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
||||||
.foregroundColor(backgroundColor)
|
|
||||||
)
|
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: controller.duration) {
|
|
||||||
poll.duration = controller.duration.timeInterval
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: controller.duration) { newValue in
|
|
||||||
poll.duration = newValue.timeInterval
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
|
||||||
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
|
|
||||||
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buttonForegroundColor: Color {
|
|
||||||
Color(uiColor: .label)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buttonBackgroundColor: Color {
|
|
||||||
Color(white: colorScheme == .dark ? 0.1 : 0.8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PollController {
|
|
||||||
enum Duration: Hashable, Equatable, CaseIterable {
|
|
||||||
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
|
|
||||||
|
|
||||||
static let formatter: DateComponentsFormatter = {
|
|
||||||
let f = DateComponentsFormatter()
|
|
||||||
f.maximumUnitCount = 1
|
|
||||||
f.unitsStyle = .full
|
|
||||||
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
|
|
||||||
for it in allCases where it.timeInterval == ti {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeInterval: TimeInterval {
|
|
||||||
switch self {
|
|
||||||
case .fiveMinutes:
|
|
||||||
return 5 * 60
|
|
||||||
case .thirtyMinutes:
|
|
||||||
return 30 * 60
|
|
||||||
case .oneHour:
|
|
||||||
return 60 * 60
|
|
||||||
case .sixHours:
|
|
||||||
return 6 * 60 * 60
|
|
||||||
case .oneDay:
|
|
||||||
return 24 * 60 * 60
|
|
||||||
case .threeDays:
|
|
||||||
return 3 * 24 * 60 * 60
|
|
||||||
case .sevenDays:
|
|
||||||
return 7 * 24 * 60 * 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,195 +0,0 @@
|
|||||||
//
|
|
||||||
// ToolbarController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/7/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class ToolbarController: ViewController {
|
|
||||||
static let height: CGFloat = 44
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
|
|
||||||
@Published var minWidth: CGFloat?
|
|
||||||
@Published var realWidth: CGFloat?
|
|
||||||
|
|
||||||
init(parent: ComposeController) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
ToolbarView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func showEmojiPicker() {
|
|
||||||
guard parent.currentInput?.autocompleteState == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parent.shouldEmojiAutocompletionBeginExpanded = true
|
|
||||||
parent.currentInput?.beginAutocompletingEmoji()
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAction(_ format: StatusFormat) -> () -> Void {
|
|
||||||
{ [weak self] in
|
|
||||||
self?.parent.currentInput?.applyFormat(format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ToolbarView: View {
|
|
||||||
@EnvironmentObject private var draft: Draft
|
|
||||||
@EnvironmentObject private var controller: ToolbarController
|
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
|
||||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
@State private var minWidth: CGFloat?
|
|
||||||
@State private var realWidth: CGFloat?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
#if os(visionOS)
|
|
||||||
buttons
|
|
||||||
#else
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
buttons
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.frame(minWidth: minWidth)
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
|
||||||
realWidth = width
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
|
||||||
.frame(height: ToolbarController.height)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Divider()
|
|
||||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
|
||||||
}
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
|
||||||
minWidth = width
|
|
||||||
}
|
|
||||||
})
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var buttons: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
cwButton
|
|
||||||
|
|
||||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
|
||||||
.disabled(draft.editedStatusID != nil)
|
|
||||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
|
||||||
localOnlyPicker
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
.padding(.leading, 4)
|
|
||||||
#endif
|
|
||||||
.disabled(draft.editedStatusID != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let currentInput = composeController.currentInput,
|
|
||||||
currentInput.toolbarElements.contains(.emojiPicker) {
|
|
||||||
customEmojiButton
|
|
||||||
}
|
|
||||||
|
|
||||||
if let currentInput = composeController.currentInput,
|
|
||||||
currentInput.toolbarElements.contains(.formattingButtons),
|
|
||||||
composeController.config.contentType != .plain {
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
formatButtons
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if #available(iOS 16.0, *),
|
|
||||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cwButton: some View {
|
|
||||||
Button("CW", action: controller.parent.toggleContentWarning)
|
|
||||||
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
|
||||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
|
||||||
// changing the visibility when local-only.
|
|
||||||
if draft.localOnly,
|
|
||||||
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
|
|
||||||
return .constant(.public)
|
|
||||||
} else {
|
|
||||||
return $draft.visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
|
||||||
let visibilities: [Pachyderm.Visibility]
|
|
||||||
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
|
|
||||||
visibilities = [.public, .unlisted, .private]
|
|
||||||
} else {
|
|
||||||
visibilities = Pachyderm.Visibility.allCases
|
|
||||||
}
|
|
||||||
return visibilities.map { vis in
|
|
||||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var localOnlyPicker: some View {
|
|
||||||
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
|
||||||
return MenuPicker(selection: $draft.localOnly, options: [
|
|
||||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
|
||||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
|
||||||
], buttonStyle: .iconOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var customEmojiButton: some View {
|
|
||||||
Button(action: controller.showEmojiPicker) {
|
|
||||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
|
||||||
}
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.font(.system(size: imageSize))
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var formatButtons: some View {
|
|
||||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
|
||||||
Button(action: controller.formatAction(format)) {
|
|
||||||
Image(systemName: format.imageName)
|
|
||||||
.font(.system(size: imageSize))
|
|
||||||
}
|
|
||||||
.accessibilityLabel(format.accessibilityLabel)
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ToolbarWidthPrefKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat? = nil
|
|
||||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
@ -67,6 +67,10 @@ public class Draft: NSManagedObject, Identifiable {
|
|||||||
lastModified = Date()
|
lastModified = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func addAttachment(_ attachment: DraftAttachment) {
|
||||||
|
attachments.add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Draft {
|
extension Draft {
|
||||||
|
@ -32,7 +32,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
|||||||
@NSManaged internal var fileType: String?
|
@NSManaged internal var fileType: String?
|
||||||
@NSManaged public var id: UUID!
|
@NSManaged public var id: UUID!
|
||||||
|
|
||||||
@NSManaged internal var draft: Draft
|
@NSManaged public var draft: Draft
|
||||||
|
|
||||||
public var drawing: PKDrawing? {
|
public var drawing: PKDrawing? {
|
||||||
get {
|
get {
|
||||||
@ -89,7 +89,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension DraftAttachment {
|
extension DraftAttachment {
|
||||||
var type: AttachmentType {
|
public var type: AttachmentType {
|
||||||
if let editedAttachmentKind {
|
if let editedAttachmentKind {
|
||||||
switch editedAttachmentKind {
|
switch editedAttachmentKind {
|
||||||
case .image:
|
case .image:
|
||||||
@ -129,7 +129,7 @@ extension DraftAttachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AttachmentType {
|
public enum AttachmentType {
|
||||||
case image, video, unknown
|
case image, video, unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,11 +131,11 @@ public final class DraftsPersistentContainer: NSPersistentContainer {
|
|||||||
draft.poll = poll
|
draft.poll = poll
|
||||||
if let expiresAt = existingPoll.expiresAt,
|
if let expiresAt = existingPoll.expiresAt,
|
||||||
!existingPoll.effectiveExpired {
|
!existingPoll.effectiveExpired {
|
||||||
poll.duration = PollController.Duration.allCases.max(by: {
|
poll.duration = PollDuration.allCases.max(by: {
|
||||||
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||||
})!.timeInterval
|
})!.timeInterval
|
||||||
} else {
|
} else {
|
||||||
poll.duration = PollController.Duration.oneDay.timeInterval
|
poll.duration = PollDuration.oneDay.timeInterval
|
||||||
}
|
}
|
||||||
poll.multiple = existingPoll.multiple
|
poll.multiple = existingPoll.multiple
|
||||||
// rmeove default empty options
|
// rmeove default empty options
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// PollDuration.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PollDuration: Hashable, Equatable, CaseIterable {
|
||||||
|
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
|
||||||
|
|
||||||
|
static let formatter: DateComponentsFormatter = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .full
|
||||||
|
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? {
|
||||||
|
for it in allCases where it.timeInterval == ti {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeInterval: TimeInterval {
|
||||||
|
switch self {
|
||||||
|
case .fiveMinutes:
|
||||||
|
return 5 * 60
|
||||||
|
case .thirtyMinutes:
|
||||||
|
return 30 * 60
|
||||||
|
case .oneHour:
|
||||||
|
return 60 * 60
|
||||||
|
case .sixHours:
|
||||||
|
return 6 * 60 * 60
|
||||||
|
case .oneDay:
|
||||||
|
return 24 * 60 * 60
|
||||||
|
case .threeDays:
|
||||||
|
return 3 * 24 * 60 * 60
|
||||||
|
case .sevenDays:
|
||||||
|
return 7 * 24 * 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
struct PlaceholderController: PlaceholderViewProvider {
|
||||||
|
|
||||||
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
|
|
||||||
|
|
||||||
static func makePlaceholderView() -> some View {
|
static func makePlaceholderView() -> some View {
|
||||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||||
if components.month == 3 && components.day == 14,
|
if components.month == 3 && components.day == 14,
|
||||||
@ -34,10 +31,6 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
|||||||
Text("What’s on your mind?")
|
Text("What’s on your mind?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
placeholderView
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
// exists to provide access to the type alias since the @State property needs it to be explicit
|
@ -151,3 +151,22 @@ private struct AttachmentThumbnailViewContent: View {
|
|||||||
case gifController(GIFController)
|
case gifController(GIFController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GIFViewWrapper: UIViewRepresentable {
|
||||||
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
|
@State var controller: GIFController
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> GIFImageView {
|
||||||
|
let view = GIFImageView()
|
||||||
|
controller.attach(to: view)
|
||||||
|
controller.startAnimating()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -7,9 +7,12 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import InstanceFeatures
|
||||||
|
import Vision
|
||||||
|
|
||||||
struct AttachmentCollectionViewCellView: View {
|
struct AttachmentCollectionViewCellView: View {
|
||||||
let attachment: DraftAttachment?
|
let attachment: DraftAttachment?
|
||||||
|
@State private var recognizingText = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let attachment {
|
if let attachment {
|
||||||
@ -19,18 +22,111 @@ struct AttachmentCollectionViewCellView: View {
|
|||||||
RoundedSquare(cornerRadius: 5)
|
RoundedSquare(cornerRadius: 5)
|
||||||
.fill(.quaternary)
|
.fill(.quaternary)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottomLeading) {
|
||||||
AttachmentDescriptionLabel(attachment: attachment)
|
if recognizingText {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else {
|
||||||
|
AttachmentDescriptionLabel(attachment: attachment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
AttachmentRemoveButton(attachment: attachment)
|
AttachmentRemoveButton(attachment: attachment)
|
||||||
}
|
}
|
||||||
.clipShape(RoundedSquare(cornerRadius: 5))
|
.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 {
|
private struct AttachmentRemoveButton: View {
|
||||||
let attachment: DraftAttachment
|
let attachment: DraftAttachment
|
||||||
|
|
||||||
|
@ -40,7 +40,6 @@ private struct ToolbarCancelButton: View {
|
|||||||
let draft: Draft
|
let draft: Draft
|
||||||
let isPosting: Bool
|
let isPosting: Bool
|
||||||
let cancel: (_ deleteDraft: Bool) -> Void
|
let cancel: (_ deleteDraft: Bool) -> Void
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
@State private var isShowingSaveDraftSheet = false
|
@State private var isShowingSaveDraftSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -102,7 +101,6 @@ private struct PostButton: View {
|
|||||||
let postStatus: () async -> Void
|
let postStatus: () async -> Void
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||||
@Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions
|
@Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import CoreData
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
// State owned by the compose UI but that needs to be accessible from outside.
|
// State owned by the compose UI but that needs to be accessible from outside.
|
||||||
public final class ComposeViewState: ObservableObject {
|
public final class ComposeViewState: ObservableObject {
|
||||||
@ -51,6 +52,7 @@ public struct ComposeView: View {
|
|||||||
)
|
)
|
||||||
.environment(\.composeUIConfig, config)
|
.environment(\.composeUIConfig, config)
|
||||||
.environment(\.currentAccount, currentAccount)
|
.environment(\.currentAccount, currentAccount)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setDraft(_ draft: Draft) {
|
private func setDraft(_ draft: Draft) {
|
||||||
@ -64,6 +66,13 @@ public struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
DraftsPersistentContainer.shared.save()
|
DraftsPersistentContainer.shared.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
|
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||||
|
deleted.contains(where: { $0.objectID == state.draft.objectID }) {
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: see if this can be broken up further
|
// 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)
|
.onDisappear(perform: self.deleteOrSaveDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,15 +16,9 @@ struct ContentWarningTextField: View {
|
|||||||
text: $draft.contentWarning,
|
text: $draft.contentWarning,
|
||||||
placeholder: "Write your warning here",
|
placeholder: "Write your warning here",
|
||||||
maxLength: nil,
|
maxLength: nil,
|
||||||
// TODO: completely replace this with FocusState
|
focusNextView: {
|
||||||
becomeFirstResponder: .constant(false),
|
focusedField = .body
|
||||||
focusNextView: Binding(get: {
|
}
|
||||||
false
|
|
||||||
}, set: {
|
|
||||||
if $0 {
|
|
||||||
focusedField = .body
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
.focused($focusedField, equals: .contentWarning)
|
.focused($focusedField, equals: .contentWarning)
|
||||||
.modifier(FocusedInputModifier())
|
.modifier(FocusedInputModifier())
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// CurrentAccountView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/4/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
struct CurrentAccountView: View {
|
|
||||||
let account: (any AccountProtocol)?
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
controller.currentAccountContainerView(AnyView(currentAccount))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentAccount: some View {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
AvatarImageView(
|
|
||||||
url: account?.avatar,
|
|
||||||
size: 50,
|
|
||||||
style: controller.config.avatarStyle,
|
|
||||||
fetchAvatar: controller.fetchAvatar
|
|
||||||
)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
|
|
||||||
if let account {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
controller.displayNameLabel(account, .title2, 24)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(verbatim: "@\(account.acct)")
|
|
||||||
.font(.body.weight(.light))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,14 +17,12 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let placeholder: String
|
let placeholder: String
|
||||||
let maxLength: Int?
|
let maxLength: Int?
|
||||||
let becomeFirstResponder: Binding<Bool>?
|
let focusNextView: (() -> Void)?
|
||||||
let focusNextView: Binding<Bool>?
|
|
||||||
|
|
||||||
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
init(text: Binding<String>, placeholder: String, maxLength: Int?, focusNextView: (() -> Void)? = nil) {
|
||||||
self._text = text
|
self._text = text
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
self.maxLength = maxLength
|
self.maxLength = maxLength
|
||||||
self.becomeFirstResponder = becomeFirstResponder
|
|
||||||
self.focusNextView = focusNextView
|
self.focusNextView = focusNextView
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,13 +64,6 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if becomeFirstResponder?.wrappedValue == true {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
uiView.becomeFirstResponder()
|
|
||||||
becomeFirstResponder!.wrappedValue = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@ -85,7 +76,7 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
|
|
||||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||||
var text: Binding<String>
|
var text: Binding<String>
|
||||||
var focusNextView: Binding<Bool>?
|
var focusNextView: (() -> Void)?
|
||||||
var maxLength: Int?
|
var maxLength: Int?
|
||||||
|
|
||||||
@Published var autocompleteState: AutocompleteState?
|
@Published var autocompleteState: AutocompleteState?
|
||||||
@ -93,7 +84,7 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
|
|
||||||
weak var textField: UITextField?
|
weak var textField: UITextField?
|
||||||
|
|
||||||
init(text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
|
init(text: Binding<String>, focusNextView: (() -> Void)?, maxLength: Int? = nil) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.focusNextView = focusNextView
|
self.focusNextView = focusNextView
|
||||||
self.maxLength = maxLength
|
self.maxLength = maxLength
|
||||||
@ -104,7 +95,7 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func returnKeyPressed() {
|
@objc func returnKeyPressed() {
|
||||||
focusNextView?.wrappedValue = true
|
focusNextView?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
//
|
|
||||||
// HeaderView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/4/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import InstanceFeatures
|
|
||||||
|
|
||||||
struct HeaderView: View {
|
|
||||||
let currentAccount: (any AccountProtocol)?
|
|
||||||
let charsRemaining: Int
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
CurrentAccountView(account: currentAccount)
|
|
||||||
.accessibilitySortPriority(1)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(verbatim: charsRemaining.description)
|
|
||||||
.foregroundColor(charsRemaining < 0 ? .red : .secondary)
|
|
||||||
.font(Font.body.monospacedDigit())
|
|
||||||
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
|
||||||
// this should come first, so VO users can back to it from the main compose text view
|
|
||||||
.accessibilitySortPriority(0)
|
|
||||||
}.frame(height: 50)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,336 +0,0 @@
|
|||||||
//
|
|
||||||
// MainTextView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/6/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MainTextView: View {
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
@EnvironmentObject private var draft: Draft
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@ScaledMetric private var fontSize = 20
|
|
||||||
|
|
||||||
@State private var hasFirstAppeared = false
|
|
||||||
@State private var height: CGFloat?
|
|
||||||
@State private var updateSelection: ((UITextView) -> Void)?
|
|
||||||
private let minHeight: CGFloat = 150
|
|
||||||
private var effectiveHeight: CGFloat { height ?? minHeight }
|
|
||||||
|
|
||||||
var config: ComposeUIConfig {
|
|
||||||
controller.config
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholderOffset: CGSize {
|
|
||||||
#if os(visionOS)
|
|
||||||
CGSize(width: 8, height: 8)
|
|
||||||
#else
|
|
||||||
CGSize(width: 4, height: 8)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textViewBackgroundColor: UIColor? {
|
|
||||||
#if os(visionOS)
|
|
||||||
nil
|
|
||||||
#else
|
|
||||||
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
MainWrappedTextViewRepresentable(
|
|
||||||
text: $draft.text,
|
|
||||||
backgroundColor: textViewBackgroundColor,
|
|
||||||
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
|
|
||||||
updateSelection: $updateSelection,
|
|
||||||
textDidChange: textDidChange
|
|
||||||
)
|
|
||||||
|
|
||||||
if draft.text.isEmpty {
|
|
||||||
ControllerView(controller: { PlaceholderController() })
|
|
||||||
.font(.system(size: fontSize))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.offset(placeholderOffset)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
.frame(height: effectiveHeight)
|
|
||||||
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func becomeFirstResponderOnFirstAppearance() {
|
|
||||||
if !hasFirstAppeared {
|
|
||||||
hasFirstAppeared = true
|
|
||||||
controller.mainComposeTextViewBecomeFirstResponder = true
|
|
||||||
if config.textSelectionStartsAtBeginning {
|
|
||||||
updateSelection = { textView in
|
|
||||||
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func textDidChange(textView: UITextView) {
|
|
||||||
height = max(textView.contentSize.height, minHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|
||||||
typealias UIViewType = UITextView
|
|
||||||
|
|
||||||
@Binding var text: String
|
|
||||||
let backgroundColor: UIColor?
|
|
||||||
@Binding var becomeFirstResponder: Bool
|
|
||||||
@Binding var updateSelection: ((UITextView) -> Void)?
|
|
||||||
let textDidChange: (UITextView) -> Void
|
|
||||||
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
|
||||||
let textView = WrappedTextView(composeController: controller)
|
|
||||||
context.coordinator.textView = textView
|
|
||||||
textView.delegate = context.coordinator
|
|
||||||
textView.isEditable = true
|
|
||||||
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
|
||||||
textView.adjustsFontForContentSizeCategory = true
|
|
||||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
|
||||||
|
|
||||||
#if os(visionOS)
|
|
||||||
textView.borderStyle = .roundedRect
|
|
||||||
// yes, the X inset is 4 less than the placeholder offset
|
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return textView
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
|
||||||
if text != uiView.text {
|
|
||||||
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
|
|
||||||
uiView.text = text
|
|
||||||
}
|
|
||||||
|
|
||||||
uiView.isEditable = isEnabled
|
|
||||||
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
|
||||||
|
|
||||||
uiView.backgroundColor = backgroundColor
|
|
||||||
|
|
||||||
context.coordinator.text = $text
|
|
||||||
|
|
||||||
if let updateSelection {
|
|
||||||
updateSelection(uiView)
|
|
||||||
self.updateSelection = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
|
||||||
// the text view knows its new content size
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
textDidChange(uiView)
|
|
||||||
|
|
||||||
if becomeFirstResponder {
|
|
||||||
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
|
|
||||||
uiView.becomeFirstResponder()
|
|
||||||
// can't update @State vars during the SwiftUI update
|
|
||||||
becomeFirstResponder = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
class WrappedTextView: UITextView {
|
|
||||||
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
|
||||||
private let composeController: ComposeController
|
|
||||||
|
|
||||||
init(composeController: ComposeController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
super.init(frame: .zero, textContainer: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
||||||
if formattingActions.contains(action) {
|
|
||||||
return composeController.config.contentType != .plain
|
|
||||||
}
|
|
||||||
return super.canPerformAction(action, withSender: sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func toggleBoldface(_ sender: Any?) {
|
|
||||||
(delegate as! Coordinator).applyFormat(.bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func toggleItalics(_ sender: Any?) {
|
|
||||||
(delegate as! Coordinator).applyFormat(.italics)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func validate(_ command: UICommand) {
|
|
||||||
super.validate(command)
|
|
||||||
|
|
||||||
if formattingActions.contains(command.action),
|
|
||||||
composeController.config.contentType != .plain {
|
|
||||||
command.attributes.remove(.disabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func paste(_ sender: Any?) {
|
|
||||||
// we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
|
|
||||||
// and things like URLs end up pasting as attachments
|
|
||||||
if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
|
|
||||||
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
|
|
||||||
} else {
|
|
||||||
super.paste(sender)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
|
|
||||||
weak var textView: UITextView?
|
|
||||||
|
|
||||||
let controller: ComposeController
|
|
||||||
var text: Binding<String>
|
|
||||||
let textDidChange: (UITextView) -> Void
|
|
||||||
|
|
||||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
|
||||||
|
|
||||||
@Published var autocompleteState: AutocompleteState?
|
|
||||||
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
|
||||||
var skipNextSelectionChangedAutocompleteUpdate = false
|
|
||||||
|
|
||||||
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
|
|
||||||
self.controller = controller
|
|
||||||
self.text = text
|
|
||||||
self.textDidChange = textDidChange
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func keyboardDidShow() {
|
|
||||||
guard let textView,
|
|
||||||
textView.isFirstResponder else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ensureCursorVisible(textView: textView)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: UITextViewDelegate
|
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
|
||||||
text.wrappedValue = textView.text
|
|
||||||
textDidChange(textView)
|
|
||||||
|
|
||||||
ensureCursorVisible(textView: textView)
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
||||||
controller.currentInput = self
|
|
||||||
updateAutocompleteState()
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidEndEditing(_ textView: UITextView) {
|
|
||||||
controller.currentInput = nil
|
|
||||||
updateAutocompleteState()
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
||||||
if skipNextSelectionChangedAutocompleteUpdate {
|
|
||||||
skipNextSelectionChangedAutocompleteUpdate = false
|
|
||||||
} else {
|
|
||||||
updateAutocompleteState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
|
||||||
var actions = suggestedActions
|
|
||||||
if controller.config.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)) { [weak self] _ in
|
|
||||||
self?.applyFormat(fmt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
actions[index] = newFormatMenu
|
|
||||||
} else {
|
|
||||||
actions.remove(at: index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: ComposeInput
|
|
||||||
|
|
||||||
var toolbarElements: [ToolbarElement] {
|
|
||||||
[.emojiPicker, .formattingButtons]
|
|
||||||
}
|
|
||||||
|
|
||||||
var textInputMode: UITextInputMode? {
|
|
||||||
textView?.textInputMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func autocomplete(with string: String) {
|
|
||||||
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyFormat(_ format: StatusFormat) {
|
|
||||||
guard let textView,
|
|
||||||
textView.isFirstResponder,
|
|
||||||
let insertionResult = format.insertionResult(for: controller.config.contentType) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentSelectedRange = textView.selectedRange
|
|
||||||
if currentSelectedRange.length == 0 {
|
|
||||||
textView.insertText(insertionResult.prefix + insertionResult.suffix)
|
|
||||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
|
|
||||||
} else {
|
|
||||||
let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
|
||||||
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
|
|
||||||
let selectedText = textView.text.utf16[start..<end]
|
|
||||||
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
|
|
||||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginAutocompletingEmoji() {
|
|
||||||
guard let textView else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var insertSpace = false
|
|
||||||
if let text = textView.text,
|
|
||||||
textView.selectedRange.upperBound > 0 {
|
|
||||||
let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
|
|
||||||
insertSpace = !text[characterBeforeCursorIndex].isWhitespace
|
|
||||||
}
|
|
||||||
textView.insertText((insertSpace ? " " : "") + ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAutocompleteState() {
|
|
||||||
guard let textView else {
|
|
||||||
autocompleteState = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,6 +6,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
struct NewMainTextView: View {
|
struct NewMainTextView: View {
|
||||||
static var minHeight: CGFloat { 150 }
|
static var minHeight: CGFloat { 150 }
|
||||||
@ -37,8 +39,9 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
|||||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
||||||
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
@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
|
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||||
let view = if #available(iOS 16.0, *) {
|
let view = if #available(iOS 16.0, *) {
|
||||||
WrappedTextView(usingTextLayoutManager: true)
|
WrappedTextView(usingTextLayoutManager: true)
|
||||||
@ -79,6 +82,8 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
|||||||
|
|
||||||
uiView.isEditable = isEnabled
|
uiView.isEditable = isEnabled
|
||||||
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||||
|
|
||||||
|
uiView.contentType = statusContentType
|
||||||
// #if !os(visionOS)
|
// #if !os(visionOS)
|
||||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||||
// #endif
|
// #endif
|
||||||
@ -247,6 +252,35 @@ extension WrappedTextViewCoordinator: UITextViewDelegate {
|
|||||||
|
|
||||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
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 {
|
//extension WrappedTextViewCoordinator: ComposeInput {
|
||||||
@ -269,6 +303,35 @@ extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final class WrappedTextView: UITextView {
|
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 {
|
private extension NSAttributedString.Key {
|
||||||
|
@ -100,44 +100,6 @@ private struct PollDurationPicker: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum PollDuration: Hashable, Equatable, CaseIterable {
|
|
||||||
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
|
|
||||||
|
|
||||||
static let formatter: DateComponentsFormatter = {
|
|
||||||
let f = DateComponentsFormatter()
|
|
||||||
f.maximumUnitCount = 1
|
|
||||||
f.unitsStyle = .full
|
|
||||||
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? {
|
|
||||||
for it in allCases where it.timeInterval == ti {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeInterval: TimeInterval {
|
|
||||||
switch self {
|
|
||||||
case .fiveMinutes:
|
|
||||||
return 5 * 60
|
|
||||||
case .thirtyMinutes:
|
|
||||||
return 30 * 60
|
|
||||||
case .oneHour:
|
|
||||||
return 60 * 60
|
|
||||||
case .sixHours:
|
|
||||||
return 6 * 60 * 60
|
|
||||||
case .oneDay:
|
|
||||||
return 24 * 60 * 60
|
|
||||||
case .threeDays:
|
|
||||||
return 3 * 24 * 60 * 60
|
|
||||||
case .sevenDays:
|
|
||||||
return 7 * 24 * 60 * 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PollOptionEditor: View {
|
private struct PollOptionEditor: View {
|
||||||
@ObservedObject var poll: Poll
|
@ObservedObject var poll: Poll
|
||||||
@ObservedObject var option: PollOption
|
@ObservedObject var option: PollOption
|
||||||
@ -163,7 +125,7 @@ private struct PollOptionEditor: View {
|
|||||||
.accessibilityLabel("Remove option")
|
.accessibilityLabel("Remove option")
|
||||||
.disabled(poll.options.count == 1)
|
.disabled(poll.options.count == 1)
|
||||||
|
|
||||||
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars)
|
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars, focusNextView: self.focusNextOption)
|
||||||
.focused($focusedField, equals: .pollOption(option.id))
|
.focused($focusedField, equals: .pollOption(option.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -183,6 +145,14 @@ private struct PollOptionEditor: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func focusNextOption() {
|
||||||
|
let index = poll.options.index(of: option)
|
||||||
|
if index != NSNotFound && index + 1 < poll.options.count {
|
||||||
|
let nextOption = poll.options.object(at: index + 1) as! PollOption
|
||||||
|
focusedField = .pollOption(nextOption.objectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PollOptionButtonStyle: ButtonStyle {
|
private struct PollOptionButtonStyle: ButtonStyle {
|
||||||
|
@ -1,72 +0,0 @@
|
|||||||
//
|
|
||||||
// PollOptionView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 3/25/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PollOptionView: View {
|
|
||||||
@EnvironmentObject private var controller: PollController
|
|
||||||
@EnvironmentObject private var poll: Poll
|
|
||||||
@ObservedObject private var option: PollOption
|
|
||||||
let remove: () -> Void
|
|
||||||
|
|
||||||
init(option: PollOption, remove: @escaping () -> Void) {
|
|
||||||
self.option = option
|
|
||||||
self.remove = remove
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
|
|
||||||
.animation(.default, value: poll.multiple)
|
|
||||||
|
|
||||||
textField
|
|
||||||
|
|
||||||
Button(action: remove) {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Remove option")
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
|
||||||
.disabled(poll.options.count == 1)
|
|
||||||
.hoverEffect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textField: some View {
|
|
||||||
let index = poll.options.index(of: option)
|
|
||||||
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
|
|
||||||
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
|
|
||||||
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Checkbox: View {
|
|
||||||
private let radiusFraction: CGFloat
|
|
||||||
private let size: CGFloat = 20
|
|
||||||
private let innerSize: CGFloat
|
|
||||||
private let background: Color
|
|
||||||
|
|
||||||
init(radiusFraction: CGFloat, background: Color) {
|
|
||||||
self.radiusFraction = radiusFraction
|
|
||||||
self.innerSize = self.size - 4
|
|
||||||
self.background = background
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.frame(width: size, height: size)
|
|
||||||
.cornerRadius(radiusFraction * size)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(background)
|
|
||||||
.frame(width: innerSize, height: innerSize)
|
|
||||||
.cornerRadius(radiusFraction * innerSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,111 +0,0 @@
|
|||||||
//
|
|
||||||
// ZoomableScrollView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/29/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
init(@ViewBuilder content: () -> Content) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> Controller {
|
|
||||||
return Controller(content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
|
||||||
uiViewController.host.rootView = content
|
|
||||||
}
|
|
||||||
|
|
||||||
class Controller: UIViewController, UIScrollViewDelegate {
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let host: UIHostingController<Content>
|
|
||||||
|
|
||||||
private var lastIntrinsicSize: CGSize?
|
|
||||||
private var contentViewTopConstraint: NSLayoutConstraint!
|
|
||||||
private var contentViewLeadingConstraint: NSLayoutConstraint!
|
|
||||||
private var hostBoundsObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(content: Content) {
|
|
||||||
self.host = UIHostingController(rootView: content)
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
scrollView.delegate = self
|
|
||||||
scrollView.bouncesZoom = true
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
|
|
||||||
host.sizingOptions = .intrinsicContentSize
|
|
||||||
host.view.backgroundColor = .clear
|
|
||||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addChild(host)
|
|
||||||
scrollView.addSubview(host.view)
|
|
||||||
host.didMove(toParent: self)
|
|
||||||
|
|
||||||
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
|
|
||||||
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
|
|
||||||
contentViewLeadingConstraint,
|
|
||||||
contentViewTopConstraint,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
if !host.view.intrinsicContentSize.equalTo(.zero),
|
|
||||||
host.view.intrinsicContentSize != lastIntrinsicSize {
|
|
||||||
self.lastIntrinsicSize = host.view.intrinsicContentSize
|
|
||||||
|
|
||||||
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
|
|
||||||
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
|
|
||||||
let heightScale = maxHeight / host.view.intrinsicContentSize.height
|
|
||||||
let widthScale = maxWidth / host.view.intrinsicContentSize.width
|
|
||||||
let minScale = min(widthScale, heightScale)
|
|
||||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
|
||||||
scrollView.minimumZoomScale = minScale
|
|
||||||
scrollView.maximumZoomScale = maxScale
|
|
||||||
scrollView.zoomScale = minScale
|
|
||||||
}
|
|
||||||
|
|
||||||
centerImage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
||||||
return host.view
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
||||||
centerImage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func centerImage() {
|
|
||||||
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
|
|
||||||
contentViewTopConstraint.constant = yOffset
|
|
||||||
|
|
||||||
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
|
|
||||||
contentViewLeadingConstraint.constant = xOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -16,8 +16,8 @@ public struct AccentColorKey: MigratablePreferenceKey {
|
|||||||
public static var defaultValue: AccentColor { .default }
|
public static var defaultValue: AccentColor { .default }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AvatarStyleKey: MigratablePreferenceKey {
|
public struct AvatarStyleKey: MigratablePreferenceKey {
|
||||||
static var defaultValue: AvatarStyle { .roundRect }
|
public static var defaultValue: AvatarStyle { .roundRect }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
|
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
|
||||||
|
@ -120,13 +120,35 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||||
// return controller.canPaste(itemProviders: itemProviders)
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
||||||
return false
|
return false
|
||||||
// TODO: pasting
|
}
|
||||||
|
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]) {
|
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) {
|
private func dismiss(mode: DismissMode) {
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2025.1
|
MARKETING_VERSION = 2025.1
|
||||||
CURRENT_PROJECT_VERSION = 142
|
CURRENT_PROJECT_VERSION = 143
|
||||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||||
|
Loading…
x
Reference in New Issue
Block a user