From 1d81510899cf7571bbc13d78a3aaa20c651c6fdf Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 7 Feb 2025 00:40:05 -0500 Subject: [PATCH] Remove old compose UI --- .../Controllers/AttachmentRowController.swift | 223 -------- .../AttachmentThumbnailController.swift | 201 ------- .../AttachmentsListController.swift | 262 --------- .../Controllers/AutocompleteController.swift | 83 --- .../AutocompleteEmojisController.swift | 188 ------- .../AutocompleteHashtagsController.swift | 125 ----- .../AutocompleteMentionsController.swift | 179 ------ .../Controllers/ComposeController.swift | 515 ------------------ .../Controllers/DraftsController.swift | 174 ------ .../FocusedAttachmentController.swift | 122 ----- .../Controllers/PollController.swift | 195 ------- .../Controllers/ToolbarController.swift | 195 ------- .../CoreData/DraftsPersistentContainer.swift | 4 +- .../ComposeUI/Model/PollDuration.swift | 47 ++ .../PlaceholderController.swift | 9 +- .../Views/AttachmentThumbnailView.swift | 19 + .../Views/ComposeNavigationBarActions.swift | 2 - .../ComposeUI/Views/CurrentAccountView.swift | 45 -- .../Sources/ComposeUI/Views/HeaderView.swift | 31 -- .../ComposeUI/Views/MainTextView.swift | 336 ------------ .../Sources/ComposeUI/Views/PollEditor.swift | 38 -- .../ComposeUI/Views/PollOptionView.swift | 72 --- .../ComposeUI/Views/ZoomableScrollView.swift | 111 ---- 23 files changed, 69 insertions(+), 3107 deletions(-) delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/PollDuration.swift rename Packages/ComposeUI/Sources/ComposeUI/{Controllers => }/PlaceholderController.swift (86%) delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/ZoomableScrollView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift deleted file mode 100644 index 5b56ad890..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift +++ /dev/null @@ -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 - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift deleted file mode 100644 index c07804a87..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift +++ /dev/null @@ -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) { - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift deleted file mode 100644 index ef51476fd..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift +++ /dev/null @@ -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, @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: 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) - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift deleted file mode 100644 index c6867d74c..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift +++ /dev/null @@ -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 - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift deleted file mode 100644 index 1ea23b0b1..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift +++ /dev/null @@ -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? - - @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() - 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) - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift deleted file mode 100644 index cb8e4e120..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift +++ /dev/null @@ -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? - - @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? - 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() - 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) - } - } - } - - -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift deleted file mode 100644 index 4272dc5f7..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift +++ /dev/null @@ -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? - - 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 - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift deleted file mode 100644 index 6545a51aa..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ /dev/null @@ -1,515 +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() - @Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)? - let scrollToAttachment = PassthroughSubject() - @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, - 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() - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift deleted file mode 100644 index 0085e3742..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift +++ /dev/null @@ -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) { - 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 - - func makeUIViewController(context: Context) -> UIHostingController { - return UIHostingController(rootView: DraftsView()) - } - - func updateUIViewController(_ uiViewController: UIHostingController, 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 - - 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(_ value: T?, modify: (Self, T) -> V) -> some View { - if let value { - modify(self, value) - } else { - self - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift deleted file mode 100644 index 436724d65..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift +++ /dev/null @@ -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") - } -} - diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift deleted file mode 100644 index 7a1e2cb9e..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift +++ /dev/null @@ -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 - } - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift deleted file mode 100644 index 2c88f38ab..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ /dev/null @@ -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 { - // 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.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() - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift index 8d370478f..951ac63b0 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -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 diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/PollDuration.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/PollDuration.swift new file mode 100644 index 000000000..224f4c9ee --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/PollDuration.swift @@ -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 + } + } +} + diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift b/Packages/ComposeUI/Sources/ComposeUI/PlaceholderController.swift similarity index 86% rename from Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift rename to Packages/ComposeUI/Sources/ComposeUI/PlaceholderController.swift index 584234c61..1d6bce215 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/PlaceholderController.swift @@ -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 diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift index e5a87d64e..eefc44a98 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -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) { + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift index 49036f171..3fe96d603 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift @@ -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 { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift deleted file mode 100644 index fae4acaf3..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift +++ /dev/null @@ -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() - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift deleted file mode 100644 index 3c57890af..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift +++ /dev/null @@ -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) - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift deleted file mode 100644 index 0d6c52650..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift +++ /dev/null @@ -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 - let textDidChange: (UITextView) -> Void - - var caretScrollPositionAnimator: UIViewPropertyAnimator? - - @Published var autocompleteState: AutocompleteState? - var autocompleteStatePublisher: Published.Publisher { $autocompleteState } - var skipNextSelectionChangedAutocompleteUpdate = false - - init(controller: ComposeController, text: Binding, 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.. 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) - } - - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift index 0653e7532..cc7489e06 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift @@ -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 diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift deleted file mode 100644 index 08b860156..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ZoomableScrollView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ZoomableScrollView.swift deleted file mode 100644 index 80d467327..000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ZoomableScrollView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// ZoomableScrollView.swift -// ComposeUI -// -// Created by Shadowfacts on 4/29/23. -// - -import SwiftUI - -@available(iOS 16.0, *) -struct ZoomableScrollView: 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 - - 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 - } - } -}