Compare commits
1 Commits
compose-re
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 01cf597b5d |
|
@ -20,15 +20,13 @@ let package = Package(
|
||||||
.package(path: "../InstanceFeatures"),
|
.package(path: "../InstanceFeatures"),
|
||||||
.package(path: "../TuskerComponents"),
|
.package(path: "../TuskerComponents"),
|
||||||
.package(path: "../MatchedGeometryPresentation"),
|
.package(path: "../MatchedGeometryPresentation"),
|
||||||
.package(path: "../TuskerPreferences"),
|
|
||||||
.package(path: "../UserAccounts"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// 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.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences", "UserAccounts"],
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.swiftLanguageMode(.v5)
|
.swiftLanguageMode(.v5)
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PostService: ObservableObject {
|
class PostService: ObservableObject {
|
||||||
private let mastodonController: ComposeMastodonContext
|
private let mastodonController: ComposeMastodonContext
|
||||||
private let config: ComposeUIConfig
|
private let config: ComposeUIConfig
|
||||||
private let draft: Draft
|
private let draft: Draft
|
||||||
|
|
|
@ -12,7 +12,7 @@ import InstanceFeatures
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
private 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 mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||||
let mentionsRemoved = removeMentions(in: text)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
protocol ComposeInput: AnyObject, ObservableObject {
|
protocol ComposeInput: AnyObject, ObservableObject {
|
||||||
var toolbarElements: [ToolbarElement] { get }
|
var toolbarElements: [ToolbarElement] { get }
|
||||||
|
@ -28,44 +27,3 @@ enum ToolbarElement {
|
||||||
case emojiPicker
|
case emojiPicker
|
||||||
case formattingButtons
|
case formattingButtons
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FocusedComposeInput: FocusedValueKey {
|
|
||||||
typealias Value = any ComposeInput
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FocusedValues {
|
|
||||||
var composeInput: (any ComposeInput)? {
|
|
||||||
get { self[FocusedComposeInput.self] }
|
|
||||||
set { self[FocusedComposeInput.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
final class MutableObservableBox<Value>: ObservableObject {
|
|
||||||
@Published var wrappedValue: Value
|
|
||||||
|
|
||||||
init(wrappedValue: Value) {
|
|
||||||
self.wrappedValue = wrappedValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FocusedComposeInputBox: EnvironmentKey {
|
|
||||||
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
|
|
||||||
get { self[FocusedComposeInputBox.self] }
|
|
||||||
set { self[FocusedComposeInputBox.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FocusedInputModifier: ViewModifier {
|
|
||||||
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.environment(\.composeInputBox, box)
|
|
||||||
.focusedValue(\.composeInput, box.wrappedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import InstanceFeatures
|
import InstanceFeatures
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public protocol ComposeMastodonContext {
|
public protocol ComposeMastodonContext {
|
||||||
var accountInfo: UserAccountInfo? { get }
|
var accountInfo: UserAccountInfo? { get }
|
||||||
|
@ -27,6 +26,4 @@ public protocol ComposeMastodonContext {
|
||||||
func searchCachedHashtags(query: String) -> [Hashtag]
|
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||||
|
|
||||||
func storeCreatedStatus(_ status: Status)
|
func storeCreatedStatus(_ status: Status)
|
||||||
|
|
||||||
func fetchStatus(id: String) -> (any StatusProtocol)?
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,7 +181,7 @@ extension EnvironmentValues {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GIFViewWrapper: UIViewRepresentable {
|
private struct GIFViewWrapper: UIViewRepresentable {
|
||||||
typealias UIViewType = GIFImageView
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
@State var controller: GIFController
|
@State var controller: GIFController
|
||||||
|
|
|
@ -131,6 +131,7 @@ class AttachmentsListController: ViewController {
|
||||||
@EnvironmentObject private var controller: AttachmentsListController
|
@EnvironmentObject private var controller: AttachmentsListController
|
||||||
@EnvironmentObject private var draft: Draft
|
@EnvironmentObject private var draft: Draft
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
attachmentsList
|
attachmentsList
|
||||||
|
@ -216,7 +217,7 @@ fileprivate extension View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
@available(visionOS 1.0, *)
|
||||||
struct AttachmentButtonLabelStyle: LabelStyle {
|
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
|
@ -130,17 +130,11 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: some View {
|
public var view: some View {
|
||||||
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
|
ComposeView(poster: poster)
|
||||||
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||||
.environment(\.currentAccount, currentAccount)
|
.environmentObject(draft)
|
||||||
.environment(\.composeUIConfig, config)
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
} else {
|
.environment(\.composeUIConfig, config)
|
||||||
ComposeView(poster: poster)
|
|
||||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
|
||||||
.environmentObject(draft)
|
|
||||||
.environmentObject(mastodonController.instanceFeatures)
|
|
||||||
.environment(\.composeUIConfig, config)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||||
Text("Happy π day!")
|
Text("Happy π day!")
|
||||||
} else if components.month == 4 && components.day == 1 {
|
} else if components.month == 4 && components.day == 1 {
|
||||||
Text("April Fool’s!").rotationEffect(.radians(.pi), anchor: .center)
|
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
||||||
} else if components.month == 9 && components.day == 5 {
|
} else if components.month == 9 && components.day == 5 {
|
||||||
// https://weirder.earth/@noracodes/109276419847254552
|
// https://weirder.earth/@noracodes/109276419847254552
|
||||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||||
|
@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
Text("Any questions?")
|
Text("Any questions?")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("What’s on your mind?")
|
Text("What's on your mind?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||||
protocol PlaceholderViewProvider {
|
private protocol PlaceholderViewProvider {
|
||||||
associatedtype PlaceholderView: View
|
associatedtype PlaceholderView: View
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static func makePlaceholderView() -> PlaceholderView
|
static func makePlaceholderView() -> PlaceholderView
|
||||||
|
|
|
@ -170,26 +170,17 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
performBackgroundTask { context in
|
performBackgroundTask { context in
|
||||||
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
|
|
||||||
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
|
|
||||||
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
|
|
||||||
do {
|
|
||||||
try context.execute(deleteReq)
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||||
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||||
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||||
for url in orphanedFiles {
|
for url in orphaned {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
try FileManager.default.removeItem(at: url)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
|
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completion()
|
completion()
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
//
|
|
||||||
// Environment.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
//@propertyWrapper
|
|
||||||
//struct RequiredEnvironment<Value>: DynamicProperty {
|
|
||||||
// private let keyPath: KeyPath<EnvironmentValues, Value?>
|
|
||||||
// @Environment private var value: Value?
|
|
||||||
//
|
|
||||||
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
|
|
||||||
// self.keyPath = keyPath
|
|
||||||
// self._value = Environment(keyPath)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// var wrappedValue: Value {
|
|
||||||
// guard let value else {
|
|
||||||
// preconditionFailure("Missing required environment value for \(keyPath)")
|
|
||||||
// }
|
|
||||||
// return value
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
private struct ComposeMastodonContextKey: EnvironmentKey {
|
|
||||||
static let defaultValue: (any ComposeMastodonContext)? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var mastodonController: (any ComposeMastodonContext)? {
|
|
||||||
get { self[ComposeMastodonContextKey.self] }
|
|
||||||
set { self[ComposeMastodonContextKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CurrentAccountKey: EnvironmentKey {
|
|
||||||
static let defaultValue: (any AccountProtocol)? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var currentAccount: (any AccountProtocol)? {
|
|
||||||
get { self[CurrentAccountKey.self] }
|
|
||||||
set { self[CurrentAccountKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,6 +11,7 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class KeyboardReader: ObservableObject {
|
class KeyboardReader: ObservableObject {
|
||||||
|
// @Published var isVisible = false
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
@Published var keyboardHeight: CGFloat = 0
|
||||||
|
|
||||||
var isVisible: Bool {
|
var isVisible: Bool {
|
||||||
|
@ -25,12 +26,14 @@ class KeyboardReader: ObservableObject {
|
||||||
|
|
||||||
@objc func willShow(_ notification: Foundation.Notification) {
|
@objc func willShow(_ notification: Foundation.Notification) {
|
||||||
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||||
|
// isVisible = endFrame.height > 72
|
||||||
keyboardHeight = endFrame.height
|
keyboardHeight = endFrame.height
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func willHide() {
|
@objc func willHide() {
|
||||||
// sometimes willHide is called during a SwiftUI view update
|
// sometimes willHide is called during a SwiftUI view update
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
// self.isVisible = false
|
||||||
self.keyboardHeight = 0
|
self.keyboardHeight = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,9 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: Int, CaseIterable, Identifiable {
|
enum StatusFormat: Int, CaseIterable {
|
||||||
case bold, italics, strikethrough, code
|
case bold, italics, strikethrough, code
|
||||||
|
|
||||||
var id: some Hashable {
|
|
||||||
rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case .plain:
|
case .plain:
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
//
|
|
||||||
// Preferences.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import TuskerPreferences
|
|
||||||
|
|
||||||
typealias Preferences = TuskerPreferences.Preferences
|
|
|
@ -11,7 +11,6 @@ import Combine
|
||||||
public protocol ViewController: ObservableObject {
|
public protocol ViewController: ObservableObject {
|
||||||
associatedtype ContentView: View
|
associatedtype ContentView: View
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var view: ContentView { get }
|
var view: ContentView { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,182 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentRowView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/18/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import InstanceFeatures
|
|
||||||
import Vision
|
|
||||||
|
|
||||||
struct AttachmentRowView: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
@State private var isRecognizingText = false
|
|
||||||
@State private var textRecognitionError: (any Error)?
|
|
||||||
|
|
||||||
private var thumbnailSize: CGFloat {
|
|
||||||
#if os(visionOS)
|
|
||||||
120
|
|
||||||
#else
|
|
||||||
80
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
|
||||||
thumbnailView
|
|
||||||
|
|
||||||
descriptionView
|
|
||||||
}
|
|
||||||
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
|
|
||||||
Button("OK") {}
|
|
||||||
} message: { error in
|
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: attachments missing descriptions feature
|
|
||||||
|
|
||||||
private var thumbnailView: some View {
|
|
||||||
AttachmentThumbnailView(attachment: attachment)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
|
||||||
.contextMenu {
|
|
||||||
EditDrawingButton(attachment: attachment)
|
|
||||||
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
|
|
||||||
DeleteButton(attachment: attachment)
|
|
||||||
} preview: {
|
|
||||||
// TODO: need to fix flash of preview changing size
|
|
||||||
AttachmentThumbnailView(attachment: attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var descriptionView: some View {
|
|
||||||
if isRecognizingText {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
} else {
|
|
||||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EditDrawingButton: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if attachment.drawingData != nil {
|
|
||||||
Button(action: editDrawing) {
|
|
||||||
Label("Edit Drawing", systemImage: "hand.draw")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func editDrawing() {
|
|
||||||
if case .drawing(let drawing) = attachment.data {
|
|
||||||
presentDrawing?(drawing) {
|
|
||||||
attachment.drawing = $0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RecognizeTextButton: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
@Binding var isRecognizingText: Bool
|
|
||||||
@Binding var error: (any Error)?
|
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if attachment.type == .image {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await recognizeText()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recognizeText() async {
|
|
||||||
isRecognizingText = true
|
|
||||||
defer { isRecognizingText = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try await getAttachmentData()
|
|
||||||
let observations = try await runRecognizeTextRequest(data: data)
|
|
||||||
if let observations {
|
|
||||||
var text = ""
|
|
||||||
for observation in observations {
|
|
||||||
let result = observation.topCandidates(1).first!
|
|
||||||
text.append(result.string)
|
|
||||||
text.append("\n")
|
|
||||||
}
|
|
||||||
self.attachment.attachmentDescription = text
|
|
||||||
}
|
|
||||||
} catch let error as NSError where error.domain == VNErrorDomain && 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 {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getAttachmentData() async throws -> Data {
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
attachment.getData(features: instanceFeatures) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let (data, _)):
|
|
||||||
continuation.resume(returning: data)
|
|
||||||
case .failure(let error):
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
let handler = VNImageRequestHandler(data: data)
|
|
||||||
let request = VNRecognizeTextRequest { request, error in
|
|
||||||
if let error {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
} else {
|
|
||||||
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request.recognitionLevel = .accurate
|
|
||||||
request.usesLanguageCorrection = true
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
try? handler.perform([request])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DeleteButton: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(role: .destructive, action: removeAttachment) {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeAttachment() {
|
|
||||||
let draft = attachment.draft
|
|
||||||
var array = draft.draftAttachments
|
|
||||||
guard let index = array.firstIndex(of: attachment) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
array.remove(at: index)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// AttachmentRowView()
|
|
||||||
//}
|
|
|
@ -1,127 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentThumbnailView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/14/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import TuskerComponents
|
|
||||||
import AVFoundation
|
|
||||||
import Photos
|
|
||||||
|
|
||||||
struct AttachmentThumbnailView: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
var contentMode: ContentMode = .fit
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
AttachmentThumbnailViewContent(attachment: attachment, contentMode: contentMode)
|
|
||||||
.id(attachment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentThumbnailViewContent: View {
|
|
||||||
var attachment: DraftAttachment
|
|
||||||
var contentMode: ContentMode = .fit
|
|
||||||
@State private var mode: Mode = .empty
|
|
||||||
@EnvironmentObject private var composeController: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch mode {
|
|
||||||
case .empty:
|
|
||||||
Image(systemName: "photo")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.gray)
|
|
||||||
.task {
|
|
||||||
await loadThumbnail()
|
|
||||||
}
|
|
||||||
case .image(let image):
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: contentMode)
|
|
||||||
case .gifController(let controller):
|
|
||||||
GIFViewWrapper(controller: controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadThumbnail() async {
|
|
||||||
switch attachment.data {
|
|
||||||
case .editing(_, let kind, let url):
|
|
||||||
switch kind {
|
|
||||||
case .image:
|
|
||||||
if let image = await composeController.fetchAttachment(url) {
|
|
||||||
self.mode = .image(image)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .video, .gifv:
|
|
||||||
await loadVideoThumbnail(url: url)
|
|
||||||
|
|
||||||
case .audio, .unknown:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case .asset(let id):
|
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let isGIF = PHAssetResource.assetResources(for: asset).contains {
|
|
||||||
$0.uniformTypeIdentifier == UTType.gif.identifier
|
|
||||||
}
|
|
||||||
if isGIF {
|
|
||||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
|
||||||
guard let data else { return }
|
|
||||||
if typeIdentifier == UTType.gif.identifier {
|
|
||||||
self.mode = .gifController(GIFController(gifData: data))
|
|
||||||
} else if let image = UIImage(data: data) {
|
|
||||||
self.mode = .image(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let size = CGSize(width: 80, height: 80)
|
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
|
|
||||||
if let image {
|
|
||||||
self.mode = .image(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .drawing(let drawing):
|
|
||||||
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
|
|
||||||
|
|
||||||
case .file(let url, let type):
|
|
||||||
if type.conforms(to: .movie) {
|
|
||||||
await loadVideoThumbnail(url: url)
|
|
||||||
} else if let data = try? Data(contentsOf: url) {
|
|
||||||
if type == .gif {
|
|
||||||
self.mode = .gifController(GIFController(gifData: data))
|
|
||||||
} else if type.conforms(to: .image) {
|
|
||||||
if let image = UIImage(data: data),
|
|
||||||
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
|
||||||
// crashing share extension. see FB12186346
|
|
||||||
let prepared = await image.byPreparingForDisplay() {
|
|
||||||
self.mode = .image(prepared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .none:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadVideoThumbnail(url: URL) async {
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
|
||||||
imageGenerator.appliesPreferredTrackTransform = true
|
|
||||||
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
|
||||||
self.mode = .image(UIImage(cgImage: cgImage))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Mode {
|
|
||||||
case empty
|
|
||||||
case image(UIImage)
|
|
||||||
case gifController(GIFController)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,152 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentCollectionViewCell.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/20/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class AttachmentCollectionViewCell: UICollectionViewCell {
|
|
||||||
let attachmentView: UIView & UIContentView
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
attachmentView = UIHostingConfiguration(content: {
|
|
||||||
AttachmentCollectionViewCellView(attachment: nil)
|
|
||||||
}).makeContentView()
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
attachmentView.frame = bounds
|
|
||||||
addSubview(attachmentView)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUI(attachment: DraftAttachment) {
|
|
||||||
attachmentView.configuration = UIHostingConfiguration(content: {
|
|
||||||
AttachmentCollectionViewCellView(attachment: attachment)
|
|
||||||
}).margins(.all, .zero)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentCollectionViewCellView: View {
|
|
||||||
let attachment: DraftAttachment?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let attachment {
|
|
||||||
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
|
|
||||||
.squareFrame()
|
|
||||||
.background {
|
|
||||||
RoundedSquare(cornerRadius: 5)
|
|
||||||
.fill(.quaternary)
|
|
||||||
}
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
AttachmentDescriptionLabel(attachment: attachment)
|
|
||||||
}
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
AttachmentRemoveButton(attachment: attachment)
|
|
||||||
}
|
|
||||||
.clipShape(RoundedSquare(cornerRadius: 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentRemoveButton: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button("Remove", systemImage: "xmark.circle.fill") {
|
|
||||||
let draft = attachment.draft
|
|
||||||
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
|
|
||||||
attachments.remove(attachment)
|
|
||||||
draft.attachments = attachments
|
|
||||||
DraftsPersistentContainer.shared.viewContext.delete(attachment)
|
|
||||||
}
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(radius: 2)
|
|
||||||
.padding([.top, .trailing], 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentDescriptionLabel: View {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .bottomLeading) {
|
|
||||||
LinearGradient(
|
|
||||||
stops: [.init(color: .clear, location: 0), .init(color: .clear, location: 0.6), .init(color: .black.opacity(0.5), location: 1)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
label
|
|
||||||
.lineLimit(1)
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(radius: 1)
|
|
||||||
.padding([.horizontal, .bottom], 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var label: some View {
|
|
||||||
if attachment.attachmentDescription.isEmpty {
|
|
||||||
Label("Add alt", systemImage: "pencil")
|
|
||||||
.labelStyle(NarrowSpacingLabelStyle())
|
|
||||||
} else {
|
|
||||||
Text(attachment.attachmentDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NarrowSpacingLabelStyle: LabelStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
configuration.icon
|
|
||||||
configuration.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RoundedSquare: Shape {
|
|
||||||
let cornerRadius: CGFloat
|
|
||||||
|
|
||||||
nonisolated func path(in rect: CGRect) -> Path {
|
|
||||||
let minDimension = min(rect.width, rect.height)
|
|
||||||
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
|
|
||||||
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SquareFrame: Layout {
|
|
||||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
||||||
precondition(subviews.count == 1)
|
|
||||||
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
|
|
||||||
let minDimension = min(size.width, size.height)
|
|
||||||
return CGSize(width: minDimension, height: minDimension)
|
|
||||||
}
|
|
||||||
|
|
||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
||||||
precondition(subviews.count == 1)
|
|
||||||
let subviewSize = subviews[0].sizeThatFits(proposal)
|
|
||||||
let minDimension = min(bounds.width, bounds.height)
|
|
||||||
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
|
|
||||||
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
func squareFrame() -> some View {
|
|
||||||
SquareFrame {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,281 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentsSection.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/17/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import PhotosUI
|
|
||||||
import PencilKit
|
|
||||||
|
|
||||||
struct AttachmentsSection: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WrappedCollectionView(
|
|
||||||
draft: draft,
|
|
||||||
spacing: 8,
|
|
||||||
minItemSize: 100
|
|
||||||
)
|
|
||||||
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
|
|
||||||
// view from laying out, and leaving the intrinsic content size at zero too.
|
|
||||||
.frame(minHeight: 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WrappedCollectionView: UIViewRepresentable {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
let spacing: CGFloat
|
|
||||||
let minItemSize: CGFloat
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> some UIView {
|
|
||||||
let layout = UICollectionViewCompositionalLayout { section, environment in
|
|
||||||
let (itemSize, itemsPerRow) = itemSize(width: environment.container.contentSize.width)
|
|
||||||
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize)))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), repeatingSubitem: item, count: itemsPerRow)
|
|
||||||
group.interItemSpacing = .fixed(spacing)
|
|
||||||
let section = NSCollectionLayoutSection(group: group)
|
|
||||||
section.interGroupSpacing = spacing
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
|
|
||||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: view) { collectionView, indexPath, itemIdentifier in
|
|
||||||
context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier)
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.canReorderItem = { item in
|
|
||||||
switch item {
|
|
||||||
case .attachment(_):
|
|
||||||
true
|
|
||||||
case .addButton:
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.didReorder = { transaction in
|
|
||||||
let attachmentChanges = transaction.difference.map {
|
|
||||||
switch $0 {
|
|
||||||
case .insert(let offset, let element, let associatedWith):
|
|
||||||
guard case .attachment(let attachment) = element else { fatalError() }
|
|
||||||
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
|
|
||||||
case .remove(let offset, let element, let associatedWith):
|
|
||||||
guard case .attachment(let attachment) = element else { fatalError() }
|
|
||||||
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let attachmentsDiff = CollectionDifference(attachmentChanges)!
|
|
||||||
let array = draft.draftAttachments.applying(attachmentsDiff)!
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
context.coordinator.dataSource = dataSource
|
|
||||||
|
|
||||||
view.isScrollEnabled = false
|
|
||||||
view.clipsToBounds = false
|
|
||||||
view.delegate = context.coordinator
|
|
||||||
|
|
||||||
let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized))
|
|
||||||
longPressRecognizer.delegate = context.coordinator
|
|
||||||
view.addGestureRecognizer(longPressRecognizer)
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
|
||||||
snapshot.appendSections([.all])
|
|
||||||
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
|
|
||||||
snapshot.appendItems([.addButton])
|
|
||||||
context.coordinator.dataSource.apply(snapshot)
|
|
||||||
context.coordinator.addAttachment = {
|
|
||||||
DraftsPersistentContainer.shared.viewContext.insert($0)
|
|
||||||
$0.draft = draft
|
|
||||||
draft.attachments.add($0)
|
|
||||||
}
|
|
||||||
context.coordinator.draft = draft
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> WrappedCollectionViewCoordinator {
|
|
||||||
WrappedCollectionViewCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func itemSize(width: CGFloat) -> (CGFloat, Int) {
|
|
||||||
// The maximum item size is 2*minItemSize + spacing - 1,
|
|
||||||
// in the case where one item fits in the row but we are one pt short of
|
|
||||||
// adding a second item.
|
|
||||||
var itemSize = minItemSize
|
|
||||||
var fittingCount = floor((width + spacing) / (itemSize + spacing))
|
|
||||||
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
|
||||||
var remainingSpace = width - usedSpaceForFittingCount
|
|
||||||
if fittingCount == 1 && remainingSpace > minItemSize / 2 {
|
|
||||||
// If there's only one item that would fit at min size, and giving
|
|
||||||
// it the rest of the space would increase it by at least 50%,
|
|
||||||
// add a second item anywyas.
|
|
||||||
itemSize = (width - spacing) / 2
|
|
||||||
fittingCount = 2
|
|
||||||
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
|
||||||
remainingSpace = width - usedSpaceForFittingCount
|
|
||||||
}
|
|
||||||
itemSize = itemSize + remainingSpace / fittingCount
|
|
||||||
return (itemSize, Int(fittingCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Section {
|
|
||||||
case all
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Item: Hashable {
|
|
||||||
case attachment(DraftAttachment)
|
|
||||||
case addButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class WrappedCollectionViewCoordinator: NSObject {
|
|
||||||
var draft: Draft!
|
|
||||||
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
|
|
||||||
var currentInteractiveMoveStartOffsetInCell: CGPoint?
|
|
||||||
var currentInteractiveMoveCell: AttachmentCollectionViewCell?
|
|
||||||
var addAttachment: ((DraftAttachment) -> Void)? = nil
|
|
||||||
|
|
||||||
private let attachmentCell = UICollectionView.CellRegistration<AttachmentCollectionViewCell, DraftAttachment> { cell, indexPath, attachment in
|
|
||||||
cell.updateUI(attachment: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let addButtonCell = UICollectionView.CellRegistration<UICollectionViewCell, (WrappedCollectionViewCoordinator, Bool)> { cell, indexPath, item in
|
|
||||||
let (coordinator, enabled) = item
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration(content: {
|
|
||||||
AddAttachmentButton(coordinator: coordinator, enabled: enabled)
|
|
||||||
}).margins(.all, .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCell(collectionView: UICollectionView, indexPath: IndexPath, item: WrappedCollectionView.Item) -> UICollectionViewCell {
|
|
||||||
switch item {
|
|
||||||
case .attachment(let attachment):
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
|
|
||||||
case .addButton:
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: (self, true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
|
|
||||||
let collectionView = recognizer.view as! UICollectionView
|
|
||||||
switch recognizer.state {
|
|
||||||
case .began:
|
|
||||||
break
|
|
||||||
case .changed:
|
|
||||||
var pos = recognizer.location(in: collectionView)
|
|
||||||
if let currentInteractiveMoveStartOffsetInCell {
|
|
||||||
pos.x -= currentInteractiveMoveStartOffsetInCell.x
|
|
||||||
pos.y -= currentInteractiveMoveStartOffsetInCell.y
|
|
||||||
}
|
|
||||||
collectionView.updateInteractiveMovementTargetPosition(pos)
|
|
||||||
case .ended:
|
|
||||||
collectionView.endInteractiveMovement()
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = nil
|
|
||||||
currentInteractiveMoveStartOffsetInCell = nil
|
|
||||||
case .cancelled:
|
|
||||||
collectionView.cancelInteractiveMovement()
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = nil
|
|
||||||
currentInteractiveMoveStartOffsetInCell = nil
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WrappedCollectionViewCoordinator: UIGestureRecognizerDelegate {
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
||||||
let collectionView = gestureRecognizer.view as! UICollectionView
|
|
||||||
let location = gestureRecognizer.location(in: collectionView)
|
|
||||||
guard let indexPath = collectionView.indexPathForItem(at: location),
|
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as? AttachmentCollectionViewCell else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
UIView.animate(withDuration: 0.2) {
|
|
||||||
cell.attachmentView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
|
||||||
}
|
|
||||||
currentInteractiveMoveCell = cell
|
|
||||||
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
|
|
||||||
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
|
|
||||||
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
|
|
||||||
return collectionView.beginInteractiveMovementForItem(at: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WrappedCollectionViewCoordinator: UICollectionViewDelegate {
|
|
||||||
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
|
|
||||||
let snapshot = dataSource.snapshot()
|
|
||||||
let items = snapshot.itemIdentifiers(inSection: .all).count
|
|
||||||
if proposedIndexPath.row == items - 1 {
|
|
||||||
return IndexPath(item: items - 2, section: proposedIndexPath.section)
|
|
||||||
} else {
|
|
||||||
return proposedIndexPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class IntrinsicContentSizeCollectionView: UICollectionView {
|
|
||||||
private var _intrinsicContentSize = CGSize.zero
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
_intrinsicContentSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
if contentSize != _intrinsicContentSize {
|
|
||||||
_intrinsicContentSize = contentSize
|
|
||||||
invalidateIntrinsicContentSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AddAttachmentButton: View {
|
|
||||||
let coordinator: WrappedCollectionViewCoordinator
|
|
||||||
let enabled: Bool
|
|
||||||
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
|
||||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Menu {
|
|
||||||
if let presentAssetPicker {
|
|
||||||
Button("Add photo or video", systemImage: "photo") {
|
|
||||||
presentAssetPicker {
|
|
||||||
let draft = coordinator.draft!
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let presentDrawing {
|
|
||||||
Button("Draw something", systemImage: "hand.draw") {
|
|
||||||
presentDrawing(PKDrawing()) { drawing in
|
|
||||||
let draft = coordinator.draft!
|
|
||||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
|
||||||
attachment.id = UUID()
|
|
||||||
attachment.drawing = drawing
|
|
||||||
attachment.draft = draft
|
|
||||||
draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "photo.badge.plus")
|
|
||||||
.imageScale(.large)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.foregroundStyle(.tint.opacity(0.1))
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(!enabled)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,182 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentsListSection.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/14/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import InstanceFeatures
|
|
||||||
import PencilKit
|
|
||||||
|
|
||||||
struct AttachmentsListSection: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
|
||||||
@Environment(\.canAddAttachment) private var canAddAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
attachmentRows
|
|
||||||
|
|
||||||
buttons
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
#if os(visionOS)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.labelStyle(AttachmentButtonLabelStyle())
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var attachmentRows: some View {
|
|
||||||
ForEach(draft.draftAttachments) { attachment in
|
|
||||||
AttachmentRowView(attachment: attachment)
|
|
||||||
}
|
|
||||||
.onMove(perform: moveAttachments)
|
|
||||||
.onDelete(perform: deleteAttachments)
|
|
||||||
.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in
|
|
||||||
Self.insertAttachments(in: draft, at: offset, itemProviders: providers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var buttons: some View {
|
|
||||||
AddPhotoButton(addAttachments: self.addAttachments)
|
|
||||||
|
|
||||||
AddDrawingButton(draft: draft)
|
|
||||||
|
|
||||||
TogglePollButton(draft: draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
|
|
||||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
|
||||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
|
||||||
guard let attachment = object as? DraftAttachment else { return }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
|
||||||
attachment.draft = draft
|
|
||||||
draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addAttachments(itemProviders: [NSItemProvider]) {
|
|
||||||
Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
|
||||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
|
||||||
// results in the order switching back to the previous order and then to the correct one
|
|
||||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
|
||||||
var array = draft.draftAttachments
|
|
||||||
array.move(fromOffsets: source, toOffset: destination)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteAttachments(at indices: IndexSet) {
|
|
||||||
var array = draft.draftAttachments
|
|
||||||
array.remove(atOffsets: indices)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AddPhotoButton: View {
|
|
||||||
let addAttachments: ([NSItemProvider]) -> Void
|
|
||||||
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let presentAssetPicker {
|
|
||||||
Button("Add photo or video", systemImage: "photo") {
|
|
||||||
presentAssetPicker { results in
|
|
||||||
addAttachments(results.map(\.itemProvider))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AddDrawingButton: View {
|
|
||||||
let draft: Draft
|
|
||||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let presentDrawing {
|
|
||||||
Button("Add drawing", systemImage: "hand.draw") {
|
|
||||||
presentDrawing(PKDrawing()) { drawing in
|
|
||||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
|
||||||
attachment.id = UUID()
|
|
||||||
attachment.drawing = drawing
|
|
||||||
attachment.draft = self.draft
|
|
||||||
self.draft.attachments.add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TogglePollButton: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") {
|
|
||||||
withAnimation {
|
|
||||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(draft.attachments.count > 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AddAttachmentConditionsModifier: ViewModifier {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
private var canAddAttachment: Bool {
|
|
||||||
if instanceFeatures.mastodonAttachmentRestrictions {
|
|
||||||
return draft.attachments.count < 4
|
|
||||||
&& draft.draftAttachments.allSatisfy { $0.type == .image }
|
|
||||||
&& draft.poll == nil
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.environment(\.canAddAttachment, canAddAttachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CanAddAttachmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue = false
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var canAddAttachment: Bool {
|
|
||||||
get { self[CanAddAttachmentKey.self] }
|
|
||||||
set { self[CanAddAttachmentKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DropAttachmentModifier: ViewModifier {
|
|
||||||
let draft: Draft
|
|
||||||
@Environment(\.canAddAttachment) private var canAddAttachment
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentDropDelegate: DropDelegate {
|
|
||||||
let draft: Draft
|
|
||||||
let canAddAttachment: Bool
|
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
|
||||||
canAddAttachment
|
|
||||||
}
|
|
||||||
|
|
||||||
func performDrop(info: DropInfo) -> Bool {
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeDraftView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/16/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
struct ComposeDraftView: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
@Environment(\.currentAccount) private var currentAccount
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
// TODO: scroll effect?
|
|
||||||
AvatarImageView(
|
|
||||||
url: currentAccount?.avatar,
|
|
||||||
size: 50,
|
|
||||||
style: controller.config.avatarStyle,
|
|
||||||
fetchAvatar: controller.fetchAvatar
|
|
||||||
)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
if let currentAccount {
|
|
||||||
AccountNameView(account: currentAccount)
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
|
||||||
|
|
||||||
DraftContentEditor(draft: draft, focusedField: $focusedField)
|
|
||||||
|
|
||||||
AttachmentsSection(draft: draft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AccountNameView: View {
|
|
||||||
let account: any AccountProtocol
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
controller.displayNameLabel(account, .body, 16)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(verbatim: "@\(account.acct)")
|
|
||||||
.font(.body.weight(.light))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,242 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeToolbarView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import TuskerComponents
|
|
||||||
import InstanceFeatures
|
|
||||||
import Pachyderm
|
|
||||||
import TuskerPreferences
|
|
||||||
|
|
||||||
struct ComposeToolbarView: View {
|
|
||||||
static let height: CGFloat = 44
|
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
let mastodonController: any ComposeMastodonContext
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
#if os(visionOS)
|
|
||||||
buttons
|
|
||||||
#else
|
|
||||||
ToolbarScrollView {
|
|
||||||
buttons
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
.frame(height: Self.height)
|
|
||||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Divider()
|
|
||||||
.ignoresSafeArea(edges: [.leading, .trailing])
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buttons: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
|
|
||||||
|
|
||||||
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
|
||||||
|
|
||||||
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
|
||||||
|
|
||||||
InsertEmojiButton()
|
|
||||||
|
|
||||||
FormatButtons()
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
private struct ToolbarScrollView<Content: View>: View {
|
|
||||||
@ViewBuilder let content: Content
|
|
||||||
@State private var minWidth: CGFloat?
|
|
||||||
@State private var realWidth: CGFloat?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
content
|
|
||||||
.frame(minWidth: minWidth)
|
|
||||||
.background {
|
|
||||||
GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
|
||||||
realWidth = $0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background {
|
|
||||||
GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
|
||||||
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
|
||||||
minWidth = $0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat? = nil
|
|
||||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
|
||||||
value = value ?? nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContentWarningButton: View {
|
|
||||||
@Binding var enabled: Bool
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button("CW", action: toggleContentWarning)
|
|
||||||
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toggleContentWarning() {
|
|
||||||
enabled.toggle()
|
|
||||||
if enabled {
|
|
||||||
focusedField = .contentWarning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct VisibilityButton: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
|
||||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
|
||||||
// changing the visibility when local-only.
|
|
||||||
if draft.localOnly,
|
|
||||||
instanceFeatures.localOnlyPostsVisibility {
|
|
||||||
return .constant(.public)
|
|
||||||
} else {
|
|
||||||
return $draft.visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
|
||||||
let visibilities: [Pachyderm.Visibility]
|
|
||||||
if !instanceFeatures.composeDirectStatuses {
|
|
||||||
visibilities = [.public, .unlisted, .private]
|
|
||||||
} else {
|
|
||||||
visibilities = Pachyderm.Visibility.allCases
|
|
||||||
}
|
|
||||||
return visibilities.map { vis in
|
|
||||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
|
||||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
|
||||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
|
||||||
.padding(.horizontal, -8)
|
|
||||||
#endif
|
|
||||||
.disabled(draft.editedStatusID != nil)
|
|
||||||
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LocalOnlyButton: View {
|
|
||||||
@Binding var enabled: Bool
|
|
||||||
var mastodonController: any ComposeMastodonContext
|
|
||||||
@ObservedObject private var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
|
|
||||||
self._enabled = enabled
|
|
||||||
self.mastodonController = mastodonController
|
|
||||||
self.instanceFeatures = mastodonController.instanceFeatures
|
|
||||||
}
|
|
||||||
|
|
||||||
private var options: [MenuPicker<Bool>.Option] {
|
|
||||||
let domain = mastodonController.accountInfo!.instanceURL.host!
|
|
||||||
return [
|
|
||||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
|
||||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if mastodonController.instanceFeatures.localOnlyPosts {
|
|
||||||
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct InsertEmojiButton: View {
|
|
||||||
@FocusedValue(\.composeInput) private var input
|
|
||||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if input?.toolbarElements.contains(.emojiPicker) == true {
|
|
||||||
Button(action: beginAutocompletingEmoji) {
|
|
||||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
|
||||||
}
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.font(.system(size: imageSize))
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginAutocompletingEmoji() {
|
|
||||||
input?.beginAutocompletingEmoji()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FormatButtons: View {
|
|
||||||
@FocusedValue(\.composeInput) private var input
|
|
||||||
@PreferenceObserving(\.$statusContentType) private var contentType
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let input,
|
|
||||||
input.toolbarElements.contains(.formattingButtons),
|
|
||||||
contentType != .plain {
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
ForEach(StatusFormat.allCases) { format in
|
|
||||||
FormatButton(format: format, input: input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FormatButton: View {
|
|
||||||
let format: StatusFormat
|
|
||||||
let input: any ComposeInput
|
|
||||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: applyFormat) {
|
|
||||||
Image(systemName: format.imageName)
|
|
||||||
.font(.system(size: imageSize))
|
|
||||||
}
|
|
||||||
.accessibilityLabel(format.accessibilityLabel)
|
|
||||||
.padding(5)
|
|
||||||
.hoverEffect()
|
|
||||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyFormat() {
|
|
||||||
input.applyFormat(format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// ComposeToolbarView()
|
|
||||||
//}
|
|
|
@ -1,237 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ComposeView: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
let mastodonController: any ComposeMastodonContext
|
|
||||||
@State private var poster: PostService? = nil
|
|
||||||
@FocusState private var focusedField: FocusableField?
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
navigationRoot
|
|
||||||
}
|
|
||||||
.environmentObject(mastodonController.instanceFeatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var navigationRoot: some View {
|
|
||||||
ZStack {
|
|
||||||
ScrollView {
|
|
||||||
scrollContent
|
|
||||||
}
|
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
|
||||||
.modifier(ToolbarSafeAreaInsetModifier())
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
if let poster {
|
|
||||||
PostProgressView(poster: poster)
|
|
||||||
.frame(alignment: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if !os(visionOS)
|
|
||||||
.overlay(alignment: .bottom, content: {
|
|
||||||
// TODO: during ducking animation, toolbar should move off the botto edge
|
|
||||||
// This needs to be in an overlay, ignoring the keyboard safe area
|
|
||||||
// doesn't work with the safeAreaInset modifier.
|
|
||||||
toolbarView
|
|
||||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
||||||
.ignoresSafeArea(.keyboard)
|
|
||||||
})
|
|
||||||
#endif
|
|
||||||
// Have these after the overlays so they barely work instead of not working at all. FB11790805
|
|
||||||
.modifier(DropAttachmentModifier(draft: draft))
|
|
||||||
.modifier(AddAttachmentConditionsModifier(draft: draft))
|
|
||||||
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarActions(draft: draft, controller: controller)
|
|
||||||
#if os(visionOS)
|
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
|
||||||
toolbarView
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var toolbarView: some View {
|
|
||||||
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var scrollContent: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
|
||||||
|
|
||||||
ComposeDraftView(draft: draft, focusedField: $focusedField)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
// NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
|
||||||
// .listRowSeparator(.hidden)
|
|
||||||
//
|
|
||||||
// ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
|
||||||
// .listRowSeparator(.hidden)
|
|
||||||
//
|
|
||||||
// NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
|
||||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
|
||||||
// .listRowSeparator(.hidden)
|
|
||||||
//
|
|
||||||
// AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NavigationTitleModifier: ViewModifier {
|
|
||||||
let draft: Draft
|
|
||||||
let mastodonController: any ComposeMastodonContext
|
|
||||||
|
|
||||||
private var navigationTitle: String {
|
|
||||||
if let id = draft.inReplyToID,
|
|
||||||
let status = mastodonController.fetchStatus(id: id) {
|
|
||||||
return "Reply to @\(status.account.acct)"
|
|
||||||
} else if draft.editedStatusID != nil {
|
|
||||||
return "Edit Post"
|
|
||||||
} else {
|
|
||||||
return "New Post"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.navigationTitle(navigationTitle)
|
|
||||||
.preference(key: NavigationTitlePreferenceKey.self, value: navigationTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public preference so that the host can read the title.
|
|
||||||
public struct NavigationTitlePreferenceKey: PreferenceKey {
|
|
||||||
public static var defaultValue: String? { nil }
|
|
||||||
public static func reduce(value: inout String?, nextValue: () -> String?) {
|
|
||||||
value = value ?? nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ToolbarActions: ToolbarContent {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
// Prior to iOS 16, the toolbar content doesn't seem to have access
|
|
||||||
// to the environment form the containing view.
|
|
||||||
let controller: ComposeController
|
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
|
||||||
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
|
||||||
#else
|
|
||||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var draftsButton: some View {
|
|
||||||
Button(action: controller.showDrafts) {
|
|
||||||
Text("Drafts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var postButton: some View {
|
|
||||||
// TODO: don't use the controller for this
|
|
||||||
Button(action: controller.postStatus) {
|
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
|
||||||
.disabled(!controller.postButtonEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
@ViewBuilder
|
|
||||||
private var postOrDraftsButton: some View {
|
|
||||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
|
||||||
postButton
|
|
||||||
} else {
|
|
||||||
draftsButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ToolbarCancelButton: View {
|
|
||||||
let draft: Draft
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: controller.cancel) {
|
|
||||||
Text("Cancel")
|
|
||||||
// otherwise all Buttons in the nav bar are made semibold
|
|
||||||
.font(.system(size: 17, weight: .regular))
|
|
||||||
}
|
|
||||||
.disabled(controller.isPosting)
|
|
||||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
|
||||||
// edit drafts can't be saved
|
|
||||||
if draft.editedStatusID == nil {
|
|
||||||
Button(action: { controller.cancel(deleteDraft: false) }) {
|
|
||||||
Text("Save Draft")
|
|
||||||
}
|
|
||||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
|
||||||
Text("Delete Draft")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
|
||||||
Text("Cancel Edit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FocusableField: Hashable {
|
|
||||||
case contentWarning
|
|
||||||
case body
|
|
||||||
case attachmentDescription(UUID)
|
|
||||||
|
|
||||||
var nextField: FocusableField? {
|
|
||||||
switch self {
|
|
||||||
case .contentWarning:
|
|
||||||
return .body
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
|
||||||
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
|
|
||||||
@StateObject private var keyboardReader = KeyboardReader()
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
content
|
|
||||||
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.safeAreaInset(edge: .bottom) {
|
|
||||||
if !keyboardReader.isVisible {
|
|
||||||
Color.clear.frame(height: ComposeToolbarView.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// ComposeView()
|
|
||||||
//}
|
|
|
@ -1,38 +0,0 @@
|
||||||
//
|
|
||||||
// ContentWarningTextField.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContentWarningTextField: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if draft.contentWarningEnabled {
|
|
||||||
EmojiTextField(
|
|
||||||
text: $draft.contentWarning,
|
|
||||||
placeholder: "Write your warning here",
|
|
||||||
maxLength: nil,
|
|
||||||
// TODO: completely replace this with FocusState
|
|
||||||
becomeFirstResponder: .constant(false),
|
|
||||||
focusNextView: Binding(get: {
|
|
||||||
false
|
|
||||||
}, set: {
|
|
||||||
if $0 {
|
|
||||||
focusedField = .body
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .contentWarning)
|
|
||||||
.modifier(FocusedInputModifier())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// ContentWarningTextField()
|
|
||||||
//}
|
|
|
@ -1,96 +0,0 @@
|
||||||
//
|
|
||||||
// DraftContentEditor.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/16/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import InstanceFeatures
|
|
||||||
|
|
||||||
struct DraftContentEditor: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline) {
|
|
||||||
LanguageButton(draft: draft)
|
|
||||||
Spacer()
|
|
||||||
CharactersRemaining(draft: draft)
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
}
|
|
||||||
.padding(.all.subtracting(.top), 2)
|
|
||||||
}
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addAttachments(_ providers: [NSItemProvider]) {
|
|
||||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CharactersRemaining: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject var instanceFeatures: InstanceFeatures
|
|
||||||
|
|
||||||
private var charsRemaining: Int {
|
|
||||||
let limit = instanceFeatures.maxStatusChars
|
|
||||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
|
||||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(verbatim: charsRemaining.description)
|
|
||||||
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
|
|
||||||
.font(.callout.monospacedDigit())
|
|
||||||
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LanguageButton: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
|
||||||
@FocusedValue(\.composeInput) private var input
|
|
||||||
@State private var hasChanged = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if instanceFeatures.createStatusWithLanguage {
|
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
|
|
||||||
.buttonStyle(LanguageButtonStyle())
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
|
|
||||||
.onChange(of: draft.id) { _ in
|
|
||||||
hasChanged = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentInputModeChanged(_ notification: Foundation.Notification) {
|
|
||||||
guard !hasChanged,
|
|
||||||
!draft.hasContent,
|
|
||||||
let mode = input?.textInputMode,
|
|
||||||
let code = LanguagePicker.codeFromInputMode(mode) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.language = code.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LanguageButtonStyle: ButtonStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
|
|
||||||
.animation(.linear(duration: 0.1), value: configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,6 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
|
|
||||||
@EnvironmentObject private var controller: ComposeController
|
@EnvironmentObject private var controller: ComposeController
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.composeInputBox) private var inputBox
|
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let placeholder: String
|
let placeholder: String
|
||||||
|
@ -76,11 +75,7 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||||
DispatchQueue.main.async {
|
|
||||||
inputBox.wrappedValue = coordinator
|
|
||||||
}
|
|
||||||
return coordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||||
|
@ -118,16 +113,12 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
DispatchQueue.main.async {
|
controller.currentInput = self
|
||||||
self.controller.currentInput = self
|
|
||||||
}
|
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
DispatchQueue.main.async {
|
controller.currentInput = nil
|
||||||
self.controller.currentInput = nil
|
|
||||||
}
|
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,19 +29,3 @@ struct HeaderView: View {
|
||||||
}.frame(height: 50)
|
}.frame(height: 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NewHeaderView: View {
|
|
||||||
@ObservedObject var draft: Draft
|
|
||||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
|
||||||
@Environment(\.currentAccount) private var currentAccount
|
|
||||||
|
|
||||||
private var charactersRemaining: Int {
|
|
||||||
let limit = instanceFeatures.maxStatusChars
|
|
||||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
|
||||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HeaderView(currentAccount: currentAccount, charsRemaining: charactersRemaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,282 +0,0 @@
|
||||||
//
|
|
||||||
// NewMainTextView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/11/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct NewMainTextView: View {
|
|
||||||
static var minHeight: CGFloat { 150 }
|
|
||||||
|
|
||||||
@Binding var value: String
|
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
|
||||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
|
||||||
@State private var becomeFirstResponder = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
|
|
||||||
.focused($focusedField, equals: .body)
|
|
||||||
.modifier(FocusedInputModifier())
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
if value.isEmpty {
|
|
||||||
PlaceholderView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
|
||||||
@Binding var value: String
|
|
||||||
@Binding var becomeFirstResponder: Bool
|
|
||||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
|
||||||
@Environment(\.composeInputBox) private var inputBox
|
|
||||||
@Environment(\.isEnabled) private var isEnabled
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
|
||||||
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
|
||||||
// TODO: test textSelectionStartsAtBeginning
|
|
||||||
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
|
||||||
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
|
||||||
let view = WrappedTextView(usingTextLayoutManager: true)
|
|
||||||
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
|
|
||||||
view.delegate = context.coordinator
|
|
||||||
view.adjustsFontForContentSizeCategory = true
|
|
||||||
view.textContainer.lineBreakMode = .byWordWrapping
|
|
||||||
view.isScrollEnabled = false
|
|
||||||
view.typingAttributes = [
|
|
||||||
.foregroundColor: UIColor.label,
|
|
||||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
|
||||||
]
|
|
||||||
view.backgroundColor = nil
|
|
||||||
|
|
||||||
// view.layer.cornerRadius = 5
|
|
||||||
// view.layer.cornerCurve = .continuous
|
|
||||||
// view.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
// view.layer.shadowOpacity = 0.15
|
|
||||||
// view.layer.shadowOffset = .zero
|
|
||||||
// view.layer.shadowRadius = 1
|
|
||||||
|
|
||||||
if textSelectionStartsAtBeginning {
|
|
||||||
// Update the text immediately so that the selection isn't invalidated by the text changing.
|
|
||||||
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
|
|
||||||
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
|
||||||
context.coordinator.value = $value
|
|
||||||
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
|
|
||||||
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
|
||||||
|
|
||||||
uiView.isEditable = isEnabled
|
|
||||||
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
|
||||||
// #if !os(visionOS)
|
|
||||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// Trying to set this with the @FocusState binding in onAppear results in the
|
|
||||||
// keyboard not appearing until after the sheet presentation animation completes :/
|
|
||||||
if becomeFirstResponder {
|
|
||||||
uiView.becomeFirstResponder()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
becomeFirstResponder = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> WrappedTextViewCoordinator {
|
|
||||||
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// inputBox.wrappedValue = coordinator
|
|
||||||
// }
|
|
||||||
return coordinator
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
|
|
||||||
let width = proposal.width ?? 10
|
|
||||||
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
|
|
||||||
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
|
|
||||||
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
|
|
||||||
|
|
||||||
private final class WrappedTextViewCoordinator: NSObject {
|
|
||||||
private static let attachment: NSTextAttachment = {
|
|
||||||
let font = UIFont.systemFont(ofSize: 20)
|
|
||||||
let size = /*1.4 * */font.capHeight
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
|
||||||
let image = renderer.image { ctx in
|
|
||||||
UIColor.systemRed.setFill()
|
|
||||||
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
|
|
||||||
}
|
|
||||||
let attachment = NSTextAttachment(image: image)
|
|
||||||
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
|
|
||||||
attachment.lineLayoutPadding = 1
|
|
||||||
return attachment
|
|
||||||
}()
|
|
||||||
|
|
||||||
var value: Binding<String>
|
|
||||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
|
||||||
|
|
||||||
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
|
|
||||||
self.value = value
|
|
||||||
self.handleAttachmentDrop = handleAttachmentDrop
|
|
||||||
}
|
|
||||||
|
|
||||||
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
|
|
||||||
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
|
|
||||||
let str = NSMutableAttributedString(string: text)
|
|
||||||
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
|
|
||||||
for match in mentionMatches.reversed() {
|
|
||||||
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
|
||||||
let range = NSRange(location: match.range.location, length: match.range.length + 1)
|
|
||||||
str.addAttributes([
|
|
||||||
.mention: true,
|
|
||||||
.foregroundColor: UIColor.tintColor,
|
|
||||||
], range: range)
|
|
||||||
}
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
|
|
||||||
if text != plainTextFromAttributed(textView.attributedText) {
|
|
||||||
textView.attributedText = attributedTextFromPlain(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAttributes(in textView: UITextView) {
|
|
||||||
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
|
|
||||||
var changed = false
|
|
||||||
var cursorOffset = 0
|
|
||||||
|
|
||||||
// remove existing mentions that aren't valid
|
|
||||||
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
|
|
||||||
var substr = (str.string as NSString).substring(with: range)
|
|
||||||
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
|
|
||||||
if hasTextAttachment {
|
|
||||||
substr = String(substr.dropFirst())
|
|
||||||
}
|
|
||||||
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
|
|
||||||
changed = true
|
|
||||||
str.removeAttribute(.mention, range: range)
|
|
||||||
str.removeAttribute(.foregroundColor, range: range)
|
|
||||||
if hasTextAttachment {
|
|
||||||
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
|
|
||||||
cursorOffset -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add mentions for those missing
|
|
||||||
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
|
|
||||||
for match in mentionMatches.reversed() {
|
|
||||||
var attributeRange = NSRange()
|
|
||||||
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
|
|
||||||
// the attribute range should always be one greater than the match range, to account for the text attachment
|
|
||||||
if attribute == nil || attributeRange.length <= match.range.length {
|
|
||||||
changed = true
|
|
||||||
let newAttributeRange: NSRange
|
|
||||||
if attribute == nil {
|
|
||||||
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
|
||||||
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
|
|
||||||
cursorOffset += 1
|
|
||||||
} else {
|
|
||||||
newAttributeRange = match.range
|
|
||||||
}
|
|
||||||
str.addAttributes([
|
|
||||||
.mention: true,
|
|
||||||
.foregroundColor: UIColor.tintColor,
|
|
||||||
], range: newAttributeRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
let selection = textView.selectedRange
|
|
||||||
|
|
||||||
textView.attributedText = str
|
|
||||||
|
|
||||||
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WrappedTextViewCoordinator: UITextViewDelegate {
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
|
||||||
if textView.text.isEmpty {
|
|
||||||
textView.typingAttributes = [
|
|
||||||
.foregroundColor: UIColor.label,
|
|
||||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
updateAttributes(in: textView)
|
|
||||||
}
|
|
||||||
|
|
||||||
let plain = plainTextFromAttributed(textView.attributedText)
|
|
||||||
if plain != value.wrappedValue {
|
|
||||||
value.wrappedValue = plain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//extension WrappedTextViewCoordinator: ComposeInput {
|
|
||||||
//
|
|
||||||
//}
|
|
||||||
|
|
||||||
// Because of FB11790805, we can't handle drops for the entire screen.
|
|
||||||
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
|
|
||||||
// So, manually add the UIInteraction to at least handle that.
|
|
||||||
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
|
|
||||||
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
|
|
||||||
session.canLoadObjects(ofClass: DraftAttachment.self)
|
|
||||||
}
|
|
||||||
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
|
|
||||||
UIDropProposal(operation: .copy)
|
|
||||||
}
|
|
||||||
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
|
|
||||||
handleAttachmentDrop(session.items.map(\.itemProvider))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class WrappedTextView: UITextView {
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension NSAttributedString.Key {
|
|
||||||
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PlaceholderView: View {
|
|
||||||
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
|
|
||||||
@ScaledMetric private var fontSize = 20
|
|
||||||
|
|
||||||
private var placeholderOffset: CGSize {
|
|
||||||
#if os(visionOS)
|
|
||||||
CGSize(width: 8, height: 8)
|
|
||||||
#else
|
|
||||||
CGSize(width: 4, height: 8)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
placeholder
|
|
||||||
.font(.system(size: fontSize))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.offset(placeholderOffset)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
//
|
|
||||||
// PostProgressView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PostProgressView: View {
|
|
||||||
@ObservedObject var poster: PostService
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
|
||||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// PostProgressView()
|
|
||||||
//}
|
|
|
@ -132,21 +132,3 @@ private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NewReplyStatusView: View {
|
|
||||||
let draft: Draft
|
|
||||||
let mastodonController: any ComposeMastodonContext
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let id = draft.inReplyToID,
|
|
||||||
let status = mastodonController.fetchStatus(id: id) {
|
|
||||||
ReplyStatusView(
|
|
||||||
status: status,
|
|
||||||
rowTopInset: 8,
|
|
||||||
globalFrameOutsideList: .zero
|
|
||||||
)
|
|
||||||
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
|
||||||
.id(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,6 +9,5 @@ import Foundation
|
||||||
|
|
||||||
public enum FeatureFlag: String, Codable {
|
public enum FeatureFlag: String, Codable {
|
||||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||||
case composeRewrite = "compose-rewrite"
|
|
||||||
case pushNotifCustomEmoji = "push-notif-custom-emoji"
|
case pushNotifCustomEmoji = "push-notif-custom-emoji"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
//
|
|
||||||
// PreferenceObserving.swift
|
|
||||||
// TuskerPreferences
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/10/24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
public struct PreferenceObserving<Key: PreferenceKey>: DynamicProperty {
|
|
||||||
public typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
|
||||||
|
|
||||||
private let keyPath: PrefKeyPath
|
|
||||||
@StateObject private var observer: Observer
|
|
||||||
|
|
||||||
public init(_ keyPath: PrefKeyPath) {
|
|
||||||
self.keyPath = keyPath
|
|
||||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
public var wrappedValue: Key.Value {
|
|
||||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private class Observer: ObservableObject {
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(keyPath: PrefKeyPath) {
|
|
||||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
|
||||||
self.objectWillChange.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -83,8 +83,4 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
||||||
|
|
||||||
func storeCreatedStatus(_ status: Status) {
|
func storeCreatedStatus(_ status: Status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,3 +59,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
|
||||||
|
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||||
|
|
||||||
|
let keyPath: PrefKeyPath
|
||||||
|
@StateObject private var observer: Observer
|
||||||
|
|
||||||
|
init(_ keyPath: PrefKeyPath) {
|
||||||
|
self.keyPath = keyPath
|
||||||
|
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedValue: Key.Value {
|
||||||
|
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private class Observer: ObservableObject {
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
|
init(keyPath: PrefKeyPath) {
|
||||||
|
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -50,10 +50,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
|
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
if let account = mastodonController.account {
|
|
||||||
controller.currentAccount = account
|
|
||||||
}
|
|
||||||
|
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
|
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
|
||||||
|
@ -133,6 +129,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
let picker = PHPickerViewController(configuration: config)
|
let picker = PHPickerViewController(configuration: config)
|
||||||
picker.delegate = self
|
picker.delegate = self
|
||||||
picker.modalPresentationStyle = .pageSheet
|
picker.modalPresentationStyle = .pageSheet
|
||||||
|
picker.overrideUserInterfaceStyle = .dark
|
||||||
// sheet detents don't play nice with PHPickerViewController, see
|
// sheet detents don't play nice with PHPickerViewController, see
|
||||||
// let sheet = picker.sheetPresentationController!
|
// let sheet = picker.sheetPresentationController!
|
||||||
// sheet.detents = [.medium(), .large()]
|
// sheet.detents = [.medium(), .large()]
|
||||||
|
@ -154,8 +151,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
var body: some SwiftUI.View {
|
var body: some SwiftUI.View {
|
||||||
ControllerView(controller: { controller })
|
ControllerView(controller: { controller })
|
||||||
.task {
|
.task {
|
||||||
if controller.currentAccount == nil,
|
if let account = try? await mastodonController.getOwnAccount() {
|
||||||
let account = try? await mastodonController.getOwnAccount() {
|
|
||||||
controller.currentAccount = account
|
controller.currentAccount = account
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -189,7 +185,6 @@ extension ComposeHostingController: DuckableViewController {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// TODO: don't conform MastodonController to this protocol, use a separate type
|
|
||||||
extension MastodonController: ComposeMastodonContext {
|
extension MastodonController: ComposeMastodonContext {
|
||||||
@MainActor
|
@MainActor
|
||||||
func searchCachedAccounts(query: String) -> [AccountProtocol] {
|
func searchCachedAccounts(query: String) -> [AccountProtocol] {
|
||||||
|
@ -232,10 +227,6 @@ extension MastodonController: ComposeMastodonContext {
|
||||||
func storeCreatedStatus(_ status: Status) {
|
func storeCreatedStatus(_ status: Status) {
|
||||||
persistentContainer.addOrUpdate(status: status)
|
persistentContainer.addOrUpdate(status: status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
|
||||||
return persistentContainer.status(for: id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
||||||
|
|
|
@ -18,6 +18,7 @@ struct AdvancedPrefsView : View {
|
||||||
@State private var mastodonCacheSize: Int64 = 0
|
@State private var mastodonCacheSize: Int64 = 0
|
||||||
@State private var cloudKitStatus: CKAccountStatus?
|
@State private var cloudKitStatus: CKAccountStatus?
|
||||||
@State private var isShowingFeatureFlagAlert = false
|
@State private var isShowingFeatureFlagAlert = false
|
||||||
|
@State private var featureFlagName = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
|
@ -31,9 +32,23 @@ struct AdvancedPrefsView : View {
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
|
featureFlagName = ""
|
||||||
isShowingFeatureFlagAlert = true
|
isShowingFeatureFlagAlert = true
|
||||||
}
|
}
|
||||||
.modifier(FeatureFlagAlertModifier(showing: $isShowingFeatureFlagAlert))
|
.alert("Enable Feature Flag", isPresented: $isShowingFeatureFlagAlert) {
|
||||||
|
TextField("Flag Name", text: $featureFlagName)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Enable") {
|
||||||
|
if let flag = FeatureFlag(rawValue: featureFlagName) {
|
||||||
|
preferences.enabledFeatureFlags.insert(flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
|
||||||
|
}
|
||||||
.navigationBarTitle(Text("Advanced"))
|
.navigationBarTitle(Text("Advanced"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,74 +252,6 @@ extension StatusContentType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FeatureFlagAlertModifier: ViewModifier {
|
|
||||||
@Binding var showing: Bool
|
|
||||||
@ObservedObject private var preferences = Preferences.shared
|
|
||||||
@State private var featureFlagName = ""
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
content
|
|
||||||
.onChange(of: showing) {
|
|
||||||
if $0 {
|
|
||||||
featureFlagName = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("Enable Feature Flag", isPresented: $showing) {
|
|
||||||
textField
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
Button("Enable", action: enableFlag)
|
|
||||||
} message: {
|
|
||||||
warning
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.sheet(isPresented: $showing) {
|
|
||||||
NavigationView {
|
|
||||||
List {
|
|
||||||
Section {
|
|
||||||
textField
|
|
||||||
} footer: {
|
|
||||||
warning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Enable Feature Flag")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") {
|
|
||||||
showing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Enable", action: self.enableFlag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textField: some View {
|
|
||||||
TextField("Flag Name", text: $featureFlagName)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var warning: Text {
|
|
||||||
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enableFlag() {
|
|
||||||
if let flag = FeatureFlag(rawValue: featureFlagName) {
|
|
||||||
preferences.enabledFeatureFlags.insert(flag)
|
|
||||||
showing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
struct AdvancedPrefsView_Previews : PreviewProvider {
|
struct AdvancedPrefsView_Previews : PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
|
|
|
@ -30,7 +30,9 @@ class AccountDisplayAndUserNameLabel: EmojiLabel {
|
||||||
|
|
||||||
private func makeAttributedText(state: State) -> NSAttributedString {
|
private func makeAttributedText(state: State) -> NSAttributedString {
|
||||||
let s = NSMutableAttributedString()
|
let s = NSMutableAttributedString()
|
||||||
s.append(NSAttributedString(string: state.displayName, attributes: [
|
// U+2068 FIRST-STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE
|
||||||
|
// to prevent bidi text in the display name influencing the username
|
||||||
|
s.append(NSAttributedString(string: "\u{2068}\(state.displayName)\u{2069}", attributes: [
|
||||||
.font: UIFont(descriptor: baseFont.addingAttributes([
|
.font: UIFont(descriptor: baseFont.addingAttributes([
|
||||||
.traits: [
|
.traits: [
|
||||||
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
|
||||||
|
|
Loading…
Reference in New Issue