Compare commits
38 Commits
compose-re
...
develop
Author | SHA1 | Date | |
---|---|---|---|
cb535196e2 | |||
238f246d64 | |||
20e7d9ed10 | |||
7c43261f9c | |||
a35b72d256 | |||
666d2c468a | |||
4ea61542a0 | |||
5ce9892a9b | |||
54376ac585 | |||
26c483fc9a | |||
a68d2ce952 | |||
adaf8dc217 | |||
572c5a0824 | |||
e469d207b4 | |||
82ec120871 | |||
242c60d74d | |||
20692b0630 | |||
9990d50e3e | |||
670047af6f | |||
e8a492ef7d | |||
583d9b97dd | |||
88176fe599 | |||
19c3008c8f | |||
51f9f421b8 | |||
b700e17d7e | |||
dc01804359 | |||
a5066140fd | |||
351efe4b58 | |||
c716f03784 | |||
fa828a5eae | |||
56d12295ba | |||
a442197adf | |||
a99fb7f0b0 | |||
f44dae632c | |||
0dcb67c44e | |||
e869fdc38f | |||
5c86feccb9 | |||
01cf597b5d |
@ -1,3 +1,13 @@
|
||||
## 2024.5
|
||||
Features/Improvements:
|
||||
- Improve gallery animations
|
||||
|
||||
Bugfixes:
|
||||
- Handle right-to-left text in display names
|
||||
- Fix crash during gifv playback
|
||||
- iPadOS: Fix app becoming unresponsive when switching accounts
|
||||
- iPadOS/macOS: Fix Cmd+R shortcuts not working
|
||||
|
||||
## 2024.4
|
||||
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
|
||||
|
||||
|
28
CHANGELOG.md
28
CHANGELOG.md
@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 2024.5 (141)
|
||||
Bugfixes:
|
||||
- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices
|
||||
- Fix gallery controls being positioned incorrectly in landscape orientations
|
||||
|
||||
## 2024.5 (139)
|
||||
Bugfixes:
|
||||
- Fix error decoding certain posts
|
||||
|
||||
## 2024.5 (138)
|
||||
Bugfixes:
|
||||
- Fix potential crash when displaying certain attachments
|
||||
- Fix potential crash due to race condition when opening push notification in app
|
||||
- Fix misaligned text between profile field values/labels
|
||||
- Fix rate limited error message not including reset timestamp
|
||||
- iPadOS/macOS: Fix Cmd+R shortcut not working
|
||||
|
||||
## 2024.5 (137)
|
||||
Features/Improvements:
|
||||
- Improve gallery presentation/dismissal transitions
|
||||
|
||||
Bugfixes:
|
||||
- Account for bidirectional text in display names
|
||||
- Fix crash when playing back gifv
|
||||
- Fix gallery controls not hiding if video loading fails
|
||||
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
|
||||
- iPadOS: Fix hang when switching accounts
|
||||
|
||||
## 2024.4 (136)
|
||||
Features/Improvements:
|
||||
- Import image description when adding attachments from Photos if possible
|
||||
|
@ -371,13 +371,14 @@ private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||
// 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),
|
||||
// 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
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} else {
|
||||
nil
|
||||
URL(string: string)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "ComposeUI",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
|
||||
Button(role: .destructive, action: controller.removeAttachment) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} preview: {
|
||||
} previewIfAvailable: {
|
||||
ControllerView(controller: { controller.thumbnailController })
|
||||
}
|
||||
|
||||
@ -221,3 +221,16 @@ extension AttachmentRowController {
|
||||
case allowEntry, recognizingText
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@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 #available(iOS 16.0, *) {
|
||||
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||
} else {
|
||||
self.contextMenu(menuItems: menuItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -214,6 +214,44 @@ fileprivate extension View {
|
||||
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, *)
|
||||
|
@ -125,7 +125,9 @@ public final class ComposeController: ViewController {
|
||||
self.toolbarController = ToolbarController(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)
|
||||
}
|
||||
|
||||
@ -322,6 +324,10 @@ public final class ComposeController: ViewController {
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
#endif
|
||||
}
|
||||
#if !os(visionOS)
|
||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||
.padding(.bottom, keyboardInset)
|
||||
#endif
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
@ -430,7 +436,7 @@ public final class ComposeController: ViewController {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
#endif
|
||||
.disabled(controller.isPosting)
|
||||
}
|
||||
@ -481,6 +487,31 @@ public final class ComposeController: ViewController {
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
}
|
||||
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
private var keyboardInset: CGFloat {
|
||||
if #unavailable(iOS 16.0),
|
||||
UIDevice.current.userInterfaceIdiom == .pad,
|
||||
keyboardReader.isVisible {
|
||||
return ToolbarController.height
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -51,11 +51,14 @@ class FocusedAttachmentController: ViewController {
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
} else if #available(iOS 16.0, *) {
|
||||
ZoomableScrollView {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
} else {
|
||||
attachmentView
|
||||
.matchedGeometryDestination(id: attachment.id)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
@ -96,7 +96,7 @@ class PollController: ViewController {
|
||||
.onMove(perform: controller.moveOptions)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollDisabled(true)
|
||||
.scrollDisabledIfAvailable(true)
|
||||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
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(maxWidth: .infinity)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
@ -122,7 +122,8 @@ class ToolbarController: ViewController {
|
||||
|
||||
Spacer()
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
if #available(iOS 16.0, *),
|
||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
class KeyboardReader: ObservableObject {
|
||||
// @Published var isVisible = false
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
@ -0,0 +1,26 @@
|
||||
//
|
||||
// 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
|
||||
}
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "Duckable",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// 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(
|
||||
name: "GalleryVC",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
@ -14,11 +14,15 @@ let package = Package(
|
||||
name: "GalleryVC",
|
||||
targets: ["GalleryVC"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../TuskerComponents"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "GalleryVC",
|
||||
dependencies: ["TuskerComponents"],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
|
@ -1,15 +1,13 @@
|
||||
//
|
||||
// FallbackGalleryContentViewController.swift
|
||||
// Tusker
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/18/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
import QuickLook
|
||||
import Pachyderm
|
||||
|
||||
private class FallbackGalleryContentViewController: QLPreviewController {
|
||||
private let previewItem = GalleryPreviewItem()
|
||||
@ -52,40 +50,40 @@ extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
|
||||
init(url: URL) {
|
||||
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
|
||||
public init(url: URL) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.viewControllers = [FallbackGalleryContentViewController(url: url)]
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.disableGalleryScrollAndZoom()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
public required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
public weak var container: (any GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
public var contentSize: CGSize {
|
||||
.zero
|
||||
}
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
public var activityItemsForSharing: [Any] {
|
||||
[]
|
||||
}
|
||||
|
||||
var caption: String? {
|
||||
public var caption: String? {
|
||||
nil
|
||||
}
|
||||
|
||||
var canAnimateFromSourceView: Bool {
|
||||
false
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fade
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,22 @@
|
||||
//
|
||||
// ImageGalleryContentViewController.swift
|
||||
// Tusker
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
@preconcurrency import VisionKit
|
||||
|
||||
class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
let url: URL
|
||||
let caption: String?
|
||||
let originalData: Data?
|
||||
let image: UIImage
|
||||
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
public let caption: String?
|
||||
public var image: UIImage {
|
||||
didSet {
|
||||
imageView?.image = image
|
||||
}
|
||||
}
|
||||
let gifController: GIFController?
|
||||
|
||||
private var imageView: GIFImageView!
|
||||
@ -27,12 +27,8 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
|
||||
|
||||
private var isGrayscale = false
|
||||
|
||||
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
|
||||
self.url = url
|
||||
public init(image: UIImage, caption: String?, gifController: GIFController?) {
|
||||
self.caption = caption
|
||||
self.originalData = originalData
|
||||
self.image = image
|
||||
self.gifController = gifController
|
||||
|
||||
@ -41,21 +37,14 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
preferredContentSize = image.size
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
let maybeGrayscaleImage = if isGrayscale {
|
||||
ImageGrayscalifier.convert(url: url, image: image) ?? image
|
||||
} else {
|
||||
image
|
||||
}
|
||||
|
||||
imageView = GIFImageView(image: maybeGrayscaleImage)
|
||||
imageView = GIFImageView(image: image)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.isUserInteractionEnabled = true
|
||||
@ -86,11 +75,9 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if let gifController {
|
||||
@ -98,37 +85,23 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
let image = if isGrayscale {
|
||||
ImageGrayscalifier.convert(url: url, image: image)
|
||||
} else {
|
||||
image
|
||||
}
|
||||
if let image {
|
||||
imageView.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
public weak var container: (any GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
public var contentSize: CGSize {
|
||||
image.size
|
||||
}
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
if let data = originalData ?? image.pngData() {
|
||||
return [ImageActivityItemSource(data: data, url: url, image: image)]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
open var activityItemsForSharing: [Any] {
|
||||
return [image]
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
|
||||
}
|
||||
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
if #available(iOS 16.0, macCatalyst 17.0, *),
|
||||
let analysisInteraction {
|
||||
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
|
||||
@ -138,7 +111,7 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
|
||||
func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
|
||||
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
|
||||
return container?.galleryControlsVisible ?? true
|
||||
}
|
||||
}
|
@ -7,43 +7,42 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
|
||||
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
private let fallbackCaption: String?
|
||||
private let provider: () async -> (any GalleryContentViewController)?
|
||||
private var wrapped: (any GalleryContentViewController)!
|
||||
|
||||
weak var container: GalleryContentViewControllerContainer?
|
||||
public weak var container: GalleryContentViewControllerContainer?
|
||||
|
||||
var contentSize: CGSize {
|
||||
public var contentSize: CGSize {
|
||||
wrapped?.contentSize ?? .zero
|
||||
}
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
public var activityItemsForSharing: [Any] {
|
||||
wrapped?.activityItemsForSharing ?? []
|
||||
}
|
||||
|
||||
var caption: String? {
|
||||
public var caption: String? {
|
||||
wrapped?.caption ?? fallbackCaption
|
||||
}
|
||||
|
||||
var canAnimateFromSourceView: Bool {
|
||||
wrapped?.canAnimateFromSourceView ?? true
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
wrapped?.presentationAnimation ?? .fade
|
||||
}
|
||||
|
||||
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
self.fallbackCaption = caption
|
||||
self.provider = provider
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.setGalleryContentLoading(true)
|
||||
@ -81,7 +80,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Error Loading"
|
||||
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
|
||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
label.textColor = .secondaryLabel
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
|
||||
@ -102,15 +101,15 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
|
||||
])
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
public func galleryContentDidAppear() {
|
||||
wrapped?.galleryContentDidAppear()
|
||||
}
|
||||
|
||||
func galleryContentWillDisappear() {
|
||||
public func galleryContentWillDisappear() {
|
||||
wrapped?.galleryContentWillDisappear()
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// VideoControlsViewController.swift
|
||||
// Tusker
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/21/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
@ -9,6 +9,15 @@
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
@propertyWrapper
|
||||
final class Box<T> {
|
||||
var wrappedValue: T
|
||||
|
||||
init(wrappedValue: T) {
|
||||
self.wrappedValue = wrappedValue
|
||||
}
|
||||
}
|
||||
|
||||
class VideoControlsViewController: UIViewController {
|
||||
private static let formatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
@ -18,33 +27,49 @@ class VideoControlsViewController: UIViewController {
|
||||
}()
|
||||
|
||||
private let player: AVPlayer
|
||||
#if !os(visionOS)
|
||||
@Box private var playbackSpeed: Float
|
||||
#endif
|
||||
|
||||
private lazy var muteButton = MuteButton().configure {
|
||||
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
|
||||
$0.setMuted(false, animated: false)
|
||||
}
|
||||
private lazy var muteButton: MuteButton = {
|
||||
let button = MuteButton()
|
||||
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
|
||||
button.setMuted(false, animated: false)
|
||||
return button
|
||||
}()
|
||||
|
||||
private let timestampLabel = UILabel().configure {
|
||||
$0.text = "0:00"
|
||||
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
}
|
||||
private let timestampLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "0:00"
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var scrubbingControl = VideoScrubbingControl().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
$0.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
|
||||
$0.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
|
||||
$0.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
|
||||
}
|
||||
private lazy var scrubbingControl: VideoScrubbingControl = {
|
||||
let control = VideoScrubbingControl()
|
||||
control.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
|
||||
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
|
||||
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
|
||||
return control
|
||||
}()
|
||||
|
||||
private let timeRemainingLabel = UILabel().configure {
|
||||
$0.text = "-0:00"
|
||||
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
}
|
||||
private let timeRemainingLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "-0:00"
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var optionsButton = MenuButton { [unowned self] in
|
||||
let imageName: String
|
||||
#if os(visionOS)
|
||||
let playbackSpeed = player.defaultRate
|
||||
#else
|
||||
let playbackSpeed = self.playbackSpeed
|
||||
#endif
|
||||
if #available(iOS 17.0, *) {
|
||||
switch player.defaultRate {
|
||||
switch playbackSpeed {
|
||||
case 0.5:
|
||||
imageName = "gauge.with.dots.needle.0percent"
|
||||
case 1:
|
||||
@ -60,8 +85,12 @@ class VideoControlsViewController: UIViewController {
|
||||
imageName = "speedometer"
|
||||
}
|
||||
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
|
||||
#else
|
||||
self.playbackSpeed = speed.rate
|
||||
#endif
|
||||
if self.player.rate > 0 {
|
||||
self.player.rate = speed.rate
|
||||
}
|
||||
@ -70,17 +99,19 @@ class VideoControlsViewController: UIViewController {
|
||||
return UIMenu(children: [speedMenu])
|
||||
}
|
||||
|
||||
private lazy var hStack = UIStackView(arrangedSubviews: [
|
||||
muteButton,
|
||||
timestampLabel,
|
||||
scrubbingControl,
|
||||
timeRemainingLabel,
|
||||
optionsButton,
|
||||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .center
|
||||
}
|
||||
private lazy var hStack: UIStackView = {
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
muteButton,
|
||||
timestampLabel,
|
||||
scrubbingControl,
|
||||
timeRemainingLabel,
|
||||
optionsButton,
|
||||
])
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.alignment = .center
|
||||
return stack
|
||||
}()
|
||||
|
||||
private var timestampObserverToken: Any?
|
||||
private var scrubberObserverToken: Any?
|
||||
@ -89,11 +120,20 @@ class VideoControlsViewController: UIViewController {
|
||||
private var scrubbingTargetTime: CMTime?
|
||||
private var isSeeking = false
|
||||
|
||||
#if os(visionOS)
|
||||
init(player: AVPlayer) {
|
||||
self.player = player
|
||||
|
||||
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) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
@ -177,7 +217,11 @@ class VideoControlsViewController: UIViewController {
|
||||
@objc private func scrubbingEnded() {
|
||||
scrubbingChanged()
|
||||
if wasPlayingWhenScrubbingStarted {
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -1,68 +1,58 @@
|
||||
//
|
||||
// VideoGalleryContentViewController.swift
|
||||
// Tusker
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/19/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
|
||||
class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
private let url: URL
|
||||
let caption: String?
|
||||
private var item: AVPlayerItem
|
||||
let player: AVPlayer
|
||||
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
public let url: URL
|
||||
public let caption: String?
|
||||
public private(set) var item: AVPlayerItem
|
||||
public let player: AVPlayer
|
||||
|
||||
private var isGrayscale: Bool
|
||||
#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 statusObservation: NSKeyValueObservation?
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
private var isFirstAppearance = true
|
||||
private var hideControlsWorkItem: DispatchWorkItem?
|
||||
private var audioSessionToken: AudioSessionCoordinator.Token?
|
||||
private var isShowingError = false
|
||||
|
||||
init(url: URL, caption: String?) {
|
||||
public init(url: URL, caption: String?) {
|
||||
self.url = url
|
||||
self.caption = caption
|
||||
|
||||
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||
|
||||
let asset = AVAsset(url: url)
|
||||
self.item = VideoGalleryContentViewController.createItem(asset: asset)
|
||||
self.item = Self.createItem(asset: asset)
|
||||
self.player = AVPlayer(playerItem: item)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private static func createItem(asset: AVAsset) -> AVPlayerItem {
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
if Preferences.shared.grayscaleImages {
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVVideoComposition CIFilter initializer")
|
||||
#else
|
||||
let filter = CIFilter(name: "CIColorMonochrome")!
|
||||
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
|
||||
filter.setValue(1.0, forKey: "inputIntensity")
|
||||
|
||||
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in
|
||||
filter.setValue(request.sourceImage, forKey: "inputImage")
|
||||
request.finish(with: filter.outputImage!, context: nil)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
return item
|
||||
open class func createItem(asset: AVAsset) -> AVPlayerItem {
|
||||
return AVPlayerItem(asset: asset)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
public func replaceCurrentItem(with item: AVPlayerItem) {
|
||||
self.item = item
|
||||
player.replaceCurrentItem(with: item)
|
||||
updateItemObservations()
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
container?.setGalleryContentLoading(true)
|
||||
@ -87,19 +77,17 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
scheduleControlsHide()
|
||||
}
|
||||
})
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
private func updateItemObservations() {
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
|
||||
MainActor.runUnsafely {
|
||||
MainActor.assumeIsolated {
|
||||
self.preferredContentSize = item.presentationSize
|
||||
self.container?.galleryContentChanged()
|
||||
}
|
||||
})
|
||||
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
|
||||
MainActor.runUnsafely {
|
||||
MainActor.assumeIsolated {
|
||||
if item.status == .readyToPlay {
|
||||
self.container?.setGalleryContentLoading(false)
|
||||
self.statusObservation = nil
|
||||
@ -108,19 +96,22 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
self.container?.setGalleryContentLoading(false)
|
||||
self.showErrorView(error)
|
||||
self.statusObservation = nil
|
||||
self.overlayVC.setVisible(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func showErrorView(_ error: any Error) {
|
||||
isShowingError = true
|
||||
|
||||
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
||||
image.tintColor = .secondaryLabel
|
||||
image.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Error Loading"
|
||||
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
|
||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
label.textColor = .secondaryLabel
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
|
||||
@ -148,22 +139,9 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
let isPlaying = player.rate > 0
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
item = VideoGalleryContentViewController.createItem(asset: item.asset)
|
||||
player.replaceCurrentItem(with: item)
|
||||
updateItemObservations()
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleControlsHide() {
|
||||
hideControlsWorkItem = DispatchWorkItem { [weak self] in
|
||||
MainActor.runUnsafely {
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let container = self.container,
|
||||
container.galleryControlsVisible else {
|
||||
@ -177,25 +155,40 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
public var contentSize: CGSize {
|
||||
item.presentationSize
|
||||
}
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
[VideoActivityItemSource(asset: item.asset, url: url)]
|
||||
open var activityItemsForSharing: [Any] {
|
||||
// [VideoActivityItemSource(asset: item.asset, url: url)]
|
||||
[]
|
||||
}
|
||||
|
||||
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
|
||||
}
|
||||
|
||||
#if os(visionOS)
|
||||
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
||||
var contentOverlayAccessoryViewController: UIViewController? {
|
||||
#else
|
||||
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
|
||||
#endif
|
||||
public var contentOverlayAccessoryViewController: UIViewController? {
|
||||
overlayVC
|
||||
}
|
||||
|
||||
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
||||
#if os(visionOS)
|
||||
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
||||
#else
|
||||
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
|
||||
#endif
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
overlayVC.setVisible(visible)
|
||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
if !isShowingError {
|
||||
overlayVC.setVisible(visible)
|
||||
}
|
||||
|
||||
if !visible {
|
||||
hideControlsWorkItem?.cancel()
|
||||
@ -205,25 +198,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||
}
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
let wasFirstAppearance = isFirstAppearance
|
||||
isFirstAppearance = false
|
||||
|
||||
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
|
||||
if wasFirstAppearance {
|
||||
DispatchQueue.main.async {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
open func galleryContentDidAppear() {
|
||||
}
|
||||
|
||||
func galleryContentWillDisappear() {
|
||||
open func galleryContentWillDisappear() {
|
||||
player.pause()
|
||||
|
||||
if let audioSessionToken {
|
||||
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -252,9 +231,9 @@ private class PlayerView: UIView {
|
||||
playerLayer.player = player
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in
|
||||
MainActor.runUnsafely {
|
||||
self.invalidateIntrinsicContentSize()
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.invalidateIntrinsicContentSize()
|
||||
}
|
||||
})
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// VideoOverlayViewController.swift
|
||||
// Tusker
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/26/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController {
|
||||
private static let pauseImage = UIImage(systemName: "pause.fill")!
|
||||
|
||||
private let player: AVPlayer
|
||||
#if !os(visionOS)
|
||||
@Box private var playbackSpeed: Float
|
||||
#endif
|
||||
|
||||
private var dimmingView: UIView!
|
||||
private var controlsStack: UIStackView!
|
||||
@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController {
|
||||
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
|
||||
#if os(visionOS)
|
||||
init(player: AVPlayer) {
|
||||
self.player = player
|
||||
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) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
@ -79,7 +90,7 @@ class VideoOverlayViewController: UIViewController {
|
||||
])
|
||||
|
||||
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
|
||||
MainActor.runUnsafely {
|
||||
MainActor.assumeIsolated {
|
||||
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
|
||||
}
|
||||
})
|
||||
@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController {
|
||||
if player.currentTime() >= player.currentItem!.duration {
|
||||
player.seek(to: .zero)
|
||||
}
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ public protocol GalleryContentViewController: UIViewController {
|
||||
var caption: String? { get }
|
||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||
var canAnimateFromSourceView: Bool { get }
|
||||
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||
func galleryContentDidAppear()
|
||||
@ -31,8 +31,8 @@ public extension GalleryContentViewController {
|
||||
nil
|
||||
}
|
||||
|
||||
var canAnimateFromSourceView: Bool {
|
||||
true
|
||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fromSourceView
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
@ -44,3 +44,9 @@ public extension GalleryContentViewController {
|
||||
func galleryContentWillDisappear() {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GalleryContentPresentationAnimation {
|
||||
case fade
|
||||
case fromSourceView
|
||||
case fromSourceViewWithoutSnapshot
|
||||
}
|
||||
|
@ -30,12 +30,37 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||
|
||||
let itemViewController = from.currentItemViewController
|
||||
|
||||
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||
nil
|
||||
} else {
|
||||
sourceView.snapshotView(afterScreenUpdates: false)
|
||||
}
|
||||
if let sourceSnapshot {
|
||||
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||
snapshotContainer.addSubview(sourceSnapshot)
|
||||
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||
sourceSnapshot.layer.opacity = 1
|
||||
self.sourceView.layer.opacity = 0
|
||||
}
|
||||
|
||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
||||
|
||||
@ -48,38 +73,39 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
||||
.scaledBy(x: scale, y: scale)
|
||||
sourceView.transform = sourceToDestTransform
|
||||
sourceSnapshot?.transform = sourceToDestTransform
|
||||
} else {
|
||||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
|
||||
let contentContainer = UIView()
|
||||
contentContainer.layer.masksToBounds = true
|
||||
contentContainer.frame = destFrameInContainer
|
||||
container.addSubview(contentContainer)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
content.view.transform = .identity
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
content.view.frame = contentContainer.bounds
|
||||
contentContainer.addSubview(content.view)
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
// Hide overlaid controls immediately, to prevent the Live Text button's position
|
||||
// getting caught up in the rest of the animation.
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
var initialVelocity: CGVector
|
||||
if let interactiveVelocity,
|
||||
let interactiveTranslation,
|
||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
|
||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot
|
||||
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
||||
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
||||
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
||||
@ -102,14 +128,34 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||
|
||||
if appliedSourceToDestTransform {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
sourceSnapshot?.transform = origSourceTransform
|
||||
}
|
||||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
contentContainer.frame = sourceFrameInContainer
|
||||
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
|
||||
// I guess autoresizing takes care of it?
|
||||
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
|
||||
// Delay fading out the content because if it's still big while it's semi-transparent,
|
||||
// seeing the stuff behind it looks odd.
|
||||
animator.addAnimations({
|
||||
content.view.layer.opacity = 0
|
||||
}, delayFactor: 0.35)
|
||||
|
||||
if let sourceSnapshot {
|
||||
animator.addAnimations({
|
||||
self.sourceView.layer.opacity = 1
|
||||
sourceSnapshot.layer.opacity = 0
|
||||
}, delayFactor: 0.5)
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
sourceSnapshot?.removeFromSuperview()
|
||||
|
||||
// Having dismissed, we don't need to undo any of the changes to the content VC.
|
||||
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,8 @@ class GalleryDismissInteraction: NSObject {
|
||||
private(set) var dismissVelocity: CGPoint?
|
||||
private(set) var dismissTranslation: CGPoint?
|
||||
|
||||
private var cancelAnimator: UIViewPropertyAnimator?
|
||||
|
||||
init(viewController: GalleryViewController) {
|
||||
self.viewController = viewController
|
||||
super.init()
|
||||
@ -38,6 +40,8 @@ class GalleryDismissInteraction: NSObject {
|
||||
content = viewController.currentItemViewController.takeContent()
|
||||
content!.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content!.view.frame = origContentFrameInGallery!
|
||||
// Make sure the context remains behind the controls
|
||||
content!.view.layer.zPosition = -1000
|
||||
viewController.view.addSubview(content!.view)
|
||||
|
||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
||||
@ -53,12 +57,42 @@ class GalleryDismissInteraction: NSObject {
|
||||
let translation = recognizer.translation(in: viewController.view)
|
||||
let velocity = recognizer.velocity(in: viewController.view)
|
||||
|
||||
dismissVelocity = velocity
|
||||
dismissTranslation = translation
|
||||
viewController.dismiss(animated: true)
|
||||
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
|
||||
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
|
||||
|
||||
if translationMagnitude < 150 && velocityMagnitude < 500 {
|
||||
isActive = false
|
||||
|
||||
cancelAnimator?.stopAnimation(true)
|
||||
|
||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
|
||||
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
|
||||
cancelAnimator!.addAnimations {
|
||||
self.content!.view.frame = self.origContentFrameInGallery!
|
||||
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
|
||||
}
|
||||
cancelAnimator!.addCompletion { _ in
|
||||
guard !self.isActive else {
|
||||
// bail in case the animation finishing raced with the user's interaction
|
||||
return
|
||||
}
|
||||
self.content!.view.layer.zPosition = 0
|
||||
self.content!.view.removeFromSuperview()
|
||||
self.viewController.currentItemViewController.addContent()
|
||||
self.content = nil
|
||||
self.origContentFrameInGallery = nil
|
||||
self.origControlsVisible = nil
|
||||
}
|
||||
cancelAnimator!.startAnimation()
|
||||
|
||||
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
||||
isActive = false
|
||||
} else {
|
||||
dismissVelocity = velocity
|
||||
dismissTranslation = translation
|
||||
viewController.dismiss(animated: true)
|
||||
|
||||
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
||||
isActive = false
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
|
@ -11,6 +11,7 @@ import AVFoundation
|
||||
@MainActor
|
||||
protocol GalleryItemViewControllerDelegate: AnyObject {
|
||||
func isGalleryBeingPresented() -> Bool
|
||||
func isGalleryBeingDismissed() -> Bool
|
||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
|
||||
func galleryItemClose(_ item: GalleryItemViewController)
|
||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
|
||||
@ -69,6 +70,10 @@ class GalleryItemViewController: UIViewController {
|
||||
scrollView = UIScrollView()
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.delegate = self
|
||||
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
|
||||
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
|
||||
// (this is readily observable with tall images on a landscape iPad).
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
view.addSubview(scrollView)
|
||||
|
||||
@ -372,13 +377,27 @@ class GalleryItemViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func updateTopControlsInsets() {
|
||||
guard delegate?.isGalleryBeingDismissed() != true else {
|
||||
return
|
||||
}
|
||||
let notchedDeviceTopInsets: [CGFloat] = [
|
||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||
48, // iPhone XR, 11
|
||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
||||
50, // iPhone 12 mini, 13 mini
|
||||
]
|
||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||
let topInset: CGFloat
|
||||
switch view.window?.windowScene?.interfaceOrientation {
|
||||
case .portraitUpsideDown:
|
||||
topInset = view.safeAreaInsets.bottom
|
||||
case .landscapeLeft:
|
||||
topInset = view.safeAreaInsets.right
|
||||
case .landscapeRight:
|
||||
topInset = view.safeAreaInsets.left
|
||||
default:
|
||||
topInset = view.safeAreaInsets.top
|
||||
}
|
||||
if notchedDeviceTopInsets.contains(topInset) {
|
||||
// the notch width is not the same for the iPhones 13,
|
||||
// but what we actually want is the same offset from the edges
|
||||
// since the corner radius didn't change
|
||||
@ -387,7 +406,7 @@ class GalleryItemViewController: UIViewController {
|
||||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
||||
shareButtonLeadingConstraint.constant = offset
|
||||
closeButtonTrailingConstraint.constant = offset
|
||||
} else if view.safeAreaInsets.top == 0 {
|
||||
} else if topInset == 0 {
|
||||
// square corner devices
|
||||
shareButtonLeadingConstraint.constant = 8
|
||||
shareButtonTopConstraint.constant = 8
|
||||
|
@ -25,11 +25,31 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||
|
||||
let itemViewController = to.currentItemViewController
|
||||
|
||||
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
|
||||
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to effectively "fade out" anything that's on top of the source view.
|
||||
// The 0.1 duration makes this happen faster than the rest of the animation,
|
||||
// and so less noticeable.
|
||||
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||
nil
|
||||
} else {
|
||||
sourceView.snapshotView(afterScreenUpdates: false)
|
||||
}
|
||||
if let sourceSnapshot {
|
||||
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||
snapshotContainer.addSubview(sourceSnapshot)
|
||||
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||
sourceSnapshot.transform = sourceView.transform
|
||||
sourceSnapshot.layer.opacity = 0
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
sourceSnapshot.layer.opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
@ -56,21 +76,70 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||
sourceToDestTransform = nil
|
||||
}
|
||||
|
||||
// Grab these before taking the content out and changing the transform.
|
||||
let origContentTransform = itemViewController.content.view.transform
|
||||
let origContentFrame = itemViewController.content.view.frame
|
||||
|
||||
// The content container provides the clipping for the content view,
|
||||
// which, in case the source/dest aspect ratios don't match, makes
|
||||
// it look like the content is expanding out from the source rect.
|
||||
let contentContainer = UIView()
|
||||
contentContainer.layer.masksToBounds = true
|
||||
container.insertSubview(contentContainer, belowSubview: to.view)
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
container.insertSubview(content.view, belowSubview: to.view)
|
||||
content.view.transform = .identity
|
||||
// The fade-in makes the aspect ratio handling look a little bit worse,
|
||||
// but papers over the z-index change and potential corner radius change.
|
||||
content.view.layer.opacity = 0
|
||||
contentContainer.addSubview(content.view)
|
||||
|
||||
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
|
||||
let dimmingView = UIView()
|
||||
dimmingView.backgroundColor = .black
|
||||
dimmingView.frame = container.bounds
|
||||
dimmingView.layer.opacity = 0
|
||||
container.insertSubview(dimmingView, belowSubview: content.view)
|
||||
container.insertSubview(dimmingView, belowSubview: contentContainer)
|
||||
|
||||
to.view.backgroundColor = nil
|
||||
to.view.layer.opacity = 0
|
||||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
contentContainer.frame = sourceFrameInContainer
|
||||
|
||||
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
|
||||
sourceFrameInContainer.width / sourceFrameInContainer.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
|
||||
destFrameInContainer.width / destFrameInContainer.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
|
||||
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
|
||||
// asepct ratios are effectively equal
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
|
||||
} else if sourceAspectRatio < destAspectRatio {
|
||||
// source aspect ratio is narrow/taller than dest
|
||||
let width = sourceFrameInContainer.height * destAspectRatio
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||
x: -(width - sourceFrameInContainer.width) / 2,
|
||||
y: 0,
|
||||
width: width,
|
||||
height: sourceFrameInContainer.height
|
||||
)
|
||||
} else {
|
||||
// source aspect ratio is wider/shorter than dest
|
||||
let height = sourceFrameInContainer.width / destAspectRatio
|
||||
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||
x: 0,
|
||||
y: -(height - sourceFrameInContainer.height) / 2,
|
||||
width: sourceFrameInContainer.width,
|
||||
height: height
|
||||
)
|
||||
}
|
||||
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
@ -78,8 +147,14 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
||||
// less bounce on bigger screens
|
||||
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
// roughly equivalent to duration: 0.35, bounce: 0.2
|
||||
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
|
||||
} else {
|
||||
// roughly equivalent to duration: 0.35, bounce: 0.3
|
||||
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
||||
}
|
||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
||||
|
||||
animator.addAnimations {
|
||||
@ -87,25 +162,35 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||
|
||||
to.view.layer.opacity = 1
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
contentContainer.frame = destFrameInContainer
|
||||
content.view.frame = contentContainer.bounds
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
|
||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||
|
||||
if let sourceToDestTransform {
|
||||
sourceSnapshot?.transform = sourceToDestTransform
|
||||
self.sourceView.transform = sourceToDestTransform
|
||||
}
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
sourceSnapshot?.removeFromSuperview()
|
||||
self.sourceView.layer.opacity = 1
|
||||
if sourceToDestTransform != nil {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
}
|
||||
|
||||
contentContainer.removeFromSuperview()
|
||||
dimmingView.removeFromSuperview()
|
||||
|
||||
to.view.backgroundColor = .black
|
||||
|
||||
if sourceToDestTransform != nil {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
}
|
||||
|
||||
// Reset the properties we changed before re-adding the content to the scroll view.
|
||||
// (I would expect UIScrollView to effectively do this itself, but w/e.)
|
||||
content.view.transform = origContentTransform
|
||||
content.view.frame = origContentFrame
|
||||
|
||||
itemViewController.addContent()
|
||||
|
||||
transitionContext.completeTransition(true)
|
||||
|
@ -139,6 +139,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
|
||||
isBeingPresented
|
||||
}
|
||||
|
||||
func isGalleryBeingDismissed() -> Bool {
|
||||
isBeingDismissed
|
||||
}
|
||||
|
||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
|
||||
presentationAnimationCompletionHandlers.append(block)
|
||||
}
|
||||
|
26
Packages/GalleryVC/Sources/GalleryVC/UIView+Utilities.swift
Normal file
26
Packages/GalleryVC/Sources/GalleryVC/UIView+Utilities.swift
Normal file
@ -0,0 +1,26 @@
|
||||
//
|
||||
// UIView+Utilities.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 11/24/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
|
||||
var ancestorForInsertingSnapshot: UIView {
|
||||
var view = self
|
||||
while let superview = view.superview {
|
||||
if superview.layer.masksToBounds {
|
||||
return superview
|
||||
} else if superview is UIScrollView {
|
||||
return self
|
||||
} else {
|
||||
view = superview
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
}
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "InstanceFeatures",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// 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(
|
||||
name: "MatchedGeometryPresentation",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "Pachyderm",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -25,27 +25,30 @@ public struct Client: Sendable {
|
||||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let iso8601 = ISO8601DateFormatter()
|
||||
return formatter
|
||||
}()
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
private static func decodeDate(string: String) -> Date? {
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
|
||||
}
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let str = try container.decode(String.self)
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
if let date = formatter.date(from: str) {
|
||||
return date
|
||||
} else if let date = iso8601.date(from: str) {
|
||||
if let date = Self.decodeDate(string: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
return decoder
|
||||
}()
|
||||
|
||||
@ -105,6 +108,15 @@ public struct Client: Sendable {
|
||||
return task
|
||||
}
|
||||
|
||||
private func error(from response: HTTPURLResponse) -> ErrorType {
|
||||
if response.statusCode == 429,
|
||||
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
|
||||
return .rateLimited(date)
|
||||
} else {
|
||||
return .unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
@ -575,6 +587,8 @@ extension Client {
|
||||
return "Invalid Model"
|
||||
case .mastodonError(let code, let error):
|
||||
return "Server Error (\(code)): \(error)"
|
||||
case .rateLimited(let reset):
|
||||
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -585,6 +599,7 @@ extension Client {
|
||||
case invalidResponse
|
||||
case invalidModel(Swift.Error)
|
||||
case mastodonError(Int, String)
|
||||
case rateLimited(Date)
|
||||
}
|
||||
|
||||
enum NodeInfoError: LocalizedError {
|
||||
|
@ -22,7 +22,12 @@ public struct Emoji: Codable, Sendable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
do {
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
} catch {
|
||||
let s = try? container.decode(String.self, forKey: .url)
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
|
||||
}
|
||||
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
||||
|
@ -11,9 +11,15 @@ import Foundation
|
||||
public struct NodeInfo: Decodable, Sendable, Equatable {
|
||||
public let version: String
|
||||
public let software: Software
|
||||
public let metadata: Metadata
|
||||
|
||||
public struct Software: Decodable, Sendable, Equatable {
|
||||
public let name: String
|
||||
public let version: String
|
||||
}
|
||||
|
||||
public struct Metadata: Decodable, Sendable, Equatable {
|
||||
public let nodeName: String
|
||||
public let nodeDescription: String
|
||||
}
|
||||
}
|
||||
|
@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
|
||||
|
||||
func testGroupSimple() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
|
||||
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
|
||||
}
|
||||
|
||||
func testGroupWithOtherGroupableInBetween() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
])
|
||||
}
|
||||
|
||||
func testDontGroupWithUngroupableInBetween() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [
|
||||
NotificationGroup(notifications: [likeA1])!,
|
||||
NotificationGroup(notifications: [mentionB])!,
|
||||
NotificationGroup(notifications: [likeA2])!,
|
||||
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||
])
|
||||
}
|
||||
|
||||
func testMergeSimpleGroups() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [likeA2])!
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
|
||||
])
|
||||
}
|
||||
|
||||
func testMergeGroupsWithOtherGroupableInBetween() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [likeB])!
|
||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
|
||||
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
])
|
||||
|
||||
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
|
||||
XCTAssertEqual(merged2, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
])
|
||||
|
||||
let group4 = NotificationGroup(notifications: [likeB2])!
|
||||
let group5 = NotificationGroup(notifications: [mentionB])!
|
||||
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
|
||||
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
|
||||
print(merged3.count)
|
||||
XCTAssertEqual(merged3, [
|
||||
group1,
|
||||
group5,
|
||||
NotificationGroup(notifications: [likeB, likeB2]),
|
||||
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
|
||||
group3
|
||||
])
|
||||
}
|
||||
|
||||
func testDontMergeWithUngroupableInBetween() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [mentionB])!
|
||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1])!,
|
||||
NotificationGroup(notifications: [mentionB])!,
|
||||
NotificationGroup(notifications: [likeA2])!,
|
||||
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "PushNotifications",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "TTTKit",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// 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(
|
||||
name: "TuskerComponents",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// 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 {
|
||||
let titleKey: LocalizedStringKey
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||
let labelHidden: Bool
|
||||
#endif
|
||||
let alignment: Alignment
|
||||
@Binding var value: V
|
||||
let onChange: (V) async -> Bool
|
||||
let content: Content
|
||||
@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
|
||||
#if !os(visionOS)
|
||||
self.labelHidden = labelHidden
|
||||
#endif
|
||||
self.alignment = alignment
|
||||
self._value = value
|
||||
self.onChange = onChange
|
||||
@ -24,9 +31,25 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(visionOS)
|
||||
LabeledContent(titleKey) {
|
||||
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 {
|
||||
|
@ -10,19 +10,42 @@ import SwiftUI
|
||||
|
||||
public struct AsyncToggle: View {
|
||||
let titleKey: LocalizedStringKey
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||
let labelHidden: Bool
|
||||
#endif
|
||||
@Binding var mode: Mode
|
||||
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
|
||||
#if !os(visionOS)
|
||||
self.labelHidden = labelHidden
|
||||
#endif
|
||||
self._mode = mode
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(visionOS)
|
||||
LabeledContent(titleKey) {
|
||||
toggleOrSpinner
|
||||
}
|
||||
#else
|
||||
if #available(iOS 16.0, *) {
|
||||
LabeledContent(titleKey) {
|
||||
toggleOrSpinner
|
||||
}
|
||||
} else if labelHidden {
|
||||
toggleOrSpinner
|
||||
} else {
|
||||
HStack {
|
||||
Text(titleKey)
|
||||
Spacer()
|
||||
toggleOrSpinner
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -47,7 +47,9 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||
|
||||
private func makeConfiguration() -> UIButton.Configuration {
|
||||
var config = UIButton.Configuration.borderless()
|
||||
config.indicator = .popup
|
||||
if #available(iOS 16.0, *) {
|
||||
config.indicator = .popup
|
||||
}
|
||||
if buttonStyle.hasIcon {
|
||||
config.image = selectedOption.image
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "TuskerPreferences",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "UserAccounts",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.iOS(.v15),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -141,6 +141,7 @@
|
||||
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
|
||||
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.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 */; };
|
||||
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
|
||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||
@ -203,20 +204,16 @@
|
||||
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
|
||||
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
|
||||
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */; };
|
||||
D69261272BB3BA610023152C /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261262BB3BA610023152C /* Box.swift */; };
|
||||
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; };
|
||||
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; };
|
||||
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; };
|
||||
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */; };
|
||||
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */; };
|
||||
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; };
|
||||
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; };
|
||||
D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; };
|
||||
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */; };
|
||||
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; };
|
||||
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */; };
|
||||
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; };
|
||||
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */; };
|
||||
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */; };
|
||||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
|
||||
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
|
||||
@ -336,7 +333,7 @@
|
||||
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
|
||||
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.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 */; };
|
||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
|
||||
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
|
||||
@ -571,6 +568,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -637,19 +635,15 @@
|
||||
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
|
||||
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
|
||||
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayViewController.swift; sourceTree = "<group>"; };
|
||||
D69261262BB3BA610023152C /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = "<group>"; };
|
||||
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
|
||||
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableImageGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.swift; sourceTree = "<group>"; };
|
||||
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = "<group>"; };
|
||||
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableVideoGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||
D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = "<group>"; };
|
||||
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
||||
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
|
||||
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
|
||||
@ -776,7 +770,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -899,13 +893,9 @@
|
||||
children = (
|
||||
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */,
|
||||
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */,
|
||||
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */,
|
||||
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */,
|
||||
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */,
|
||||
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */,
|
||||
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */,
|
||||
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */,
|
||||
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */,
|
||||
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */,
|
||||
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */,
|
||||
);
|
||||
path = Gallery;
|
||||
sourceTree = "<group>";
|
||||
@ -1488,7 +1478,7 @@
|
||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||
D6D9498E298EB79400C59229 /* CopyableLabel.swift */,
|
||||
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
|
||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
||||
@ -1625,6 +1615,7 @@
|
||||
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
|
||||
D6B81F432560390300F6E31D /* MenuController.swift */,
|
||||
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
|
||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
|
||||
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
||||
@ -2122,7 +2113,6 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */,
|
||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
|
||||
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
|
||||
@ -2169,8 +2159,7 @@
|
||||
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
|
||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */,
|
||||
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
|
||||
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */,
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
|
||||
@ -2190,7 +2179,6 @@
|
||||
D6D94955298963A900C59229 /* Colors.swift in Sources */,
|
||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||
D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */,
|
||||
@ -2218,7 +2206,6 @@
|
||||
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
|
||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
|
||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
|
||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||
@ -2303,7 +2290,7 @@
|
||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
|
||||
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */,
|
||||
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
@ -2341,7 +2328,7 @@
|
||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||
D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */,
|
||||
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
|
||||
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */,
|
||||
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */,
|
||||
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
||||
@ -2387,6 +2374,7 @@
|
||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
|
||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
|
||||
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
|
||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||
@ -2543,7 +2531,6 @@
|
||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2576,7 +2563,6 @@
|
||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2608,7 +2594,6 @@
|
||||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2699,7 +2684,6 @@
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2743,7 +2727,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2766,7 +2750,6 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2794,7 +2777,6 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2823,7 +2805,6 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -2852,7 +2833,6 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3008,7 +2988,6 @@
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3041,7 +3020,6 @@
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3106,7 +3084,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3126,7 +3104,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3149,7 +3127,6 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -3174,7 +3151,6 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -8,7 +8,6 @@
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import os
|
||||
|
||||
struct DiskCacheTransformer<T> {
|
||||
let toData: (T) throws -> Data
|
||||
@ -22,7 +21,7 @@ class DiskCache<T> {
|
||||
let defaultExpiry: CacheExpiry
|
||||
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 {
|
||||
self.defaultExpiry = defaultExpiry
|
||||
@ -60,9 +59,7 @@ class DiskCache<T> {
|
||||
}
|
||||
|
||||
private func fileState(forKey key: String) -> FileState {
|
||||
return fileStates.withLock {
|
||||
$0[key] ?? .unknown
|
||||
}
|
||||
return fileStates[key] ?? .unknown
|
||||
}
|
||||
|
||||
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 {
|
||||
throw Error.couldNotCreateFile
|
||||
}
|
||||
fileStates.withLock {
|
||||
$0[key] = .exists
|
||||
}
|
||||
fileStates[key] = .exists
|
||||
}
|
||||
|
||||
func removeObject(forKey key: String) throws {
|
||||
let path = makeFilePath(for: key)
|
||||
try fileManager.removeItem(atPath: path)
|
||||
fileStates.withLock {
|
||||
$0[key] = .doesNotExist
|
||||
}
|
||||
fileStates[key] = .doesNotExist
|
||||
}
|
||||
|
||||
func existsObject(forKey key: String) throws -> Bool {
|
||||
@ -112,9 +105,7 @@ class DiskCache<T> {
|
||||
}
|
||||
guard date.timeIntervalSinceNow >= 0 else {
|
||||
try fileManager.removeItem(atPath: path)
|
||||
fileStates.withLock {
|
||||
$0[key] = .doesNotExist
|
||||
}
|
||||
fileStates[key] = .doesNotExist
|
||||
throw Error.expired
|
||||
}
|
||||
|
||||
|
@ -375,13 +375,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Se
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
func addAll(notifications: [Pachyderm.Notification], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
||||
let context = context ?? backgroundContext
|
||||
context.perform {
|
||||
let statuses = notifications.compactMap { $0.status }
|
||||
let accounts = notifications.map { $0.account }
|
||||
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
||||
self.save(context: self.backgroundContext)
|
||||
statuses.forEach { self.upsert(status: $0, context: context) }
|
||||
accounts.forEach { self.upsert(account: $0, in: context) }
|
||||
self.save(context: context)
|
||||
completion?()
|
||||
statuses.forEach { self.statusSubject.send($0.id) }
|
||||
accounts.forEach { self.accountSubject.send($0.id) }
|
||||
|
@ -76,10 +76,17 @@ func fromTimelineKind(_ kind: String) -> Timeline {
|
||||
} else if kind == "direct" {
|
||||
return .direct
|
||||
} 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:") {
|
||||
return .list(id: String(kind.trimmingPrefix("list:")))
|
||||
return .list(id: String(trimmingPrefix("list:", of: kind)))
|
||||
} else {
|
||||
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 {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
if #available(iOS 16.0, *) {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,13 +48,14 @@ extension HTMLConverter {
|
||||
// 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),
|
||||
// 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
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} 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 {
|
||||
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
|
||||
return DuckableContainerViewController(child: mainVC)
|
||||
} else {
|
||||
|
@ -86,7 +86,7 @@ struct AddReactionView: View {
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.presentationDetents([.medium, .large])
|
||||
.mediumPresentationDetentIfAvailable()
|
||||
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
@ -171,6 +171,17 @@ private struct AddReactionButton<Label: View>: 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(visionOS 1.0, *)
|
||||
@ViewBuilder
|
||||
|
@ -20,10 +20,14 @@ struct AnnouncementListRow: View {
|
||||
@State private var isShowingAddReactionSheet = false
|
||||
|
||||
var body: some View {
|
||||
mostOfTheBody
|
||||
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
||||
dimension[.leading]
|
||||
})
|
||||
if #available(iOS 16.0, *) {
|
||||
mostOfTheBody
|
||||
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
||||
dimension[.leading]
|
||||
})
|
||||
} else {
|
||||
mostOfTheBody
|
||||
}
|
||||
}
|
||||
|
||||
private var mostOfTheBody: some View {
|
||||
@ -50,7 +54,11 @@ struct AnnouncementListRow: View {
|
||||
Label {
|
||||
Text("Add Reaction")
|
||||
} icon: {
|
||||
Image("face.smiling.badge.plus")
|
||||
if #available(iOS 16.0, *) {
|
||||
Image("face.smiling.badge.plus")
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
|
@ -484,3 +484,11 @@ extension ConversationViewController: StatusBarTappableViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
Task {
|
||||
await refreshContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,20 @@
|
||||
import SwiftUI
|
||||
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 {
|
||||
@EnvironmentObject private var mastodonController: MastodonController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ -35,6 +49,9 @@ struct AddHashtagPinnedTimelineView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
list
|
||||
#if !os(visionOS)
|
||||
.appGroupedListBackground(container: AddHashtagPinnedTimelineRepresentable.UIViewControllerType.self)
|
||||
#endif
|
||||
.listStyle(.grouped)
|
||||
.navigationTitle("Add Hashtag")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
@ -36,8 +36,15 @@ struct CustomizeTimelinesList: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
navigationBody
|
||||
if #available(iOS 16.0, *) {
|
||||
NavigationStack {
|
||||
navigationBody
|
||||
}
|
||||
} else {
|
||||
NavigationView {
|
||||
navigationBody
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,7 +149,7 @@ struct EditFilterView: View {
|
||||
}
|
||||
.appGroupedListBackground(container: UIHostingController<CustomizeTimelinesList>.self)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
#endif
|
||||
.navigationTitle(create ? "Add Filter" : "Edit Filter")
|
||||
.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 {
|
||||
// static var previews: some View {
|
||||
// EditFilterView()
|
||||
|
@ -115,8 +115,18 @@ struct PinnedTimelinesModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
||||
#if os(visionOS)
|
||||
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
#else
|
||||
if #available(iOS 16.0, *) {
|
||||
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
} else {
|
||||
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
#endif
|
||||
})
|
||||
.sheet(isPresented: $isShowingAddInstanceSheet, content: {
|
||||
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||
|
@ -41,7 +41,9 @@ class InlineTrendsViewController: UIViewController {
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
if #available(iOS 16.0, *) {
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
}
|
||||
|
||||
let trends = TrendsViewController(mastodonController: mastodonController)
|
||||
trends.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -16,15 +16,11 @@ struct TrendingLinkCardView: View {
|
||||
let card: Card
|
||||
|
||||
private var imageURL: URL? {
|
||||
if let image = card.image {
|
||||
URL(image)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
card.image.flatMap { URL($0) }
|
||||
}
|
||||
|
||||
private var descriptionText: String {
|
||||
var converter = TextConverter(configuration: .init(insertNewlines: false))
|
||||
let converter = TextConverter(configuration: .init(insertNewlines: false))
|
||||
return converter.convert(html: card.description)
|
||||
}
|
||||
|
||||
|
@ -525,12 +525,12 @@ extension TrendsViewController: UICollectionViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard indexPaths.count == 1,
|
||||
let item = dataSource.itemIdentifier(for: indexPaths[0]) else {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@available(visionOS 1.0, *)
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
let indexPath = indexPaths[0]
|
||||
|
||||
switch item {
|
||||
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) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||
}
|
||||
|
@ -69,4 +69,8 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
|
||||
[VideoActivityItemSource(asset: controller.item.asset, url: url)]
|
||||
}
|
||||
|
||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fromSourceViewWithoutSnapshot
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,60 @@
|
||||
//
|
||||
// GrayscalableImageGalleryContentViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/21/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import TuskerComponents
|
||||
import GalleryVC
|
||||
|
||||
class GrayscalableImageGalleryContentViewController: GalleryVC.ImageGalleryContentViewController {
|
||||
private let url: URL
|
||||
private let originalImage: UIImage
|
||||
private let originalData: Data?
|
||||
private var isGrayscale = false
|
||||
|
||||
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
|
||||
self.url = url
|
||||
self.originalImage = image
|
||||
self.originalData = originalData
|
||||
|
||||
super.init(image: image, caption: caption, gifController: gifController)
|
||||
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
if isGrayscale {
|
||||
self.image = ImageGrayscalifier.convert(url: url, image: image) ?? image
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
@MainActor required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
let image = if isGrayscale {
|
||||
ImageGrayscalifier.convert(url: url, image: originalImage)
|
||||
} else {
|
||||
originalImage
|
||||
}
|
||||
if let image {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var activityItemsForSharing: [Any] {
|
||||
if let data = originalData ?? image.pngData() {
|
||||
return [ImageActivityItemSource(data: data, url: url, image: image)]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
//
|
||||
// GrayscalableVideoGalleryContentViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/21/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
import AVFoundation
|
||||
|
||||
class GrayscalableVideoGalleryContentViewController: GalleryVC.VideoGalleryContentViewController {
|
||||
private var audioSessionToken: AudioSessionCoordinator.Token?
|
||||
private var isGrayscale: Bool
|
||||
private var isFirstAppearance = true
|
||||
|
||||
override init(url: URL, caption: String?) {
|
||||
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||
|
||||
super.init(url: url, caption: caption)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
@MainActor required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override class func createItem(asset: AVAsset) -> AVPlayerItem {
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
if Preferences.shared.grayscaleImages {
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVVideoComposition CIFilter initializer")
|
||||
#else
|
||||
let filter = CIFilter(name: "CIColorMonochrome")!
|
||||
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
|
||||
filter.setValue(1.0, forKey: "inputIntensity")
|
||||
|
||||
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in
|
||||
filter.setValue(request.sourceImage, forKey: "inputImage")
|
||||
request.finish(with: filter.outputImage!, context: nil)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
let isPlaying = player.rate > 0
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
replaceCurrentItem(with: Self.createItem(asset: item.asset))
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func galleryContentDidAppear() {
|
||||
super.galleryContentDidAppear()
|
||||
|
||||
let wasFirstAppearance = isFirstAppearance
|
||||
isFirstAppearance = false
|
||||
|
||||
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
|
||||
if wasFirstAppearance {
|
||||
DispatchQueue.main.async {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func galleryContentWillDisappear() {
|
||||
super.galleryContentWillDisappear()
|
||||
|
||||
if let audioSessionToken {
|
||||
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -34,7 +34,7 @@ class ImageGalleryDataSource: GalleryDataSource {
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return ImageGalleryContentViewController(
|
||||
return GrayscalableImageGalleryContentViewController(
|
||||
url: url,
|
||||
caption: nil,
|
||||
originalData: entry.data,
|
||||
@ -52,7 +52,7 @@ class ImageGalleryDataSource: GalleryDataSource {
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return ImageGalleryContentViewController(
|
||||
return GrayscalableImageGalleryContentViewController(
|
||||
url: self.url,
|
||||
caption: nil,
|
||||
originalData: data,
|
||||
|
@ -33,7 +33,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
||||
case .image:
|
||||
if let view = attachmentView(for: attachment),
|
||||
let image = view.attachmentImage {
|
||||
return ImageGalleryContentViewController(
|
||||
return GrayscalableImageGalleryContentViewController(
|
||||
url: attachment.url,
|
||||
caption: attachment.description,
|
||||
originalData: view.originalData,
|
||||
@ -49,7 +49,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return ImageGalleryContentViewController(
|
||||
return GrayscalableImageGalleryContentViewController(
|
||||
url: attachment.url,
|
||||
caption: attachment.description,
|
||||
originalData: entry.data,
|
||||
@ -68,7 +68,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return ImageGalleryContentViewController(
|
||||
return GrayscalableImageGalleryContentViewController(
|
||||
url: attachment.url,
|
||||
caption: attachment.description,
|
||||
originalData: data,
|
||||
@ -91,10 +91,10 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
||||
}
|
||||
return GifvGalleryContentViewController(controller: controller, url: attachment.url, caption: attachment.description)
|
||||
case .video:
|
||||
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
case .audio:
|
||||
// TODO: use separate content VC with audio visualization?
|
||||
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
case .unknown:
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
do {
|
||||
|
@ -100,13 +100,28 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
|
||||
navigationItem.renameDelegate = self
|
||||
navigationItem.titleMenuProvider = { [unowned self] suggested in
|
||||
var children = suggested
|
||||
children.append(contentsOf: self.listSettingsMenuElements())
|
||||
return UIMenu(children: children)
|
||||
if #available(iOS 16.0, *) {
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
|
||||
navigationItem.renameDelegate = self
|
||||
navigationItem.titleMenuProvider = { [unowned self] suggested in
|
||||
var children = suggested
|
||||
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)
|
||||
})
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -151,6 +151,22 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
|
||||
return false
|
||||
#endif // !os(visionOS)
|
||||
}
|
||||
|
||||
// MARK: Keyboard shortcuts
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||
// we manually delegate to the top view controller if possible.
|
||||
if action == #selector(RefreshableViewController.refresh),
|
||||
let selected = selectedViewController as? NavigationControllerProtocol,
|
||||
let top = selected.topViewController as? RefreshableViewController {
|
||||
return top
|
||||
} else {
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BaseMainTabBarViewController: TuskerNavigationDelegate {
|
||||
|
@ -219,6 +219,19 @@ class MainSplitViewController: UISplitViewController {
|
||||
@objc func handleComposeKeyCommand() {
|
||||
compose(editing: nil)
|
||||
}
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||
// we manually delegate to the top view controller if possible.
|
||||
if action == #selector(RefreshableViewController.refresh),
|
||||
traitCollection.horizontalSizeClass == .regular,
|
||||
let top = secondaryNavController.topViewController as? RefreshableViewController {
|
||||
return top
|
||||
} else {
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,13 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController {
|
||||
private var isCompact: Bool?
|
||||
@Box fileprivate var myProfileCell: UIView?
|
||||
private var sidebarTapRecognizer: UITapGestureRecognizer?
|
||||
|
||||
private lazy var fastAccountSwitcherIndicator: UIView = {
|
||||
let indicator = FastAccountSwitcherIndicatorView()
|
||||
// need to explicitly set the frame to get it vertically centered
|
||||
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
|
||||
return indicator
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@ -513,13 +520,6 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private var fastAccountSwitcherIndicator: UIView = {
|
||||
let indicator = FastAccountSwitcherIndicatorView()
|
||||
// need to explicitly set the frame to get it vertically centered
|
||||
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
|
||||
return indicator
|
||||
}()
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
|
||||
func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {
|
||||
|
@ -41,8 +41,15 @@ struct MuteAccountView: View {
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
navigationViewContent
|
||||
if #available(iOS 16.0, *) {
|
||||
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: "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: [
|
||||
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren),
|
||||
acceptRejectMenu,
|
||||
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
||||
])
|
||||
}
|
||||
|
@ -48,7 +48,9 @@ class NotificationLoadingViewController: UIViewController {
|
||||
do {
|
||||
let (notification, _) = try await mastodonController.run(request)
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(notifications: [notification]) {
|
||||
let container = mastodonController.persistentContainer
|
||||
let context = container.viewContext
|
||||
container.addAll(notifications: [notification], in: context) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
@ -700,8 +700,14 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
||||
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
|
||||
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: [
|
||||
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren),
|
||||
acceptRejectMenu,
|
||||
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
|
||||
])
|
||||
}
|
||||
|
@ -180,3 +180,9 @@ extension NotificationsPageViewController: StateRestorableViewController {
|
||||
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsPageViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
(currentViewController as? RefreshableViewController)?.refresh()
|
||||
}
|
||||
}
|
||||
|
@ -75,13 +75,14 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||
|
||||
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
switch item {
|
||||
case let .selected(_, instance):
|
||||
case let .selected(_, info):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
||||
cell.updateUI(instance: instance)
|
||||
cell.updateUI(info: info)
|
||||
return cell
|
||||
case let .recommended(instance):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
||||
cell.updateUI(instance: instance)
|
||||
let info = Info(host: instance.domain, description: instance.description, thumbnail: instance.proxiedThumbnailURL, adult: instance.category == "adult")
|
||||
cell.updateUI(info: info)
|
||||
return cell
|
||||
}
|
||||
})
|
||||
@ -96,7 +97,9 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||
searchController.searchBar.placeholder = "Search or enter a URL"
|
||||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
if #available(iOS 16.0, *) {
|
||||
navigationItem.preferredSearchBarPlacement = .stacked
|
||||
}
|
||||
definesPresentationContext = true
|
||||
|
||||
urlHandler = urlCheckerSubject
|
||||
@ -162,22 +165,20 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||
return
|
||||
}
|
||||
|
||||
let client = Client(baseURL: url, session: .appDefault)
|
||||
let request = Client.getInstanceV1()
|
||||
client.run(request) { (response) in
|
||||
checkSpecificInstance(url: url) { (info) in
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if snapshot.indexOfSection(.selected) != nil {
|
||||
snapshot.deleteSections([.selected])
|
||||
}
|
||||
|
||||
if case let .success(instance, _) = response {
|
||||
if let info {
|
||||
if snapshot.indexOfSection(.recommendedInstances) != nil {
|
||||
snapshot.insertSections([.selected], beforeSection: .recommendedInstances)
|
||||
} else {
|
||||
snapshot.appendSections([.selected])
|
||||
}
|
||||
|
||||
snapshot.appendItems([.selected(url, instance)], toSection: .selected)
|
||||
snapshot.appendItems([.selected(url, info)], toSection: .selected)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(snapshot) {
|
||||
@ -192,6 +193,29 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSpecificInstance(url: URL, completionHandler: @escaping (Info?) -> Void) {
|
||||
let client = Client(baseURL: url, session: .appDefault)
|
||||
let request = Client.getInstanceV1()
|
||||
client.run(request) { response in
|
||||
switch response {
|
||||
case .success(let instance, _):
|
||||
let host = url.host ?? URLComponents(string: instance.uri)?.host ?? instance.uri
|
||||
let info = Info(host: host, description: instance.shortDescription ?? instance.description, thumbnail: instance.thumbnail, adult: false)
|
||||
completionHandler(info)
|
||||
case .failure(_):
|
||||
Task {
|
||||
do {
|
||||
let nodeInfo = try await client.nodeInfo()
|
||||
let info = Info(host: url.host!, description: nodeInfo.metadata.nodeDescription, thumbnail: nil, adult: false)
|
||||
completionHandler(info)
|
||||
} catch {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRecommendedInstances() {
|
||||
InstanceSelector.getInstances(category: nil) { (response) in
|
||||
DispatchQueue.main.async {
|
||||
@ -310,13 +334,13 @@ extension InstanceSelectorTableViewController {
|
||||
case recommendedInstances
|
||||
}
|
||||
enum Item: Equatable, Hashable, Sendable {
|
||||
case selected(URL, InstanceV1)
|
||||
case selected(URL, Info)
|
||||
case recommended(InstanceSelector.Instance)
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.selected(urlA, instanceA), .selected(urlB, instanceB)):
|
||||
return urlA == urlB && instanceA.uri == instanceB.uri
|
||||
case let (.selected(urlA, _), .selected(urlB, _)):
|
||||
return urlA == urlB
|
||||
case let (.recommended(a), .recommended(b)):
|
||||
return a.domain == b.domain
|
||||
default:
|
||||
@ -326,16 +350,21 @@ extension InstanceSelectorTableViewController {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .selected(url, instance):
|
||||
case let .selected(url, _):
|
||||
hasher.combine(0)
|
||||
hasher.combine(url)
|
||||
hasher.combine(instance.uri)
|
||||
case let .recommended(instance):
|
||||
hasher.combine(1)
|
||||
hasher.combine(instance.domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Info: Hashable {
|
||||
let host: String
|
||||
let description: String
|
||||
let thumbnail: URL?
|
||||
let adult: Bool
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
||||
|
@ -91,10 +91,14 @@ struct AboutView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var iconOrGame: some View {
|
||||
FlipView {
|
||||
if #available(iOS 16.0, *) {
|
||||
FlipView {
|
||||
appIcon
|
||||
} back: {
|
||||
TTTView()
|
||||
}
|
||||
} else {
|
||||
appIcon
|
||||
} back: {
|
||||
TTTView()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,14 @@ struct AppearancePrefsView: View {
|
||||
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
|
||||
var image: UIImage?
|
||||
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)
|
||||
}
|
||||
|
@ -36,7 +36,12 @@ struct NotificationsPrefsView: View {
|
||||
if #available(iOS 15.4, *) {
|
||||
Section {
|
||||
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)
|
||||
}
|
||||
} label: {
|
||||
|
@ -34,7 +34,7 @@ struct PushInstanceSettingsView: View {
|
||||
HStack {
|
||||
PrefsAccountView(account: account)
|
||||
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()
|
||||
}
|
||||
PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
|
||||
|
@ -43,9 +43,21 @@ struct OppositeCollapseKeywordsView: View {
|
||||
.listStyle(.grouped)
|
||||
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.onAppear(perform: updateAppearance)
|
||||
#endif
|
||||
.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 {
|
||||
return {
|
||||
if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
|
@ -393,3 +393,9 @@ extension ProfileViewController: StatusBarTappableViewController {
|
||||
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
currentViewController.refresh()
|
||||
}
|
||||
}
|
||||
|
@ -69,30 +69,34 @@ private struct ScrollBackgroundModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background {
|
||||
// otherwise the pureBlackDarkMode isn't propagated, for some reason?
|
||||
// even though it is for ReportSelectRulesView??
|
||||
let traits: UITraitCollection = {
|
||||
var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
|
||||
#if os(visionOS)
|
||||
t = t.modifyingTraits({ mutableTraits in
|
||||
mutableTraits.pureBlackDarkMode = true
|
||||
})
|
||||
#else
|
||||
if #available(iOS 17.0, *) {
|
||||
if #available(iOS 16.0, *) {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background {
|
||||
// otherwise the pureBlackDarkMode isn't propagated, for some reason?
|
||||
// even though it is for ReportSelectRulesView??
|
||||
let traits: UITraitCollection = {
|
||||
var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
|
||||
#if os(visionOS)
|
||||
t = t.modifyingTraits({ mutableTraits in
|
||||
mutableTraits.pureBlackDarkMode = true
|
||||
})
|
||||
} else {
|
||||
t.obsoletePureBlackDarkMode = true
|
||||
}
|
||||
#endif
|
||||
return t
|
||||
}()
|
||||
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits))
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
#else
|
||||
if #available(iOS 17.0, *) {
|
||||
t = t.modifyingTraits({ mutableTraits in
|
||||
mutableTraits.pureBlackDarkMode = true
|
||||
})
|
||||
} else {
|
||||
t.obsoletePureBlackDarkMode = true
|
||||
}
|
||||
#endif
|
||||
return t
|
||||
}()
|
||||
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits))
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,12 +49,26 @@ struct ReportSelectRulesView: View {
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground)
|
||||
.withAppBackgroundIfAvailable()
|
||||
.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 {
|
||||
// static var previews: some View {
|
||||
// ReportSelectRulesView()
|
||||
|
@ -27,11 +27,18 @@ struct ReportView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
navigationViewContent
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
if #available(iOS 16.0, *) {
|
||||
NavigationStack {
|
||||
navigationViewContent
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
NavigationView {
|
||||
navigationViewContent
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,9 @@ class MastodonSearchController: UISearchController {
|
||||
searchResultsUpdater = searchResultsController
|
||||
automaticallyShowsSearchResultsController = false
|
||||
showsSearchResultsController = true
|
||||
scopeBarActivation = .onSearchActivation
|
||||
if #available(iOS 16.0, *) {
|
||||
scopeBarActivation = .onSearchActivation
|
||||
}
|
||||
|
||||
searchBar.autocapitalizationType = .none
|
||||
searchBar.delegate = self
|
||||
@ -76,8 +78,12 @@ class MastodonSearchController: UISearchController {
|
||||
if searchText != defaultLanguage,
|
||||
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))
|
||||
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
|
||||
langSuggestions.append("language:\(identifier)")
|
||||
if #available(iOS 16.0, *) {
|
||||
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))
|
||||
|
@ -77,11 +77,25 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
guard browsingEnabled else {
|
||||
return false
|
||||
}
|
||||
return super.collectionView(collectionView, shouldSelectItemAt: indexPath)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard browsingEnabled else { return }
|
||||
super.collectionView(collectionView, didSelectItemAt: indexPath)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard browsingEnabled else {
|
||||
return nil
|
||||
}
|
||||
return super.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
// MARK: Timeline
|
||||
|
||||
override func handleLoadAllError(_ error: Swift.Error) async {
|
||||
|
@ -212,3 +212,9 @@ extension TimelinesPageViewController: StateRestorableViewController {
|
||||
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelinesPageViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
(currentViewController as? RefreshableViewController)?.refresh()
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,8 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
override var viewControllers: [UIViewController] {
|
||||
didSet {
|
||||
poppedViewControllers = []
|
||||
if useBrowserStyleNavigation {
|
||||
if #available(iOS 16.0, *),
|
||||
useBrowserStyleNavigation {
|
||||
// TODO: this for loop might not be necessary
|
||||
for vc in viewControllers {
|
||||
configureNavItem(vc.navigationItem)
|
||||
@ -39,7 +40,8 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
|
||||
#endif
|
||||
|
||||
if useBrowserStyleNavigation,
|
||||
if #available(iOS 16.0, *),
|
||||
useBrowserStyleNavigation,
|
||||
let topViewController {
|
||||
configureNavItem(topViewController.navigationItem)
|
||||
updateTopNavItemState()
|
||||
@ -50,7 +52,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
let popped = performAfterAnimating(block: {
|
||||
super.popViewController(animated: animated)
|
||||
}, after: {
|
||||
self.updateTopNavItemState()
|
||||
if #available(iOS 16.0, *) {
|
||||
self.updateTopNavItemState()
|
||||
}
|
||||
}, animated: animated)
|
||||
if let popped {
|
||||
poppedViewControllers.insert(popped, at: 0)
|
||||
@ -62,7 +66,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
let popped = performAfterAnimating(block: {
|
||||
super.popToRootViewController(animated: animated)
|
||||
}, after: {
|
||||
self.updateTopNavItemState()
|
||||
if #available(iOS 16.0, *) {
|
||||
self.updateTopNavItemState()
|
||||
}
|
||||
}, animated: animated)
|
||||
if let popped {
|
||||
poppedViewControllers = popped
|
||||
@ -74,7 +80,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
let popped = performAfterAnimating(block: {
|
||||
super.popToViewController(viewController, animated: animated)
|
||||
}, after: {
|
||||
self.updateTopNavItemState()
|
||||
if #available(iOS 16.0, *) {
|
||||
self.updateTopNavItemState()
|
||||
}
|
||||
}, animated: animated)
|
||||
if let popped {
|
||||
poppedViewControllers.insert(contentsOf: popped, at: 0)
|
||||
@ -89,11 +97,15 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
self.poppedViewControllers = []
|
||||
}
|
||||
|
||||
configureNavItem(viewController.navigationItem)
|
||||
if #available(iOS 16.0, *) {
|
||||
configureNavItem(viewController.navigationItem)
|
||||
}
|
||||
|
||||
super.pushViewController(viewController, animated: animated)
|
||||
|
||||
updateTopNavItemState()
|
||||
if #available(iOS 16.0, *) {
|
||||
updateTopNavItemState()
|
||||
}
|
||||
}
|
||||
|
||||
func pushPoppedViewController() {
|
||||
@ -123,7 +135,9 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||
pushViewController(target, animated: true)
|
||||
}, after: {
|
||||
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
|
||||
self.updateTopNavItemState()
|
||||
if #available(iOS 16.0, *) {
|
||||
self.updateTopNavItemState()
|
||||
}
|
||||
}, animated: true)
|
||||
}
|
||||
|
||||
|
@ -204,7 +204,8 @@ extension MenuActionProvider {
|
||||
}),
|
||||
]
|
||||
|
||||
if includeStatusButtonActions {
|
||||
if #available(iOS 16.0, *),
|
||||
includeStatusButtonActions {
|
||||
let favorited = status.favourited
|
||||
// TODO: move this color into an asset catalog or something
|
||||
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))
|
||||
|
||||
let toggleableAndActions = toggleableSection + actionsSection
|
||||
return [
|
||||
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
|
||||
UIMenu(options: .displayInline, children: shareSection),
|
||||
]
|
||||
if #available(iOS 16.0, *) {
|
||||
let toggleableAndActions = toggleableSection + actionsSection
|
||||
return [
|
||||
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] {
|
||||
|
@ -108,7 +108,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
@ -122,7 +123,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
||||
precondition(state > .initial)
|
||||
navigation.run()
|
||||
#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)
|
||||
}
|
||||
#endif
|
||||
|
@ -114,7 +114,8 @@ extension TuskerNavigationDelegate {
|
||||
#if os(visionOS)
|
||||
fatalError("unreachable")
|
||||
#else
|
||||
if presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
|
||||
if #available(iOS 16.0, *),
|
||||
presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
|
||||
return
|
||||
} else {
|
||||
present(compose, animated: animated, completion: completion)
|
||||
|
@ -30,7 +30,9 @@ class AccountDisplayAndUserNameLabel: EmojiLabel {
|
||||
|
||||
private func makeAttributedText(state: State) -> NSAttributedString {
|
||||
let s = NSMutableAttributedString()
|
||||
s.append(NSAttributedString(string: state.displayName, attributes: [
|
||||
// U+2068 FIRST-STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE
|
||||
// to prevent bidi text in the display name influencing the username
|
||||
s.append(NSAttributedString(string: "\u{2068}\(state.displayName)\u{2069}", attributes: [
|
||||
.font: UIFont(descriptor: baseFont.addingAttributes([
|
||||
.traits: [
|
||||
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
|
||||
|
@ -9,7 +9,6 @@
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import os
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
@ -41,7 +40,7 @@ struct AccountDisplayNameView: View {
|
||||
guard !matches.isEmpty else { return }
|
||||
|
||||
let emojiSize = self.emojiSize
|
||||
let emojiImages = OSAllocatedUnfairLock(initialState: [String: Image]())
|
||||
let emojiImages = MultiThreadDictionary<String, Image>()
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
@ -64,9 +63,7 @@ struct AccountDisplayNameView: View {
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
|
||||
emojiImages.withLock {
|
||||
$0[emoji.shortcode] = Image(uiImage: resized)
|
||||
}
|
||||
emojiImages[emoji.shortcode] = Image(uiImage: resized)
|
||||
}
|
||||
if let request = request {
|
||||
emojiRequests.append(request)
|
||||
@ -81,7 +78,7 @@ struct AccountDisplayNameView: View {
|
||||
// iterate backwards as to not alter the indices of earlier matches
|
||||
for match in matches.reversed() {
|
||||
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))
|
||||
|
||||
|
@ -263,7 +263,17 @@ class AttachmentView: GIFImageView {
|
||||
let asset = AVURLAsset(url: attachment.url)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
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(),
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
@ -402,7 +412,7 @@ class AttachmentView: GIFImageView {
|
||||
makeBadgeView(text: "ALT")
|
||||
}
|
||||
if badges.contains(.noAlt) {
|
||||
makeBadgeView(text: "No ALT")
|
||||
makeBadgeView(text: "NO ALT")
|
||||
}
|
||||
|
||||
let first = stack.arrangedSubviews.first!
|
||||
@ -467,12 +477,12 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
|
||||
return UIContextMenuConfiguration { [unowned self] () -> UIViewController? in
|
||||
if self.attachment.kind == .image,
|
||||
let image {
|
||||
return ImageGalleryContentViewController(url: self.attachment.url, caption: nil, originalData: nil, image: image, gifController: self.gifController)
|
||||
return GrayscalableImageGalleryContentViewController(url: self.attachment.url, caption: nil, originalData: nil, image: image, gifController: self.gifController)
|
||||
} else if self.attachment.kind == .gifv,
|
||||
let gifvView {
|
||||
return GifvGalleryContentViewController(controller: gifvView.controller, url: self.attachment.url, caption: nil)
|
||||
} else if self.attachment.kind == .video || self.attachment.kind == .audio {
|
||||
let vc = VideoGalleryContentViewController(url: self.attachment.url, caption: nil)
|
||||
let vc = GrayscalableVideoGalleryContentViewController(url: self.attachment.url, caption: nil)
|
||||
vc.player.isMuted = true
|
||||
return vc
|
||||
} else {
|
||||
|
@ -295,7 +295,11 @@ class AttachmentsContainerView: UIView {
|
||||
accessibilityElements.append(moreView)
|
||||
}
|
||||
|
||||
self.aspectRatio = aspectRatio
|
||||
self.aspectRatio = if aspectRatio.isNaN || aspectRatio.isInfinite {
|
||||
16/9
|
||||
} else {
|
||||
aspectRatio
|
||||
}
|
||||
} else {
|
||||
self.isHidden = true
|
||||
}
|
||||
|
@ -60,9 +60,9 @@ class GifvController {
|
||||
}
|
||||
|
||||
private func updatePresentationSizeObservation() {
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
|
||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] item, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.presentationSizeSubject.send(item.presentationSize)
|
||||
self?.presentationSizeSubject.send(item.presentationSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -9,7 +9,6 @@
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import os
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
@ -57,7 +56,7 @@ extension BaseEmojiLabel {
|
||||
return imageSizeMatchingFontSize
|
||||
}
|
||||
|
||||
let emojiImages = OSAllocatedUnfairLock(initialState: [String: UIImage]())
|
||||
let emojiImages = MultiThreadDictionary<String, UIImage>()
|
||||
var foundEmojis = false
|
||||
|
||||
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
|
||||
if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)),
|
||||
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
|
||||
// see FB12187798
|
||||
$0[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
|
||||
}
|
||||
// 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
|
||||
emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
|
||||
}
|
||||
} else {
|
||||
// otherwise, perform the network request
|
||||
@ -102,9 +99,7 @@ extension BaseEmojiLabel {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
emojiImages.withLock {
|
||||
$0[emoji.shortcode] = transformedImage
|
||||
}
|
||||
emojiImages[emoji.shortcode] = transformedImage
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
@ -146,7 +146,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||
|
||||
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
|
||||
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 {
|
||||
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.
|
||||
var rects = [CGRect]()
|
||||
if let textLayoutManager,
|
||||
if #available(iOS 16.0, *),
|
||||
let textLayoutManager,
|
||||
let contentManager = textLayoutManager.textContentManager {
|
||||
// convert from NSRange to NSTextRange
|
||||
// i have no idea under what circumstances any of these calls could fail
|
||||
|
@ -1,5 +1,5 @@
|
||||
//
|
||||
// CopyableLabel.swift
|
||||
// CopyableLable.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/4/23.
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class CopyableLabel: UILabel {
|
||||
class CopyableLable: UILabel {
|
||||
|
||||
private var _editMenuInteraction: Any!
|
||||
@available(iOS 16.0, *)
|
||||
@ -28,10 +28,12 @@ class CopyableLabel: UILabel {
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
editMenuInteraction = UIEditMenuInteraction(delegate: nil)
|
||||
addInteraction(editMenuInteraction)
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
|
||||
if #available(iOS 16.0, *) {
|
||||
editMenuInteraction = UIEditMenuInteraction(delegate: nil)
|
||||
addInteraction(editMenuInteraction)
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
|
||||
}
|
||||
}
|
||||
|
||||
override func copy(_ sender: Any?) {
|
@ -16,8 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
|
||||
@IBOutlet weak var adultLabel: UILabel!
|
||||
@IBOutlet weak var descriptionTextView: ContentTextView!
|
||||
|
||||
var instance: InstanceV1?
|
||||
var selectorInstance: InstanceSelector.Instance?
|
||||
private var instance: InstanceSelectorTableViewController.Info?
|
||||
|
||||
private var thumbnailTask: Task<Void, Never>?
|
||||
|
||||
@ -44,25 +43,14 @@ class InstanceTableViewCell: UITableViewCell {
|
||||
backgroundConfiguration = .appListGroupedCell(for: state)
|
||||
}
|
||||
|
||||
func updateUI(instance: InstanceSelector.Instance) {
|
||||
self.selectorInstance = instance
|
||||
self.instance = nil
|
||||
|
||||
domainLabel.text = instance.domain
|
||||
adultLabel.isHidden = instance.category != "adult"
|
||||
descriptionTextView.setBodyTextFromHTML(instance.description)
|
||||
updateThumbnail(url: instance.proxiedThumbnailURL)
|
||||
}
|
||||
|
||||
func updateUI(instance: InstanceV1) {
|
||||
self.instance = instance
|
||||
self.selectorInstance = nil
|
||||
func updateUI(info: InstanceSelectorTableViewController.Info) {
|
||||
self.instance = info
|
||||
|
||||
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
|
||||
adultLabel.isHidden = true
|
||||
descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description)
|
||||
domainLabel.text = info.host
|
||||
adultLabel.isHidden = !info.adult
|
||||
descriptionTextView.setBodyTextFromHTML(info.description)
|
||||
|
||||
if let thumbnail = instance.thumbnail {
|
||||
if let thumbnail = info.thumbnail {
|
||||
updateThumbnail(url: thumbnail)
|
||||
} else {
|
||||
thumbnailImageView.image = nil
|
||||
@ -85,7 +73,6 @@ class InstanceTableViewCell: UITableViewCell {
|
||||
|
||||
thumbnailTask?.cancel()
|
||||
instance = nil
|
||||
selectorInstance = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user