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()
|
||||
}
|
||||
|
||||
public func addAttachment(_ attachment: DraftAttachment) {
|
||||
attachments.add(attachment)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Draft {
|
||||
|
@ -32,7 +32,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||
@NSManaged internal var fileType: String?
|
||||
@NSManaged public var id: UUID!
|
||||
|
||||
@NSManaged internal var draft: Draft
|
||||
@NSManaged public var draft: Draft
|
||||
|
||||
public var drawing: PKDrawing? {
|
||||
get {
|
||||
@ -89,7 +89,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||
}
|
||||
|
||||
extension DraftAttachment {
|
||||
var type: AttachmentType {
|
||||
public var type: AttachmentType {
|
||||
if let editedAttachmentKind {
|
||||
switch editedAttachmentKind {
|
||||
case .image:
|
||||
@ -129,7 +129,7 @@ extension DraftAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
enum AttachmentType {
|
||||
public enum AttachmentType {
|
||||
case image, video, unknown
|
||||
}
|
||||
}
|
||||
|
@ -131,11 +131,11 @@ public final class DraftsPersistentContainer: NSPersistentContainer {
|
||||
draft.poll = poll
|
||||
if let expiresAt = existingPoll.expiresAt,
|
||||
!existingPoll.effectiveExpired {
|
||||
poll.duration = PollController.Duration.allCases.max(by: {
|
||||
poll.duration = PollDuration.allCases.max(by: {
|
||||
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||
})!.timeInterval
|
||||
} else {
|
||||
poll.duration = PollController.Duration.oneDay.timeInterval
|
||||
poll.duration = PollDuration.oneDay.timeInterval
|
||||
}
|
||||
poll.multiple = existingPoll.multiple
|
||||
// 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
|
||||
|
||||
final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||
|
||||
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||
|
||||
struct PlaceholderController: PlaceholderViewProvider {
|
||||
static func makePlaceholderView() -> some View {
|
||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||
if components.month == 3 && components.day == 14,
|
||||
@ -34,10 +31,6 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||
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
|
@ -151,3 +151,22 @@ private struct AttachmentThumbnailViewContent: View {
|
||||
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 SwiftUI
|
||||
import InstanceFeatures
|
||||
import Vision
|
||||
|
||||
struct AttachmentCollectionViewCellView: View {
|
||||
let attachment: DraftAttachment?
|
||||
@State private var recognizingText = false
|
||||
|
||||
var body: some View {
|
||||
if let attachment {
|
||||
@ -19,18 +22,111 @@ struct AttachmentCollectionViewCellView: View {
|
||||
RoundedSquare(cornerRadius: 5)
|
||||
.fill(.quaternary)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
AttachmentDescriptionLabel(attachment: attachment)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if recognizingText {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
AttachmentDescriptionLabel(attachment: attachment)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
AttachmentRemoveButton(attachment: attachment)
|
||||
}
|
||||
.clipShape(RoundedSquare(cornerRadius: 5))
|
||||
// TODO: context menu preview?
|
||||
.contextMenu {
|
||||
if attachment.drawingData != nil {
|
||||
EditDrawingButton(attachment: attachment)
|
||||
} else if attachment.type == .image {
|
||||
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct RecognizeTextButton: View {
|
||||
let attachment: DraftAttachment
|
||||
@Binding var recognizingText: Bool
|
||||
@State private var error: (any Error)?
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.recognizeText) {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
}
|
||||
.alertWithData("Text Recognition Failed", data: $error) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func recognizeText() {
|
||||
recognizingText = true
|
||||
attachment.getData(features: instanceFeatures) { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
self.recognizingText = false
|
||||
self.error = error
|
||||
case .success(let (data, _)):
|
||||
let handler = VNImageRequestHandler(data: data)
|
||||
let request = VNRecognizeTextRequest { request, error in
|
||||
DispatchQueue.main.async {
|
||||
if let results = request.results as? [VNRecognizedTextObservation] {
|
||||
var text = ""
|
||||
for observation in results {
|
||||
let result = observation.topCandidates(1).first!
|
||||
text.append(result.string)
|
||||
text.append("\n")
|
||||
}
|
||||
self.attachment.attachmentDescription = text
|
||||
}
|
||||
self.recognizingText = false
|
||||
}
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try handler.perform([request])
|
||||
} catch let error as NSError where error.code == 1 {
|
||||
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
|
||||
return
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.recognizingText = false
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditDrawingButton: View {
|
||||
let attachment: DraftAttachment
|
||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.editDrawing) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
}
|
||||
}
|
||||
|
||||
private func editDrawing() {
|
||||
guard let drawing = attachment.drawing else {
|
||||
return
|
||||
}
|
||||
presentDrawing?(drawing) { drawing in
|
||||
self.attachment.drawing = drawing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentRemoveButton: View {
|
||||
let attachment: DraftAttachment
|
||||
|
||||
|
@ -40,7 +40,6 @@ private struct ToolbarCancelButton: View {
|
||||
let draft: Draft
|
||||
let isPosting: Bool
|
||||
let cancel: (_ deleteDraft: Bool) -> Void
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@State private var isShowingSaveDraftSheet = false
|
||||
|
||||
var body: some View {
|
||||
@ -102,7 +101,6 @@ private struct PostButton: View {
|
||||
let postStatus: () async -> Void
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
@Environment(\.composeUIConfig.requireAttachmentDescriptions) private var requireAttachmentDescriptions
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
|
@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
// State owned by the compose UI but that needs to be accessible from outside.
|
||||
public final class ComposeViewState: ObservableObject {
|
||||
@ -51,6 +52,7 @@ public struct ComposeView: View {
|
||||
)
|
||||
.environment(\.composeUIConfig, config)
|
||||
.environment(\.currentAccount, currentAccount)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||
}
|
||||
|
||||
private func setDraft(_ draft: Draft) {
|
||||
@ -64,6 +66,13 @@ public struct ComposeView: View {
|
||||
}
|
||||
DraftsPersistentContainer.shared.save()
|
||||
}
|
||||
|
||||
private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||
deleted.contains(where: { $0.objectID == state.draft.objectID }) {
|
||||
config.dismiss(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: see if this can be broken up further
|
||||
@ -93,6 +102,11 @@ private struct ComposeViewBody: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.alertWithData("Error Posting", data: $postError, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
Text(error.localizedDescription)
|
||||
})
|
||||
.onDisappear(perform: self.deleteOrSaveDraft)
|
||||
}
|
||||
|
||||
|
@ -16,15 +16,9 @@ struct ContentWarningTextField: View {
|
||||
text: $draft.contentWarning,
|
||||
placeholder: "Write your warning here",
|
||||
maxLength: nil,
|
||||
// TODO: completely replace this with FocusState
|
||||
becomeFirstResponder: .constant(false),
|
||||
focusNextView: Binding(get: {
|
||||
false
|
||||
}, set: {
|
||||
if $0 {
|
||||
focusedField = .body
|
||||
}
|
||||
})
|
||||
focusNextView: {
|
||||
focusedField = .body
|
||||
}
|
||||
)
|
||||
.focused($focusedField, equals: .contentWarning)
|
||||
.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
|
||||
let placeholder: String
|
||||
let maxLength: Int?
|
||||
let becomeFirstResponder: Binding<Bool>?
|
||||
let focusNextView: Binding<Bool>?
|
||||
let focusNextView: (() -> Void)?
|
||||
|
||||
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.placeholder = placeholder
|
||||
self.maxLength = maxLength
|
||||
self.becomeFirstResponder = becomeFirstResponder
|
||||
self.focusNextView = focusNextView
|
||||
}
|
||||
|
||||
@ -66,13 +64,6 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
#if !os(visionOS)
|
||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
|
||||
if becomeFirstResponder?.wrappedValue == true {
|
||||
DispatchQueue.main.async {
|
||||
uiView.becomeFirstResponder()
|
||||
becomeFirstResponder!.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
@ -85,7 +76,7 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||
var text: Binding<String>
|
||||
var focusNextView: Binding<Bool>?
|
||||
var focusNextView: (() -> Void)?
|
||||
var maxLength: Int?
|
||||
|
||||
@Published var autocompleteState: AutocompleteState?
|
||||
@ -93,7 +84,7 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
|
||||
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.focusNextView = focusNextView
|
||||
self.maxLength = maxLength
|
||||
@ -104,7 +95,7 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
}
|
||||
|
||||
@objc func returnKeyPressed() {
|
||||
focusNextView?.wrappedValue = true
|
||||
focusNextView?()
|
||||
}
|
||||
|
||||
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 Pachyderm
|
||||
import TuskerPreferences
|
||||
|
||||
struct NewMainTextView: View {
|
||||
static var minHeight: CGFloat { 150 }
|
||||
@ -37,8 +39,9 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
||||
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
||||
@PreferenceObserving(\.$statusContentType) private var statusContentType
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
func makeUIView(context: Context) -> WrappedTextView {
|
||||
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||
let view = if #available(iOS 16.0, *) {
|
||||
WrappedTextView(usingTextLayoutManager: true)
|
||||
@ -79,6 +82,8 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
|
||||
uiView.isEditable = isEnabled
|
||||
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||
|
||||
uiView.contentType = statusContentType
|
||||
// #if !os(visionOS)
|
||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||
// #endif
|
||||
@ -247,6 +252,35 @@ extension WrappedTextViewCoordinator: UITextViewDelegate {
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
guard let textView = textView as? WrappedTextView else {
|
||||
return nil
|
||||
}
|
||||
var actions = suggestedActions
|
||||
if textView.contentType != .plain,
|
||||
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
|
||||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { _ in
|
||||
// TODO
|
||||
}
|
||||
})
|
||||
actions[index] = newFormatMenu
|
||||
} else {
|
||||
actions.remove(at: index)
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// if range.length == 0 {
|
||||
// actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
|
||||
// self?.controller.shouldEmojiAutocompletionBeginExpanded = true
|
||||
// self?.beginAutocompletingEmoji()
|
||||
// }))
|
||||
// }
|
||||
return UIMenu(children: actions)
|
||||
}
|
||||
}
|
||||
|
||||
//extension WrappedTextViewCoordinator: ComposeInput {
|
||||
@ -269,6 +303,35 @@ extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
|
||||
}
|
||||
|
||||
private final class WrappedTextView: UITextView {
|
||||
var contentType: StatusContentType = .plain
|
||||
|
||||
private static var formattingActions: [Selector] {
|
||||
[#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if Self.formattingActions.contains(action) {
|
||||
return contentType != .plain
|
||||
}
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
override func toggleBoldface(_ sender: Any?) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override func toggleItalics(_ sender: Any?) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override func validate(_ command: UICommand) {
|
||||
super.validate(command)
|
||||
|
||||
if Self.formattingActions.contains(command.action),
|
||||
contentType != .plain {
|
||||
command.attributes.remove(.disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSAttributedString.Key {
|
||||
|
@ -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 {
|
||||
@ObservedObject var poll: Poll
|
||||
@ObservedObject var option: PollOption
|
||||
@ -163,7 +125,7 @@ private struct PollOptionEditor: View {
|
||||
.accessibilityLabel("Remove option")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
||||
struct AvatarStyleKey: MigratablePreferenceKey {
|
||||
static var defaultValue: AvatarStyle { .roundRect }
|
||||
public struct AvatarStyleKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: AvatarStyle { .roundRect }
|
||||
}
|
||||
|
||||
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
|
||||
|
@ -120,13 +120,35 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||
}
|
||||
|
||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||
// return controller.canPaste(itemProviders: itemProviders)
|
||||
return false
|
||||
// TODO: pasting
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
||||
return false
|
||||
}
|
||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
let existing = state.draft.draftAttachments
|
||||
if existing.allSatisfy({ $0.type == .image }) {
|
||||
// if providers are videos, this technically allows invalid video/image combinations
|
||||
return itemProviders.count + existing.count <= 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func paste(itemProviders: [NSItemProvider]) {
|
||||
// controller.paste(itemProviders: itemProviders)
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||
guard let attachment = object as? DraftAttachment else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = self.state.draft
|
||||
self.state.draft.addAttachment(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dismiss(mode: DismissMode) {
|
||||
|
@ -10,7 +10,7 @@
|
||||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
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_BUILD_SUFFIX_Debug=-dev
|
||||
|
Loading…
x
Reference in New Issue
Block a user