From 0746e127376ca52e5a3c390a18e11b3044fd86cb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:23:13 -0400 Subject: [PATCH] Extract compose UI into separate package --- Packages/ComposeUI/.gitignore | 9 + Packages/ComposeUI/Package.resolved | 23 ++ Packages/ComposeUI/Package.swift | 33 ++ Packages/ComposeUI/README.md | 3 + .../Sources/ComposeUI/API/PostService.swift | 139 +++++++ .../Sources/ComposeUI}/CharacterCounter.swift | 11 +- .../Sources/ComposeUI/ComposeInput.swift | 27 ++ .../ComposeUI/ComposeMastodonContext.swift | 26 ++ .../Sources/ComposeUI/ComposeUIConfig.swift | 46 +++ .../Controllers/AttachmentRowController.swift | 162 ++++++++ .../AttachmentsListController.swift | 233 +++++++++++ .../Controllers/AutocompleteController.swift | 83 ++++ .../AutocompleteEmojisController.swift | 196 +++++++++ .../AutocompleteHashtagsController.swift | 124 ++++++ .../AutocompleteMentionsController.swift | 171 ++++++++ .../Controllers/ComposeController.swift | 379 ++++++++++++++++++ .../Controllers/DraftsController.swift | 165 ++++++++ .../Controllers/PlaceholderController.swift | 48 +++ .../Controllers/PollController.swift | 182 +++++++++ .../Controllers/ToolbarController.swift | 160 ++++++++ .../Sources/ComposeUI/FuzzyMatcher.swift | 62 +++ .../Sources/ComposeUI/KeyboardReader.swift | 29 ++ .../ComposeUI/Model/AttachmentData.swift | 278 +++++++++++++ .../Sources/ComposeUI/Model/DismissMode.swift | 12 + .../Sources/ComposeUI/Model/Draft.swift | 177 ++++++++ .../ComposeUI/Model/DraftAttachment.swift | 117 ++++++ .../ComposeUI/Model/DraftsManager.swift | 93 +++++ .../ComposeUI/Model/StatusFormat.swift | 95 +++++ .../ComposeUI/OptionalObservedObject.swift | 33 ++ .../Sources/ComposeUI/PKDrawing+Render.swift | 33 ++ .../ComposeUI/TextViewCaretScrolling.swift | 60 +++ .../ComposeUI/UITextInput+Autocomplete.swift | 183 +++++++++ .../ComposeUI/View+ForwardsCompat.swift | 20 + .../Sources/ComposeUI/ViewController.swift | 29 ++ .../Views/AttachmentDescriptionTextView.swift | 112 ++++++ .../Views/AttachmentThumbnailView.swift | 117 ++++++ .../ComposeUI/Views/AvatarImageView.swift | 42 ++ .../ComposeUI/Views/CurrentAccountView.swift | 35 ++ .../ComposeUI/Views/EmojiTextField.swift | 137 +++++++ .../Sources/ComposeUI/Views/HeaderView.swift | 34 ++ .../ComposeUI/Views/MainTextView.swift | 293 ++++++++++++++ .../ComposeUI/Views/PollOptionView.swift | 75 ++++ .../ComposeUI/Views/ReplyStatusView.swift | 90 +++++ .../ComposeUI/Views/WrappedProgressView.swift | 29 ++ .../ComposeUITests/FuzzyMatcherTests.swift | 25 ++ .../InstanceFeatures/InstanceFeatures.swift | 8 + .../Sources/Pachyderm/Model/Hashtag.swift | 2 +- .../Model/Protocols/AccountProtocol.swift | 3 +- .../Protocols/RelationshipProtocol.swift | 21 + .../Pachyderm/Model/Relationship.swift | 20 +- .../AbbreviatedTimeAgoFormatStyle.swift | 70 ++++ .../TuskerComponents}/AlertWithData.swift | 4 +- Tusker.xcodeproj/project.pbxproj | 29 +- Tusker/API/MastodonController.swift | 50 +++ Tusker/API/PostService.swift | 8 +- .../MastodonCachePersistentStore.swift | 4 +- Tusker/CoreData/RelationshipMO.swift | 10 +- Tusker/Extensions/Date+TimeAgo.swift | 26 +- Tusker/Models/CompositionAttachment.swift | 4 +- Tusker/Models/DraftsManager.swift | 56 +-- Tusker/Models/{Draft.swift => OldDraft.swift} | 20 +- Tusker/Scenes/ComposeSceneDelegate.swift | 11 +- Tusker/Scenes/MainSceneDelegate.swift | 4 +- .../Screens/Compose/ComposeAssetPicker.swift | 3 +- .../Compose/ComposeAttachmentRow.swift | 2 +- .../Compose/ComposeAttachmentsList.swift | 16 +- .../Compose/ComposeHostingController.swift | 26 +- Tusker/Screens/Compose/ComposePollView.swift | 12 +- .../Compose/ComposeReplyContentView.swift | 15 +- Tusker/Screens/Compose/ComposeReplyView.swift | 3 +- Tusker/Screens/Compose/ComposeToolbar.swift | 4 +- Tusker/Screens/Compose/ComposeUIState.swift | 6 +- Tusker/Screens/Compose/ComposeView.swift | 9 +- Tusker/Screens/Compose/DraftsView.swift | 117 +++--- .../Screens/Compose/MainComposeTextView.swift | 2 +- .../Compose/NewComposeHostingController.swift | 223 +++++++++++ ...ountSwitchingContainerViewController.swift | 1 + Tusker/Screens/Main/Duckable+Root.swift | 7 +- .../Main/MainTabBarViewController.swift | 1 + .../Main/TuskerRootViewController.swift | 1 + Tusker/Screens/Utilities/Previewing.swift | 14 +- .../UserActivityHandlingContext.swift | 1 + Tusker/Shortcuts/UserActivityManager.swift | 1 + Tusker/TuskerNavigationDelegate.swift | 17 +- Tusker/Views/StatusContentTextView.swift | 2 +- TuskerUITests/ComposeTests.swift | 44 +- 86 files changed, 5062 insertions(+), 245 deletions(-) create mode 100644 Packages/ComposeUI/.gitignore create mode 100644 Packages/ComposeUI/Package.resolved create mode 100644 Packages/ComposeUI/Package.swift create mode 100644 Packages/ComposeUI/README.md create mode 100644 Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift rename Packages/{Pachyderm/Sources/Pachyderm/Utilities => ComposeUI/Sources/ComposeUI}/CharacterCounter.swift (67%) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/ViewController.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift create mode 100644 Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift create mode 100644 Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift rename {Tusker/Views => Packages/TuskerComponents/Sources/TuskerComponents}/AlertWithData.swift (83%) rename Tusker/Models/{Draft.swift => OldDraft.swift} (94%) create mode 100644 Tusker/Screens/Compose/NewComposeHostingController.swift diff --git a/Packages/ComposeUI/.gitignore b/Packages/ComposeUI/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/ComposeUI/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/ComposeUI/Package.resolved b/Packages/ComposeUI/Package.resolved new file mode 100644 index 00000000..944ec2a7 --- /dev/null +++ b/Packages/ComposeUI/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-url", + "kind" : "remoteSourceControl", + "location" : "https://github.com/karwa/swift-url.git", + "state" : { + "branch" : "main", + "revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30" + } + } + ], + "version" : 2 +} diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift new file mode 100644 index 00000000..1de4d548 --- /dev/null +++ b/Packages/ComposeUI/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ComposeUI", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ComposeUI", + targets: ["ComposeUI"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(path: "../Pachyderm"), + .package(path: "../InstanceFeatures"), + .package(path: "../TuskerComponents"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ComposeUI", + dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]), + .testTarget( + name: "ComposeUITests", + dependencies: ["ComposeUI"]), + ] +) diff --git a/Packages/ComposeUI/README.md b/Packages/ComposeUI/README.md new file mode 100644 index 00000000..99e51ad0 --- /dev/null +++ b/Packages/ComposeUI/README.md @@ -0,0 +1,3 @@ +# ComposeUI + +A description of this package. diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift new file mode 100644 index 00000000..7d823359 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -0,0 +1,139 @@ +// +// PostService.swift +// Tusker +// +// Created by Shadowfacts on 4/27/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm +import UniformTypeIdentifiers + +class PostService: ObservableObject { + private let mastodonController: ComposeMastodonContext + private let config: ComposeUIConfig + private let draft: Draft + let totalSteps: Int + + @Published var currentStep = 1 + + init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) { + self.mastodonController = mastodonController + self.config = config + self.draft = draft + // 2 steps (request data, then upload) for each attachment + self.totalSteps = 2 + (draft.attachments.count * 2) + } + + @MainActor + func post() async throws { + guard draft.hasContent else { + return + } + + // save before posting, so if a crash occurs during network request, the status won't be lost + DraftsManager.save() + + let uploadedAttachments = try await uploadAttachments() + + let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil + let sensitive = contentWarning != nil + + let request = Client.createStatus( + text: textForPosting(), + contentType: config.contentType, + inReplyTo: draft.inReplyToID, + media: uploadedAttachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: draft.visibility, + language: nil, + pollOptions: draft.poll?.options.map(\.text), + pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), + pollMultiple: draft.poll?.multiple, + localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil + ) + do { + let (_, _) = try await mastodonController.run(request) + currentStep += 1 + + DraftsManager.shared.remove(self.draft) + DraftsManager.save() + } catch let error as Client.Error { + throw Error.posting(error) + } + } + + private func uploadAttachments() async throws -> [Attachment] { + var attachments: [Attachment] = [] + attachments.reserveCapacity(draft.attachments.count) + for (index, attachment) in draft.attachments.enumerated() { + let data: Data + let utType: UTType + do { + (data, utType) = try await getData(for: attachment) + currentStep += 1 + } catch let error as AttachmentData.Error { + throw Error.attachmentData(index: index, cause: error) + } + do { + let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) + attachments.append(uploaded) + currentStep += 1 + } catch let error as Client.Error { + throw Error.attachmentUpload(index: index, cause: error) + } + } + return attachments + } + + private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) { + return try await withCheckedThrowingContinuation { continuation in + attachment.data.getData(features: mastodonController.instanceFeatures) { result in + switch result { + case let .success(res): + continuation.resume(returning: res) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { + let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") + let req = Client.upload(attachment: formAttachment, description: description) + return try await mastodonController.run(req).0 + } + + private func textForPosting() -> String { + var text = draft.text + // when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text, + // which we want to strip out before actually posting the status + text = text.replacingOccurrences(of: "\u{fffc}", with: "") + + if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack { + text += " 👁" + } + + return text + } + + enum Error: Swift.Error, LocalizedError { + case attachmentData(index: Int, cause: AttachmentData.Error) + case attachmentUpload(index: Int, cause: Client.Error) + case posting(Client.Error) + + var localizedDescription: String { + switch self { + case let .attachmentData(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .attachmentUpload(index: index, cause: cause): + return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .posting(error): + return error.localizedDescription + } + } + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift similarity index 67% rename from Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift rename to Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift index 59bf1f25..2e2002cc 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/CharacterCounter.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift @@ -1,24 +1,25 @@ // // CharacterCounter.swift -// Pachyderm +// ComposeUI // // Created by Shadowfacts on 9/29/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation +import InstanceFeatures public struct CharacterCounter { - static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) + private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) - public static func count(text: String, for instance: Instance? = nil) -> Int { + public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int { let mentionsRemoved = removeMentions(in: text) var count = mentionsRemoved.count for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) { count -= match.range.length - count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length + count += instanceFeatures.charsReservedPerURL } return count } diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift new file mode 100644 index 00000000..a5f47aaf --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -0,0 +1,27 @@ +// +// ComposeInput.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import Foundation +import Combine + +protocol ComposeInput: AnyObject, ObservableObject { + var toolbarElements: [ToolbarElement] { get } + + var autocompleteState: AutocompleteState? { get } + var autocompleteStatePublisher: Published.Publisher { get } + + func autocomplete(with string: String) + + func applyFormat(_ format: StatusFormat) + + func beginAutocompletingEmoji() +} + +enum ToolbarElement { + case emojiPicker + case formattingButtons +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift new file mode 100644 index 00000000..2eb2482a --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift @@ -0,0 +1,26 @@ +// +// ComposeMastodonContext.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import Foundation +import Pachyderm +import InstanceFeatures +import UserAccounts + +public protocol ComposeMastodonContext { + var accountInfo: UserAccountInfo? { get } + var instanceFeatures: InstanceFeatures { get } + + func run(_ request: Request) async throws -> (Result, Pagination?) + func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) + + @MainActor + func searchCachedAccounts(query: String) -> [AccountProtocol] + @MainActor + func cachedRelationship(for accountID: String) -> RelationshipProtocol? + @MainActor + func searchCachedHashtags(query: String) -> [Hashtag] +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift new file mode 100644 index 00000000..f94f62df --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -0,0 +1,46 @@ +// +// ComposeUIConfig.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm +import PhotosUI +import PencilKit + +public struct ComposeUIConfig { + public var backgroundColor = Color(uiColor: .systemBackground) + public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground) + public var groupedCellBackgroundColor = Color(uiColor: .systemBackground) + public var fillColor = Color(uiColor: .systemFill) + public var avatarStyle = AvatarStyle.roundRect + public var useTwitterKeyboard = false + public var contentType = StatusContentType.plain + public var automaticallySaveDrafts = false + public var requireAttachmentDescriptions = false + + public var dismiss: @MainActor (DismissMode) -> Void = { _ in } + public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? + public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)? + public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil } + + public init() { + } +} + +extension ComposeUIConfig { + public enum AvatarStyle: Equatable { + case roundRect, circle + + var cornerRadiusFraction: CGFloat { + switch self { + case .roundRect: + return 0.1 + case .circle: + return 0.5 + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift new file mode 100644 index 00000000..4f09c9d9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -0,0 +1,162 @@ +// +// AttachmentRowController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/12/23. +// + +import SwiftUI +import TuskerComponents +import Vision + +class AttachmentRowController: ViewController { + let parent: ComposeController + let attachment: DraftAttachment + + @Published var descriptionMode: DescriptionMode = .allowEntry + @Published var textRecognitionError: Error? + + init(parent: ComposeController, attachment: DraftAttachment) { + self.parent = parent + self.attachment = attachment + } + + var view: some View { + AttachmentView(attachment: attachment) + } + + private func removeAttachment() { + withAnimation { + parent.draft.attachments.removeAll(where: { $0.id == attachment.id }) + } + } + + private func editDrawing() { + guard case .drawing(let drawing) = attachment.data else { + return + } + parent.config.presentDrawing?(drawing) { newDrawing in + self.attachment.data = .drawing(newDrawing) + } + } + + private func recognizeText() { + descriptionMode = .recognizingText + + DispatchQueue.global(qos: .userInitiated).async { + self.attachment.data.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in + 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 + + init(attachment: DraftAttachment) { + self.attachment = attachment + } + + var body: some View { + HStack(alignment: .center, spacing: 4) { + AttachmentThumbnailView(attachment: attachment, fullSize: false) + .frame(width: 80, height: 80) + .cornerRadius(8) + .contextMenu { + if case .drawing(_) = attachment.data { + Button(action: controller.editDrawing) { + Label("Edit Drawing", systemImage: "hand.draw") + } + } else if attachment.data.type == .image { + Button(action: controller.recognizeText) { + Label("Recognize Text", systemImage: "doc.text.viewfinder") + } + } + + Button(role: .destructive, action: controller.removeAttachment) { + Label("Delete", systemImage: "trash") + } + } previewIfAvailable: { + AttachmentThumbnailView(attachment: attachment, fullSize: true) + } + + switch controller.descriptionMode { + case .allowEntry: + AttachmentDescriptionTextView( + text: $attachment.attachmentDescription, + placeholder: Text("Describe for the visually impaired…"), + minHeight: 80 + ) + + case .recognizingText: + ProgressView() + .progressViewStyle(.circular) + } + } + .alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in + Button("OK") {} + } message: { error in + Text(error.localizedDescription) + } + } + } + +} + +extension AttachmentRowController { + enum DescriptionMode { + case allowEntry, recognizingText + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { + if #available(iOS 16.0, *) { + self.contextMenu(menuItems: menuItems, preview: preview) + } else { + self.contextMenu(menuItems: menuItems) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift new file mode 100644 index 00000000..182df4bf --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -0,0 +1,233 @@ +// +// 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 { + return draft.attachments.allSatisfy { + !$0.attachmentDescription.isEmpty + } + } else { + return false + } + } + + private var validAttachmentCombination: Bool { + if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + return true + } else if draft.attachments.contains(where: { $0.data.type == .video }) && + draft.attachments.count > 1 { + return false + } else if draft.attachments.count > 4 { + return false + } + return true + } + + init(parent: ComposeController) { + self.parent = parent + } + + private var canAddAttachment: Bool { + if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil + } else { + return true + } + } + + private var canAddPoll: Bool { + if parent.mastodonController.instanceFeatures.pollsAndAttachments { + return true + } else { + return draft.attachments.isEmpty + } + } + + var view: some View { + AttachmentsList() + } + + private func moveAttachments(from source: IndexSet, to destination: Int) { + draft.attachments.move(fromOffsets: source, toOffset: destination) + } + + private func deleteAttachments(at indices: IndexSet) { + draft.attachments.remove(atOffsets: indices) + } + + @MainActor + private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) async { + 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.canAddAttachment else { return } + self.draft.attachments.append(attachment) + } + } + } + } + + private func addImage() { + parent.config.presentAssetPicker?({ results in + Task { + await self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider)) + } + }) + } + + private func addDrawing() { + parent.config.presentDrawing?(PKDrawing()) { drawing in + self.draft.attachments.append(DraftAttachment(data: .drawing(drawing))) + } + } + + private func togglePoll() { + UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) + + withAnimation { + draft.poll = draft.poll == nil ? Draft.Poll() : 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 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + Group { + attachmentsList + + 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)) + } + } + + private var attachmentsList: some View { + ForEach(draft.attachments) { attachment in + ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + .onDrag { + NSItemProvider(object: attachment) + } + } + .onMove(perform: controller.moveAttachments) + .onDelete(perform: controller.deleteAttachments) + .conditionally(controller.canAddAttachment) { + $0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in + Task { + await controller.insertAttachments(at: offset, itemProviders: providers) + } + }) + } + } + + 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) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift new file mode 100644 index 00000000..c6867d74 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteController.swift @@ -0,0 +1,83 @@ +// +// 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 new file mode 100644 index 00000000..fba41c9b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift @@ -0,0 +1,196 @@ +// +// AutocompleteEmojisController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/26/23. +// + +import SwiftUI +import Pachyderm +import Combine + +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] = [] + + var emojisBySection: [String: [Emoji]] { + var values: [String: [Emoji]] = [:] + for emoji in emojis { + let key = emoji.category ?? "" + if !values.keys.contains(key) { + values[key] = [emoji] + } else { + values[key]!.append(emoji) + } + } + return values + } + + 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 { + await self.queryChanged(query) + } + } + } + + @MainActor + private func queryChanged(_ query: String) async { + var emojis = await withCheckedContinuation { continuation in + composeController.mastodonController.getCustomEmojis { + continuation.resume(returning: $0) + } + } + 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]() + for emoji in emojis where !shortcodes.contains(emoji.shortcode) { + newEmojis.append(emoji) + shortcodes.insert(emoji.shortcode) + } + self.emojis = newEmojis + } + + 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) { + HStack(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) + + Spacer(minLength: emojiSize) + } + .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 new file mode 100644 index 00000000..3d964d1e --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteHashtagsController.swift @@ -0,0 +1,124 @@ +// +// AutocompleteHashtagsController.swift +// ComposeUI +// +// Created by Shadowfacts on 4/1/23. +// + +import SwiftUI +import Combine +import Pachyderm + +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 { + 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 new file mode 100644 index 00000000..2e63c5fe --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift @@ -0,0 +1,171 @@ +// +// AutocompleteMentionsController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import Combine +import Pachyderm + +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() + self.searchTask = Task { + 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 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) + + 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 new file mode 100644 index 00000000..2088c456 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -0,0 +1,379 @@ +// +// ComposeController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Combine +import Pachyderm + +public final class ComposeController: ViewController { + public typealias FetchAvatar = (URL) async -> UIImage? + public typealias FetchStatus = (String) -> (any StatusProtocol)? + public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView + public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView + public typealias EmojiImageView = (Emoji) -> AnyView + + @Published public private(set) var draft: Draft + @Published public var config: ComposeUIConfig + let mastodonController: ComposeMastodonContext + let fetchAvatar: FetchAvatar + let fetchStatus: FetchStatus + let displayNameLabel: DisplayNameLabel + let replyContentView: ReplyContentView + let emojiImageView: EmojiImageView + + @Published public var currentAccount: (any AccountProtocol)? + @Published public var showToolbar = true + + @Published var autocompleteController: AutocompleteController! + @Published var toolbarController: ToolbarController! + @Published var attachmentsListController: AttachmentsListController! + + @Published var contentWarningBecomeFirstResponder = false + @Published var mainComposeTextViewBecomeFirstResponder = false + @Published var currentInput: (any ComposeInput)? = nil + @Published var shouldEmojiAutocompletionBeginExpanded = false + @Published var isShowingSaveDraftSheet = false + @Published var isShowingDraftsList = false + @Published var poster: PostService? + @Published var postError: (any Error)? + + var isPosting: Bool { + poster != nil + } + + var charactersRemaining: Int { + let instanceFeatures = mastodonController.instanceFeatures + let limit = instanceFeatures.maxStatusChars + let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 + return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures)) + } + + var postButtonEnabled: Bool { + draft.hasContent + && charactersRemaining >= 0 + && !isPosting + && attachmentsListController.isValid + && isPollValid + } + + private var isPollValid: Bool { + draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty } + } + + public init( + draft: Draft, + config: ComposeUIConfig, + mastodonController: ComposeMastodonContext, + fetchAvatar: @escaping FetchAvatar, + fetchStatus: @escaping FetchStatus, + displayNameLabel: @escaping DisplayNameLabel, + replyContentView: @escaping ReplyContentView, + emojiImageView: @escaping EmojiImageView + ) { + self.draft = draft + self.config = config + self.mastodonController = mastodonController + self.fetchAvatar = fetchAvatar + self.fetchStatus = fetchStatus + self.displayNameLabel = displayNameLabel + self.replyContentView = replyContentView + self.emojiImageView = emojiImageView + + self.autocompleteController = AutocompleteController(parent: self) + self.toolbarController = ToolbarController(parent: self) + self.attachmentsListController = AttachmentsListController(parent: self) + } + + public var view: some View { + ComposeView(poster: poster) + .environmentObject(draft) + .environmentObject(mastodonController.instanceFeatures) + } + + public func canPaste(itemProviders: [NSItemProvider]) -> Bool { + guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else { + return false + } + if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { + if draft.attachments.allSatisfy({ $0.data.type == .image }) { + // if providers are videos, this technically allows invalid video/image combinations + return itemProviders.count + draft.attachments.count <= 4 + } else { + return false + } + } else { + return true + } + } + + public func paste(itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { + self.draft.attachments.append(attachment) + } + } + } + } + + @MainActor + func cancel() { + if config.automaticallySaveDrafts { + config.dismiss(.cancel) + } else { + if draft.hasContent { + isShowingSaveDraftSheet = true + } else { + DraftsManager.shared.remove(draft) + config.dismiss(.cancel) + } + } + } + + func postStatus() { + guard !isPosting, + draft.hasContent else { + return + } + + Task { @MainActor in + let poster = PostService(mastodonController: mastodonController, config: config, draft: draft) + self.poster = poster + + // try to resign the first responder, if there is one. + // otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide + // and the first responder to change during a view update, which in turn triggers a bunch of state changes + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + do { + try await poster.post() + + // wait .25 seconds so the user can see the progress bar has completed + try? await Task.sleep(nanoseconds: 250_000_000) + + config.dismiss(.post) + + // don't unset the poster, so the ui remains disabled while dismissing + + } catch let error as PostService.Error { + self.postError = error + self.poster = nil + } catch { + fatalError("unreachable") + } + } + } + + func showDrafts() { + isShowingDraftsList = true + } + + func selectDraft(_ draft: Draft) { + if !self.draft.hasContent { + DraftsManager.shared.remove(self.draft) + } + DraftsManager.save() + + self.draft = draft + } + + func onDisappear() { + if !draft.hasContent { + DraftsManager.shared.remove(draft) + } + DraftsManager.save() + } + + func toggleContentWarning() { + draft.contentWarningEnabled.toggle() + if draft.contentWarningEnabled { + contentWarningBecomeFirstResponder = true + } + } + + struct ComposeView: View { + @OptionalObservedObject var poster: PostService? + @EnvironmentObject var controller: ComposeController + @EnvironmentObject var draft: Draft + @StateObject private var keyboardReader = KeyboardReader() + @State private var globalFrameOutsideList = CGRect.zero + + init(poster: PostService?) { + self.poster = poster + } + + var config: ComposeUIConfig { + controller.config + } + + var body: some View { + ZStack(alignment: .top) { + // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed + config.backgroundColor + .edgesIgnoringSafeArea(.all) + + mainList + + if let poster = poster { + // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 + WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if controller.showToolbar { + VStack(spacing: 0) { + ControllerView(controller: { controller.autocompleteController }) + .transition(.move(edge: .bottom)) + .animation(.default, value: controller.currentInput?.autocompleteState) + + ControllerView(controller: { controller.toolbarController }) + } + // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this + .padding(.bottom, keyboardInset) + .transition(.move(edge: .bottom)) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { cancelButton } + ToolbarItem(placement: .confirmationAction) { postButton } + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global)) + .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in + globalFrameOutsideList = newValue + } + }) + .sheet(isPresented: $controller.isShowingDraftsList) { + ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) }) + } + .alertWithData("Error Posting", data: $controller.postError, actions: { _ in + Button("OK") {} + }, message: { error in + Text(error.localizedDescription) + }) + .onDisappear(perform: controller.onDisappear) + .navigationTitle(navTitle) + } + + private var navTitle: String { + if let id = draft.inReplyToID, + let status = controller.fetchStatus(id) { + return "Reply to @\(status.account.acct)" + } else { + return "New Post" + } + } + + private var mainList: some View { + List { + if let id = draft.inReplyToID, + let status = controller.fetchStatus(id) { + ReplyStatusView( + status: status, + rowTopInset: 8, + globalFrameOutsideList: globalFrameOutsideList + ) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + HeaderView() + .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + + if draft.contentWarningEnabled { + EmojiTextField( + text: $draft.contentWarning, + placeholder: "Write your warning here", + maxLength: nil, + becomeFirstResponder: $controller.contentWarningBecomeFirstResponder, + focusNextView: $controller.mainComposeTextViewBecomeFirstResponder + ) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + MainTextView() + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + + if let poll = draft.poll { + ControllerView(controller: { PollController(parent: controller, poll: poll) }) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) + } + + ControllerView(controller: { controller.attachmentsListController }) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) + .listRowBackground(config.backgroundColor) + } + .listStyle(.plain) + .scrollDismissesKeyboardInteractivelyIfAvailable() + .disabled(controller.isPosting) + } + + private var cancelButton: some View { + Button(action: controller.cancel) { + Text("Cancel") + // otherwise all Buttons in the nav bar are made semibold + .font(.system(size: 17, weight: .regular)) + } + } + + @ViewBuilder + private var postButton: some View { + if draft.hasContent { + Button(action: controller.postStatus) { + Text("Post") + } + .keyboardShortcut(.return, modifiers: .command) + .disabled(!controller.postButtonEnabled) + } else { + Button(action: controller.showDrafts) { + Text("Drafts") + } + } + } + + @available(iOS, obsoleted: 16.0) + private var keyboardInset: CGFloat { + if #unavailable(iOS 16.0), + UIDevice.current.userInterfaceIdiom == .pad, + keyboardReader.isVisible { + return ToolbarController.height + } else { + return 0 + } + } + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } + } +} + +private struct GlobalFrameOutsideListPrefKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift new file mode 100644 index 00000000..8ee4c017 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -0,0 +1,165 @@ +// +// DraftsController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import SwiftUI +import TuskerComponents + +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) { + DraftsManager.shared.remove(draft) + } + + func closeDrafts() { + isPresented = false + DraftsManager.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 + @ObservedObject private var draftsManager = DraftsManager.shared + + private var visibleDrafts: [Draft] { + draftsManager.sorted.filter { + $0.accountID == controller.parent.mastodonController.accountInfo!.id && $0.id != currentDraft.id + } + } + + var body: some View { + NavigationView { + List { + ForEach(visibleDrafts) { 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 { visibleDrafts[$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?") + } + } + + private var cancelButton: some View { + Button(action: controller.closeDrafts) { + Text("Cancel") + } + } + } +} + +private struct DraftRow: View { + @ObservedObject var draft: Draft + + var body: some View { + HStack { + VStack(alignment: .leading) { + if draft.contentWarningEnabled { + Text(draft.contentWarning) + .font(.body.bold()) + .foregroundColor(.secondary) + } + + Text(draft.text) + .font(.body) + + HStack(spacing: 8) { + ForEach(draft.attachments) { attachment in + AttachmentThumbnailView(attachment: attachment, fullSize: false) + .frame(width: 50, height: 50) + .cornerRadius(5) + } + } + } + + Spacer() + + Text(draft.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/PlaceholderController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift new file mode 100644 index 00000000..0b04a5d4 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift @@ -0,0 +1,48 @@ +// +// PlaceholderController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/6/23. +// + +import SwiftUI + +final class PlaceholderController: ViewController, PlaceholderViewProvider { + + private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView() + + static func makePlaceholderView() -> some View { + let components = Calendar.current.dateComponents([.month, .day], from: Date()) + if components.month == 3 && components.day == 14, + Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { + Text("Happy π day!") + } else if components.month == 4 && components.day == 1 { + Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) + } else if components.month == 9 && components.day == 5 { + // https://weirder.earth/@noracodes/109276419847254552 + // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 + Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic() + } else if components.month == 9 && components.day == 21 { + Text("Do you remember?") + } else if components.month == 10 && components.day == 31 { + if .random() { + Text("Post something spooky!") + } else { + Text("Any questions?") + } + } else { + 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 +private protocol PlaceholderViewProvider { + associatedtype PlaceholderView: View + @ViewBuilder + static func makePlaceholderView() -> PlaceholderView +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift new file mode 100644 index 00000000..c13b8d85 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift @@ -0,0 +1,182 @@ +// +// 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: Draft.Poll + + @Published var duration: Duration + + init(parent: ComposeController, poll: Draft.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) { + poll.options.move(fromOffsets: indices, toOffset: newIndex) + } + + private func removeOption(_ option: Draft.Poll.Option) { + poll.options.removeAll(where: { $0.id == option.id }) + } + + private var canAddOption: Bool { + if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount { + return poll.options.count < max + } else { + return true + } + } + + private func addOption() { + poll.options.append(.init("")) + } + + struct PollView: View { + @EnvironmentObject private var controller: PollController + @EnvironmentObject private var poll: Draft.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.options) { 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( + backgroundColor + .cornerRadius(10) + ) + .onChange(of: controller.duration) { newValue in + poll.duration = newValue.timeInterval + } + } + + 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 new file mode 100644 index 00000000..a5002d3b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -0,0 +1,160 @@ +// +// ToolbarController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import SwiftUI +import Pachyderm +import TuskerComponents + +class ToolbarController: ViewController { + static let height: CGFloat = 44 + private static let visibilityOptions: [MenuPicker.Option] = Pachyderm.Visibility.allCases.map { vis in + .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") + } + + 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 + + @State private var minWidth: CGFloat? + @State private var realWidth: CGFloat? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + cwButton + + MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly) + // the button has a bunch of extra space by default, but combined with what we add it's too much + .padding(.horizontal, -8) + + if composeController.mastodonController.instanceFeatures.localOnlyPosts { + localOnlyPicker + .padding(.horizontal, -8) + } + + 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() + } + .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) + .overlay(alignment: .top) { + Divider() + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(ToolbarWidthPrefKey.self) { width in + minWidth = width + } + }) + } + + 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 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)) { + if let imageName = format.imageName { + Image(systemName: imageName) + .font(.system(size: imageSize)) + } else if let (str, attrs) = format.title { + let container = try! AttributeContainer(attrs, including: \.uiKit) + Text(AttributedString(str, attributes: container)) + } + } + .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/FuzzyMatcher.swift b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift new file mode 100644 index 00000000..a1344836 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/FuzzyMatcher.swift @@ -0,0 +1,62 @@ +// +// FuzzyMatcher.swift +// ComposeUI +// +// Created by Shadowfacts on 10/10/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +struct FuzzyMatcher { + + private init() {} + + /// Rudimentary string fuzzy matching algorithm. + /// + /// Operates on UTF-8 code points, so attempting to match strings which include characters composed of + /// multiple code points may produce unexpected results. + /// + /// Scoring is as follows: + /// +2 points for every char in `pattern` that occurs in `str` sequentially + /// -2 points for every char in `pattern` that does not occur in `str` sequentially + /// -1 point for every char in `str` skipped between matching chars from the `pattern` + static func match(pattern: String, str: String) -> (matched: Bool, score: Int) { + let pattern = pattern.lowercased() + let str = str.lowercased() + + var patternIndex = pattern.utf8.startIndex + var lastStrMatchIndex: String.UTF8View.Index? + var strIndex = str.utf8.startIndex + + var score = 0 + + while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex { + let patternChar = pattern.utf8[patternIndex] + let strChar = str.utf8[strIndex] + if patternChar == strChar { + let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex) + if distance > 1 { + score -= distance - 1 + } + + patternIndex = pattern.utf8.index(after: patternIndex) + lastStrMatchIndex = strIndex + strIndex = str.utf8.index(after: strIndex) + + score += 2 + } else { + strIndex = str.utf8.index(after: strIndex) + + if strIndex >= str.utf8.endIndex { + patternIndex = pattern.utf8.index(after: patternIndex) + strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex) + score -= 2 + } + } + } + + return (score > 0, score) + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift new file mode 100644 index 00000000..323dfe83 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift @@ -0,0 +1,29 @@ +// +// KeyboardReader.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import UIKit +import Combine + +@available(iOS, obsoleted: 16.0) +class KeyboardReader: ObservableObject { + @Published var isVisible = false + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc func willShow(_ notification: Foundation.Notification) { + // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" + let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect + isVisible = endFrame.height > 72 + } + + @objc func willHide() { + isVisible = false + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift new file mode 100644 index 00000000..7bd31cd2 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift @@ -0,0 +1,278 @@ +// +// AttachmentData.swift +// ComposeUI +// +// Created by Shadowfacts on 1/1/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import Photos +import UniformTypeIdentifiers +import PencilKit +import InstanceFeatures + +enum AttachmentData { + case asset(PHAsset) + case image(Data, originalType: UTType) + case video(URL) + case drawing(PKDrawing) + case gif(Data) + + var type: AttachmentType { + switch self { + case let .asset(asset): + return asset.attachmentType! + case .image(_, originalType: _): + return .image + case .video(_): + return .video + case .drawing(_): + return .image + case .gif(_): + return .image + } + } + + var isAsset: Bool { + switch self { + case .asset(_): + return true + default: + return false + } + } + + var canSaveToDraft: Bool { + switch self { + case .video(_): + return false + default: + return true + } + } + + func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { + switch self { + case let .image(originalData, originalType): + let data: Data + let type: UTType + switch originalType { + case .png, .jpeg: + data = originalData + type = originalType + default: + let image = UIImage(data: originalData)! + // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. + data = image.jpegData(compressionQuality: 0.8)! + type = .jpeg + } + let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion) + completion(.success(processed)) + case let .asset(asset): + if asset.mediaType == .image { + let options = PHImageRequestOptions() + options.version = .current + options.deliveryMode = .highQualityFormat + options.resizeMode = .none + options.isNetworkAccessAllowed = true + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in + guard let data = data, let dataUTI = dataUTI else { + completion(.failure(.missingData)) + return + } + let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) + completion(.success(processed)) + } + } else if asset.mediaType == .video { + let options = PHVideoRequestOptions() + options.deliveryMode = .automatic + options.isNetworkAccessAllowed = true + options.version = .current + PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in + if let exportSession = exportSession { + AttachmentData.exportVideoData(session: exportSession, completion: completion) + } else if let error = info?[PHImageErrorKey] as? Error { + completion(.failure(.videoExport(error))) + } else { + completion(.failure(.noVideoExportSession)) + } + } + } else { + fatalError("assetType must be either image or video") + } + case let .video(url): + let asset = AVURLAsset(url: url) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + completion(.failure(.noVideoExportSession)) + return + } + AttachmentData.exportVideoData(session: session, completion: completion) + + case let .drawing(drawing): + let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) + completion(.success((image.pngData()!, .png))) + case let .gif(data): + completion(.success((data, .gif))) + } + } + + private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { + guard !skipAllConversion else { + return (data, type) + } + + var data = data + var type = type + let image = CIImage(data: data)! + let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB + + // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG + // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) + // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion + if needsColorSpaceConversion || type == .heic { + let context = CIContext() + let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! + if type == .png { + data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! + } else { + data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! + type = .jpeg + } + } + + return (data, type) + } + + private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { + session.outputFileType = .mp4 + session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") + session.exportAsynchronously { + guard session.status == .completed else { + completion(.failure(.videoExport(session.error!))) + return + } + do { + let data = try Data(contentsOf: session.outputURL!) + completion(.success((data, .mpeg4Movie))) + } catch { + completion(.failure(.videoExport(error))) + } + } + } + + enum AttachmentType { + case image, video + } + + enum Error: Swift.Error, LocalizedError { + case missingData + case videoExport(Swift.Error) + case noVideoExportSession + + var localizedDescription: String { + switch self { + case .missingData: + return "Missing Data" + case .videoExport(let error): + return "Exporting video: \(error)" + case .noVideoExportSession: + return "Couldn't create video export session" + } + } + } +} + +extension PHAsset { + var attachmentType: AttachmentData.AttachmentType? { + switch self.mediaType { + case .image: + return .image + case .video: + return .video + default: + return nil + } + } +} + +extension AttachmentData: Codable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .asset(asset): + try container.encode("asset", forKey: .type) + try container.encode(asset.localIdentifier, forKey: .assetIdentifier) + case let .image(originalData, originalType): + try container.encode("image", forKey: .type) + try container.encode(originalType, forKey: .imageType) + try container.encode(originalData, forKey: .imageData) + case .video(_): + throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded")) + case let .drawing(drawing): + try container.encode("drawing", forKey: .type) + let drawingData = drawing.dataRepresentation() + try container.encode(drawingData, forKey: .drawing) + case .gif(_): + throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded")) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + switch try container.decode(String.self, forKey: .type) { + case "asset": + let identifier = try container.decode(String.self, forKey: .assetIdentifier) + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else { + throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier") + } + self = .asset(asset) + case "image": + let data = try container.decode(Data.self, forKey: .imageData) + if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) { + self = .image(data, originalType: type) + } else { + guard let image = UIImage(data: data) else { + throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage") + } + let jpegData = image.jpegData(compressionQuality: 1)! + self = .image(jpegData, originalType: .jpeg) + } + case "drawing": + let drawingData = try container.decode(Data.self, forKey: .drawing) + let drawing = try PKDrawing(data: drawingData) + self = .drawing(drawing) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") + } + } + + enum CodingKeys: CodingKey { + case type + case imageData + case imageType + /// The local identifier of the PHAsset for this attachment + case assetIdentifier + /// The PKDrawing object for this attachment. + case drawing + } +} + +extension AttachmentData: Equatable { + static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool { + switch (lhs, rhs) { + case let (.asset(a), .asset(b)): + return a.localIdentifier == b.localIdentifier + case let (.image(a, originalType: aType), .image(b, originalType: bType)): + return a == b && aType == bType + case let (.video(a), .video(b)): + return a == b + case let (.drawing(a), .drawing(b)): + return a == b + default: + return false + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift new file mode 100644 index 00000000..cbbeec69 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DismissMode.swift @@ -0,0 +1,12 @@ +// +// DismissMode.swift +// ComposeUI +// +// Created by Shadowfacts on 3/7/23. +// + +import Foundation + +public enum DismissMode { + case cancel, post +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift new file mode 100644 index 00000000..0d24852f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/Draft.swift @@ -0,0 +1,177 @@ +// +// Draft.swift +// ComposeUI +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import Combine +import Pachyderm + +public class Draft: Codable, Identifiable, ObservableObject { + public let id: UUID + var lastModified: Date + + @Published public var accountID: String + @Published public var text: String + @Published public var contentWarningEnabled: Bool + @Published public var contentWarning: String + @Published public var attachments: [DraftAttachment] + @Published public var inReplyToID: String? + @Published public var visibility: Visibility + @Published public var poll: Poll? + @Published public var localOnly: Bool + + var initialText: String + + public var hasContent: Bool { + (!text.isEmpty && text != initialText) || + (contentWarningEnabled && !contentWarning.isEmpty) || + attachments.count > 0 || + poll?.hasContent == true + } + + public init( + accountID: String, + text: String, + contentWarning: String, + inReplyToID: String?, + visibility: Visibility, + localOnly: Bool + ) { + self.id = UUID() + self.lastModified = Date() + + self.accountID = accountID + self.text = text + self.contentWarning = contentWarning + self.contentWarningEnabled = !contentWarning.isEmpty + self.attachments = [] + self.inReplyToID = inReplyToID + self.visibility = visibility + self.localOnly = localOnly + + self.initialText = text + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.lastModified = try container.decode(Date.self, forKey: .lastModified) + + self.accountID = try container.decode(String.self, forKey: .accountID) + self.text = try container.decode(String.self, forKey: .text) + self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) + self.contentWarning = try container.decode(String.self, forKey: .contentWarning) + self.attachments = try container.decode([DraftAttachment].self, forKey: .attachments) + self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) + self.visibility = try container.decode(Visibility.self, forKey: .visibility) + self.poll = try container.decode(Poll?.self, forKey: .poll) + self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false + + self.initialText = try container.decode(String.self, forKey: .initialText) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(lastModified, forKey: .lastModified) + + try container.encode(accountID, forKey: .accountID) + try container.encode(text, forKey: .text) + try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) + try container.encode(contentWarning, forKey: .contentWarning) + try container.encode(attachments, forKey: .attachments) + try container.encode(inReplyToID, forKey: .inReplyToID) + try container.encode(visibility, forKey: .visibility) + try container.encode(poll, forKey: .poll) + try container.encode(localOnly, forKey: .localOnly) + + try container.encode(initialText, forKey: .initialText) + } +} + +extension Draft: Equatable { + public static func ==(lhs: Draft, rhs: Draft) -> Bool { + return lhs.id == rhs.id + } +} + +extension Draft { + enum CodingKeys: String, CodingKey { + case id + case lastModified + + case accountID + case text + case contentWarningEnabled + case contentWarning + case attachments + case inReplyToID + case visibility + case poll + case localOnly + + case initialText + } +} + +extension Draft { + public class Poll: Codable, ObservableObject { + @Published public var options: [Option] + @Published public var multiple: Bool + @Published public var duration: TimeInterval + + var hasContent: Bool { + options.contains { !$0.text.isEmpty } + } + + public init() { + self.options = [Option(""), Option("")] + self.multiple = false + self.duration = 24 * 60 * 60 // 1 day + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.options = try container.decode([Option].self, forKey: .options) + self.multiple = try container.decode(Bool.self, forKey: .multiple) + self.duration = try container.decode(TimeInterval.self, forKey: .duration) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(options, forKey: .options) + try container.encode(multiple, forKey: .multiple) + try container.encode(duration, forKey: .duration) + } + + private enum CodingKeys: String, CodingKey { + case options + case multiple + case duration + } + + public class Option: Identifiable, Codable, ObservableObject { + public let id = UUID() + @Published public var text: String + + init(_ text: String) { + self.text = text + } + + public required init(from decoder: Decoder) throws { + self.text = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(text) + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift new file mode 100644 index 00000000..7f8de0f2 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftAttachment.swift @@ -0,0 +1,117 @@ +// +// DraftAttachment.swift +// ComposeUI +// +// Created by Shadowfacts on 3/14/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import UIKit +import UniformTypeIdentifiers + +public final class DraftAttachment: NSObject, Codable, ObservableObject, Identifiable { + static let typeIdentifier = "space.vaccor.Tusker.composition-attachment" + + public let id: UUID + @Published var data: AttachmentData + @Published var attachmentDescription: String + + init(data: AttachmentData, description: String = "") { + self.id = UUID() + self.data = data + self.attachmentDescription = description + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.data = try container.decode(AttachmentData.self, forKey: .data) + self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(data, forKey: .data) + try container.encode(attachmentDescription, forKey: .attachmentDescription) + } + + static func ==(lhs: DraftAttachment, rhs: DraftAttachment) -> Bool { + return lhs.id == rhs.id + } + + enum CodingKeys: String, CodingKey { + case id + case data + case attachmentDescription + } +} + +private let imageType = UTType.image.identifier +private let mp4Type = UTType.mpeg4Movie.identifier +private let quickTimeType = UTType.quickTimeMovie.identifier +private let dataType = UTType.data.identifier +private let gifType = UTType.gif.identifier + +extension DraftAttachment: NSItemProviderWriting { + public static var writableTypeIdentifiersForItemProvider: [String] { + [typeIdentifier] + } + + public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { + if typeIdentifier == DraftAttachment.typeIdentifier { + do { + completionHandler(try PropertyListEncoder().encode(self), nil) + } catch { + completionHandler(nil, error) + } + } else { + completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) + } + return nil + } + + enum ItemProviderError: Error { + case incompatibleTypeIdentifier + + var localizedDescription: String { + switch self { + case .incompatibleTypeIdentifier: + return "Cannot provide data for given type" + } + } + } +} + +extension DraftAttachment: NSItemProviderReading { + public static var readableTypeIdentifiersForItemProvider: [String] { + // todo: is there a better way of handling movies than manually adding all possible UTI types? + // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension + // without the file extension, getting the thumbnail and exporting the video for attachment upload fails + [typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider + } + + public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { + if typeIdentifier == DraftAttachment.typeIdentifier { + return try PropertyListDecoder().decode(DraftAttachment.self, from: data) + } else if typeIdentifier == gifType { + return DraftAttachment(data: .gif(data)) + } else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier) { + return DraftAttachment(data: .image(data, originalType: UTType(typeIdentifier)!)) + } else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let temporaryFileName = ProcessInfo().globallyUniqueString + let fileExt = type.preferredFilenameExtension! + let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt) + try data.write(to: temporaryFileURL) + return DraftAttachment(data: .video(temporaryFileURL)) + } else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL { + return DraftAttachment(data: .video(url)) + } else { + throw ItemProviderError.incompatibleTypeIdentifier + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift new file mode 100644 index 00000000..ef46ef08 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift @@ -0,0 +1,93 @@ +// +// DraftsManager.swift +// ComposeUI +// +// Created by Shadowfacts on 10/22/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation +import Combine + +public class DraftsManager: Codable, ObservableObject { + + public private(set) static var shared: DraftsManager = load() + + private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! + private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + + private static let saveQueue = DispatchQueue(label: "DraftsManager", qos: .utility) + + public static func save() { + saveQueue.async { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(shared) + try? data?.write(to: archiveURL, options: .noFileProtection) + } + } + + static func load() -> DraftsManager { + let decoder = PropertyListDecoder() + if let data = try? Data(contentsOf: archiveURL), + let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { + return draftsManager + } + return DraftsManager() + } + + private init() {} + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) { + self.drafts = dict.compactMapValues { $0.draft } + } else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) { + self.drafts = array.reduce(into: [:], { partialResult, safeDraft in + if let draft = safeDraft.draft { + partialResult[draft.id] = draft + } + }) + } else { + throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(drafts, forKey: .drafts) + } + + @Published private var drafts: [UUID: Draft] = [:] + var sorted: [Draft] { + return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) + } + + public func add(_ draft: Draft) { + drafts[draft.id] = draft + } + + public func remove(_ draft: Draft) { + drafts.removeValue(forKey: draft.id) + } + + public func getBy(id: UUID) -> Draft? { + return drafts[id] + } + + enum CodingKeys: String, CodingKey { + case drafts + } + + // a container that always succeeds at decoding + // so if a single draft can't be decoded, we don't lose all drafts + struct SafeDraft: Decodable { + let draft: Draft? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.draft = try? container.decode(Draft.self) + } + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift new file mode 100644 index 00000000..2357b019 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/StatusFormat.swift @@ -0,0 +1,95 @@ +// +// StatusFormat.swift +// ComposeUI +// +// Created by Shadowfacts on 1/12/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +enum StatusFormat: Int, CaseIterable { + case bold, italics, strikethrough, code + + func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? { + switch contentType { + case .plain: + return nil + case .markdown: + return Markdown.format(self) + case .html: + return HTML.format(self) + } + } + + var imageName: String? { + switch self { + case .italics: + return "italic" + case .bold: + return "bold" + case .strikethrough: + return "strikethrough" + default: + return nil + } + } + + var title: (String, [NSAttributedString.Key: Any])? { + if self == .code { + return ("", [.font: UIFont(name: "Menlo", size: 17)!]) + } else { + return nil + } + } + + var accessibilityLabel: String { + switch self { + case .italics: + return NSLocalizedString("Italics", comment: "italics text format accessibility label") + case .bold: + return NSLocalizedString("Bold", comment: "bold text format accessibility label") + case .strikethrough: + return NSLocalizedString("Strikethrough", comment: "strikethrough text format accessibility label") + case .code: + return NSLocalizedString("Code", comment: "code text format accessibility label") + } + } +} + +typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int) + +fileprivate protocol FormatType { + static func format(_ format: StatusFormat) -> FormatInsertionResult +} + +extension StatusFormat { + struct Markdown: FormatType { + static var formats: [StatusFormat: String] = [ + .italics: "_", + .bold: "**", + .strikethrough: "~~", + .code: "`" + ] + + static func format(_ format: StatusFormat) -> FormatInsertionResult { + let str = formats[format]! + return (str, str, str.count) + } + } + + struct HTML: FormatType { + static var tags: [StatusFormat: String] = [ + .italics: "em", + .bold: "strong", + .strikethrough: "del", + .code: "code" + ] + + static func format(_ format: StatusFormat) -> FormatInsertionResult { + let tag = tags[format]! + return ("<\(tag)>", "", tag.count + 2) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift new file mode 100644 index 00000000..fe2e9316 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/OptionalObservedObject.swift @@ -0,0 +1,33 @@ +// +// OptionalObservedObject.swift +// ComposeUI +// +// Created by Shadowfacts on 4/15/23. +// + +import SwiftUI +import Combine + +@propertyWrapper +struct OptionalObservedObject: DynamicProperty { + private class Republisher: ObservableObject { + var cancellable: AnyCancellable? + var wrapped: T? { + didSet { + cancellable?.cancel() + cancellable = wrapped?.objectWillChange + .receive(on: RunLoop.main) + .sink { [unowned self] _ in + self.objectWillChange.send() + } + } + } + } + + @StateObject private var republisher = Republisher() + var wrappedValue: T? + + func update() { + republisher.wrapped = wrappedValue + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift new file mode 100644 index 00000000..f6eb4273 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/PKDrawing+Render.swift @@ -0,0 +1,33 @@ +// +// PKDrawing+Render.swift +// ComposeUI +// +// Created by Shadowfacts on 5/9/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import PencilKit + +extension PKDrawing { + + func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage { + let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light) + var drawingImage: UIImage! + lightTraitCollection.performAsCurrent { + drawingImage = self.image(from: rect, scale: scale) + } + + let imageRect = CGRect(origin: .zero, size: rect.size) + let format = UIGraphicsImageRendererFormat() + format.opaque = false + format.scale = scale + let renderer = UIGraphicsImageRenderer(size: rect.size, format: format) + return renderer.image { (context) in + UIColor.white.setFill() + context.fill(imageRect) + drawingImage.draw(in: imageRect) + } + } + +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift new file mode 100644 index 00000000..aa38ce18 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/TextViewCaretScrolling.swift @@ -0,0 +1,60 @@ +// +// TextViewCaretScrolling.swift +// Tusker +// +// Created by Shadowfacts on 11/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +protocol TextViewCaretScrolling: AnyObject { + var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set } +} + +extension TextViewCaretScrolling { + func ensureCursorVisible(textView: UITextView) { + guard textView.isFirstResponder, + let range = textView.selectedTextRange, + let scrollView = findParentScrollView(of: textView) else { + return + } + + // We use a UIViewProperty animator to change the scroll view position so that we can store the currently + // running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations + // from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can + // happen if the user is pressing return and quickly creating many new lines. + + if let existing = caretScrollPositionAnimator { + existing.stopAnimation(true) + } + + let cursorRect = textView.caretRect(for: range.start) + var rectToMakeVisible = textView.convert(cursorRect, to: scrollView) + + // expand the rect to be three times the cursor height centered on the cursor so that there's + // some space between the bottom of the line of text being edited and the top of the keyboard + rectToMakeVisible.origin.y -= cursorRect.height + rectToMakeVisible.size.height *= 3 + + let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { + scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) + } + self.caretScrollPositionAnimator = animator + animator.startAnimation() + } + + private func findParentScrollView(of view: UIView) -> UIScrollView? { + var current: UIView = view + while let superview = current.superview { + if let scrollView = superview as? UIScrollView, + scrollView.isScrollEnabled { + return scrollView + } else { + current = superview + } + } + + return nil + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift new file mode 100644 index 00000000..5e8d5607 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/UITextInput+Autocomplete.swift @@ -0,0 +1,183 @@ +// +// UITextInput+Autocomplete.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import UIKit +import SwiftUI + +extension UITextInput { + func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) { + guard let selectedTextRange, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + let (lastWordStartIndex, _) = findAutocompleteLastWord() else { + return + } + + let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument) + + let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) + + let insertSpace: Bool + if distanceToEnd > 0 { + let charAfterCursor = text[characterBeforeCursorIndex] + insertSpace = charAfterCursor != " " && charAfterCursor != "\n" + } else { + insertSpace = true + } + let string = insertSpace ? string + " " : string + + let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))! + let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)! + replace(lastWordRange, withText: string) + + autocompleteState = updateAutocompleteState(permittedModes: permittedModes) + + // keep the cursor at the same position in the text, immediately after what was inserted + // if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space + let insertSpaceOffset = insertSpace ? 0 : 1 + let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)! + self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition) + } + + func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? { + guard let selectedTextRange, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + !text.isEmpty, + let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else { + return nil + } + + let triggerChars = permittedModes.triggerChars + + if lastWordStartIndex > text.startIndex { + // if the character before the "word" beginning is a valid part of a "word", + // we aren't able to autocomplete + let c = text[text.index(before: lastWordStartIndex)] + if isPermittedForAutocomplete(c) || triggerChars.contains(c) { + return nil + } + } + + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)) + + if lastWordStartIndex >= text.startIndex { + let lastWord = text[lastWordStartIndex.. (index: String.Index, foundFirstAtSign: Bool)? { + guard (self as? UIView)?.isFirstResponder == true, + let selectedTextRange, + selectedTextRange.isEmpty, + let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), + let text = self.text(in: wholeDocumentRange), + !text.isEmpty else { + return nil + } + + let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) + let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) + + guard cursorIndex != text.startIndex else { + return nil + } + + var lastWordStartIndex = text.index(before: cursorIndex) + var foundFirstAtSign = false + while true { + let c = text[lastWordStartIndex] + + if !isPermittedForAutocomplete(c) { + if foundFirstAtSign { + if c != "@" { + // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it + lastWordStartIndex = text.index(after: lastWordStartIndex) + } + break + } else { + if c == "@" { + foundFirstAtSign = true + } else if c != "." { + // periods are allowed for domain names in mentions + break + } + } + } + + guard lastWordStartIndex > text.startIndex else { + break + } + + lastWordStartIndex = text.index(before: lastWordStartIndex) + } + + return (lastWordStartIndex, foundFirstAtSign) + } +} + +enum AutocompleteState: Equatable { + case mention(String) + case emoji(String) + case hashtag(String) +} + +struct AutocompleteModes: OptionSet { + static let mentions = AutocompleteModes(rawValue: 1 << 0) + static let hashtags = AutocompleteModes(rawValue: 1 << 2) + static let emojis = AutocompleteModes(rawValue: 1 << 3) + + static let all: AutocompleteModes = [ + .mentions, + .hashtags, + .emojis, + ] + + let rawValue: Int + + var triggerChars: [Character] { + var chars: [Character] = [] + if contains(.mentions) { + chars.append("@") + } + if contains(.hashtags) { + chars.append("#") + } + if contains(.emojis) { + chars.append(":") + } + return chars + } +} + +private func isPermittedForAutocomplete(_ c: Character) -> Bool { + return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift new file mode 100644 index 00000000..418e36b9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift @@ -0,0 +1,20 @@ +// +// View+ForwardsCompat.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI + +extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { + if #available(iOS 16.0, *) { + self.scrollDisabled(disabled) + } else { + self + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift new file mode 100644 index 00000000..b08d953e --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift @@ -0,0 +1,29 @@ +// +// ViewController.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Combine + +public protocol ViewController: ObservableObject { + associatedtype ContentView: View + + @ViewBuilder + var view: ContentView { get } +} + +public struct ControllerView: View { + @StateObject private var controller: Controller + + public init(controller: @escaping () -> Controller) { + self._controller = StateObject(wrappedValue: controller()) + } + + public var body: some View { + controller.view + .environmentObject(controller) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift new file mode 100644 index 00000000..4f7dd68b --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift @@ -0,0 +1,112 @@ +// +// AttachmentDescriptionTextView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/12/23. +// + +import SwiftUI + +struct AttachmentDescriptionTextView: View { + @Binding private var text: String + private let placeholder: Text? + private let minHeight: CGFloat + + @State private var height: CGFloat? + + init(text: Binding, placeholder: Text?, minHeight: CGFloat) { + self._text = text + self.placeholder = placeholder + self.minHeight = minHeight + } + + var body: some View { + ZStack(alignment: .topLeading) { + if text.isEmpty, let placeholder { + placeholder + .font(.body) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + } + + WrappedTextView( + text: $text, + textDidChange: self.textDidChange, + font: .preferredFont(forTextStyle: .body) + ) + .frame(height: height ?? minHeight) + } + } + + private func textDidChange(_ textView: UITextView) { + height = max(minHeight, textView.contentSize.height) + } +} + +private struct WrappedTextView: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + let textDidChange: ((UITextView) -> Void) + let font: UIFont + + @Environment(\.isEnabled) private var isEnabled + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.delegate = context.coordinator + view.backgroundColor = .clear + view.font = font + view.adjustsFontForContentSizeCategory = true + view.textContainer.lineBreakMode = .byWordWrapping + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.text = text + uiView.isEditable = isEnabled + context.coordinator.textView = uiView + context.coordinator.text = $text + context.coordinator.didChange = textDidChange + // 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 { + self.textDidChange(uiView) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, didChange: textDidChange) + } + + class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling { + weak var textView: UITextView? + var text: Binding + var didChange: (UITextView) -> Void + var caretScrollPositionAnimator: UIViewPropertyAnimator? + + init(text: Binding, didChange: @escaping (UITextView) -> Void) { + self.text = text + self.didChange = didChange + + 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) + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + didChange(textView) + + ensureCursorVisible(textView: textView) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift new file mode 100644 index 00000000..caeaaac9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -0,0 +1,117 @@ +// +// AttachmentThumbnailView.swift +// ComposeUI +// +// Created by Shadowfacts on 11/10/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Photos +import TuskerComponents + +struct AttachmentThumbnailView: View { + let attachment: DraftAttachment + let fullSize: Bool + + @State private var gifData: Data? = nil + @State private var image: UIImage? = nil + @State private var imageContentMode: ContentMode = .fill + @State private var imageBackgroundColor: Color = .black + + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + var body: some View { + if let gifData { + GIFViewWrapper(gifData: gifData) + } else if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: imageContentMode) + .background(imageBackgroundColor) + } else { + Image(systemName: placeholderImageName) + .onAppear(perform: self.loadImage) + } + } + + private var placeholderImageName: String { + switch colorScheme { + case .light: + return "photo" + case .dark: + return "photo.fill" + @unknown default: + return "photo" + } + } + + private func loadImage() { + switch attachment.data { + case let .image(originalData, originalType: _): + self.image = UIImage(data: originalData) + case let .asset(asset): + let size: CGSize + if fullSize { + size = PHImageManagerMaximumSize + } else { + // currently only used as thumbnail in ComposeAttachmentRow + size = CGSize(width: 80, height: 80) + } + 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 + if typeIdentifier == UTType.gif.identifier { + self.gifData = data + } else if let data { + let image = UIImage(data: data) + DispatchQueue.main.async { + self.image = image + } + } + } + } else { + PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in + DispatchQueue.main.async { + self.image = image + } + } + } + case let .video(url): + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { + self.image = UIImage(cgImage: cgImage) + } + case let .drawing(drawing): + image = drawing.imageInLightMode(from: drawing.bounds) + imageContentMode = .fit + imageBackgroundColor = .white + case let .gif(data): + self.gifData = data + } + } +} + +private struct GIFViewWrapper: UIViewRepresentable { + typealias UIViewType = GIFImageView + + @State private var controller: GIFController + + init(gifData: Data) { + self._controller = State(wrappedValue: GIFController(gifData: gifData)) + } + + 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/AvatarImageView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift new file mode 100644 index 00000000..993e81fe --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift @@ -0,0 +1,42 @@ +// +// AvatarImageView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI + +struct AvatarImageView: View { + let url: URL? + let size: CGFloat + @State private var image: UIImage? + @EnvironmentObject private var controller: ComposeController + + var body: some View { + imageView + .resizable() + .frame(width: size, height: size) + .cornerRadius(controller.config.avatarStyle.cornerRadiusFraction * size) + .task { + if let url { + image = await controller.fetchAvatar(url) + } + } + // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes + .id(url) + + } + + private var imageView: Image { + if let image { + return Image(uiImage: image) + } else { + return placeholder + } + } + + private var placeholder: Image { + Image(systemName: controller.config.avatarStyle == .roundRect ? "person.crop.square" : "person.crop.circle") + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift new file mode 100644 index 00000000..87f6b2af --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -0,0 +1,35 @@ +// +// CurrentAccountView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm + +struct CurrentAccountView: View { + let account: (any AccountProtocol)? + @EnvironmentObject private var controller: ComposeController + + var body: some View { + HStack(alignment: .top) { + AvatarImageView(url: account?.avatar, size: 50) + .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/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift new file mode 100644 index 00000000..b5a0809c --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift @@ -0,0 +1,137 @@ +// +// EmojiTextField.swift +// ComposeUI +// +// Created by Shadowfacts on 3/5/23. +// + +import SwiftUI + +struct EmojiTextField: UIViewRepresentable { + typealias UIViewType = UITextField + + @EnvironmentObject private var controller: ComposeController + @Environment(\.colorScheme) private var colorScheme + + @Binding var text: String + let placeholder: String + let maxLength: Int? + let becomeFirstResponder: Binding? + let focusNextView: Binding? + + init(text: Binding, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) { + self._text = text + self.placeholder = placeholder + self.maxLength = maxLength + self.becomeFirstResponder = becomeFirstResponder + self.focusNextView = focusNextView + } + + func makeUIView(context: Context) -> UITextField { + let view = UITextField() + view.borderStyle = .roundedRect + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [ + .foregroundColor: UIColor.secondaryLabel, + ]) + + context.coordinator.textField = view + + view.delegate = context.coordinator + view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) + view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered) + + // otherwise when the text gets too wide it starts expanding the ComposeView + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + return view + } + + func updateUIView(_ uiView: UITextField, context: Context) { + if text != uiView.text { + uiView.text = text + } + + context.coordinator.text = $text + context.coordinator.maxLength = maxLength + context.coordinator.focusNextView = focusNextView + + uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground + + if becomeFirstResponder?.wrappedValue == true { + DispatchQueue.main.async { + uiView.becomeFirstResponder() + becomeFirstResponder!.wrappedValue = false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller, text: $text, focusNextView: focusNextView) + } + + class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { + let controller: ComposeController + var text: Binding + var focusNextView: Binding? + var maxLength: Int? + + @Published var autocompleteState: AutocompleteState? + var autocompleteStatePublisher: Published.Publisher { $autocompleteState } + + weak var textField: UITextField? + + init(controller: ComposeController, text: Binding, focusNextView: Binding?, maxLength: Int? = nil) { + self.controller = controller + self.text = text + self.focusNextView = focusNextView + self.maxLength = maxLength + } + + @objc func didChange(_ textField: UITextField) { + text.wrappedValue = textField.text ?? "" + } + + @objc func returnKeyPressed() { + focusNextView?.wrappedValue = true + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let maxLength { + return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength + } else { + return true + } + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + controller.currentInput = self + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + controller.currentInput = nil + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis) + } + + // MARK: ComposeInput + + var toolbarElements: [ToolbarElement] { [.emojiPicker] } + + func applyFormat(_ format: StatusFormat) { + } + + func beginAutocompletingEmoji() { + textField?.insertText(":") + } + + func autocomplete(with string: String) { + textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift new file mode 100644 index 00000000..9786f606 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/HeaderView.swift @@ -0,0 +1,34 @@ +// +// HeaderView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI +import Pachyderm +import InstanceFeatures + +struct HeaderView: View { + @EnvironmentObject private var controller: ComposeController + @EnvironmentObject private var draft: Draft + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + private var charsRemaining: Int { controller.charactersRemaining } + + var body: some View { + HStack(alignment: .top) { + CurrentAccountView(account: controller.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 new file mode 100644 index 00000000..079584c6 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift @@ -0,0 +1,293 @@ +// +// 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? + private let minHeight: CGFloat = 150 + private var effectiveHeight: CGFloat { height ?? minHeight } + + var config: ComposeUIConfig { + controller.config + } + + var body: some View { + ZStack(alignment: .topLeading) { + colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground) + + if draft.text.isEmpty { + ControllerView(controller: { PlaceholderController() }) + .font(.system(size: fontSize)) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + .accessibilityHidden(true) + } + + MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, textDidChange: textDidChange) + } + .frame(height: effectiveHeight) + .onAppear(perform: becomeFirstResponderOnFirstAppearance) + } + + private func becomeFirstResponderOnFirstAppearance() { + if !hasFirstAppeared { + hasFirstAppeared = true + controller.mainComposeTextViewBecomeFirstResponder = true + } + } + + private func textDidChange(textView: UITextView) { + height = max(textView.contentSize.height, minHeight) + } +} + +fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + @Binding var becomeFirstResponder: Bool + 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.backgroundColor = .clear + textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)) + textView.adjustsFontForContentSizeCategory = true + textView.textContainer.lineBreakMode = .byWordWrapping + 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 + + context.coordinator.text = $text + + // 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 + var image: UIImage? + if let imageName = fmt.imageName { + image = UIImage(systemName: imageName) + } + return UIAction(title: fmt.accessibilityLabel, image: image) { [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] + } + + 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/PollOptionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift new file mode 100644 index 00000000..10e4b198 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollOptionView.swift @@ -0,0 +1,75 @@ +// +// PollOptionView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI + +struct PollOptionView: View { + @EnvironmentObject private var controller: PollController + @EnvironmentObject private var poll: Draft.Poll + @ObservedObject private var option: Draft.Poll.Option + let remove: () -> Void + + init(option: Draft.Poll.Option, remove: @escaping () -> Void) { + self.option = option + self.remove = remove + } + + private var optionIndex: Int { + poll.options.firstIndex(where: { $0.id == option.id }) ?? 0 + } + + 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 placeholder = "Option \(optionIndex + 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/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift new file mode 100644 index 00000000..bf0e3b58 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -0,0 +1,90 @@ +// +// ReplyStatusView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI +import Pachyderm + +struct ReplyStatusView: View { + let status: any StatusProtocol + let rowTopInset: CGFloat + let globalFrameOutsideList: CGRect + + @EnvironmentObject private var controller: ComposeController + @State private var displayNameHeight: CGFloat? + @State private var contentHeight: CGFloat? + + private let horizSpacing: CGFloat = 8 + + var body: some View { + HStack(alignment: .top, spacing: horizSpacing) { + GeometryReader(content: self.replyAvatarImage) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 0) { + HStack { + controller.displayNameLabel(status.account, .body, 17) + .lineLimit(1) + .layoutPriority(1) + + Text(verbatim: "@\(status.account.acct)") + .font(.body.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height) + .onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in + displayNameHeight = newValue + } + }) + + controller.replyContentView(status) { newHeight in + // otherwise, with long in-reply-to statuses, the main content text view position seems not to update + // and it ends up partially behind the header + DispatchQueue.main.async { + contentHeight = newHeight + } + } + .frame(height: contentHeight ?? 0) + } + } + .frame(minHeight: 50, alignment: .top) + } + + private func replyAvatarImage(geometry: GeometryProxy) -> some View { + // using a coordinate space declared outside of the List doesn't work, so we do the math ourselves + let globalFrame = geometry.frame(in: .global) + let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY) + + // add rowTopInset so that the image is always at least rowTopInset away from the top + var offset = scrollOffset + rowTopInset + + // offset can never be less than 0 (i.e., above the top of the in-reply-to content) + offset = max(offset, 0) + + // subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view + let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0) + + // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content + offset = min(offset, maxOffset) + + return AvatarImageView(url: status.account.avatar, size: 50) + .offset(x: 0, y: offset) + .accessibilityHidden(true) + } + +} + +private struct DisplayNameHeightPrefKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift new file mode 100644 index 00000000..a6037682 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/WrappedProgressView.swift @@ -0,0 +1,29 @@ +// +// WrappedProgressView.swift +// Tusker +// +// Created by Shadowfacts on 8/30/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct WrappedProgressView: UIViewRepresentable { + typealias UIViewType = UIProgressView + + let value: Int + let total: Int + + func makeUIView(context: Context) -> UIProgressView { + return UIProgressView(progressViewStyle: .bar) + } + + func updateUIView(_ uiView: UIProgressView, context: Context) { + if total > 0 { + let progress = Float(value) / Float(total) + uiView.setProgress(progress, animated: true) + } else { + uiView.setProgress(0, animated: true) + } + } +} diff --git a/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift new file mode 100644 index 00000000..c00916b4 --- /dev/null +++ b/Packages/ComposeUI/Tests/ComposeUITests/FuzzyMatcherTests.swift @@ -0,0 +1,25 @@ +// +// FuzzyMatcherTests.swift +// ComposeUITests +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import ComposeUI + +class FuzzyMatcherTests: XCTestCase { + + func testExample() throws { + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6) + + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2) + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1) + + XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score) + } + +} diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index c8760bc8..6ddf11dc 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -19,6 +19,9 @@ public class InstanceFeatures: ObservableObject { @Published private var instanceType: InstanceType = .mastodon(.vanilla, nil) @Published public private(set) var maxStatusChars = 500 + @Published public private(set) var charsReservedPerURL = 23 + @Published public private(set) var maxPollOptionChars: Int? + @Published public private(set) var maxPollOptionsCount: Int? public var localOnlyPosts: Bool { switch instanceType { @@ -155,6 +158,11 @@ public class InstanceFeatures: ObservableObject { } maxStatusChars = instance.maxStatusCharacters ?? 500 + charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length + if let pollsConfig = instance.pollsConfiguration { + maxPollOptionChars = pollsConfig.maxCharactersPerOption + maxPollOptionsCount = pollsConfig.maxOptions + } _featuresUpdated.send() } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index e5c98bd4..85b2c877 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable { } public func hash(into hasher: inout Hasher) { - hasher.combine(url) + hasher.combine(name) } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift index 6b1be094..c3f26ae7 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/AccountProtocol.swift @@ -9,7 +9,6 @@ import Foundation public protocol AccountProtocol { - associatedtype Account: AccountProtocol var id: String { get } var username: String { get } @@ -27,7 +26,7 @@ public protocol AccountProtocol { var moved: Bool? { get } var bot: Bool? { get } - var movedTo: Account? { get } + var movedTo: Self? { get } var emojis: [Emoji] { get } var fields: [Pachyderm.Account.Field] { get } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift new file mode 100644 index 00000000..bfe47579 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/RelationshipProtocol.swift @@ -0,0 +1,21 @@ +// +// RelationshipProtocol.swift +// Pachyderm +// +// Created by Shadowfacts on 3/26/23. +// + +import Foundation + +public protocol RelationshipProtocol { + var accountID: String { get } + var following: Bool { get } + var followedBy: Bool { get } + var blocking: Bool { get } + var muting: Bool { get } + var mutingNotifications: Bool { get } + var followRequested: Bool { get } + var domainBlocking: Bool { get } + var showingReblogs: Bool { get } + var endorsed: Bool { get } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift index 66245702..96c1d03b 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Relationship.swift @@ -8,8 +8,8 @@ import Foundation -public struct Relationship: Decodable, Sendable { - public let id: String +public struct Relationship: RelationshipProtocol, Decodable, Sendable { + public let accountID: String public let following: Bool public let followedBy: Bool public let blocking: Bool @@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable { public let followRequested: Bool public let domainBlocking: Bool public let showingReblogs: Bool - public let endorsed: Bool? + public let endorsed: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accountID = try container.decode(String.self, forKey: .id) + self.following = try container.decode(Bool.self, forKey: .following) + self.followedBy = try container.decode(Bool.self, forKey: .followedBy) + self.blocking = try container.decode(Bool.self, forKey: .blocking) + self.muting = try container.decode(Bool.self, forKey: .muting) + self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications) + self.followRequested = try container.decode(Bool.self, forKey: .followRequested) + self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking) + self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs) + self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false + } private enum CodingKeys: String, CodingKey { case id diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift new file mode 100644 index 00000000..df10e99f --- /dev/null +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AbbreviatedTimeAgoFormatStyle.swift @@ -0,0 +1,70 @@ +// +// AbbreviatedTimeAgoFormatStyle.swift +// +// +// Created by Shadowfacts on 4/9/23. +// + +import Foundation + +public struct AbbreviatedTimeAgoFormatStyle: FormatStyle { + public typealias FormatInput = Date + public typealias FormatOutput = String + + public func format(_ value: Date) -> String { + let (amount, component) = timeAgo(value: value) + + switch component { + case .year: + return "\(amount)y" + case .month: + return "\(amount)mo" + case .weekOfYear: + return "\(amount)w" + case .day: + return "\(amount)d" + case .hour: + return "\(amount)h" + case .minute: + return "\(amount)m" + case .second: + if amount >= 3 { + return "\(amount)s" + } else { + return "Now" + } + default: + fatalError("Unexpected component: \(component)") + } + } + + private static let unitFlags = Set([.second, .minute, .hour, .day, .weekOfYear, .month, .year]) + + private func timeAgo(value: Date) -> (Int, Calendar.Component) { + let calendar = NSCalendar.current + let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date()) + + if components.year! >= 1 { + return (components.year!, .year) + } else if components.month! >= 1 { + return (components.month!, .month) + } else if components.weekOfYear! >= 1 { + return (components.weekOfYear!, .weekOfYear) + } else if components.day! >= 1 { + return (components.day!, .day) + } else if components.hour! >= 1 { + return (components.hour!, .hour) + } else if components.minute! >= 1 { + return (components.minute!, .minute) + } else { + return (components.second!, .second) + } + } + +} + +public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle { + static var abbreviatedTimeAgo: Self { + Self() + } +} diff --git a/Tusker/Views/AlertWithData.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift similarity index 83% rename from Tusker/Views/AlertWithData.swift rename to Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift index 226ab9f2..8b3b6238 100644 --- a/Tusker/Views/AlertWithData.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AlertWithData.swift @@ -1,6 +1,6 @@ // // AlertWithData.swift -// Tusker +// TuskerComponents // // Created by Shadowfacts on 11/9/22. // Copyright © 2022 Shadowfacts. All rights reserved. @@ -39,7 +39,7 @@ struct AlertWithData: ViewModifier { } extension View { - func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View { + public func alertWithData(_ title: LocalizedStringKey, data: Binding, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View { modifier(AlertWithData(title: title, data: data, actions: actions, message: message)) } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 376e2314..f27dd33b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -40,7 +40,6 @@ D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; }; D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; }; D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; }; - D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; }; D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; @@ -184,7 +183,7 @@ D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; }; - D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; }; + D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* OldDraft.swift */; }; D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; @@ -289,10 +288,11 @@ D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; + D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = D6BD395829B64426005FFD2B /* ComposeUI */; }; + D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; }; - D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; @@ -456,7 +456,6 @@ D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = ""; }; D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = ""; }; D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = ""; }; - D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; @@ -600,7 +599,7 @@ D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; }; - D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = ""; }; + D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = ""; }; D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; @@ -706,11 +705,12 @@ D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; + D6BD395729B6441F005FFD2B /* ComposeUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ComposeUI; path = Packages/ComposeUI; sourceTree = ""; }; + D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewComposeHostingController.swift; sourceTree = ""; }; D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; }; - D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; @@ -807,6 +807,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D6BD395929B64426005FFD2B /* ComposeUI in Frameworks */, D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */, D635237129B78A7D009ED5E7 /* TuskerComponents in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, @@ -880,7 +881,7 @@ D6285B5221EA708700FE4B39 /* StatusFormat.swift */, D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */, D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, - D677284D24ECC01D00C732D3 /* Draft.swift */, + D677284D24ECC01D00C732D3 /* OldDraft.swift */, D627FF75217E923E00CC0648 /* DraftsManager.swift */, D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, D65B4B532971F71D00DABDFB /* EditedReport.swift */, @@ -1150,6 +1151,7 @@ D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, D6F6A5582920676800F496A8 /* ComposeToolbar.swift */, D6BEA248291C6118002F4D01 /* DraftsView.swift */, + D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */, ); path = Compose; sourceTree = ""; @@ -1446,7 +1448,6 @@ isa = PBXGroup; children = ( D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, - D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, @@ -1536,6 +1537,7 @@ D6B0026C29B5245400C70BE2 /* UserAccounts */, D6FA94DF29B52891006AAC51 /* InstanceFeatures */, D6BD395C29B789D5005FFD2B /* TuskerComponents */, + D6BD395729B6441F005FFD2B /* ComposeUI */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1607,7 +1609,6 @@ D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */, D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */, - D6114E1627F8BB210080E273 /* VersionTests.swift */, D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */, D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */, D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */, @@ -1741,6 +1742,7 @@ D6B0026D29B5248800C70BE2 /* UserAccounts */, D6FA94E029B52898006AAC51 /* InstanceFeatures */, D635237029B78A7D009ED5E7 /* TuskerComponents */, + D6BD395829B64426005FFD2B /* ComposeUI */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1985,6 +1987,7 @@ D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */, D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */, + D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */, D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */, D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */, D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */, @@ -2155,7 +2158,6 @@ D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */, - D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, @@ -2261,7 +2263,7 @@ D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, - D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, + D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, @@ -2293,7 +2295,6 @@ D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */, - D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */, D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, ); @@ -2969,6 +2970,10 @@ isa = XCSwiftPackageProductDependency; productName = UserAccounts; }; + D6BD395829B64426005FFD2B /* ComposeUI */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposeUI; + }; D6BEA244291A0EDE002F4D01 /* Duckable */ = { isa = XCSwiftPackageProductDependency; productName = Duckable; diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 76d80669..5739b45e 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -12,6 +12,7 @@ import Combine import UserAccounts import InstanceFeatures import Sentry +import ComposeUI class MastodonController: ObservableObject { @@ -456,6 +457,55 @@ class MastodonController: ObservableObject { filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] } + func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { + var acctsToMention = [String]() + + var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility + var localOnly = false + var contentWarning = "" + + if let inReplyToID = inReplyToID, + let inReplyTo = persistentContainer.status(for: inReplyToID) { + acctsToMention.append(inReplyTo.account.acct) + acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) + visibility = min(visibility, inReplyTo.visibility) + localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly + + if !inReplyTo.spoilerText.isEmpty { + switch Preferences.shared.contentWarningCopyMode { + case .doNotCopy: + break + case .asIs: + contentWarning = inReplyTo.spoilerText + case .prependRe: + if inReplyTo.spoilerText.lowercased().starts(with: "re:") { + contentWarning = inReplyTo.spoilerText + } else { + contentWarning = "re: \(inReplyTo.spoilerText)" + } + } + } + } + if let mentioningAcct = mentioningAcct { + acctsToMention.append(mentioningAcct) + } + if let ownAccount = self.account { + acctsToMention.removeAll(where: { $0 == ownAccount.acct }) + } + acctsToMention = acctsToMention.uniques() + + let draft = Draft( + accountID: accountInfo!.id, + text: text ?? acctsToMention.map { "@\($0) " }.joined(), + contentWarning: contentWarning, + inReplyToID: inReplyToID, + visibility: visibility, + localOnly: localOnly + ) + DraftsManager.shared.add(draft) + return draft + } + } private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { diff --git a/Tusker/API/PostService.swift b/Tusker/API/PostService.swift index 91a689aa..72ef7281 100644 --- a/Tusker/API/PostService.swift +++ b/Tusker/API/PostService.swift @@ -12,12 +12,12 @@ import UniformTypeIdentifiers class PostService: ObservableObject { private let mastodonController: MastodonController - private let draft: Draft + private let draft: OldDraft let totalSteps: Int @Published var currentStep = 1 - init(mastodonController: MastodonController, draft: Draft) { + init(mastodonController: MastodonController, draft: OldDraft) { self.mastodonController = mastodonController self.draft = draft // 2 steps (request data, then upload) for each attachment @@ -31,7 +31,7 @@ class PostService: ObservableObject { } // save before posting, so if a crash occurs during network request, the status won't be lost - DraftsManager.save() + OldDraftsManager.save() let uploadedAttachments = try await uploadAttachments() @@ -56,7 +56,7 @@ class PostService: ObservableObject { let (_, _) = try await mastodonController.run(request) currentStep += 1 - DraftsManager.shared.remove(self.draft) + OldDraftsManager.shared.remove(self.draft) } catch let error as Client.Error { throw Error.posting(error) } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 05e4560f..1859cdb2 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -321,7 +321,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { @discardableResult private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO { - if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) { + if let relationshipMO = self.relationship(forAccount: relationship.accountID, in: context) { relationshipMO.updateFrom(apiRelationship: relationship, container: self) return relationshipMO } else { @@ -336,7 +336,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { let relationshipMO = self.upsert(relationship: relationship, in: context) self.save(context: context) completion?(relationshipMO) - self.relationshipSubject.send(relationship.id) + self.relationshipSubject.send(relationship.accountID) } } diff --git a/Tusker/CoreData/RelationshipMO.swift b/Tusker/CoreData/RelationshipMO.swift index 175287bc..260830fc 100644 --- a/Tusker/CoreData/RelationshipMO.swift +++ b/Tusker/CoreData/RelationshipMO.swift @@ -11,7 +11,7 @@ import CoreData import Pachyderm @objc(RelationshipMO) -public final class RelationshipMO: NSManagedObject { +public final class RelationshipMO: NSManagedObject, RelationshipProtocol { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "Relationship") @@ -29,6 +29,8 @@ public final class RelationshipMO: NSManagedObject { @NSManaged public var showingReblogs: Bool @NSManaged public var account: AccountMO? + public var followRequested: Bool { requested } + } extension RelationshipMO { @@ -43,10 +45,10 @@ extension RelationshipMO { return } - self.accountID = relationship.id + self.accountID = relationship.accountID self.blocking = relationship.blocking self.domainBlocking = relationship.domainBlocking - self.endorsed = relationship.endorsed ?? false + self.endorsed = relationship.endorsed self.followedBy = relationship.followedBy self.following = relationship.following self.muting = relationship.muting @@ -54,6 +56,6 @@ extension RelationshipMO { self.requested = relationship.followRequested self.showingReblogs = relationship.showingReblogs - self.account = container.account(for: relationship.id, in: context) + self.account = container.account(for: relationship.accountID, in: context) } } diff --git a/Tusker/Extensions/Date+TimeAgo.swift b/Tusker/Extensions/Date+TimeAgo.swift index bce334e2..cf99ff2b 100644 --- a/Tusker/Extensions/Date+TimeAgo.swift +++ b/Tusker/Extensions/Date+TimeAgo.swift @@ -7,6 +7,7 @@ // import Foundation +import TuskerComponents extension Date { @@ -34,30 +35,7 @@ extension Date { } func timeAgoString() -> String { - let (amount, component) = timeAgo() - - switch component { - case .year: - return "\(amount)y" - case .month: - return "\(amount)mo" - case .weekOfYear: - return "\(amount)w" - case .day: - return "\(amount)d" - case .hour: - return "\(amount)h" - case .minute: - return "\(amount)m" - case .second: - if amount >= 3 { - return "\(amount)s" - } else { - return "Now" - } - default: - fatalError("Unexpected component: \(component)") - } + self.formatted(.abbreviatedTimeAgo) } } diff --git a/Tusker/Models/CompositionAttachment.swift b/Tusker/Models/CompositionAttachment.swift index 9f7d621f..2c51302e 100644 --- a/Tusker/Models/CompositionAttachment.swift +++ b/Tusker/Models/CompositionAttachment.swift @@ -70,9 +70,9 @@ extension CompositionAttachment: NSItemProviderWriting { } catch { completionHandler(nil, error) } + } else { + completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) } - - completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) return nil } diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift index 8f7d9c1e..723d8a9e 100644 --- a/Tusker/Models/DraftsManager.swift +++ b/Tusker/Models/DraftsManager.swift @@ -1,5 +1,5 @@ // -// DraftsManager.swift +// OldDraftsManager.swift // Tusker // // Created by Shadowfacts on 10/22/18. @@ -8,13 +8,13 @@ import Foundation -class DraftsManager: Codable, ObservableObject { +class OldDraftsManager: Codable, ObservableObject { + + private(set) static var shared: OldDraftsManager = load() - private(set) static var shared: DraftsManager = load() - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - + private static var archiveURL = OldDraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + static func save() { DispatchQueue.global(qos: .utility).async { let encoder = PropertyListEncoder() @@ -22,24 +22,24 @@ class DraftsManager: Codable, ObservableObject { try? data?.write(to: archiveURL, options: .noFileProtection) } } - - static func load() -> DraftsManager { + + static func load() -> OldDraftsManager { let decoder = PropertyListDecoder() if let data = try? Data(contentsOf: archiveURL), - let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { - return draftsManager + let OldDraftsManager = try? decoder.decode(OldDraftsManager.self, from: data) { + return OldDraftsManager } - return DraftsManager() + return OldDraftsManager() } - + private init() {} - + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - - if let dict = try? container.decode([UUID: Draft].self, forKey: .drafts) { + + if let dict = try? container.decode([UUID: OldDraft].self, forKey: .drafts) { self.drafts = dict - } else if let array = try? container.decode([Draft].self, forKey: .drafts) { + } else if let array = try? container.decode([OldDraft].self, forKey: .drafts) { self.drafts = array.reduce(into: [:], { partialResult, draft in partialResult[draft.id] = draft }) @@ -47,31 +47,31 @@ class DraftsManager: Codable, ObservableObject { throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") } } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(drafts, forKey: .drafts) } - - @Published private var drafts: [UUID: Draft] = [:] - var sorted: [Draft] { + + @Published private var drafts: [UUID: OldDraft] = [:] + var sorted: [OldDraft] { return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) } - - func add(_ draft: Draft) { + + func add(_ draft: OldDraft) { drafts[draft.id] = draft } - - func remove(_ draft: Draft) { + + func remove(_ draft: OldDraft) { drafts.removeValue(forKey: draft.id) } - - func getBy(id: UUID) -> Draft? { + + func getBy(id: UUID) -> OldDraft? { return drafts[id] } - + enum CodingKeys: String, CodingKey { case drafts } - + } diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/OldDraft.swift similarity index 94% rename from Tusker/Models/Draft.swift rename to Tusker/Models/OldDraft.swift index 6cfab936..24ca5808 100644 --- a/Tusker/Models/Draft.swift +++ b/Tusker/Models/OldDraft.swift @@ -1,5 +1,5 @@ // -// Draft.swift +// OldDraft.swift // Tusker // // Created by Shadowfacts on 8/18/20. @@ -9,7 +9,7 @@ import Foundation import Pachyderm -class Draft: Codable, ObservableObject { +class OldDraft: Codable, ObservableObject { let id: UUID var lastModified: Date @@ -88,15 +88,15 @@ class Draft: Codable, ObservableObject { } } -extension Draft: Equatable { - static func ==(lhs: Draft, rhs: Draft) -> Bool { +extension OldDraft: Equatable { + static func ==(lhs: OldDraft, rhs: OldDraft) -> Bool { return lhs.id == rhs.id } } -extension Draft: Identifiable {} +extension OldDraft: Identifiable {} -extension Draft { +extension OldDraft { enum CodingKeys: String, CodingKey { case id case lastModified @@ -115,7 +115,7 @@ extension Draft { } } -extension Draft { +extension OldDraft { class Poll: Codable, ObservableObject { @Published var options: [Option] @Published var multiple: Bool @@ -173,7 +173,7 @@ extension Draft { extension MastodonController { - func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft { + func createOldDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> OldDraft { var acctsToMention = [String]() var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility @@ -210,7 +210,7 @@ extension MastodonController { } acctsToMention = acctsToMention.uniques() - let draft = Draft(accountID: accountInfo!.id) + let draft = OldDraft(accountID: accountInfo!.id) draft.inReplyToID = inReplyToID draft.text = acctsToMention.map { "@\($0) " }.joined() draft.initialText = draft.text @@ -219,7 +219,7 @@ extension MastodonController { draft.contentWarning = contentWarning draft.contentWarningEnabled = !contentWarning.isEmpty - DraftsManager.shared.add(draft) + OldDraftsManager.shared.add(draft) return draft } diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 2ae505a5..7eb558eb 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -9,6 +9,7 @@ import UIKit import Combine import UserAccounts +import ComposeUI class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -58,7 +59,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg session.mastodonController = controller controller.initialize() - let composeVC = ComposeHostingController(draft: draft, mastodonController: controller) + let composeVC = NewComposeHostingController(draft: draft, mastodonController: controller) composeVC.delegate = self let nav = EnhancedNavigationViewController(rootViewController: composeVC) @@ -66,7 +67,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg window!.rootViewController = nav window!.makeKeyAndVisible() - updateTitle(draft: composeVC.draft) + updateTitle(draft: composeVC.controller.draft) composeVC.controller.$draft .sink { [unowned self] in self.updateTitle(draft: $0) } .store(in: &cancellables) @@ -76,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - DraftsManager.save() + OldDraftsManager.save() if let window = window, let nav = window.rootViewController as? UINavigationController, @@ -108,8 +109,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } -extension ComposeSceneDelegate: ComposeHostingControllerDelegate { - func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool { +extension ComposeSceneDelegate: NewComposeHostingControllerDelegate { + func dismissCompose(mode: DismissMode) -> Bool { let animation: UIWindowScene.DismissalAnimation switch mode { case .cancel: diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 8b6f5547..685874f2 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -87,7 +87,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - DraftsManager.save() + OldDraftsManager.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -100,7 +100,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - DraftsManager.save() + OldDraftsManager.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift index 12b3cb50..4a0cf43b 100644 --- a/Tusker/Screens/Compose/ComposeAssetPicker.swift +++ b/Tusker/Screens/Compose/ComposeAssetPicker.swift @@ -7,11 +7,12 @@ // import SwiftUI +import ComposeUI struct ComposeAssetPicker: UIViewControllerRepresentable { typealias UIViewControllerType = AssetPickerViewController - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft let delegate: AssetPickerViewControllerDelegate? @EnvironmentObject var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift index 29eb78b1..5839d760 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentRow.swift @@ -12,7 +12,7 @@ import AVFoundation import Vision struct ComposeAttachmentRow: View { - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @ObservedObject var attachment: CompositionAttachment @EnvironmentObject var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift index 743caa0e..011e2c92 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -12,7 +12,7 @@ struct ComposeAttachmentsList: View { private let cellHeight: CGFloat = 80 private let cellPadding: CGFloat = 12 - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState @@ -119,13 +119,13 @@ struct ComposeAttachmentsList: View { } private func addAttachment() { - if #available(iOS 16.0, *) { - isShowingAssetPickerPopover = true - } else if horizontalSizeClass == .regular { - isShowingAssetPickerPopover = true - } else { +// if #available(iOS 16.0, *) { +// isShowingAssetPickerPopover = true +// } else if horizontalSizeClass == .regular { +// isShowingAssetPickerPopover = true +// } else { uiState.delegate?.presentAssetPickerSheet() - } +// } } private func moveAttachments(from source: IndexSet, to destination: Int) { @@ -158,7 +158,7 @@ struct ComposeAttachmentsList: View { UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) withAnimation { - draft.poll = draft.poll == nil ? Draft.Poll() : nil + draft.poll = draft.poll == nil ? OldDraft.Poll() : nil } } } diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index d346604d..9a38cdc8 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -25,14 +25,14 @@ class ComposeHostingController: UIHostingController Bool { @@ -153,7 +153,7 @@ extension ComposeHostingController { struct Wrapper: View { let mastodonController: MastodonController @ObservedObject var uiState: ComposeUIState - var draft: Draft { + var draft: OldDraft { uiState.draft } @@ -202,11 +202,11 @@ extension ComposeHostingController: ComposeUIStateDelegate { present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) } - func selectDraft(_ draft: Draft) { + func selectDraft(_ draft: OldDraft) { if self.draft.hasContent { - DraftsManager.save() + OldDraftsManager.save() } else { - DraftsManager.shared.remove(self.draft) + OldDraftsManager.shared.remove(self.draft) } uiState.draft = draft uiState.isShowingDraftsList = false @@ -253,7 +253,7 @@ extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - DraftsManager.save() + OldDraftsManager.save() } } diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 467c8c21..073b29e6 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -18,15 +18,15 @@ struct ComposePollView: View { return f }() - @ObservedObject var draft: Draft - @ObservedObject var poll: Draft.Poll + @ObservedObject var draft: OldDraft + @ObservedObject var poll: OldDraft.Poll @EnvironmentObject var mastodonController: MastodonController @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var duration: Duration - init(draft: Draft, poll: Draft.Poll) { + init(draft: OldDraft, poll: OldDraft.Poll) { self.draft = draft self.poll = poll @@ -130,7 +130,7 @@ struct ComposePollView: View { } private func addOption() { - poll.options.append(Draft.Poll.Option("")) + poll.options.append(OldDraft.Poll.Option("")) } } @@ -167,8 +167,8 @@ extension ComposePollView { } struct ComposePollOption: View { - @ObservedObject var poll: Draft.Poll - @ObservedObject var option: Draft.Poll.Option + @ObservedObject var poll: OldDraft.Poll + @ObservedObject var option: OldDraft.Poll.Option let optionIndex: Int @EnvironmentObject private var mastodonController: MastodonController diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift index 19b47daa..199a1f28 100644 --- a/Tusker/Screens/Compose/ComposeReplyContentView.swift +++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift @@ -7,26 +7,31 @@ // import SwiftUI +import Pachyderm struct ComposeReplyContentView: UIViewRepresentable { typealias UIViewType = ComposeReplyContentTextView - let status: StatusMO - - @EnvironmentObject var mastodonController: MastodonController + let status: any StatusProtocol + let mastodonController: MastodonController let heightChanged: (CGFloat) -> Void func makeUIView(context: Context) -> UIViewType { let view = ComposeReplyContentTextView() - view.overrideMastodonController = mastodonController - view.setTextFrom(status: status) view.isUserInteractionEnabled = false // scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line view.isScrollEnabled = true view.backgroundColor = .clear view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.adjustsFontForContentSizeCategory = true + view.defaultFont = TimelineStatusCollectionViewCell.contentFont + view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont + + view.overrideMastodonController = mastodonController + view.setTextFrom(status: status) + return view } diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift index 0ada5abe..48d95247 100644 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ b/Tusker/Screens/Compose/ComposeReplyView.swift @@ -16,6 +16,7 @@ struct ComposeReplyView: View { @State private var displayNameHeight: CGFloat? @State private var contentHeight: CGFloat? + @EnvironmentObject private var mastodonController: MastodonController @ObservedObject private var preferences = Preferences.shared private let horizSpacing: CGFloat = 8 @@ -46,7 +47,7 @@ struct ComposeReplyView: View { } }) - ComposeReplyContentView(status: status) { newHeight in + ComposeReplyContentView(status: status, mastodonController: mastodonController) { newHeight in // otherwise, with long in-reply-to statuses, the main content text view position seems not to update // and it ends up partially behind the header DispatchQueue.main.async { diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift index f5574053..62132a75 100644 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ b/Tusker/Screens/Compose/ComposeToolbar.swift @@ -16,7 +16,7 @@ struct ComposeToolbar: View { .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") } - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @EnvironmentObject private var uiState: ComposeUIState @EnvironmentObject private var mastodonController: MastodonController @@ -144,6 +144,6 @@ private extension View { struct ComposeToolbar_Previews: PreviewProvider { static var previews: some View { - ComposeToolbar(draft: Draft(accountID: "")) + ComposeToolbar(draft: OldDraft(accountID: "")) } } diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift index f5d40ffc..46e93f7f 100644 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -15,7 +15,7 @@ protocol ComposeUIStateDelegate: AnyObject { // @available(iOS, obsoleted: 16.0) func presentAssetPickerSheet() func presentComposeDrawing() - func selectDraft(_ draft: Draft) + func selectDraft(_ draft: OldDraft) func paste(itemProviders: [NSItemProvider]) } @@ -23,7 +23,7 @@ class ComposeUIState: ObservableObject { weak var delegate: ComposeUIStateDelegate? - @Published var draft: Draft + @Published var draft: OldDraft @Published var isShowingSaveDraftSheet = false @Published var isShowingDraftsList = false @Published var attachmentsMissingDescriptions = Set() @@ -35,7 +35,7 @@ class ComposeUIState: ObservableObject { var shouldEmojiAutocompletionBeginExpanded = false @Published var currentInput: ComposeInput? - init(draft: Draft) { + init(draft: OldDraft) { self.draft = draft } diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index 1d178f3a..dfc811f0 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -9,6 +9,7 @@ import SwiftUI import Pachyderm import Combine +import ComposeUI @propertyWrapper struct OptionalStateObject: DynamicProperty { private class Republisher: ObservableObject { @@ -44,7 +45,7 @@ import Combine struct ComposeView: View { @EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var draft: Draft + @EnvironmentObject var draft: OldDraft @State private var globalFrameOutsideList: CGRect = .zero @State private var contentWarningBecomeFirstResponder = false @@ -63,7 +64,7 @@ struct ComposeView: View { private var charactersRemaining: Int { let limit = mastodonController.instanceFeatures.maxStatusChars let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 - return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance)) + return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instanceFeatures)) } private var requiresAttachmentDescriptions: Bool { @@ -279,7 +280,7 @@ struct ComposeView: View { if draft.hasContent { uiState.isShowingSaveDraftSheet = true } else { - DraftsManager.shared.remove(draft) + OldDraftsManager.shared.remove(draft) uiState.delegate?.dismissCompose(mode: .cancel) } } @@ -293,7 +294,7 @@ struct ComposeView: View { uiState.delegate?.dismissCompose(mode: .cancel) }), .destructive(Text("Delete Draft"), action: { - DraftsManager.shared.remove(draft) + OldDraftsManager.shared.remove(draft) uiState.isShowingSaveDraftSheet = false uiState.delegate?.dismissCompose(mode: .cancel) }), diff --git a/Tusker/Screens/Compose/DraftsView.swift b/Tusker/Screens/Compose/DraftsView.swift index 4c872566..97dc58b1 100644 --- a/Tusker/Screens/Compose/DraftsView.swift +++ b/Tusker/Screens/Compose/DraftsView.swift @@ -12,7 +12,7 @@ import SwiftUI struct DraftsRepresentable: UIViewControllerRepresentable { typealias UIViewControllerType = UIHostingController - let currentDraft: Draft + let currentDraft: OldDraft let mastodonController: MastodonController func makeUIViewController(context: Context) -> UIHostingController { @@ -24,73 +24,74 @@ struct DraftsRepresentable: UIViewControllerRepresentable { } struct DraftsView: View { - let currentDraft: Draft + let currentDraft: OldDraft // don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something let mastodonController: MastodonController @EnvironmentObject var uiState: ComposeUIState - @StateObject private var draftsManager = DraftsManager.shared - @State private var draftForDifferentReply: Draft? + @StateObject private var draftsManager = OldDraftsManager.shared + @State private var draftForDifferentReply: OldDraft? - private var visibleDrafts: [Draft] { + private var visibleDrafts: [OldDraft] { draftsManager.sorted.filter { $0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id } } var body: some View { - NavigationView { - List { - ForEach(visibleDrafts) { draft in - Button { - maybeSelectDraft(draft) - } label: { - DraftView(draft: draft) - } - .contextMenu { - Button(role: .destructive) { - draftsManager.remove(draft) - } label: { - Label("Delete Draft", systemImage: "trash") - } - } - .onDrag { - let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) - activity.displaysAuxiliaryScene = true - return NSItemProvider(object: activity) - } - } - .onDelete { indices in - indices - .map { visibleDrafts[$0] } - .forEach { draftsManager.remove($0) } - } - .appGroupedListRowBackground() - } - .listStyle(.plain) - .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) - .navigationTitle(Text("Drafts")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - uiState.isShowingDraftsList = false - } - } - } - } - .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in - Button("Cancel", role: .cancel) { - draftForDifferentReply = nil - } - Button("Restore Draft") { - uiState.delegate?.selectDraft(draft) - } - } message: { draft in - Text("The selected draft is a reply to a different post, do you wish to use it?") - } +// NavigationView { +// List { +// ForEach(visibleDrafts) { draft in +// Button { +// maybeSelectDraft(draft) +// } label: { +// DraftView(draft: draft) +// } +// .contextMenu { +// Button(role: .destructive) { +// OldDraftsManager.remove(draft) +// } label: { +// Label("Delete Draft", systemImage: "trash") +// } +// } +// .onDrag { +// let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) +// activity.displaysAuxiliaryScene = true +// return NSItemProvider(object: activity) +// } +// } +// .onDelete { indices in +// indices +// .map { visibleDrafts[$0] } +// .forEach { OldDraftsManager.remove($0) } +// } +// .appGroupedListRowBackground() +// } +// .listStyle(.plain) +// .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) +// .navigationTitle(Text("Drafts")) +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// uiState.isShowingDraftsList = false +// } +// } +// } +// } +// .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in +// Button("Cancel", role: .cancel) { +// draftForDifferentReply = nil +// } +// Button("Restore Draft") { +// uiState.delegate?.selectDraft(draft) +// } +// } message: { draft in +// Text("The selected draft is a reply to a different post, do you wish to use it?") +// } + Text("drafts") } - private func maybeSelectDraft(_ draft: Draft) { + private func maybeSelectDraft(_ draft: OldDraft) { if draft.inReplyToID != currentDraft.inReplyToID, currentDraft.hasContent { draftForDifferentReply = draft @@ -101,9 +102,9 @@ struct DraftsView: View { } struct DraftView: View { - @ObservedObject private var draft: Draft + @ObservedObject private var draft: OldDraft - init(draft: Draft) { + init(draft: OldDraft) { self._draft = ObservedObject(wrappedValue: draft) } diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 1ef34ea1..f7d29904 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -10,7 +10,7 @@ import SwiftUI import Pachyderm struct MainComposeTextView: View, PlaceholderViewProvider { - @ObservedObject var draft: Draft + @ObservedObject var draft: OldDraft @State private var placeholder: PlaceholderView = Self.placeholderView() let minHeight: CGFloat = 150 diff --git a/Tusker/Screens/Compose/NewComposeHostingController.swift b/Tusker/Screens/Compose/NewComposeHostingController.swift new file mode 100644 index 00000000..77e3441a --- /dev/null +++ b/Tusker/Screens/Compose/NewComposeHostingController.swift @@ -0,0 +1,223 @@ +// +// NewComposeHostingController.swift +// Tusker +// +// Created by Shadowfacts on 3/6/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI +import ComposeUI +import Combine +import PhotosUI +import PencilKit +import Pachyderm +import CoreData +import Duckable + +protocol NewComposeHostingControllerDelegate: AnyObject { + func dismissCompose(mode: DismissMode) -> Bool +} + +class NewComposeHostingController: UIHostingController, DuckableViewController { + + weak var delegate: NewComposeHostingControllerDelegate? + weak var duckableDelegate: DuckableViewControllerDelegate? + + let controller: ComposeController + let mastodonController: MastodonController + + private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? + private var drawingCompletion: ((PKDrawing) -> Void)? + + init(draft: Draft?, mastodonController: MastodonController) { + let draft = draft ?? mastodonController.createDraft() + DraftsManager.shared.add(draft) + + self.controller = ComposeController( + draft: draft, + config: ComposeUIConfig(), + mastodonController: mastodonController, + fetchAvatar: { await ImageCache.avatars.get($0).1 }, + fetchStatus: { mastodonController.persistentContainer.status(for: $0) }, + displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) }, + replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, + emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) } + ) + controller.currentAccount = mastodonController.account + + self.mastodonController = mastodonController + + super.init(rootView: View(mastodonController: mastodonController, controller: controller)) + + self.updateConfig() + + pasteConfiguration = UIPasteConfiguration(forAccepting: ComposeUI.DraftAttachment.self) + + NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func updateConfig() { + var config = ComposeUIConfig() + config.backgroundColor = .appBackground + config.groupedBackgroundColor = .appGroupedBackground + config.groupedCellBackgroundColor = .appGroupedCellBackground + config.fillColor = .appFill + switch Preferences.shared.avatarStyle { + case .roundRect: + config.avatarStyle = .roundRect + case .circle: + config.avatarStyle = .circle + } + + config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard + config.contentType = Preferences.shared.statusContentType + config.automaticallySaveDrafts = Preferences.shared.automaticallySaveDrafts + config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions + + config.dismiss = { [unowned self] in self.dismiss(mode: $0) } + config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) } + config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) } + config.userActivityForDraft = { [unowned self] in + let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: self.mastodonController.accountInfo!.id) + activity.displaysAuxiliaryScene = true + return NSItemProvider(object: activity) + } + + controller.config = config + } + + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { + return controller.canPaste(itemProviders: itemProviders) + } + + override func paste(itemProviders: [NSItemProvider]) { + controller.paste(itemProviders: itemProviders) + } + + private func dismiss(mode: DismissMode) { + if delegate?.dismissCompose(mode: mode) == true { + return + } else { + dismiss(animated: true) + duckableDelegate?.duckableViewControllerWillDismiss(animated: true) + } + } + + private func presentAssetPicker(completion: @MainActor @escaping ([PHPickerResult]) -> Void) { + self.assetPickerCompletion = completion + + var config = PHPickerConfiguration() + config.selection = .ordered + config.selectionLimit = 0 + config.preferredAssetRepresentationMode = .compatible + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + picker.modalPresentationStyle = .pageSheet + picker.overrideUserInterfaceStyle = .dark + // sheet detents don't play nice with PHPickerViewController, see +// let sheet = picker.sheetPresentationController! +// sheet.detents = [.medium(), .large()] +// sheet.prefersEdgeAttachedInCompactHeight = true +// sheet.prefersGrabberVisible = true + present(picker, animated: true) + } + + private func presentDrawing(_ drawing: PKDrawing, completion: @escaping (PKDrawing) -> Void) { + self.drawingCompletion = completion + + present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) + } + + // MARK: Duckable + + func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { + withAnimation(.linear(duration: duration).delay(delay)) { + controller.showToolbar = false + } + } + + func duckableViewControllerDidFinishAnimatingDuck() { + controller.showToolbar = true + } + + struct View: SwiftUI.View { + let mastodonController: MastodonController + let controller: ComposeController + + var body: some SwiftUI.View { + ControllerView(controller: { controller }) + .task { + if let account = try? await mastodonController.getOwnAccount() { + controller.currentAccount = account + } + } + } + } +} + +extension MastodonController: ComposeMastodonContext { + @MainActor + func searchCachedAccounts(query: String) -> [AccountProtocol] { + // todo: there's got to be something more efficient than this :/ + let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" + let request: NSFetchRequest = AccountMO.fetchRequest() + request.predicate = NSPredicate(format: "displayName LIKE[cd] %@ OR acct LIKE[cd] %@", wildcardedQuery, wildcardedQuery) + + if let results = try? persistentContainer.viewContext.fetch(request) { + return results + } else { + return [] + } + } + + @MainActor + func cachedRelationship(for accountID: String) -> RelationshipProtocol? { + return persistentContainer.relationship(forAccount: accountID) + } + + @MainActor + func searchCachedHashtags(query: String) -> [Hashtag] { + let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" + let predicate = NSPredicate(format: "name LIKE[cd] %@", wildcardedQuery) + let savedReq = SavedHashtag.fetchRequest(account: accountInfo!) + savedReq.predicate = predicate + let followedReq = FollowedHashtag.fetchRequest() + followedReq.predicate = predicate + + let saved = try? persistentContainer.viewContext.fetch(savedReq).map { Hashtag(name: $0.name, url: $0.url) } + let followed = try? persistentContainer.viewContext.fetch(followedReq).map { Hashtag(name: $0.name, url: $0.url) } + + var results = saved ?? [] + if let followed { + results.append(contentsOf: followed) + } + return results + } +} + +extension NewComposeHostingController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismiss(animated: true) + + assetPickerCompletion?(results) + assetPickerCompletion = nil + } +} + +extension NewComposeHostingController: ComposeDrawingViewControllerDelegate { + func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { + dismiss(animated: true) + drawingCompletion = nil + } + + func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { + dismiss(animated: true) + drawingCompletion?(drawing) + drawingCompletion = nil + } +} diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index 29a0b767..1b325867 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -9,6 +9,7 @@ import UIKit import ScreenCorners import UserAccounts +import ComposeUI class AccountSwitchingContainerViewController: UIViewController { diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift index d740c9d5..a10e40bd 100644 --- a/Tusker/Screens/Main/Duckable+Root.swift +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -8,14 +8,15 @@ import UIKit import Duckable +import ComposeUI @available(iOS 16.0, *) extension DuckableContainerViewController: TuskerRootViewController { func stateRestorationActivity() -> NSUserActivity? { var activity = (child as? TuskerRootViewController)?.stateRestorationActivity() - if let compose = duckedViewController as? ComposeHostingController, - compose.draft.hasContent { - activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft) + if let compose = duckedViewController as? NewComposeHostingController, + compose.controller.draft.hasContent { + activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.controller.draft) } return activity } diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index a6a950c4..eefdfc3b 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import ComposeUI class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift index 861e664d..38c9171b 100644 --- a/Tusker/Screens/Main/TuskerRootViewController.swift +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import ComposeUI @MainActor protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 1ecca0e5..b72d555f 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -358,16 +358,16 @@ extension MenuActionProvider { }), createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in guard let self = self else { return } - let draft = self.mastodonController!.createDraft() + + var text = "" let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty { - draft.text += title - draft.text += ":\n" + text += title + text += ":\n" } - draft.text += url.absoluteString - // prevents the draft from being saved automatically until the user makes a change - // also prevents it from being posted without being changed - draft.initialText = draft.text + text += url.absoluteString + + let draft = self.mastodonController!.createDraft(text: text) self.navigationDelegate?.compose(editing: draft) }) ] diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift index 9931d962..4bf6e663 100644 --- a/Tusker/Shortcuts/UserActivityHandlingContext.swift +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -8,6 +8,7 @@ import UIKit import Duckable +import ComposeUI @MainActor protocol UserActivityHandlingContext { diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index eaa3eff0..d4c82ad5 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -11,6 +11,7 @@ import Intents import Pachyderm import OSLog import UserAccounts +import ComposeUI private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 4e0a2172..29b57cde 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -9,6 +9,7 @@ import UIKit import SafariServices import Pachyderm +import ComposeUI @MainActor protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { @@ -97,16 +98,26 @@ extension TuskerNavigationDelegate { options.preferredPresentationStyle = .prominent UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) } else { - let compose = ComposeHostingController(draft: draft, mastodonController: apiController) + let compose = NewComposeHostingController(draft: draft, mastodonController: apiController) if #available(iOS 16.0, *), presentDuckable(compose, animated: animated, isDucked: isDucked) { return } else { - let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let nav = UINavigationController(rootViewController: compose) - nav.presentationController?.delegate = compose + // TODO: is this still necessary? +// nav.presentationController?.delegate = compose present(nav, animated: animated) } +// let compose = ComposeHostingController(draft: draft, mastodonController: apiController) +// if #available(iOS 16.0, *), +// presentDuckable(compose, animated: animated, isDucked: isDucked) { +// return +// } else { +// let compose = ComposeHostingController(draft: draft, mastodonController: apiController) +// let nav = UINavigationController(rootViewController: compose) +// nav.presentationController?.delegate = compose +// present(nav, animated: animated) +// } } } diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift index ceb1ca45..05f3677b 100644 --- a/Tusker/Views/StatusContentTextView.swift +++ b/Tusker/Views/StatusContentTextView.swift @@ -14,7 +14,7 @@ class StatusContentTextView: ContentTextView { private var statusID: String? - func setTextFrom(status: StatusMO, precomputed attributedText: NSAttributedString? = nil) { + func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) { statusID = status.id if let attributedText { self.attributedText = attributedText diff --git a/TuskerUITests/ComposeTests.swift b/TuskerUITests/ComposeTests.swift index ef96222f..546fe031 100644 --- a/TuskerUITests/ComposeTests.swift +++ b/TuskerUITests/ComposeTests.swift @@ -34,28 +34,28 @@ class ComposeTests: TuskerUITests { XCTAssertFalse(app.staticTexts["What's on your mind?"].exists, "placeholder does not exist") } - func testCharacterCounter() { - XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500") - let textView = app.textViews.firstMatch - - let fragments = [ - "Hello", - "World", - "@admin", - "@admin@example.com", - "https://foo.example.com/?bar=baz#qux", - ] - - var remaining = 500 - for s in fragments { - let length = CharacterCounter.count(text: s) - // add 1 for newline - remaining -= length + 1 - - textView.typeText(s + "\n") - XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)") - } - } +// func testCharacterCounter() { +// XCTAssertTrue(app.staticTexts["500 characters remaining"].exists, "initial character count is 500") +// let textView = app.textViews.firstMatch +// +// let fragments = [ +// "Hello", +// "World", +// "@admin", +// "@admin@example.com", +// "https://foo.example.com/?bar=baz#qux", +// ] +// +// var remaining = 500 +// for s in fragments { +// let length = CharacterCounter.count(text: s) +// // add 1 for newline +// remaining -= length + 1 +// +// textView.typeText(s + "\n") +// XCTAssertTrue(app.staticTexts["\(remaining) characters remaining"].exists, "subsequent character count is \(remaining)") +// } +// } // func testToolbarSwitching() { // // text view is automatically focused, so unfocus