Compare commits
30 Commits
develop
...
compose-re
Author | SHA1 | Date | |
---|---|---|---|
bee3e53be7 | |||
96fdef0558 | |||
e0e9d4a185 | |||
6730575aed | |||
c68902b34b | |||
ad5f45c620 | |||
c564bb4112 | |||
ec9673f6c0 | |||
8cc9849b36 | |||
8006b0add9 | |||
b9e3d8ec5e | |||
2fb76e322a | |||
57990f8339 | |||
381f3ee737 | |||
5be80d8e68 | |||
02fd724b0b | |||
7d47f1f259 | |||
cad074bcc3 | |||
8243e06e95 | |||
5f6699749c | |||
ec50dd6bb6 | |||
5d9974ddf8 | |||
f001e8edcd | |||
17c67a3d5d | |||
54fadaa270 | |||
ff433c4270 | |||
71fd804fd7 | |||
198b201a51 | |||
66626c8f62 | |||
727f28e39f |
@ -371,13 +371,14 @@ private struct HTMLCallbacks: HTMLConversionCallbacks {
|
|||||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||||
// so, if available, use the system parser which doesn't require another round trip.
|
// so, if available, use the system parser which doesn't require another round trip.
|
||||||
if let url = try? URL.ParseStrategy().parse(string) {
|
if #available(iOS 16.0, macOS 13.0, *),
|
||||||
|
let url = try? URL.ParseStrategy().parse(string) {
|
||||||
url
|
url
|
||||||
} else if let web = WebURL(string),
|
} else if let web = WebURL(string),
|
||||||
let url = URL(web) {
|
let url = URL(web) {
|
||||||
url
|
url
|
||||||
} else {
|
} else {
|
||||||
nil
|
URL(string: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
@ -20,13 +20,24 @@ 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"),
|
||||||
|
.package(path: "../GalleryVC"),
|
||||||
],
|
],
|
||||||
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"],
|
dependencies: [
|
||||||
|
"Pachyderm",
|
||||||
|
"InstanceFeatures",
|
||||||
|
"TuskerComponents",
|
||||||
|
"MatchedGeometryPresentation",
|
||||||
|
"TuskerPreferences",
|
||||||
|
"UserAccounts",
|
||||||
|
"GalleryVC",
|
||||||
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.swiftLanguageMode(.v5)
|
.swiftLanguageMode(.v5)
|
||||||
]),
|
]),
|
||||||
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class PostService: ObservableObject {
|
final 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)
|
||||||
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
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,6 +8,7 @@
|
|||||||
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 }
|
||||||
@ -27,3 +28,45 @@ enum ToolbarElement {
|
|||||||
case emojiPicker
|
case emojiPicker
|
||||||
case formattingButtons
|
case formattingButtons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct FocusedComposeInput: FocusedValueKey {
|
||||||
|
typealias Value = (any ComposeInput)?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
// double optional is necessary pre-iOS 16
|
||||||
|
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,6 +9,7 @@ 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 }
|
||||||
@ -26,4 +27,6 @@ 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)?
|
||||||
}
|
}
|
||||||
|
@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
|
|||||||
Button(role: .destructive, action: controller.removeAttachment) {
|
Button(role: .destructive, action: controller.removeAttachment) {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
} preview: {
|
} previewIfAvailable: {
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,7 +181,7 @@ extension EnvironmentValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GIFViewWrapper: UIViewRepresentable {
|
struct GIFViewWrapper: UIViewRepresentable {
|
||||||
typealias UIViewType = GIFImageView
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
@State var controller: GIFController
|
@State var controller: GIFController
|
||||||
|
@ -131,7 +131,6 @@ 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
|
||||||
@ -214,10 +213,48 @@ fileprivate extension View {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
|
||||||
|
} else {
|
||||||
|
self.popover(isPresented: isPresented, content: content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func withSheetDetentsIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@ViewBuilder let view: () -> V
|
||||||
|
|
||||||
|
@Environment(\.horizontalSizeClass) var sizeClass
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if sizeClass == .compact {
|
||||||
|
content.sheet(isPresented: $isPresented, content: view)
|
||||||
|
} else {
|
||||||
|
content.popover(isPresented: $isPresented, content: view)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
@available(visionOS 1.0, *)
|
||||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
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)
|
||||||
|
@ -125,16 +125,24 @@ public final class ComposeController: ViewController {
|
|||||||
self.toolbarController = ToolbarController(parent: self)
|
self.toolbarController = ToolbarController(parent: self)
|
||||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
if #available(iOS 16.0, *) {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: some View {
|
public var view: some View {
|
||||||
ComposeView(poster: poster)
|
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
|
||||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
|
||||||
.environmentObject(draft)
|
.environment(\.currentAccount, currentAccount)
|
||||||
.environmentObject(mastodonController.instanceFeatures)
|
.environment(\.composeUIConfig, config)
|
||||||
.environment(\.composeUIConfig, config)
|
} else {
|
||||||
|
ComposeView(poster: poster)
|
||||||
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||||
|
.environmentObject(draft)
|
||||||
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
|
.environment(\.composeUIConfig, config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -322,6 +330,10 @@ public final class ComposeController: ViewController {
|
|||||||
ControllerView(controller: { controller.toolbarController })
|
ControllerView(controller: { controller.toolbarController })
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if !os(visionOS)
|
||||||
|
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||||
|
.padding(.bottom, keyboardInset)
|
||||||
|
#endif
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,7 +442,7 @@ public final class ComposeController: ViewController {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
#endif
|
#endif
|
||||||
.disabled(controller.isPosting)
|
.disabled(controller.isPosting)
|
||||||
}
|
}
|
||||||
@ -481,6 +493,19 @@ public final class ComposeController: ViewController {
|
|||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
.disabled(!controller.postButtonEnabled)
|
.disabled(!controller.postButtonEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private var keyboardInset: CGFloat {
|
||||||
|
if #unavailable(iOS 16.0),
|
||||||
|
UIDevice.current.userInterfaceIdiom == .pad,
|
||||||
|
keyboardReader.isVisible {
|
||||||
|
return ToolbarController.height
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,11 +51,14 @@ class FocusedAttachmentController: ViewController {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
} else {
|
} else if #available(iOS 16.0, *) {
|
||||||
ZoomableScrollView {
|
ZoomableScrollView {
|
||||||
attachmentView
|
attachmentView
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
attachmentView
|
||||||
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
@ -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
|
||||||
private protocol PlaceholderViewProvider {
|
protocol PlaceholderViewProvider {
|
||||||
associatedtype PlaceholderView: View
|
associatedtype PlaceholderView: View
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static func makePlaceholderView() -> PlaceholderView
|
static func makePlaceholderView() -> PlaceholderView
|
||||||
|
@ -96,7 +96,7 @@ class PollController: ViewController {
|
|||||||
.onMove(perform: controller.moveOptions)
|
.onMove(perform: controller.moveOptions)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollDisabled(true)
|
.scrollDisabledIfAvailable(true)
|
||||||
.frame(height: 44 * CGFloat(poll.options.count))
|
.frame(height: 44 * CGFloat(poll.options.count))
|
||||||
|
|
||||||
Button(action: controller.addOption) {
|
Button(action: controller.addOption) {
|
||||||
|
@ -66,7 +66,7 @@ class ToolbarController: ViewController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
.frame(height: ToolbarController.height)
|
.frame(height: ToolbarController.height)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||||
@ -122,7 +122,8 @@ class ToolbarController: ViewController {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
if #available(iOS 16.0, *),
|
||||||
|
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -170,17 +170,26 @@ 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 orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||||
for url in orphaned {
|
for url in orphanedFiles {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
try FileManager.default.removeItem(at: url)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completion()
|
completion()
|
||||||
|
49
Packages/ComposeUI/Sources/ComposeUI/Environment.swift
Normal file
49
Packages/ComposeUI/Sources/ComposeUI/Environment.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
}
|
@ -10,8 +10,8 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
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 {
|
||||||
@ -26,14 +26,12 @@ 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,9 +9,13 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: Int, CaseIterable {
|
enum StatusFormat: Int, CaseIterable, Identifiable {
|
||||||
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:
|
||||||
|
11
Packages/ComposeUI/Sources/ComposeUI/Preferences.swift
Normal file
11
Packages/ComposeUI/Sources/ComposeUI/Preferences.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// Preferences.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/10/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import TuskerPreferences
|
||||||
|
|
||||||
|
typealias Preferences = TuskerPreferences.Preferences
|
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// View+ForwardsCompat.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
#if os(visionOS)
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||||
|
} else {
|
||||||
|
self.contextMenu(menuItems: menuItems)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ 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 }
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// 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 os(visionOS)
|
||||||
|
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.mode = .image(UIImage(cgImage: cgImage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case empty
|
||||||
|
case image(UIImage)
|
||||||
|
case gifController(GIFController)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,158 @@
|
|||||||
|
//
|
||||||
|
// AttachmentCollectionViewCell.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/20/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
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 {
|
||||||
|
@ObservedObject var 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
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(color: .black.opacity(0.5), radius: 1)
|
||||||
|
.padding([.horizontal, .bottom], 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var label: some View {
|
||||||
|
if attachment.attachmentDescription.isEmpty {
|
||||||
|
Label("Add alt", systemImage: "pencil")
|
||||||
|
.labelStyle(NarrowSpacingLabelStyle())
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text(attachment.attachmentDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private struct LegacySquareFrame<Content: View>: View {
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let minDimension = min(proxy.size.width, proxy.size.height)
|
||||||
|
content
|
||||||
|
.frame(width: minDimension, height: minDimension, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func squareFrame() -> some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
SquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
SquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LegacySquareFrame {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// AttachmentGalleryDataSource.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/21/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GalleryVC
|
||||||
|
import TuskerComponents
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
struct AttachmentsGalleryDataSource: GalleryDataSource {
|
||||||
|
let collectionView: UICollectionView
|
||||||
|
let attachmentAtIndex: (Int) -> DraftAttachment?
|
||||||
|
|
||||||
|
func galleryItemsCount() -> Int {
|
||||||
|
collectionView.numberOfItems(inSection: 0) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
|
||||||
|
let attachment = attachmentAtIndex(index)!
|
||||||
|
|
||||||
|
let content: any GalleryContentViewController
|
||||||
|
switch attachment.data {
|
||||||
|
case .editing(_, _, _):
|
||||||
|
fatalError("TODO")
|
||||||
|
|
||||||
|
case .asset(let id):
|
||||||
|
content = LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
if let (image, gifData) = await fetchImageAndGIFData(assetID: id) {
|
||||||
|
let gifController = gifData.map(GIFController.init)
|
||||||
|
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .drawing(let drawing):
|
||||||
|
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
|
||||||
|
|
||||||
|
case .file(let url, let type):
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
content = VideoGalleryContentViewController(url: url, caption: nil)
|
||||||
|
} else if type.conforms(to: .image),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
let gifController = type == .gif ? GIFController(gifData: data) : nil
|
||||||
|
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||||
|
} else {
|
||||||
|
return LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .none:
|
||||||
|
return LoadingGalleryContentViewController(caption: nil) {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EditAttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
|
||||||
|
collectionView.cellForItem(at: IndexPath(item: index, section: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let (type, data) = await withCheckedContinuation { continuation in
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
continuation.resume(returning: (typeIdentifier, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let data,
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if type == UTType.gif.identifier {
|
||||||
|
return (image, data)
|
||||||
|
} else {
|
||||||
|
return (image, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,529 @@
|
|||||||
|
//
|
||||||
|
// AttachmentsSection.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/17/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
import GalleryVC
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
|
struct AttachmentsSection: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
private let spacing: CGFloat = 8
|
||||||
|
private let minItemSize: CGFloat = 100
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
collectionView
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
collectionView
|
||||||
|
} else {
|
||||||
|
LegacyCollectionViewSizingView {
|
||||||
|
collectionView
|
||||||
|
} computeHeight: { width in
|
||||||
|
WrappedCollectionView.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: draft.attachments.count + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var collectionView: some View {
|
||||||
|
WrappedCollectionView(
|
||||||
|
draft: draft,
|
||||||
|
spacing: spacing,
|
||||||
|
minItemSize: minItemSize
|
||||||
|
)
|
||||||
|
// 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.
|
||||||
|
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
|
||||||
|
.frame(minHeight: minItemSize + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private struct LegacyCollectionViewSizingView<Content: View>: View {
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
let computeHeight: (CGFloat) -> CGFloat
|
||||||
|
@State private var width: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let height = computeHeight(width)
|
||||||
|
|
||||||
|
content
|
||||||
|
.frame(height: max(height, 10))
|
||||||
|
.overlay {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: WidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(WidthPrefKey.self) {
|
||||||
|
width = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WidthPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat { 0 }
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
let next = nextValue()
|
||||||
|
if next != 0 {
|
||||||
|
value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
|
||||||
|
private struct WrappedCollectionView: UIViewControllerRepresentable {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
let spacing: CGFloat
|
||||||
|
let minItemSize: CGFloat
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
|
||||||
|
WrappedCollectionViewController(spacing: spacing, minItemSize: minItemSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
|
||||||
|
uiViewController.draft = draft
|
||||||
|
uiViewController.addAttachment = {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.insert($0)
|
||||||
|
$0.draft = draft
|
||||||
|
draft.attachments.add($0)
|
||||||
|
}
|
||||||
|
uiViewController.updateAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: WrappedCollectionViewController, context: Context) -> CGSize? {
|
||||||
|
guard let width = proposal.width,
|
||||||
|
width.isFinite else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let count = draft.attachments.count + 1
|
||||||
|
return CGSize(
|
||||||
|
width: width,
|
||||||
|
height: Self.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func itemSize(width: CGFloat, minItemSize: CGFloat, spacing: 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 == 0 {
|
||||||
|
return (0, 0)
|
||||||
|
} else 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func totalHeight(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat, items: Int) -> CGFloat {
|
||||||
|
let (size, itemsPerRow) = itemSize(width: width, minItemSize: minItemSize, spacing: spacing)
|
||||||
|
guard itemsPerRow != 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let rows = ceil(Double(items) / Double(itemsPerRow))
|
||||||
|
return size * rows + spacing * (rows - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WrappedCollectionViewController: UIViewController {
|
||||||
|
let spacing: CGFloat
|
||||||
|
let minItemSize: CGFloat
|
||||||
|
var draft: Draft!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
|
||||||
|
fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell?
|
||||||
|
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
|
||||||
|
|
||||||
|
var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
|
|
||||||
|
init(spacing: CGFloat, minItemSize: CGFloat) {
|
||||||
|
self.spacing = spacing
|
||||||
|
self.minItemSize = minItemSize
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
|
||||||
|
let (itemSize, itemsPerRow) = WrappedCollectionView.itemSize(width: environment.container.contentSize.width, minItemSize: minItemSize, spacing: spacing)
|
||||||
|
|
||||||
|
let items = Array(repeating: NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize))), count: itemsPerRow)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), subitems: items)
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = spacing
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
let attachmentCell = UICollectionView.CellRegistration<HostingCollectionViewCell, DraftAttachment> { [unowned self] cell, indexPath, attachment in
|
||||||
|
cell.containingViewController = self
|
||||||
|
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
|
||||||
|
}
|
||||||
|
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Bool> { [unowned self] cell, indexPath, item in
|
||||||
|
cell.containingViewController = self
|
||||||
|
cell.setView(AddAttachmentButton(viewController: self, enabled: item))
|
||||||
|
}
|
||||||
|
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
self.view = collectionView
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .attachment(let attachment):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
|
||||||
|
case .addButton:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.reorderingHandlers.canReorderItem = { item in
|
||||||
|
switch item {
|
||||||
|
case .attachment(_):
|
||||||
|
true
|
||||||
|
case .addButton:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.reorderingHandlers.didReorder = { [unowned self] 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView.isScrollEnabled = false
|
||||||
|
collectionView.clipsToBounds = false
|
||||||
|
collectionView.delegate = self
|
||||||
|
|
||||||
|
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
|
||||||
|
longPressRecognizer.delegate = self
|
||||||
|
collectionView.addGestureRecognizer(longPressRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAttachments() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.all])
|
||||||
|
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
|
||||||
|
snapshot.appendItems([.addButton])
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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?.hostView?.transform = .identity
|
||||||
|
}
|
||||||
|
currentInteractiveMoveCell = nil
|
||||||
|
currentInteractiveMoveStartOffsetInCell = nil
|
||||||
|
case .cancelled:
|
||||||
|
collectionView.cancelInteractiveMovement()
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.currentInteractiveMoveCell?.hostView?.transform = .identity
|
||||||
|
}
|
||||||
|
currentInteractiveMoveCell = nil
|
||||||
|
currentInteractiveMoveStartOffsetInCell = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Section {
|
||||||
|
case all
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Item: Hashable {
|
||||||
|
case attachment(DraftAttachment)
|
||||||
|
case addButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedCollectionViewController: 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? HostingCollectionViewCell else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard collectionView.beginInteractiveMovementForItem(at: indexPath) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
cell.hostView?.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 true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedCollectionViewController: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dataSource = AttachmentsGalleryDataSource(collectionView: collectionView) { [dataSource] in
|
||||||
|
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
|
||||||
|
switch item {
|
||||||
|
case .attachment(let attachment):
|
||||||
|
return attachment
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
|
||||||
|
galleryVC.showShareButton = false
|
||||||
|
present(galleryVC, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
|
private class HostingCollectionViewCell: UICollectionViewCell {
|
||||||
|
private(set) var hostView: UIView?
|
||||||
|
|
||||||
|
func setView<V: View>(_ view: V) {
|
||||||
|
let config = UIHostingConfiguration(content: {
|
||||||
|
view
|
||||||
|
}).margins(.all, 0)
|
||||||
|
|
||||||
|
if let hostView = hostView as? UIContentView {
|
||||||
|
hostView.configuration = config
|
||||||
|
} else {
|
||||||
|
hostView = config.makeContentView()
|
||||||
|
hostView!.frame = contentView.bounds
|
||||||
|
contentView.addSubview(hostView!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private class HostingCollectionViewCell: UICollectionViewCell {
|
||||||
|
weak var containingViewController: UIViewController?
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private var hostController: UIHostingController<AnyView>?
|
||||||
|
private(set) var hostView: UIView?
|
||||||
|
|
||||||
|
func setView<V: View>(_ view: V) {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let config = UIHostingConfiguration(content: {
|
||||||
|
view
|
||||||
|
}).margins(.all, 0)
|
||||||
|
|
||||||
|
// We don't just use the cell's contentConfiguration property because we need to animate
|
||||||
|
// the size of the host view, and when the host view is the contentView, that doesn't work.
|
||||||
|
if let hostView = hostView as? UIContentView {
|
||||||
|
hostView.configuration = config
|
||||||
|
} else {
|
||||||
|
hostView = config.makeContentView()
|
||||||
|
hostView!.frame = contentView.bounds
|
||||||
|
contentView.addSubview(hostView!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let hostController {
|
||||||
|
hostController.rootView = AnyView(view)
|
||||||
|
} else {
|
||||||
|
let host = UIHostingController(rootView: AnyView(view))
|
||||||
|
containingViewController!.addChild(host)
|
||||||
|
host.view.frame = contentView.bounds
|
||||||
|
contentView.addSubview(host.view)
|
||||||
|
host.didMove(toParent: containingViewController!)
|
||||||
|
hostController = host
|
||||||
|
hostView = host.view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct AddAttachmentButton: View {
|
||||||
|
unowned let viewController: WrappedCollectionViewController
|
||||||
|
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 = viewController.draft!
|
||||||
|
AttachmentsSection.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 = viewController.draft!
|
||||||
|
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
attachment.id = UUID()
|
||||||
|
attachment.drawing = drawing
|
||||||
|
attachment.draft = draft
|
||||||
|
draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
"photo.badge.plus"
|
||||||
|
} else {
|
||||||
|
"photo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,198 @@
|
|||||||
|
//
|
||||||
|
// EditAttachmentWrapperGalleryContentViewController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/22/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
|
class EditAttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||||
|
let draftAttachment: DraftAttachment
|
||||||
|
let wrapped: any GalleryContentViewController
|
||||||
|
|
||||||
|
var container: (any GalleryContentViewControllerContainer)?
|
||||||
|
|
||||||
|
var contentSize: CGSize {
|
||||||
|
wrapped.contentSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var activityItemsForSharing: [Any] {
|
||||||
|
wrapped.activityItemsForSharing
|
||||||
|
}
|
||||||
|
|
||||||
|
var caption: String? {
|
||||||
|
wrapped.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
|
||||||
|
|
||||||
|
var bottomControlsAccessoryViewController: UIViewController? {
|
||||||
|
editDescriptionViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
|
wrapped.presentationAnimation
|
||||||
|
}
|
||||||
|
|
||||||
|
var hideControlsOnZoom: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
|
||||||
|
self.draftAttachment = draftAttachment
|
||||||
|
self.wrapped = wrapped
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
wrapped.container = container
|
||||||
|
addChild(wrapped)
|
||||||
|
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(wrapped.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
wrapped.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
|
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||||
|
if !visible {
|
||||||
|
editDescriptionViewController.textView?.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentDidAppear() {
|
||||||
|
wrapped.galleryContentDidAppear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func galleryContentWillDisappear() {
|
||||||
|
wrapped.galleryContentWillDisappear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldHideControls() -> Bool {
|
||||||
|
if editDescriptionViewController.textView.isFirstResponder {
|
||||||
|
editDescriptionViewController.textView.resignFirstResponder()
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EditAttachmentDescriptionViewController: UIViewController {
|
||||||
|
private let draftAttachment: DraftAttachment
|
||||||
|
private let wrapped: UIViewController?
|
||||||
|
|
||||||
|
private(set) var textView: UITextView!
|
||||||
|
private var isShowingPlaceholder = false
|
||||||
|
|
||||||
|
private var descriptionObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
|
||||||
|
self.draftAttachment = draftAttachment
|
||||||
|
self.wrapped = wrapped
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.overrideUserInterfaceStyle = .dark
|
||||||
|
view.backgroundColor = .secondarySystemFill
|
||||||
|
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.distribution = .fill
|
||||||
|
stack.spacing = 0
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
if let wrapped {
|
||||||
|
stack.addArrangedSubview(wrapped.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
textView = UITextView()
|
||||||
|
textView.backgroundColor = nil
|
||||||
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
|
if draftAttachment.attachmentDescription.isEmpty {
|
||||||
|
showPlaceholder()
|
||||||
|
} else {
|
||||||
|
removePlaceholder()
|
||||||
|
textView.text = draftAttachment.attachmentDescription
|
||||||
|
}
|
||||||
|
textView.delegate = self
|
||||||
|
stack.addArrangedSubview(textView)
|
||||||
|
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
||||||
|
|
||||||
|
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
|
||||||
|
let desc = self.draftAttachment.attachmentDescription
|
||||||
|
if desc.isEmpty {
|
||||||
|
if !isShowingPlaceholder {
|
||||||
|
showPlaceholder()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isShowingPlaceholder {
|
||||||
|
removePlaceholder()
|
||||||
|
}
|
||||||
|
self.textView.text = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func showPlaceholder() {
|
||||||
|
isShowingPlaceholder = true
|
||||||
|
textView.text = "Describe for the visually impaired"
|
||||||
|
textView.textColor = .secondaryLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func removePlaceholder() {
|
||||||
|
isShowingPlaceholder = false
|
||||||
|
textView.text = ""
|
||||||
|
textView.textColor = .label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
if isShowingPlaceholder {
|
||||||
|
removePlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
draftAttachment.attachmentDescription = textView.text
|
||||||
|
|
||||||
|
if textView.text.isEmpty {
|
||||||
|
showPlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(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.flatMap(\.self),
|
||||||
|
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()
|
||||||
|
//}
|
242
Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift
Normal file
242
Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
navigation
|
||||||
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var navigation: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NavigationStack {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationRoot
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationRoot: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView {
|
||||||
|
scrollContent
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
||||||
|
AttachmentsSection.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()
|
||||||
|
//}
|
@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// 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()
|
||||||
|
//}
|
@ -0,0 +1,98 @@
|
|||||||
|
//
|
||||||
|
// 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]) {
|
||||||
|
AttachmentsSection.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 #available(iOS 16.0, *),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
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,6 +12,7 @@ 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
|
||||||
@ -75,7 +76,11 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
let coordinator = 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 {
|
||||||
@ -113,12 +118,16 @@ struct EmojiTextField: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
controller.currentInput = self
|
DispatchQueue.main.async {
|
||||||
|
self.controller.currentInput = self
|
||||||
|
}
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
controller.currentInput = nil
|
DispatchQueue.main.async {
|
||||||
|
self.controller.currentInput = nil
|
||||||
|
}
|
||||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
286
Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift
Normal file
286
Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
//
|
||||||
|
// 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 = if #available(iOS 16.0, *) {
|
||||||
|
WrappedTextView(usingTextLayoutManager: true)
|
||||||
|
} else {
|
||||||
|
WrappedTextView()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// 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,3 +132,21 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Duckable",
|
name: "Duckable",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "GalleryVC",
|
name: "GalleryVC",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -9,6 +9,15 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
final class Box<T> {
|
||||||
|
var wrappedValue: T
|
||||||
|
|
||||||
|
init(wrappedValue: T) {
|
||||||
|
self.wrappedValue = wrappedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class VideoControlsViewController: UIViewController {
|
class VideoControlsViewController: UIViewController {
|
||||||
private static let formatter: DateComponentsFormatter = {
|
private static let formatter: DateComponentsFormatter = {
|
||||||
let f = DateComponentsFormatter()
|
let f = DateComponentsFormatter()
|
||||||
@ -18,6 +27,9 @@ class VideoControlsViewController: UIViewController {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
private let player: AVPlayer
|
private let player: AVPlayer
|
||||||
|
#if !os(visionOS)
|
||||||
|
@Box private var playbackSpeed: Float
|
||||||
|
#endif
|
||||||
|
|
||||||
private lazy var muteButton: MuteButton = {
|
private lazy var muteButton: MuteButton = {
|
||||||
let button = MuteButton()
|
let button = MuteButton()
|
||||||
@ -51,8 +63,13 @@ class VideoControlsViewController: UIViewController {
|
|||||||
|
|
||||||
private lazy var optionsButton = MenuButton { [unowned self] in
|
private lazy var optionsButton = MenuButton { [unowned self] in
|
||||||
let imageName: String
|
let imageName: String
|
||||||
|
#if os(visionOS)
|
||||||
|
let playbackSpeed = player.defaultRate
|
||||||
|
#else
|
||||||
|
let playbackSpeed = self.playbackSpeed
|
||||||
|
#endif
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
switch player.defaultRate {
|
switch playbackSpeed {
|
||||||
case 0.5:
|
case 0.5:
|
||||||
imageName = "gauge.with.dots.needle.0percent"
|
imageName = "gauge.with.dots.needle.0percent"
|
||||||
case 1:
|
case 1:
|
||||||
@ -68,8 +85,12 @@ class VideoControlsViewController: UIViewController {
|
|||||||
imageName = "speedometer"
|
imageName = "speedometer"
|
||||||
}
|
}
|
||||||
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
|
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
|
||||||
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in
|
UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
|
||||||
|
#if os(visionOS)
|
||||||
self.player.defaultRate = speed.rate
|
self.player.defaultRate = speed.rate
|
||||||
|
#else
|
||||||
|
self.playbackSpeed = speed.rate
|
||||||
|
#endif
|
||||||
if self.player.rate > 0 {
|
if self.player.rate > 0 {
|
||||||
self.player.rate = speed.rate
|
self.player.rate = speed.rate
|
||||||
}
|
}
|
||||||
@ -99,11 +120,20 @@ class VideoControlsViewController: UIViewController {
|
|||||||
private var scrubbingTargetTime: CMTime?
|
private var scrubbingTargetTime: CMTime?
|
||||||
private var isSeeking = false
|
private var isSeeking = false
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
init(player: AVPlayer) {
|
init(player: AVPlayer) {
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
init(player: AVPlayer, playbackSpeed: Box<Float>) {
|
||||||
|
self.player = player
|
||||||
|
self._playbackSpeed = playbackSpeed
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
@ -187,7 +217,11 @@ class VideoControlsViewController: UIViewController {
|
|||||||
@objc private func scrubbingEnded() {
|
@objc private func scrubbingEnded() {
|
||||||
scrubbingChanged()
|
scrubbingChanged()
|
||||||
if wasPlayingWhenScrubbingStarted {
|
if wasPlayingWhenScrubbingStarted {
|
||||||
|
#if os(visionOS)
|
||||||
player.play()
|
player.play()
|
||||||
|
#else
|
||||||
|
player.rate = playbackSpeed
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,6 +16,11 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
|||||||
public private(set) var item: AVPlayerItem
|
public private(set) var item: AVPlayerItem
|
||||||
public let player: AVPlayer
|
public let player: AVPlayer
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
|
||||||
|
@Box private var playbackSpeed: Float = 1
|
||||||
|
#endif
|
||||||
|
|
||||||
private var presentationSizeObservation: NSKeyValueObservation?
|
private var presentationSizeObservation: NSKeyValueObservation?
|
||||||
private var statusObservation: NSKeyValueObservation?
|
private var statusObservation: NSKeyValueObservation?
|
||||||
private var rateObservation: NSKeyValueObservation?
|
private var rateObservation: NSKeyValueObservation?
|
||||||
@ -157,7 +162,6 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
open var activityItemsForSharing: [Any] {
|
open var activityItemsForSharing: [Any] {
|
||||||
// [VideoActivityItemSource(asset: item.asset, url: url)]
|
|
||||||
[]
|
[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,12 +169,20 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
|||||||
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
|
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
||||||
|
#else
|
||||||
|
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
|
||||||
|
#endif
|
||||||
public var contentOverlayAccessoryViewController: UIViewController? {
|
public var contentOverlayAccessoryViewController: UIViewController? {
|
||||||
overlayVC
|
overlayVC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
||||||
|
#else
|
||||||
|
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
|
||||||
|
#endif
|
||||||
|
|
||||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
if !isShowingError {
|
if !isShowingError {
|
||||||
|
@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController {
|
|||||||
private static let pauseImage = UIImage(systemName: "pause.fill")!
|
private static let pauseImage = UIImage(systemName: "pause.fill")!
|
||||||
|
|
||||||
private let player: AVPlayer
|
private let player: AVPlayer
|
||||||
|
#if !os(visionOS)
|
||||||
|
@Box private var playbackSpeed: Float
|
||||||
|
#endif
|
||||||
|
|
||||||
private var dimmingView: UIView!
|
private var dimmingView: UIView!
|
||||||
private var controlsStack: UIStackView!
|
private var controlsStack: UIStackView!
|
||||||
@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController {
|
|||||||
|
|
||||||
private var rateObservation: NSKeyValueObservation?
|
private var rateObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
#if os(visionOS)
|
||||||
init(player: AVPlayer) {
|
init(player: AVPlayer) {
|
||||||
self.player = player
|
self.player = player
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
init(player: AVPlayer, playbackSpeed: Box<Float>) {
|
||||||
|
self.player = player
|
||||||
|
self._playbackSpeed = playbackSpeed
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController {
|
|||||||
if player.currentTime() >= player.currentItem!.duration {
|
if player.currentTime() >= player.currentItem!.duration {
|
||||||
player.seek(to: .zero)
|
player.seek(to: .zero)
|
||||||
}
|
}
|
||||||
|
#if os(visionOS)
|
||||||
player.play()
|
player.play()
|
||||||
|
#else
|
||||||
|
player.rate = playbackSpeed
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,8 +15,11 @@ public protocol GalleryContentViewController: UIViewController {
|
|||||||
var caption: String? { get }
|
var caption: String? { get }
|
||||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
var contentOverlayAccessoryViewController: UIViewController? { get }
|
||||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||||
|
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
|
||||||
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
||||||
|
var hideControlsOnZoom: Bool { get }
|
||||||
|
|
||||||
|
func shouldHideControls() -> Bool
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||||
func galleryContentDidAppear()
|
func galleryContentDidAppear()
|
||||||
func galleryContentWillDisappear()
|
func galleryContentWillDisappear()
|
||||||
@ -31,10 +34,22 @@ public extension GalleryContentViewController {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
.fromSourceView
|
.fromSourceView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hideControlsOnZoom: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldHideControls() -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,12 @@ class GalleryItemViewController: UIViewController {
|
|||||||
|
|
||||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||||
|
|
||||||
|
var showShareButton: Bool = true {
|
||||||
|
didSet {
|
||||||
|
shareButton?.isHidden = !showShareButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||||
return !controlsVisible
|
return !controlsVisible
|
||||||
}
|
}
|
||||||
@ -66,6 +72,10 @@ class GalleryItemViewController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
// Need to use the keyboard layout guide in some way in this VC,
|
||||||
|
// otherwise the keyboardLayoutGuide inside the bottom controls accessory view doesn't animate
|
||||||
|
_ = view.keyboardLayoutGuide
|
||||||
|
|
||||||
scrollView = UIScrollView()
|
scrollView = UIScrollView()
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
scrollView.delegate = self
|
scrollView.delegate = self
|
||||||
@ -109,6 +119,7 @@ class GalleryItemViewController: UIViewController {
|
|||||||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
||||||
}
|
}
|
||||||
shareButton.preferredBehavioralStyle = .pad
|
shareButton.preferredBehavioralStyle = .pad
|
||||||
|
shareButton.isHidden = !showShareButton
|
||||||
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
updateShareButton()
|
updateShareButton()
|
||||||
topControlsView.addSubview(shareButton)
|
topControlsView.addSubview(shareButton)
|
||||||
@ -141,12 +152,14 @@ class GalleryItemViewController: UIViewController {
|
|||||||
bottomControlsView.addArrangedSubview(controlsAccessory.view)
|
bottomControlsView.addArrangedSubview(controlsAccessory.view)
|
||||||
controlsAccessory.didMove(toParent: self)
|
controlsAccessory.didMove(toParent: self)
|
||||||
|
|
||||||
// Make sure the controls accessory is within the safe area.
|
if content.insetBottomControlsAccessoryViewControllerToSafeArea {
|
||||||
let spacer = UIView()
|
// Make sure the controls accessory is within the safe area.
|
||||||
bottomControlsView.addArrangedSubview(spacer)
|
let spacer = UIView()
|
||||||
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
bottomControlsView.addArrangedSubview(spacer)
|
||||||
spacerTopConstraint.priority = .init(999)
|
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
||||||
spacerTopConstraint.isActive = true
|
spacerTopConstraint.priority = .init(999)
|
||||||
|
spacerTopConstraint.isActive = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
captionTextView = UITextView()
|
captionTextView = UITextView()
|
||||||
@ -210,6 +223,14 @@ class GalleryItemViewController: UIViewController {
|
|||||||
singleTap.require(toFail: doubleTap)
|
singleTap.require(toFail: doubleTap)
|
||||||
view.addGestureRecognizer(singleTap)
|
view.addGestureRecognizer(singleTap)
|
||||||
view.addGestureRecognizer(doubleTap)
|
view.addGestureRecognizer(doubleTap)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillUpdate() {
|
||||||
|
updateZoomScale(resetZoom: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewSafeAreaInsetsDidChange() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
@ -332,7 +353,7 @@ class GalleryItemViewController: UIViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let heightScale = view.bounds.height / content.contentSize.height
|
let heightScale = (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height) / content.contentSize.height
|
||||||
let widthScale = view.bounds.width / content.contentSize.width
|
let widthScale = view.bounds.width / content.contentSize.width
|
||||||
let minScale = min(widthScale, heightScale)
|
let minScale = min(widthScale, heightScale)
|
||||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
||||||
@ -355,7 +376,7 @@ class GalleryItemViewController: UIViewController {
|
|||||||
|
|
||||||
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
|
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
|
||||||
// which means it's already been scaled by the zoom factor.
|
// which means it's already been scaled by the zoom factor.
|
||||||
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
|
let yOffset = max(0, (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height - content.view.frame.height) / 2)
|
||||||
contentViewTopConstraint!.constant = yOffset
|
contentViewTopConstraint!.constant = yOffset
|
||||||
|
|
||||||
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
|
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
|
||||||
@ -432,7 +453,9 @@ class GalleryItemViewController: UIViewController {
|
|||||||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
scrollView.zoomScale > scrollView.minimumZoomScale {
|
||||||
animateZoomOut()
|
animateZoomOut()
|
||||||
} else {
|
} else {
|
||||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
if content.shouldHideControls() {
|
||||||
|
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,7 +574,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
|
|||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||||
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
||||||
} else {
|
} else {
|
||||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
if content.hideControlsOnZoom {
|
||||||
|
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
centerContent()
|
centerContent()
|
||||||
|
@ -26,6 +26,14 @@ public class GalleryViewController: UIPageViewController {
|
|||||||
private var dismissInteraction: GalleryDismissInteraction!
|
private var dismissInteraction: GalleryDismissInteraction!
|
||||||
private var presentationAnimationCompletionHandlers: [() -> Void] = []
|
private var presentationAnimationCompletionHandlers: [() -> Void] = []
|
||||||
|
|
||||||
|
public var showShareButton: Bool = true {
|
||||||
|
didSet {
|
||||||
|
if viewControllers?.isEmpty == false {
|
||||||
|
currentItemViewController.showShareButton = showShareButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override public var prefersStatusBarHidden: Bool {
|
override public var prefersStatusBarHidden: Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -89,7 +97,9 @@ public class GalleryViewController: UIPageViewController {
|
|||||||
|
|
||||||
private func makeItemVC(index: Int) -> GalleryItemViewController {
|
private func makeItemVC(index: Int) -> GalleryItemViewController {
|
||||||
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
|
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
|
||||||
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
let itemVC = GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
||||||
|
itemVC.showShareButton = showShareButton
|
||||||
|
return itemVC
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentationAnimationCompleted() {
|
func presentationAnimationCompleted() {
|
||||||
|
@ -14,7 +14,8 @@ extension UIView {
|
|||||||
while let superview = view.superview {
|
while let superview = view.superview {
|
||||||
if superview.layer.masksToBounds {
|
if superview.layer.masksToBounds {
|
||||||
return superview
|
return superview
|
||||||
} else if superview is UIScrollView {
|
} else if let scrollView = superview as? UIScrollView,
|
||||||
|
scrollView.isScrollEnabled {
|
||||||
return self
|
return self
|
||||||
} else {
|
} else {
|
||||||
view = superview
|
view = superview
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "InstanceFeatures",
|
name: "InstanceFeatures",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "MatchedGeometryPresentation",
|
name: "MatchedGeometryPresentation",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Pachyderm",
|
name: "Pachyderm",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "PushNotifications",
|
name: "PushNotifications",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TTTKit",
|
name: "TTTKit",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TuskerComponents",
|
name: "TuskerComponents",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -9,14 +9,21 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct AsyncPicker<V: Hashable, Content: View>: View {
|
public struct AsyncPicker<V: Hashable, Content: View>: View {
|
||||||
let titleKey: LocalizedStringKey
|
let titleKey: LocalizedStringKey
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||||
|
let labelHidden: Bool
|
||||||
|
#endif
|
||||||
let alignment: Alignment
|
let alignment: Alignment
|
||||||
@Binding var value: V
|
@Binding var value: V
|
||||||
let onChange: (V) async -> Bool
|
let onChange: (V) async -> Bool
|
||||||
let content: Content
|
let content: Content
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
|
||||||
public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
|
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
|
||||||
self.titleKey = titleKey
|
self.titleKey = titleKey
|
||||||
|
#if !os(visionOS)
|
||||||
|
self.labelHidden = labelHidden
|
||||||
|
#endif
|
||||||
self.alignment = alignment
|
self.alignment = alignment
|
||||||
self._value = value
|
self._value = value
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
@ -24,9 +31,25 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
#if os(visionOS)
|
||||||
LabeledContent(titleKey) {
|
LabeledContent(titleKey) {
|
||||||
picker
|
picker
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
} else if labelHidden {
|
||||||
|
picker
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(titleKey)
|
||||||
|
Spacer()
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var picker: some View {
|
private var picker: some View {
|
||||||
|
@ -10,19 +10,42 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct AsyncToggle: View {
|
public struct AsyncToggle: View {
|
||||||
let titleKey: LocalizedStringKey
|
let titleKey: LocalizedStringKey
|
||||||
|
#if !os(visionOS)
|
||||||
|
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||||
|
let labelHidden: Bool
|
||||||
|
#endif
|
||||||
@Binding var mode: Mode
|
@Binding var mode: Mode
|
||||||
let onChange: (Bool) async -> Bool
|
let onChange: (Bool) async -> Bool
|
||||||
|
|
||||||
public init(_ titleKey: LocalizedStringKey, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
||||||
self.titleKey = titleKey
|
self.titleKey = titleKey
|
||||||
|
#if !os(visionOS)
|
||||||
|
self.labelHidden = labelHidden
|
||||||
|
#endif
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
#if os(visionOS)
|
||||||
LabeledContent(titleKey) {
|
LabeledContent(titleKey) {
|
||||||
toggleOrSpinner
|
toggleOrSpinner
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
LabeledContent(titleKey) {
|
||||||
|
toggleOrSpinner
|
||||||
|
}
|
||||||
|
} else if labelHidden {
|
||||||
|
toggleOrSpinner
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(titleKey)
|
||||||
|
Spacer()
|
||||||
|
toggleOrSpinner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -47,7 +47,9 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
|||||||
|
|
||||||
private func makeConfiguration() -> UIButton.Configuration {
|
private func makeConfiguration() -> UIButton.Configuration {
|
||||||
var config = UIButton.Configuration.borderless()
|
var config = UIButton.Configuration.borderless()
|
||||||
config.indicator = .popup
|
if #available(iOS 16.0, *) {
|
||||||
|
config.indicator = .popup
|
||||||
|
}
|
||||||
if buttonStyle.hasIcon {
|
if buttonStyle.hasIcon {
|
||||||
config.image = selectedOption.image
|
config.image = selectedOption.image
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TuskerPreferences",
|
name: "TuskerPreferences",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
@ -9,5 +9,6 @@ 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"
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "UserAccounts",
|
name: "UserAccounts",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
@ -83,4 +83,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
|||||||
|
|
||||||
func storeCreatedStatus(_ status: Status) {
|
func storeCreatedStatus(_ status: Status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -141,6 +141,7 @@
|
|||||||
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
|
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
|
||||||
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
|
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
||||||
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
||||||
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
|
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
|
||||||
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
|
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
|
||||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||||
@ -332,7 +333,7 @@
|
|||||||
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
|
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
|
||||||
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
|
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
|
||||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
|
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
|
||||||
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; };
|
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
|
||||||
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
|
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
|
||||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
|
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
|
||||||
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
|
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
|
||||||
@ -567,6 +568,7 @@
|
|||||||
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
|
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
|
||||||
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
|
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
|
||||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
||||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
||||||
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
|
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
|
||||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
||||||
@ -768,7 +770,7 @@
|
|||||||
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
|
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
|
||||||
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
||||||
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6D9498E298EB79400C59229 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = "<group>"; };
|
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
|
||||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
|
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
|
||||||
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
|
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
|
||||||
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
|
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
|
||||||
@ -1476,7 +1478,7 @@
|
|||||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||||
D6D9498E298EB79400C59229 /* CopyableLabel.swift */,
|
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
|
||||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||||
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
||||||
@ -1613,6 +1615,7 @@
|
|||||||
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
|
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
|
||||||
D6B81F432560390300F6E31D /* MenuController.swift */,
|
D6B81F432560390300F6E31D /* MenuController.swift */,
|
||||||
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
|
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
|
||||||
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
||||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||||
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
|
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
|
||||||
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
||||||
@ -2287,7 +2290,7 @@
|
|||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||||
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||||
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
|
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
|
||||||
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */,
|
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
|
||||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
@ -2371,6 +2374,7 @@
|
|||||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||||
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
|
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
|
||||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
||||||
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||||
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
|
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
|
||||||
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
|
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
|
||||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||||
@ -2527,7 +2531,6 @@
|
|||||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2560,7 +2563,6 @@
|
|||||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2592,7 +2594,6 @@
|
|||||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2683,7 +2684,6 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2727,7 +2727,7 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2750,7 +2750,6 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2778,7 +2777,6 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2807,7 +2805,6 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2836,7 +2833,6 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -2992,7 +2988,6 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -3025,7 +3020,6 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -3090,7 +3084,7 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -3110,7 +3104,7 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -3133,7 +3127,6 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -3158,7 +3151,6 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import os
|
|
||||||
|
|
||||||
struct DiskCacheTransformer<T> {
|
struct DiskCacheTransformer<T> {
|
||||||
let toData: (T) throws -> Data
|
let toData: (T) throws -> Data
|
||||||
@ -22,7 +21,7 @@ class DiskCache<T> {
|
|||||||
let defaultExpiry: CacheExpiry
|
let defaultExpiry: CacheExpiry
|
||||||
let transformer: DiskCacheTransformer<T>
|
let transformer: DiskCacheTransformer<T>
|
||||||
|
|
||||||
private var fileStates = OSAllocatedUnfairLock(initialState: [String: FileState]())
|
private var fileStates = MultiThreadDictionary<String, FileState>()
|
||||||
|
|
||||||
init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws {
|
init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws {
|
||||||
self.defaultExpiry = defaultExpiry
|
self.defaultExpiry = defaultExpiry
|
||||||
@ -60,9 +59,7 @@ class DiskCache<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fileState(forKey key: String) -> FileState {
|
private func fileState(forKey key: String) -> FileState {
|
||||||
return fileStates.withLock {
|
return fileStates[key] ?? .unknown
|
||||||
$0[key] ?? .unknown
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setObject(_ object: T, forKey key: String) throws {
|
func setObject(_ object: T, forKey key: String) throws {
|
||||||
@ -71,17 +68,13 @@ class DiskCache<T> {
|
|||||||
guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else {
|
guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else {
|
||||||
throw Error.couldNotCreateFile
|
throw Error.couldNotCreateFile
|
||||||
}
|
}
|
||||||
fileStates.withLock {
|
fileStates[key] = .exists
|
||||||
$0[key] = .exists
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeObject(forKey key: String) throws {
|
func removeObject(forKey key: String) throws {
|
||||||
let path = makeFilePath(for: key)
|
let path = makeFilePath(for: key)
|
||||||
try fileManager.removeItem(atPath: path)
|
try fileManager.removeItem(atPath: path)
|
||||||
fileStates.withLock {
|
fileStates[key] = .doesNotExist
|
||||||
$0[key] = .doesNotExist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func existsObject(forKey key: String) throws -> Bool {
|
func existsObject(forKey key: String) throws -> Bool {
|
||||||
@ -112,9 +105,7 @@ class DiskCache<T> {
|
|||||||
}
|
}
|
||||||
guard date.timeIntervalSinceNow >= 0 else {
|
guard date.timeIntervalSinceNow >= 0 else {
|
||||||
try fileManager.removeItem(atPath: path)
|
try fileManager.removeItem(atPath: path)
|
||||||
fileStates.withLock {
|
fileStates[key] = .doesNotExist
|
||||||
$0[key] = .doesNotExist
|
|
||||||
}
|
|
||||||
throw Error.expired
|
throw Error.expired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,10 +76,17 @@ func fromTimelineKind(_ kind: String) -> Timeline {
|
|||||||
} else if kind == "direct" {
|
} else if kind == "direct" {
|
||||||
return .direct
|
return .direct
|
||||||
} else if kind.starts(with: "hashtag:") {
|
} else if kind.starts(with: "hashtag:") {
|
||||||
return .tag(hashtag: String(kind.trimmingPrefix("hashtag:")))
|
return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
|
||||||
} else if kind.starts(with: "list:") {
|
} else if kind.starts(with: "list:") {
|
||||||
return .list(id: String(kind.trimmingPrefix("list:")))
|
return .list(id: String(trimmingPrefix("list:", of: kind)))
|
||||||
} else {
|
} else {
|
||||||
fatalError("invalid timeline kind \(kind)")
|
fatalError("invalid timeline kind \(kind)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replace with Collection.trimmingPrefix
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
|
||||||
|
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
|
||||||
|
}
|
||||||
|
@ -36,12 +36,19 @@ private struct AppGroupedListBackground: ViewModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if colorScheme == .dark, !pureBlackDarkMode {
|
if #available(iOS 16.0, *) {
|
||||||
content
|
if colorScheme == .dark, !pureBlackDarkMode {
|
||||||
.scrollContentBackground(.hidden)
|
content
|
||||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
|
.onAppear {
|
||||||
|
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,31 +66,3 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -48,13 +48,14 @@ extension HTMLConverter {
|
|||||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||||
// so, if available, use the system parser which doesn't require another round trip.
|
// so, if available, use the system parser which doesn't require another round trip.
|
||||||
if let url = try? URL.ParseStrategy().parse(string) {
|
if #available(iOS 16.0, macOS 13.0, *),
|
||||||
|
let url = try? URL.ParseStrategy().parse(string) {
|
||||||
url
|
url
|
||||||
} else if let web = WebURL(string),
|
} else if let web = WebURL(string),
|
||||||
let url = URL(web) {
|
let url = URL(web) {
|
||||||
url
|
url
|
||||||
} else {
|
} else {
|
||||||
nil
|
URL(string: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
104
Tusker/MultiThreadDictionary.swift
Normal file
104
Tusker/MultiThreadDictionary.swift
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// MultiThreadDictionary.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/6/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
|
||||||
|
// to make the lock semantics more clear
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
final class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
|
||||||
|
#if os(visionOS)
|
||||||
|
private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]())
|
||||||
|
#else
|
||||||
|
private let lock: any Lock<[Key: Value]>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if !os(visionOS)
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.lock = OSAllocatedUnfairLock(initialState: [:])
|
||||||
|
} else {
|
||||||
|
self.lock = UnfairLock(initialState: [:])
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript(key: Key) -> Value? {
|
||||||
|
get {
|
||||||
|
return lock.withLock { dict in
|
||||||
|
dict[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
#if os(visionOS)
|
||||||
|
lock.withLock { dict in
|
||||||
|
dict[key] = value
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = lock.withLock { dict in
|
||||||
|
dict[key] = value
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
|
||||||
|
func removeValue(forKey key: Key) -> Value? {
|
||||||
|
return lock.withLock { dict in
|
||||||
|
dict.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(key: Key) -> Bool {
|
||||||
|
return lock.withLock { dict in
|
||||||
|
dict.keys.contains(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should really be throws/rethrows but the stupid type-erased lock makes that not possible
|
||||||
|
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
|
||||||
|
return try lock.withLock { dict in
|
||||||
|
return try body(&dict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
// TODO: replace this only with OSAllocatedUnfairLock
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
fileprivate protocol Lock<State> {
|
||||||
|
associatedtype State
|
||||||
|
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension OSAllocatedUnfairLock: Lock {
|
||||||
|
}
|
||||||
|
|
||||||
|
// from http://www.russbishop.net/the-law
|
||||||
|
fileprivate class UnfairLock<State>: Lock {
|
||||||
|
private var lock: UnsafeMutablePointer<os_unfair_lock>
|
||||||
|
private var state: State
|
||||||
|
init(initialState: State) {
|
||||||
|
self.state = initialState
|
||||||
|
self.lock = .allocate(capacity: 1)
|
||||||
|
self.lock.initialize(to: os_unfair_lock())
|
||||||
|
}
|
||||||
|
deinit {
|
||||||
|
self.lock.deinitialize(count: 1)
|
||||||
|
self.lock.deallocate()
|
||||||
|
}
|
||||||
|
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
|
||||||
|
os_unfair_lock_lock(lock)
|
||||||
|
defer { os_unfair_lock_unlock(lock) }
|
||||||
|
return try body(&state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
@ -274,7 +274,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||||||
} else {
|
} else {
|
||||||
mainVC = MainSplitViewController(mastodonController: mastodonController)
|
mainVC = MainSplitViewController(mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
#available(iOS 16.0, *) {
|
||||||
// TODO: maybe the duckable container should be outside the account switching container
|
// TODO: maybe the duckable container should be outside the account switching container
|
||||||
return DuckableContainerViewController(child: mainVC)
|
return DuckableContainerViewController(child: mainVC)
|
||||||
} else {
|
} else {
|
||||||
|
@ -86,7 +86,7 @@ struct AddReactionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
.navigationViewStyle(.stack)
|
||||||
.presentationDetents([.medium, .large])
|
.mediumPresentationDetentIfAvailable()
|
||||||
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
|
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
|
||||||
Button("OK") {}
|
Button("OK") {}
|
||||||
}, message: { error in
|
}, message: { error in
|
||||||
@ -171,6 +171,17 @@ private struct AddReactionButton<Label: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
func mediumPresentationDetentIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.presentationDetents([.medium, .large])
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS, obsoleted: 17.1)
|
@available(iOS, obsoleted: 17.1)
|
||||||
@available(visionOS 1.0, *)
|
@available(visionOS 1.0, *)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -20,10 +20,14 @@ struct AnnouncementListRow: View {
|
|||||||
@State private var isShowingAddReactionSheet = false
|
@State private var isShowingAddReactionSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
mostOfTheBody
|
if #available(iOS 16.0, *) {
|
||||||
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
mostOfTheBody
|
||||||
dimension[.leading]
|
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
||||||
})
|
dimension[.leading]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
mostOfTheBody
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mostOfTheBody: some View {
|
private var mostOfTheBody: some View {
|
||||||
@ -50,7 +54,11 @@ struct AnnouncementListRow: View {
|
|||||||
Label {
|
Label {
|
||||||
Text("Add Reaction")
|
Text("Add Reaction")
|
||||||
} icon: {
|
} icon: {
|
||||||
Image("face.smiling.badge.plus")
|
if #available(iOS 16.0, *) {
|
||||||
|
Image("face.smiling.badge.plus")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "face.smiling")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
@ -50,6 +50,10 @@ 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))
|
||||||
@ -129,7 +133,6 @@ 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()]
|
||||||
@ -151,7 +154,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||||||
var body: some SwiftUI.View {
|
var body: some SwiftUI.View {
|
||||||
ControllerView(controller: { controller })
|
ControllerView(controller: { controller })
|
||||||
.task {
|
.task {
|
||||||
if let account = try? await mastodonController.getOwnAccount() {
|
if controller.currentAccount == nil,
|
||||||
|
let account = try? await mastodonController.getOwnAccount() {
|
||||||
controller.currentAccount = account
|
controller.currentAccount = account
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,6 +189,7 @@ 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] {
|
||||||
@ -227,6 +232,10 @@ 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 {
|
||||||
|
@ -9,6 +9,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
struct AddHashtagPinnedTimelineRepresentable: UIViewControllerRepresentable {
|
||||||
|
typealias UIViewControllerType = UIHostingController<AddHashtagPinnedTimelineView>
|
||||||
|
|
||||||
|
@Binding var pinnedTimelines: [PinnedTimeline]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIHostingController<AddHashtagPinnedTimelineView> {
|
||||||
|
return UIHostingController(rootView: AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines))
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIHostingController<AddHashtagPinnedTimelineView>, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AddHashtagPinnedTimelineView: View {
|
struct AddHashtagPinnedTimelineView: View {
|
||||||
@EnvironmentObject private var mastodonController: MastodonController
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ -35,6 +49,9 @@ struct AddHashtagPinnedTimelineView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
list
|
list
|
||||||
|
#if !os(visionOS)
|
||||||
|
.appGroupedListBackground(container: AddHashtagPinnedTimelineRepresentable.UIViewControllerType.self)
|
||||||
|
#endif
|
||||||
.listStyle(.grouped)
|
.listStyle(.grouped)
|
||||||
.navigationTitle("Add Hashtag")
|
.navigationTitle("Add Hashtag")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
@ -36,8 +36,15 @@ struct CustomizeTimelinesList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
if #available(iOS 16.0, *) {
|
||||||
navigationBody
|
NavigationStack {
|
||||||
|
navigationBody
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationBody
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +149,7 @@ struct EditFilterView: View {
|
|||||||
}
|
}
|
||||||
.appGroupedListBackground(container: UIHostingController<CustomizeTimelinesList>.self)
|
.appGroupedListBackground(container: UIHostingController<CustomizeTimelinesList>.self)
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
#endif
|
#endif
|
||||||
.navigationTitle(create ? "Add Filter" : "Edit Filter")
|
.navigationTitle(create ? "Add Filter" : "Edit Filter")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@ -226,6 +226,18 @@ private struct FilterContextToggleStyle: ToggleStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//struct EditFilterView_Previews: PreviewProvider {
|
//struct EditFilterView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// EditFilterView()
|
// EditFilterView()
|
||||||
|
@ -115,8 +115,18 @@ struct PinnedTimelinesModifier: ViewModifier {
|
|||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
||||||
|
#if os(visionOS)
|
||||||
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
} else {
|
||||||
|
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
})
|
})
|
||||||
.sheet(isPresented: $isShowingAddInstanceSheet, content: {
|
.sheet(isPresented: $isShowingAddInstanceSheet, content: {
|
||||||
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
|
@ -41,7 +41,9 @@ class InlineTrendsViewController: UIViewController {
|
|||||||
|
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
navigationItem.preferredSearchBarPlacement = .stacked
|
if #available(iOS 16.0, *) {
|
||||||
|
navigationItem.preferredSearchBarPlacement = .stacked
|
||||||
|
}
|
||||||
|
|
||||||
let trends = TrendsViewController(mastodonController: mastodonController)
|
let trends = TrendsViewController(mastodonController: mastodonController)
|
||||||
trends.view.translatesAutoresizingMaskIntoConstraints = false
|
trends.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -525,12 +525,12 @@ extension TrendsViewController: UICollectionViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
|
@available(iOS, obsoleted: 16.0)
|
||||||
guard indexPaths.count == 1,
|
@available(visionOS 1.0, *)
|
||||||
let item = dataSource.itemIdentifier(for: indexPaths[0]) else {
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let indexPath = indexPaths[0]
|
|
||||||
|
|
||||||
switch item {
|
switch item {
|
||||||
case .loadingIndicator, .confirmLoadMoreStatuses(_):
|
case .loadingIndicator, .confirmLoadMoreStatuses(_):
|
||||||
@ -584,6 +584,15 @@ extension TrendsViewController: UICollectionViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16
|
||||||
|
@available(iOS 16.0, visionOS 1.0, *)
|
||||||
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard indexPaths.count == 1 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point)
|
||||||
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||||
}
|
}
|
||||||
|
@ -100,13 +100,28 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||||||
|
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
navigationItem.preferredSearchBarPlacement = .stacked
|
if #available(iOS 16.0, *) {
|
||||||
|
navigationItem.preferredSearchBarPlacement = .stacked
|
||||||
navigationItem.renameDelegate = self
|
|
||||||
navigationItem.titleMenuProvider = { [unowned self] suggested in
|
navigationItem.renameDelegate = self
|
||||||
var children = suggested
|
navigationItem.titleMenuProvider = { [unowned self] suggested in
|
||||||
children.append(contentsOf: self.listSettingsMenuElements())
|
var children = suggested
|
||||||
return UIMenu(children: children)
|
children.append(contentsOf: self.listSettingsMenuElements())
|
||||||
|
return UIMenu(children: children)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
|
||||||
|
// uncached so that menu always reflects the current state of the list
|
||||||
|
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
|
||||||
|
var elements = self.listSettingsMenuElements()
|
||||||
|
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
|
||||||
|
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
|
||||||
|
self.present($0, animated: true)
|
||||||
|
}).run()
|
||||||
|
}), at: 0)
|
||||||
|
elementHandler(elements)
|
||||||
|
})
|
||||||
|
]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,8 +41,15 @@ struct MuteAccountView: View {
|
|||||||
@State private var error: Error?
|
@State private var error: Error?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
if #available(iOS 16.0, *) {
|
||||||
navigationViewContent
|
NavigationStack {
|
||||||
|
navigationViewContent
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationViewContent
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,8 +101,14 @@ extension FollowRequestNotificationViewController: UICollectionViewDelegate {
|
|||||||
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
|
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
|
||||||
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
|
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
|
||||||
]
|
]
|
||||||
|
let acceptRejectMenu: UIMenu
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
|
||||||
|
} else {
|
||||||
|
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
|
||||||
|
}
|
||||||
return UIMenu(children: [
|
return UIMenu(children: [
|
||||||
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren),
|
acceptRejectMenu,
|
||||||
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
@ -700,8 +700,14 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
|||||||
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
|
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
|
||||||
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
|
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
|
||||||
]
|
]
|
||||||
|
let acceptRejectMenu: UIMenu
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
|
||||||
|
} else {
|
||||||
|
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
|
||||||
|
}
|
||||||
return UIMenu(children: [
|
return UIMenu(children: [
|
||||||
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren),
|
acceptRejectMenu,
|
||||||
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,9 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||||||
searchController.searchBar.placeholder = "Search or enter a URL"
|
searchController.searchBar.placeholder = "Search or enter a URL"
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
navigationItem.preferredSearchBarPlacement = .stacked
|
if #available(iOS 16.0, *) {
|
||||||
|
navigationItem.preferredSearchBarPlacement = .stacked
|
||||||
|
}
|
||||||
definesPresentationContext = true
|
definesPresentationContext = true
|
||||||
|
|
||||||
urlHandler = urlCheckerSubject
|
urlHandler = urlCheckerSubject
|
||||||
|
@ -91,10 +91,14 @@ struct AboutView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var iconOrGame: some View {
|
private var iconOrGame: some View {
|
||||||
FlipView {
|
if #available(iOS 16.0, *) {
|
||||||
|
FlipView {
|
||||||
|
appIcon
|
||||||
|
} back: {
|
||||||
|
TTTView()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
appIcon
|
appIcon
|
||||||
} back: {
|
|
||||||
TTTView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,7 +18,6 @@ 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 {
|
||||||
@ -32,23 +31,9 @@ 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
|
||||||
}
|
}
|
||||||
.alert("Enable Feature Flag", isPresented: $isShowingFeatureFlagAlert) {
|
.modifier(FeatureFlagAlertModifier(showing: $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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,6 +237,74 @@ 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 {
|
||||||
|
@ -27,7 +27,14 @@ struct AppearancePrefsView: View {
|
|||||||
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
|
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
if let color = color.color {
|
if let color = color.color {
|
||||||
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)
|
if #available(iOS 16.0, *) {
|
||||||
|
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)
|
||||||
|
} else {
|
||||||
|
image = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { context in
|
||||||
|
color.setFill()
|
||||||
|
context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: 20, height: 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (color, image)
|
return (color, image)
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,12 @@ struct NotificationsPrefsView: View {
|
|||||||
if #available(iOS 15.4, *) {
|
if #available(iOS 15.4, *) {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
let str = if #available(iOS 16.0, *) {
|
||||||
|
UIApplication.openNotificationSettingsURLString
|
||||||
|
} else {
|
||||||
|
UIApplicationOpenNotificationSettingsURLString
|
||||||
|
}
|
||||||
|
if let url = URL(string: str) {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -34,7 +34,7 @@ struct PushInstanceSettingsView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
PrefsAccountView(account: account)
|
PrefsAccountView(account: account)
|
||||||
Spacer()
|
Spacer()
|
||||||
AsyncToggle("\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:))
|
AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
|
PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
|
||||||
|
@ -43,9 +43,21 @@ struct OppositeCollapseKeywordsView: View {
|
|||||||
.listStyle(.grouped)
|
.listStyle(.grouped)
|
||||||
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
||||||
}
|
}
|
||||||
|
#if !os(visionOS)
|
||||||
|
.onAppear(perform: updateAppearance)
|
||||||
|
#endif
|
||||||
.navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords")
|
.navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private func updateAppearance() {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
// no longer necessary
|
||||||
|
} else {
|
||||||
|
UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func commitExisting(at index: Int) -> () -> Void {
|
private func commitExisting(at index: Int) -> () -> Void {
|
||||||
return {
|
return {
|
||||||
if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
@ -69,30 +69,34 @@ private struct ScrollBackgroundModifier: ViewModifier {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
if #available(iOS 16.0, *) {
|
||||||
.scrollContentBackground(.hidden)
|
content
|
||||||
.background {
|
.scrollContentBackground(.hidden)
|
||||||
// otherwise the pureBlackDarkMode isn't propagated, for some reason?
|
.background {
|
||||||
// even though it is for ReportSelectRulesView??
|
// otherwise the pureBlackDarkMode isn't propagated, for some reason?
|
||||||
let traits: UITraitCollection = {
|
// even though it is for ReportSelectRulesView??
|
||||||
var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
|
let traits: UITraitCollection = {
|
||||||
#if os(visionOS)
|
var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
|
||||||
t = t.modifyingTraits({ mutableTraits in
|
#if os(visionOS)
|
||||||
mutableTraits.pureBlackDarkMode = true
|
|
||||||
})
|
|
||||||
#else
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
t = t.modifyingTraits({ mutableTraits in
|
t = t.modifyingTraits({ mutableTraits in
|
||||||
mutableTraits.pureBlackDarkMode = true
|
mutableTraits.pureBlackDarkMode = true
|
||||||
})
|
})
|
||||||
} else {
|
#else
|
||||||
t.obsoletePureBlackDarkMode = true
|
if #available(iOS 17.0, *) {
|
||||||
}
|
t = t.modifyingTraits({ mutableTraits in
|
||||||
#endif
|
mutableTraits.pureBlackDarkMode = true
|
||||||
return t
|
})
|
||||||
}()
|
} else {
|
||||||
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits))
|
t.obsoletePureBlackDarkMode = true
|
||||||
.edgesIgnoringSafeArea(.all)
|
}
|
||||||
}
|
#endif
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits))
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,12 +49,26 @@ struct ReportSelectRulesView: View {
|
|||||||
}
|
}
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.withAppBackgroundIfAvailable()
|
||||||
.background(Color.appGroupedBackground)
|
|
||||||
.navigationTitle("Rules")
|
.navigationTitle("Rules")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
func withAppBackgroundIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.appGroupedBackground)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//struct ReportSelectRulesView_Previews: PreviewProvider {
|
//struct ReportSelectRulesView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// ReportSelectRulesView()
|
// ReportSelectRulesView()
|
||||||
|
@ -27,11 +27,18 @@ struct ReportView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
if #available(iOS 16.0, *) {
|
||||||
navigationViewContent
|
NavigationStack {
|
||||||
#if !os(visionOS)
|
navigationViewContent
|
||||||
.scrollDismissesKeyboard(.interactively)
|
#if !os(visionOS)
|
||||||
#endif
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationViewContent
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,7 +39,9 @@ class MastodonSearchController: UISearchController {
|
|||||||
searchResultsUpdater = searchResultsController
|
searchResultsUpdater = searchResultsController
|
||||||
automaticallyShowsSearchResultsController = false
|
automaticallyShowsSearchResultsController = false
|
||||||
showsSearchResultsController = true
|
showsSearchResultsController = true
|
||||||
scopeBarActivation = .onSearchActivation
|
if #available(iOS 16.0, *) {
|
||||||
|
scopeBarActivation = .onSearchActivation
|
||||||
|
}
|
||||||
|
|
||||||
searchBar.autocapitalizationType = .none
|
searchBar.autocapitalizationType = .none
|
||||||
searchBar.delegate = self
|
searchBar.delegate = self
|
||||||
@ -76,8 +78,12 @@ class MastodonSearchController: UISearchController {
|
|||||||
if searchText != defaultLanguage,
|
if searchText != defaultLanguage,
|
||||||
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
|
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
|
||||||
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
|
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
|
||||||
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
|
if #available(iOS 16.0, *) {
|
||||||
langSuggestions.append("language:\(identifier)")
|
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
|
||||||
|
langSuggestions.append("language:\(identifier)")
|
||||||
|
}
|
||||||
|
} else if searchText != "en" {
|
||||||
|
langSuggestions.append("language:\(searchText)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suggestions.append((.language, langSuggestions))
|
suggestions.append((.language, langSuggestions))
|
||||||
|
@ -22,7 +22,8 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
override var viewControllers: [UIViewController] {
|
override var viewControllers: [UIViewController] {
|
||||||
didSet {
|
didSet {
|
||||||
poppedViewControllers = []
|
poppedViewControllers = []
|
||||||
if useBrowserStyleNavigation {
|
if #available(iOS 16.0, *),
|
||||||
|
useBrowserStyleNavigation {
|
||||||
// TODO: this for loop might not be necessary
|
// TODO: this for loop might not be necessary
|
||||||
for vc in viewControllers {
|
for vc in viewControllers {
|
||||||
configureNavItem(vc.navigationItem)
|
configureNavItem(vc.navigationItem)
|
||||||
@ -39,7 +40,8 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
|
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if useBrowserStyleNavigation,
|
if #available(iOS 16.0, *),
|
||||||
|
useBrowserStyleNavigation,
|
||||||
let topViewController {
|
let topViewController {
|
||||||
configureNavItem(topViewController.navigationItem)
|
configureNavItem(topViewController.navigationItem)
|
||||||
updateTopNavItemState()
|
updateTopNavItemState()
|
||||||
@ -50,7 +52,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
let popped = performAfterAnimating(block: {
|
let popped = performAfterAnimating(block: {
|
||||||
super.popViewController(animated: animated)
|
super.popViewController(animated: animated)
|
||||||
}, after: {
|
}, after: {
|
||||||
self.updateTopNavItemState()
|
if #available(iOS 16.0, *) {
|
||||||
|
self.updateTopNavItemState()
|
||||||
|
}
|
||||||
}, animated: animated)
|
}, animated: animated)
|
||||||
if let popped {
|
if let popped {
|
||||||
poppedViewControllers.insert(popped, at: 0)
|
poppedViewControllers.insert(popped, at: 0)
|
||||||
@ -62,7 +66,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
let popped = performAfterAnimating(block: {
|
let popped = performAfterAnimating(block: {
|
||||||
super.popToRootViewController(animated: animated)
|
super.popToRootViewController(animated: animated)
|
||||||
}, after: {
|
}, after: {
|
||||||
self.updateTopNavItemState()
|
if #available(iOS 16.0, *) {
|
||||||
|
self.updateTopNavItemState()
|
||||||
|
}
|
||||||
}, animated: animated)
|
}, animated: animated)
|
||||||
if let popped {
|
if let popped {
|
||||||
poppedViewControllers = popped
|
poppedViewControllers = popped
|
||||||
@ -74,7 +80,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
let popped = performAfterAnimating(block: {
|
let popped = performAfterAnimating(block: {
|
||||||
super.popToViewController(viewController, animated: animated)
|
super.popToViewController(viewController, animated: animated)
|
||||||
}, after: {
|
}, after: {
|
||||||
self.updateTopNavItemState()
|
if #available(iOS 16.0, *) {
|
||||||
|
self.updateTopNavItemState()
|
||||||
|
}
|
||||||
}, animated: animated)
|
}, animated: animated)
|
||||||
if let popped {
|
if let popped {
|
||||||
poppedViewControllers.insert(contentsOf: popped, at: 0)
|
poppedViewControllers.insert(contentsOf: popped, at: 0)
|
||||||
@ -89,11 +97,15 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
self.poppedViewControllers = []
|
self.poppedViewControllers = []
|
||||||
}
|
}
|
||||||
|
|
||||||
configureNavItem(viewController.navigationItem)
|
if #available(iOS 16.0, *) {
|
||||||
|
configureNavItem(viewController.navigationItem)
|
||||||
|
}
|
||||||
|
|
||||||
super.pushViewController(viewController, animated: animated)
|
super.pushViewController(viewController, animated: animated)
|
||||||
|
|
||||||
updateTopNavItemState()
|
if #available(iOS 16.0, *) {
|
||||||
|
updateTopNavItemState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushPoppedViewController() {
|
func pushPoppedViewController() {
|
||||||
@ -123,7 +135,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
|||||||
pushViewController(target, animated: true)
|
pushViewController(target, animated: true)
|
||||||
}, after: {
|
}, after: {
|
||||||
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
|
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
|
||||||
self.updateTopNavItemState()
|
if #available(iOS 16.0, *) {
|
||||||
|
self.updateTopNavItemState()
|
||||||
|
}
|
||||||
}, animated: true)
|
}, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,7 +204,8 @@ extension MenuActionProvider {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
if includeStatusButtonActions {
|
if #available(iOS 16.0, *),
|
||||||
|
includeStatusButtonActions {
|
||||||
let favorited = status.favourited
|
let favorited = status.favourited
|
||||||
// TODO: move this color into an asset catalog or something
|
// TODO: move this color into an asset catalog or something
|
||||||
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
|
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
|
||||||
@ -367,11 +368,19 @@ extension MenuActionProvider {
|
|||||||
|
|
||||||
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
||||||
|
|
||||||
let toggleableAndActions = toggleableSection + actionsSection
|
if #available(iOS 16.0, *) {
|
||||||
return [
|
let toggleableAndActions = toggleableSection + actionsSection
|
||||||
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
|
return [
|
||||||
UIMenu(options: .displayInline, children: shareSection),
|
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
|
||||||
]
|
UIMenu(options: .displayInline, children: shareSection),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
UIMenu(options: .displayInline, children: shareSection),
|
||||||
|
UIMenu(options: .displayInline, children: toggleableSection),
|
||||||
|
UIMenu(options: .displayInline, children: actionsSection),
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
|
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
|
||||||
|
@ -108,7 +108,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func compose(editing draft: Draft) {
|
func compose(editing draft: Draft) {
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
if #available(iOS 16.0, *),
|
||||||
|
UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil)
|
self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil)
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -122,7 +123,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
|||||||
precondition(state > .initial)
|
precondition(state > .initial)
|
||||||
navigation.run()
|
navigation.run()
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
if let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
|
if #available(iOS 16.0, *),
|
||||||
|
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
|
||||||
self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil)
|
self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@ -114,7 +114,8 @@ extension TuskerNavigationDelegate {
|
|||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
#else
|
#else
|
||||||
if presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
|
if #available(iOS 16.0, *),
|
||||||
|
presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
present(compose, animated: animated, completion: completion)
|
present(compose, animated: animated, completion: completion)
|
||||||
|
@ -9,7 +9,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
import os
|
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
@ -41,7 +40,7 @@ struct AccountDisplayNameView: View {
|
|||||||
guard !matches.isEmpty else { return }
|
guard !matches.isEmpty else { return }
|
||||||
|
|
||||||
let emojiSize = self.emojiSize
|
let emojiSize = self.emojiSize
|
||||||
let emojiImages = OSAllocatedUnfairLock(initialState: [String: Image]())
|
let emojiImages = MultiThreadDictionary<String, Image>()
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
|
|
||||||
@ -64,9 +63,7 @@ struct AccountDisplayNameView: View {
|
|||||||
image.draw(in: CGRect(origin: .zero, size: size))
|
image.draw(in: CGRect(origin: .zero, size: size))
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiImages.withLock {
|
emojiImages[emoji.shortcode] = Image(uiImage: resized)
|
||||||
$0[emoji.shortcode] = Image(uiImage: resized)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let request = request {
|
if let request = request {
|
||||||
emojiRequests.append(request)
|
emojiRequests.append(request)
|
||||||
@ -81,7 +78,7 @@ struct AccountDisplayNameView: View {
|
|||||||
// iterate backwards as to not alter the indices of earlier matches
|
// iterate backwards as to not alter the indices of earlier matches
|
||||||
for match in matches.reversed() {
|
for match in matches.reversed() {
|
||||||
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
|
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
|
||||||
guard let image = emojiImages.withLock({ $0[shortcode] }) else { continue }
|
guard let image = emojiImages[shortcode] else { continue }
|
||||||
|
|
||||||
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))
|
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))
|
||||||
|
|
||||||
|
@ -263,7 +263,17 @@ class AttachmentView: GIFImageView {
|
|||||||
let asset = AVURLAsset(url: attachment.url)
|
let asset = AVURLAsset(url: attachment.url)
|
||||||
let generator = AVAssetImageGenerator(asset: asset)
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
generator.appliesPreferredTrackTransform = true
|
generator.appliesPreferredTrackTransform = true
|
||||||
guard let image = try? await generator.image(at: .zero).image,
|
let image: CGImage?
|
||||||
|
#if os(visionOS)
|
||||||
|
image = try? await generator.image(at: .zero).image
|
||||||
|
#else
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
image = try? await generator.image(at: .zero).image
|
||||||
|
} else {
|
||||||
|
image = try? generator.copyCGImage(at: .zero, actualTime: nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
guard let image,
|
||||||
let prepared = await UIImage(cgImage: image).byPreparingForDisplay(),
|
let prepared = await UIImage(cgImage: image).byPreparingForDisplay(),
|
||||||
!Task.isCancelled else {
|
!Task.isCancelled else {
|
||||||
return
|
return
|
||||||
|
@ -9,7 +9,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
import os
|
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
@ -57,7 +56,7 @@ extension BaseEmojiLabel {
|
|||||||
return imageSizeMatchingFontSize
|
return imageSizeMatchingFontSize
|
||||||
}
|
}
|
||||||
|
|
||||||
let emojiImages = OSAllocatedUnfairLock(initialState: [String: UIImage]())
|
let emojiImages = MultiThreadDictionary<String, UIImage>()
|
||||||
var foundEmojis = false
|
var foundEmojis = false
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
@ -80,11 +79,9 @@ extension BaseEmojiLabel {
|
|||||||
// todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache
|
// todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache
|
||||||
if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)),
|
if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)),
|
||||||
let cgImage = thumbnail.cgImage {
|
let cgImage = thumbnail.cgImage {
|
||||||
emojiImages.withLock {
|
// the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert
|
||||||
// the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert
|
// see FB12187798
|
||||||
// see FB12187798
|
emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
|
||||||
$0[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// otherwise, perform the network request
|
// otherwise, perform the network request
|
||||||
@ -102,9 +99,7 @@ extension BaseEmojiLabel {
|
|||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emojiImages.withLock {
|
emojiImages[emoji.shortcode] = transformedImage
|
||||||
$0[emoji.shortcode] = transformedImage
|
|
||||||
}
|
|
||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -146,7 +146,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||||||
|
|
||||||
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
|
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
|
||||||
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
|
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
|
||||||
if let textLayoutManager {
|
if #available(iOS 16.0, *),
|
||||||
|
let textLayoutManager {
|
||||||
guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
|
guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -304,7 +305,8 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
|||||||
|
|
||||||
// Determine the line rects that the link takes up in the coordinate space of this view.
|
// Determine the line rects that the link takes up in the coordinate space of this view.
|
||||||
var rects = [CGRect]()
|
var rects = [CGRect]()
|
||||||
if let textLayoutManager,
|
if #available(iOS 16.0, *),
|
||||||
|
let textLayoutManager,
|
||||||
let contentManager = textLayoutManager.textContentManager {
|
let contentManager = textLayoutManager.textContentManager {
|
||||||
// convert from NSRange to NSTextRange
|
// convert from NSRange to NSTextRange
|
||||||
// i have no idea under what circumstances any of these calls could fail
|
// i have no idea under what circumstances any of these calls could fail
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// CopyableLabel.swift
|
// CopyableLable.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 2/4/23.
|
// Created by Shadowfacts on 2/4/23.
|
||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class CopyableLabel: UILabel {
|
class CopyableLable: UILabel {
|
||||||
|
|
||||||
private var _editMenuInteraction: Any!
|
private var _editMenuInteraction: Any!
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
@ -28,10 +28,12 @@ class CopyableLabel: UILabel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func commonInit() {
|
private func commonInit() {
|
||||||
editMenuInteraction = UIEditMenuInteraction(delegate: nil)
|
if #available(iOS 16.0, *) {
|
||||||
addInteraction(editMenuInteraction)
|
editMenuInteraction = UIEditMenuInteraction(delegate: nil)
|
||||||
isUserInteractionEnabled = true
|
addInteraction(editMenuInteraction)
|
||||||
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
|
isUserInteractionEnabled = true
|
||||||
|
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func copy(_ sender: Any?) {
|
override func copy(_ sender: Any?) {
|
@ -1,8 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/>
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
@ -124,16 +125,16 @@
|
|||||||
</constraints>
|
</constraints>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
|
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
|
||||||
<rect key="frame" x="144" y="235" width="101.5" height="23"/>
|
<rect key="frame" x="144" y="235" width="103.5" height="23"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL" customClass="CopyableLabel" customModule="Tusker" customModuleProvider="target">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL" customClass="CopyableLable" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
|
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
|
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC">
|
||||||
<rect key="frame" x="85" y="1.5" width="16.5" height="19.5"/>
|
<rect key="frame" x="85" y="1.5" width="18.5" height="19.5"/>
|
||||||
<color key="tintColor" systemColor="secondaryLabelColor"/>
|
<color key="tintColor" systemColor="secondaryLabelColor"/>
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/>
|
<preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
@ -186,14 +187,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</objects>
|
</objects>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="ellipsis" catalog="system" width="32" height="32"/>
|
<image name="ellipsis" catalog="system" width="128" height="37"/>
|
||||||
<image name="lock.fill" catalog="system" width="32" height="32"/>
|
<image name="lock.fill" catalog="system" width="125" height="128"/>
|
||||||
<image name="person.badge.plus" catalog="system" width="32" height="32"/>
|
<image name="person.badge.plus" catalog="system" width="128" height="124"/>
|
||||||
<systemColor name="labelColor">
|
<systemColor name="labelColor">
|
||||||
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="secondaryLabelColor">
|
<systemColor name="secondaryLabelColor">
|
||||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
@ -143,7 +143,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||||||
$0.isEditable = false
|
$0.isEditable = false
|
||||||
$0.isSelectable = true
|
$0.isSelectable = true
|
||||||
$0.emojiFont = ConversationMainStatusCollectionViewCell.contentFont
|
$0.emojiFont = ConversationMainStatusCollectionViewCell.contentFont
|
||||||
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber, .money, .physicalValue]
|
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
$0.dataDetectorTypes.formUnion([.money, .physicalValue])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var translateButton: TranslateButton?
|
private var translateButton: TranslateButton?
|
||||||
|
Loading…
x
Reference in New Issue
Block a user