Compare commits

..

No commits in common. "develop" and "release" have entirely different histories.

128 changed files with 1360 additions and 1293 deletions

View File

@ -1,28 +1,5 @@
# Changelog # Changelog
## 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) ## 2024.4 (136)
Features/Improvements: Features/Improvements:
- Import image description when adding attachments from Photos if possible - Import image description when adding attachments from Photos if possible

View File

@ -14,6 +14,7 @@ import OSLog
import Pachyderm import Pachyderm
import Intents import Intents
import HTMLStreamer import HTMLStreamer
import WebURL
import UIKit import UIKit
import TuskerPreferences import TuskerPreferences
@ -237,7 +238,8 @@ class NotificationService: UNNotificationServiceExtension {
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() { for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
let emojiName = (content.body as NSString).substring(with: match.range(at: 1)) let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }), guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
let (data, _) = try? await URLSession.shared.data(from: emoji.url), let url = URL(emoji.url),
let (data, _) = try? await URLSession.shared.data(from: url),
let image = UIImage(data: data) else { let image = UIImage(data: data) else {
continue continue
} }
@ -366,7 +368,18 @@ private func decodeBase64URL(_ s: String) -> Data? {
// copied from HTMLConverter.Callbacks, blergh // copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks { private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? { static func makeURL(string: String) -> URL? {
try? URL.ParseStrategy().parse(string) // 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 #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 {
URL(string: string)
}
} }
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ComposeUI", name: "ComposeUI",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -26,15 +26,9 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "ComposeUI", name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"], dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "ComposeUITests", name: "ComposeUITests",
dependencies: ["ComposeUI"], dependencies: ["ComposeUI"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
Button(role: .destructive, action: controller.removeAttachment) { Button(role: .destructive, action: controller.removeAttachment) {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
} preview: { } previewIfAvailable: {
ControllerView(controller: { controller.thumbnailController }) ControllerView(controller: { controller.thumbnailController })
} }
@ -221,3 +221,16 @@ extension AttachmentRowController {
case allowEntry, recognizingText 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)
}
}
}

View File

@ -214,6 +214,44 @@ fileprivate extension View {
self self
} }
} }
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
@available(iOS 16.0, *)
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
} }
@available(visionOS 1.0, *) @available(visionOS 1.0, *)

View File

@ -125,7 +125,9 @@ public final class ComposeController: ViewController {
self.toolbarController = ToolbarController(parent: self) self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self) self.attachmentsListController = AttachmentsListController(parent: self)
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
}
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
} }
@ -322,6 +324,10 @@ public final class ComposeController: ViewController {
ControllerView(controller: { controller.toolbarController }) ControllerView(controller: { controller.toolbarController })
#endif #endif
} }
#if !os(visionOS)
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
#endif
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
} }
} }
@ -430,7 +436,7 @@ public final class ComposeController: ViewController {
} }
.listStyle(.plain) .listStyle(.plain)
#if !os(visionOS) #if !os(visionOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboardInteractivelyIfAvailable()
#endif #endif
.disabled(controller.isPosting) .disabled(controller.isPosting)
} }
@ -481,6 +487,31 @@ public final class ComposeController: ViewController {
.keyboardShortcut(.return, modifiers: .command) .keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled) .disabled(!controller.postButtonEnabled)
} }
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return ToolbarController.height
} else {
return 0
}
}
#endif
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
} }
} }

View File

@ -51,11 +51,14 @@ class FocusedAttachmentController: ViewController {
.onAppear { .onAppear {
player.play() player.play()
} }
} else { } else if #available(iOS 16.0, *) {
ZoomableScrollView { ZoomableScrollView {
attachmentView attachmentView
.matchedGeometryDestination(id: attachment.id) .matchedGeometryDestination(id: attachment.id)
} }
} else {
attachmentView
.matchedGeometryDestination(id: attachment.id)
} }
Spacer(minLength: 0) Spacer(minLength: 0)

View File

@ -96,7 +96,7 @@ class PollController: ViewController {
.onMove(perform: controller.moveOptions) .onMove(perform: controller.moveOptions)
} }
.listStyle(.plain) .listStyle(.plain)
.scrollDisabled(true) .scrollDisabledIfAvailable(true)
.frame(height: 44 * CGFloat(poll.options.count)) .frame(height: 44 * CGFloat(poll.options.count))
Button(action: controller.addOption) { Button(action: controller.addOption) {

View File

@ -66,7 +66,7 @@ class ToolbarController: ViewController {
} }
}) })
} }
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0) .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height) .frame(height: ToolbarController.height)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing]) .background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
@ -122,7 +122,8 @@ class ToolbarController: ViewController {
Spacer() Spacer()
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage { if #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection) LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
} }
} }

View File

@ -10,6 +10,7 @@
import UIKit import UIKit
import Combine import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject { class KeyboardReader: ObservableObject {
// @Published var isVisible = false // @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0 @Published var keyboardHeight: CGFloat = 0

View File

@ -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
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Duckable", name: "Duckable",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,10 +23,7 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "Duckable", name: "Duckable",
dependencies: [], dependencies: []),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "DuckableTests", // name: "DuckableTests",
// dependencies: ["Duckable"]), // dependencies: ["Duckable"]),

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "GalleryVC", name: "GalleryVC",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -14,23 +14,13 @@ let package = Package(
name: "GalleryVC", name: "GalleryVC",
targets: ["GalleryVC"]), targets: ["GalleryVC"]),
], ],
dependencies: [
.package(path: "../TuskerComponents"),
],
targets: [ targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite. // 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. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "GalleryVC", name: "GalleryVC"),
dependencies: ["TuskerComponents"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "GalleryVCTests", name: "GalleryVCTests",
dependencies: ["GalleryVC"], dependencies: ["GalleryVC"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -15,7 +15,7 @@ public protocol GalleryContentViewController: UIViewController {
var caption: String? { get } var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get } var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get } var bottomControlsAccessoryViewController: UIViewController? { get }
var presentationAnimation: GalleryContentPresentationAnimation { get } var canAnimateFromSourceView: Bool { get }
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func galleryContentDidAppear() func galleryContentDidAppear()
@ -31,8 +31,8 @@ public extension GalleryContentViewController {
nil nil
} }
var presentationAnimation: GalleryContentPresentationAnimation { var canAnimateFromSourceView: Bool {
.fromSourceView true
} }
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
@ -44,9 +44,3 @@ public extension GalleryContentViewController {
func galleryContentWillDisappear() { func galleryContentWillDisappear() {
} }
} }
public enum GalleryContentPresentationAnimation {
case fade
case fromSourceView
case fromSourceViewWithoutSnapshot
}

View File

@ -30,37 +30,12 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
let itemViewController = from.currentItemViewController let itemViewController = from.currentItemViewController
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) { if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext) animateCrossFadeTransition(using: transitionContext)
return return
} }
let container = transitionContext.containerView 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 sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view) let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
@ -73,39 +48,38 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY) .translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale) .scaledBy(x: scale, y: scale)
sourceView.transform = sourceToDestTransform sourceView.transform = sourceToDestTransform
sourceSnapshot?.transform = sourceToDestTransform
} else { } else {
appliedSourceToDestTransform = false 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 from.view.frame = container.bounds
container.addSubview(from.view) container.addSubview(from.view)
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
contentContainer.frame = destFrameInContainer
container.addSubview(contentContainer)
let content = itemViewController.takeContent() let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.transform = .identity content.view.layer.masksToBounds = true
container.addSubview(content.view)
content.view.frame = destFrameInContainer
content.view.layer.opacity = 1 content.view.layer.opacity = 1
content.view.frame = contentContainer.bounds
contentContainer.addSubview(content.view)
container.layoutIfNeeded() 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) let duration = self.transitionDuration(using: transitionContext)
var initialVelocity: CGVector var initialVelocity: CGVector
if let interactiveVelocity, if let interactiveVelocity,
let interactiveTranslation, let interactiveTranslation,
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot // very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100, sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 { sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
@ -128,34 +102,14 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
if appliedSourceToDestTransform { if appliedSourceToDestTransform {
self.sourceView.transform = origSourceTransform self.sourceView.transform = origSourceTransform
sourceSnapshot?.transform = origSourceTransform
} }
content.view.frame = sourceFrameInContainer
contentContainer.frame = sourceFrameInContainer content.view.layer.opacity = 0
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
// I guess autoresizing takes care of it?
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false) 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 animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
// Having dismissed, we don't need to undo any of the changes to the content VC.
transitionContext.completeTransition(true) transitionContext.completeTransition(true)
} }

View File

@ -20,8 +20,6 @@ class GalleryDismissInteraction: NSObject {
private(set) var dismissVelocity: CGPoint? private(set) var dismissVelocity: CGPoint?
private(set) var dismissTranslation: CGPoint? private(set) var dismissTranslation: CGPoint?
private var cancelAnimator: UIViewPropertyAnimator?
init(viewController: GalleryViewController) { init(viewController: GalleryViewController) {
self.viewController = viewController self.viewController = viewController
super.init() super.init()
@ -40,8 +38,6 @@ class GalleryDismissInteraction: NSObject {
content = viewController.currentItemViewController.takeContent() content = viewController.currentItemViewController.takeContent()
content!.view.translatesAutoresizingMaskIntoConstraints = true content!.view.translatesAutoresizingMaskIntoConstraints = true
content!.view.frame = origContentFrameInGallery! content!.view.frame = origContentFrameInGallery!
// Make sure the context remains behind the controls
content!.view.layer.zPosition = -1000
viewController.view.addSubview(content!.view) viewController.view.addSubview(content!.view)
origControlsVisible = viewController.currentItemViewController.controlsVisible origControlsVisible = viewController.currentItemViewController.controlsVisible
@ -57,42 +53,12 @@ class GalleryDismissInteraction: NSObject {
let translation = recognizer.translation(in: viewController.view) let translation = recognizer.translation(in: viewController.view)
let velocity = recognizer.velocity(in: viewController.view) let velocity = recognizer.velocity(in: viewController.view)
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared) dismissVelocity = velocity
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared) dismissTranslation = translation
viewController.dismiss(animated: true)
if translationMagnitude < 150 && velocityMagnitude < 500 { // don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false 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()
} 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: default:
break break

View File

@ -69,10 +69,6 @@ class GalleryItemViewController: UIViewController {
scrollView = UIScrollView() scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self 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) view.addSubview(scrollView)

View File

@ -25,31 +25,11 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
let itemViewController = to.currentItemViewController let itemViewController = to.currentItemViewController
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions { if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext) animateCrossFadeTransition(using: transitionContext)
return 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 let container = transitionContext.containerView
to.view.frame = container.bounds to.view.frame = container.bounds
container.addSubview(to.view) container.addSubview(to.view)
@ -76,70 +56,21 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
sourceToDestTransform = nil 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() let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.transform = .identity container.insertSubview(content.view, belowSubview: to.view)
// 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. // Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
let dimmingView = UIView() let dimmingView = UIView()
dimmingView.backgroundColor = .black dimmingView.backgroundColor = .black
dimmingView.frame = container.bounds dimmingView.frame = container.bounds
dimmingView.layer.opacity = 0 dimmingView.layer.opacity = 0
container.insertSubview(dimmingView, belowSubview: contentContainer) container.insertSubview(dimmingView, belowSubview: content.view)
to.view.backgroundColor = nil to.view.backgroundColor = nil
to.view.layer.opacity = 0 to.view.layer.opacity = 0
content.view.frame = sourceFrameInContainer
contentContainer.frame = sourceFrameInContainer content.view.layer.opacity = 0
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() container.layoutIfNeeded()
@ -147,14 +78,8 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false) itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
let duration = self.transitionDuration(using: transitionContext) let duration = self.transitionDuration(using: transitionContext)
// less bounce on bigger screens // rougly equivalent to duration: 0.35, bounce: 0.3
let spring = if UIDevice.current.userInterfaceIdiom == .pad { let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
// 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) let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations { animator.addAnimations {
@ -162,34 +87,24 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
to.view.layer.opacity = 1 to.view.layer.opacity = 1
contentContainer.frame = destFrameInContainer content.view.frame = destFrameInContainer
content.view.frame = contentContainer.bounds
content.view.layer.opacity = 1 content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false) itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform { if let sourceToDestTransform {
sourceSnapshot?.transform = sourceToDestTransform
self.sourceView.transform = sourceToDestTransform self.sourceView.transform = sourceToDestTransform
} }
} }
animator.addCompletion { _ in animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
self.sourceView.layer.opacity = 1
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
contentContainer.removeFromSuperview()
dimmingView.removeFromSuperview() dimmingView.removeFromSuperview()
to.view.backgroundColor = .black to.view.backgroundColor = .black
// Reset the properties we changed before re-adding the content to the scroll view. if sourceToDestTransform != nil {
// (I would expect UIScrollView to effectively do this itself, but w/e.) self.sourceView.transform = origSourceTransform
content.view.transform = origContentTransform }
content.view.frame = origContentFrame
itemViewController.addContent() itemViewController.addContent()

View File

@ -1,26 +0,0 @@
//
// 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
}
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "InstanceFeatures", name: "InstanceFeatures",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,15 +23,9 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "InstanceFeatures", name: "InstanceFeatures",
dependencies: ["Pachyderm"], dependencies: ["Pachyderm"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "InstanceFeaturesTests", name: "InstanceFeaturesTests",
dependencies: ["InstanceFeatures"], dependencies: ["InstanceFeatures"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "MatchedGeometryPresentation", name: "MatchedGeometryPresentation",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -18,10 +18,7 @@ let package = Package(
// Targets are the basic building blocks of a package, defining a module or a test suite. // 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. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "MatchedGeometryPresentation", name: "MatchedGeometryPresentation"),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "MatchedGeometryPresentationTests", // name: "MatchedGeometryPresentationTests",
// dependencies: ["MatchedGeometryPresentation"]), // dependencies: ["MatchedGeometryPresentation"]),

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,8 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Pachyderm", name: "Pachyderm",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
.macOS(.v13),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -17,6 +16,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// Dependencies declare other packages that this package depends on. // Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -24,15 +24,11 @@ let package = Package(
.target( .target(
name: "Pachyderm", name: "Pachyderm",
dependencies: [ dependencies: [
], .product(name: "WebURL", package: "swift-url"),
swiftSettings: [ .product(name: "WebURLFoundationExtras", package: "swift-url"),
.swiftLanguageMode(.v5)
]), ]),
.testTarget( .testTarget(
name: "PachydermTests", name: "PachydermTests",
dependencies: ["Pachyderm"], dependencies: ["Pachyderm"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -24,30 +25,27 @@ public struct Client: Sendable {
public var timeoutInterval: TimeInterval = 60 public var timeoutInterval: TimeInterval = 60
private static let dateFormatter: DateFormatter = { static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter let iso8601 = ISO8601DateFormatter()
}()
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 decoder.dateDecodingStrategy = .custom({ (decoder) in
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
let str = try container.decode(String.self) let str = try container.decode(String.self)
if let date = Self.decodeDate(string: str) { // 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) {
return date return date
} else { } else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)")) throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
} }
}) })
return decoder return decoder
}() }()
@ -107,15 +105,6 @@ public struct Client: Sendable {
return task 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 @discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) { public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
@ -201,8 +190,8 @@ public struct Client: Sendable {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo") let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
let wellKnownResults = try await run(wellKnown).0 let wellKnownResults = try await run(wellKnown).0
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = try? URL.ParseStrategy().parse(url.href), let href = WebURL(url.href),
href.host == self.baseURL.host() { href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0 return try await run(nodeInfo).0
} else { } else {
@ -586,8 +575,6 @@ extension Client {
return "Invalid Model" return "Invalid Model"
case .mastodonError(let code, let error): case .mastodonError(let code, let error):
return "Server Error (\(code)): \(error)" return "Server Error (\(code)): \(error)"
case .rateLimited(let reset):
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
} }
} }
} }
@ -598,7 +585,6 @@ extension Client {
case invalidResponse case invalidResponse
case invalidModel(Swift.Error) case invalidModel(Swift.Error)
case mastodonError(Int, String) case mastodonError(Int, String)
case rateLimited(Date)
} }
enum NodeInfoError: LocalizedError { enum NodeInfoError: LocalizedError {

View File

@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Announcement: Decodable, Sendable, Hashable, Identifiable { public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
public let id: String public let id: String
@ -59,7 +60,7 @@ extension Announcement {
public struct Account: Decodable, Sendable, Hashable { public struct Account: Decodable, Sendable, Hashable {
public let id: String public let id: String
public let username: String public let username: String
@URLDecoder public var url: URL public let url: WebURL
public let acct: String public let acct: String
} }
} }
@ -67,7 +68,7 @@ extension Announcement {
extension Announcement { extension Announcement {
public struct Status: Decodable, Sendable, Hashable { public struct Status: Decodable, Sendable, Hashable {
public let id: String public let id: String
@URLDecoder public var url: URL public let url: WebURL
} }
} }

View File

@ -7,17 +7,18 @@
// //
import Foundation import Foundation
import WebURL
public struct Card: Codable, Sendable { public struct Card: Codable, Sendable {
@URLDecoder public var url: URL public let url: WebURL
public let title: String public let title: String
public let description: String public let description: String
@OptionalURLDecoder public var image: URL? public let image: WebURL?
public let kind: Kind public let kind: Kind
public let authorName: String? public let authorName: String?
@OptionalURLDecoder public var authorURL: URL? public let authorURL: WebURL?
public let providerName: String? public let providerName: String?
@OptionalURLDecoder public var providerURL: URL? public let providerURL: WebURL?
public let html: String? public let html: String?
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
@ -26,15 +27,15 @@ public struct Card: Codable, Sendable {
public let history: [History]? public let history: [History]?
public init( public init(
url: URL, url: WebURL,
title: String, title: String,
description: String, description: String,
image: URL? = nil, image: WebURL? = nil,
kind: Card.Kind, kind: Card.Kind,
authorName: String? = nil, authorName: String? = nil,
authorURL: URL? = nil, authorURL: WebURL? = nil,
providerName: String? = nil, providerName: String? = nil,
providerURL: URL? = nil, providerURL: WebURL? = nil,
html: String? = nil, html: String? = nil,
width: Int? = nil, width: Int? = nil,
height: Int? = nil, height: Int? = nil,
@ -60,15 +61,15 @@ public struct Card: Codable, Sendable {
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self._url = try container.decode(URLDecoder.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
self.title = try container.decode(String.self, forKey: .title) self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description) self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind) self.kind = try container.decode(Kind.self, forKey: .kind)
self._image = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .image) ?? nil self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image)
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName) self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
self._authorURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .authorURL) ?? nil self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL)
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName) self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
self._providerURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .providerURL) ?? nil self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL)
self.html = try? container.decodeIfPresent(String.self, forKey: .html) self.html = try? container.decodeIfPresent(String.self, forKey: .html)
self.width = try? container.decodeIfPresent(Int.self, forKey: .width) self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
self.height = try? container.decodeIfPresent(Int.self, forKey: .height) self.height = try? container.decodeIfPresent(Int.self, forKey: .height)

View File

@ -7,11 +7,14 @@
// //
import Foundation import Foundation
import WebURL
public struct Emoji: Codable, Sendable { public struct Emoji: Codable, Sendable {
public let shortcode: String public let shortcode: String
@URLDecoder public var url: URL // these shouldn't need to be WebURLs as they're not external resources,
@URLDecoder public var staticURL: URL // but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
public let url: WebURL
public let staticURL: WebURL
public let visibleInPicker: Bool public let visibleInPicker: Bool
public let category: String? public let category: String?
@ -19,8 +22,8 @@ public struct Emoji: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode) self.shortcode = try container.decode(String.self, forKey: .shortcode)
self._url = try container.decode(URLDecoder.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL) self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker) self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category) self.category = try container.decodeIfPresent(String.self, forKey: .category)
} }

View File

@ -7,10 +7,12 @@
// //
import Foundation import Foundation
import WebURL
import WebURLFoundationExtras
public struct Hashtag: Codable, Sendable { public struct Hashtag: Codable, Sendable {
public let name: String public let name: String
@URLDecoder public var url: URL public let url: WebURL
/// Only present when returned from the trending hashtags endpoint /// Only present when returned from the trending hashtags endpoint
public let history: [History]? public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in /// Only present on Mastodon >= 4 and when logged in
@ -18,7 +20,7 @@ public struct Hashtag: Codable, Sendable {
public init(name: String, url: URL) { public init(name: String, url: URL) {
self.name = name self.name = name
self.url = url self.url = WebURL(url)!
self.history = nil self.history = nil
self.following = nil self.following = nil
} }
@ -27,7 +29,7 @@ public struct Hashtag: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name) self.name = try container.decode(String.self, forKey: .name)
// pixelfed (possibly others) don't fully escape special characters in the hashtag url // pixelfed (possibly others) don't fully escape special characters in the hashtag url
self._url = try container.decode(URLDecoder.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history) self.history = try container.decodeIfPresent([History].self, forKey: .history)
self.following = try container.decodeIfPresent(Bool.self, forKey: .following) self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
} }

View File

@ -7,9 +7,10 @@
// //
import Foundation import Foundation
import WebURL
public struct Mention: Codable, Sendable { public struct Mention: Codable, Sendable {
@URLDecoder public var url: URL public let url: WebURL
public let username: String public let username: String
public let acct: String public let acct: String
/// The instance-local ID of the user being mentioned. /// The instance-local ID of the user being mentioned.
@ -20,10 +21,15 @@ public struct Mention: Codable, Sendable {
self.username = try container.decode(String.self, forKey: .username) self.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct) self.acct = try container.decode(String.self, forKey: .acct)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self._url = try container.decode(URLDecoder.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.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'")
}
} }
public init(url: URL, username: String, acct: String, id: String) { public init(url: WebURL, username: String, acct: String, id: String) {
self.url = url self.url = url
self.username = username self.username = username
self.acct = acct self.acct = acct

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Notification: Decodable, Sendable { public struct Notification: Decodable, Sendable {
public let id: String public let id: String
@ -17,7 +18,7 @@ public struct Notification: Decodable, Sendable {
// Only present for pleroma emoji reactions // Only present for pleroma emoji reactions
// Either an emoji or :shortcode: (for akkoma custom emoji reactions) // Either an emoji or :shortcode: (for akkoma custom emoji reactions)
public let emoji: String? public let emoji: String?
@OptionalURLDecoder public var emojiURL: URL? public let emojiURL: WebURL?
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
@ -32,7 +33,7 @@ public struct Notification: Decodable, Sendable {
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status) self.status = try container.decodeIfPresent(Status.self, forKey: .status)
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji) self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
self._emojiURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .emojiURL) ?? nil self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
} }
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {

View File

@ -6,13 +6,14 @@
// //
import Foundation import Foundation
import WebURL
public struct PushNotification: Decodable { public struct PushNotification: Decodable {
public let accessToken: String public let accessToken: String
public let preferredLocale: String public let preferredLocale: String
public let notificationID: String public let notificationID: String
public let notificationType: Notification.Kind public let notificationType: Notification.Kind
@URLDecoder public var icon: URL public let icon: WebURL
public let title: String public let title: String
public let body: String public let body: String
@ -28,7 +29,7 @@ public struct PushNotification: Decodable {
self.notificationID = i.description self.notificationID = i.description
} }
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType) self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
self._icon = try container.decode(URLDecoder.self, forKey: .icon) self.icon = try container.decode(WebURL.self, forKey: .icon)
self.title = try container.decode(String.self, forKey: .title) self.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body) self.body = try container.decode(String.self, forKey: .body)
} }

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable { public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts. /// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
@ -14,8 +15,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let id: String public let id: String
public let uri: String public let uri: String
private let _url: OptionalURLDecoder public let url: WebURL?
public var url: URL? { _url.wrappedValue }
public let account: Account public let account: Account
public let inReplyToID: String? public let inReplyToID: String?
public let inReplyToAccountID: String? public let inReplyToAccountID: String?
@ -55,13 +55,13 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri) self.uri = try container.decode(String.self, forKey: .uri)
do { do {
self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
} catch { } catch {
let s = try? container.decode(String.self, forKey: .url) let s = try? container.decode(String.self, forKey: .url)
if s == "" { if s == "" {
self._url = OptionalURLDecoder(wrappedValue: nil) self.url = nil
} else { } else {
throw error throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
} }
} }
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
@ -149,7 +150,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
case poll case poll
case update case update
case status case status
case emojiReaction(String, URL?) case emojiReaction(String, WebURL?)
case unknown case unknown
var notificationKind: Notification.Kind { var notificationKind: Notification.Kind {

View File

@ -1,86 +0,0 @@
//
// URLDecoder.swift
// Pachyderm
//
// Created by Shadowfacts on 12/15/24.
//
import Foundation
private let parseStrategy = URL.ParseStrategy()
.scheme(.required)
.user(.optional)
.password(.optional)
.host(.required)
.port(.optional)
.path(.optional)
.query(.optional)
.fragment(.optional)
private let formatStyle = URL.FormatStyle()
.scheme(.always)
.user(.omitWhen(.user, matches: [""]))
.password(.omitWhen(.password, matches: [""]))
.host(.always)
.port(.omitIfHTTPFamily)
.path(.always)
.query(.omitWhen(.query, matches: [""]))
.fragment(.omitWhen(.fragment, matches: [""]))
@propertyWrapper
public struct URLDecoder: Codable, Sendable, Hashable {
public var wrappedValue: URL
public init(wrappedValue: URL) {
self.wrappedValue = wrappedValue
}
public init(from decoder: any Decoder) throws {
let s = try decoder.singleValueContainer().decode(String.self)
self.wrappedValue = try parseStrategy.parse(s)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(wrappedValue.formatted(formatStyle))
}
}
@propertyWrapper
public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilLiteral {
public var wrappedValue: URL?
public init(wrappedValue: URL?) {
self.wrappedValue = wrappedValue
}
public init(nilLiteral: ()) {
self.wrappedValue = nil
}
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self.wrappedValue = nil
} else {
let s = try container.decode(String.self)
if s.isEmpty {
self.wrappedValue = nil
} else {
do {
self.wrappedValue = try parseStrategy.parse(s)
} catch {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error))
}
}
}
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
if let wrappedValue {
try container.encode(wrappedValue.formatted(formatStyle))
} else {
try container.encodeNil()
}
}
}

View File

@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
func testGroupSimple() { func testGroupSimple() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite]) let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!]) XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
} }
func testGroupWithOtherGroupableInBetween() { func testGroupWithOtherGroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite]) let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [ XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB], kind: .favourite)!, NotificationGroup(notifications: [likeB])!,
]) ])
} }
func testDontGroupWithUngroupableInBetween() { func testDontGroupWithUngroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite]) let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [ XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1], kind: .favourite)!, NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB], kind: .mention)!, NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!, NotificationGroup(notifications: [likeA2])!,
]) ])
} }
func testMergeSimpleGroups() { func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let group2 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite]) let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
XCTAssertEqual(merged, [ XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)! NotificationGroup(notifications: [likeA1, likeA2])!
]) ])
} }
func testMergeGroupsWithOtherGroupableInBetween() { func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)! let group2 = NotificationGroup(notifications: [likeB])!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite]) let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [ XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB], kind: .favourite)!, NotificationGroup(notifications: [likeB])!,
]) ])
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite]) let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
XCTAssertEqual(merged2, [ XCTAssertEqual(merged2, [
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB], kind: .favourite)!, NotificationGroup(notifications: [likeB])!,
]) ])
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)! let group4 = NotificationGroup(notifications: [likeB2])!
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)! let group5 = NotificationGroup(notifications: [mentionB])!
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite]) let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
print(merged3.count) print(merged3.count)
XCTAssertEqual(merged3, [ XCTAssertEqual(merged3, [
group1, group1,
group5, group5,
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite), NotificationGroup(notifications: [likeB, likeB2]),
group3 group3
]) ])
} }
func testDontMergeWithUngroupableInBetween() { func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)! let group2 = NotificationGroup(notifications: [mentionB])!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite]) let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [ XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1], kind: .favourite)!, NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB], kind: .mention)!, NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!, NotificationGroup(notifications: [likeA2])!,
]) ])
} }

View File

@ -6,21 +6,20 @@
// //
import XCTest import XCTest
@testable import Pachyderm import WebURL
import WebURLFoundationExtras
class URLTests: XCTestCase { class URLTests: XCTestCase {
func testDecodeURL() { func testDecodeURL() {
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen"))
} XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é"))
func testRoundtripURL() throws { if #available(iOS 16.0, *) {
let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!) XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
let encoded = try JSONEncoder().encode(orig) XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
print(String(data: encoded, encoding: .utf8)!) }
let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded)
XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue)
} }
} }

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "PushNotifications", name: "PushNotifications",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -23,17 +23,10 @@ let package = Package(
// Targets can depend on other targets in this package and products from dependencies. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "PushNotifications", name: "PushNotifications",
dependencies: ["UserAccounts", "Pachyderm"], dependencies: ["UserAccounts", "Pachyderm"]
swiftSettings: [
.swiftLanguageMode(.v5)
]
), ),
.testTarget( .testTarget(
name: "PushNotificationsTests", name: "PushNotificationsTests",
dependencies: ["PushNotifications"], dependencies: ["PushNotifications"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]
),
] ]
) )

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TTTKit", name: "TTTKit",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,15 +23,9 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "TTTKit", name: "TTTKit",
dependencies: [], dependencies: []),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "TTTKitTests", name: "TTTKitTests",
dependencies: ["TTTKit"], dependencies: ["TTTKit"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TuskerComponents", name: "TuskerComponents",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,10 +23,7 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "TuskerComponents", name: "TuskerComponents",
dependencies: [], dependencies: []),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "TuskerComponentsTests", // name: "TuskerComponentsTests",
// dependencies: ["TuskerComponents"]), // dependencies: ["TuskerComponents"]),

View File

@ -9,14 +9,21 @@ import SwiftUI
public struct AsyncPicker<V: Hashable, Content: View>: View { public struct AsyncPicker<V: Hashable, Content: View>: View {
let titleKey: LocalizedStringKey let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
let alignment: Alignment let alignment: Alignment
@Binding var value: V @Binding var value: V
let onChange: (V) async -> Bool let onChange: (V) async -> Bool
let content: Content let content: Content
@State private var isLoading = false @State private var isLoading = false
public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) { public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
self.titleKey = titleKey self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self.alignment = alignment self.alignment = alignment
self._value = value self._value = value
self.onChange = onChange self.onChange = onChange
@ -24,9 +31,25 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
} }
public var body: some View { public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) { LabeledContent(titleKey) {
picker picker
} }
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
picker
}
} else if labelHidden {
picker
} else {
HStack {
Text(titleKey)
Spacer()
picker
}
}
#endif
} }
private var picker: some View { private var picker: some View {

View File

@ -10,19 +10,42 @@ import SwiftUI
public struct AsyncToggle: View { public struct AsyncToggle: View {
let titleKey: LocalizedStringKey let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
@Binding var mode: Mode @Binding var mode: Mode
let onChange: (Bool) async -> Bool let onChange: (Bool) async -> Bool
public init(_ titleKey: LocalizedStringKey, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) { public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
self.titleKey = titleKey self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self._mode = mode self._mode = mode
self.onChange = onChange self.onChange = onChange
} }
public var body: some View { public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) { LabeledContent(titleKey) {
toggleOrSpinner toggleOrSpinner
} }
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
toggleOrSpinner
}
} else if labelHidden {
toggleOrSpinner
} else {
HStack {
Text(titleKey)
Spacer()
toggleOrSpinner
}
}
#endif
} }
@ViewBuilder @ViewBuilder

View File

@ -47,7 +47,9 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
private func makeConfiguration() -> UIButton.Configuration { private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless() var config = UIButton.Configuration.borderless()
config.indicator = .popup if #available(iOS 16.0, *) {
config.indicator = .popup
}
if buttonStyle.hasIcon { if buttonStyle.hasIcon {
config.image = selectedOption.image config.image = selectedOption.image
} }

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TuskerPreferences", name: "TuskerPreferences",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -22,17 +22,11 @@ let package = Package(
// Targets can depend on other targets in this package and products from dependencies. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "TuskerPreferences", name: "TuskerPreferences",
dependencies: ["Pachyderm"], dependencies: ["Pachyderm"]
swiftSettings: [
.swiftLanguageMode(.v5)
]
), ),
.testTarget( .testTarget(
name: "TuskerPreferencesTests", name: "TuskerPreferencesTests",
dependencies: ["TuskerPreferences"], dependencies: ["TuskerPreferences"]
swiftSettings: [
.swiftLanguageMode(.v5)
]
) )
] ]
) )

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "UserAccounts", name: "UserAccounts",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,10 +23,7 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "UserAccounts", name: "UserAccounts",
dependencies: ["Pachyderm"], dependencies: ["Pachyderm"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "UserAccountsTests", // name: "UserAccountsTests",
// dependencies: ["UserAccounts"]), // dependencies: ["UserAccounts"]),

View File

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
import ComposeUI import ComposeUI
import TuskerComponents import TuskerComponents
import WebURLFoundationExtras
import Combine import Combine
import TuskerPreferences import TuskerPreferences
@ -45,7 +46,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") }, replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
emojiImageView: { emojiImageView: {
AnyView(AsyncImage(url: $0.url) { AnyView(AsyncImage(url: URL($0.url)!) {
$0 $0
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)

View File

@ -103,6 +103,7 @@
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; }; D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; }; D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; }; D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; };
D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; };
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
@ -140,6 +141,7 @@
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; }; D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; }; D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; }; D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
@ -163,6 +165,7 @@
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
@ -201,16 +204,20 @@
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; }; D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; }; D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.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 */; }; D69261272BB3BA610023152C /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261262BB3BA610023152C /* Box.swift */; };
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; }; D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; };
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; }; D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; };
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */; }; D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; };
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */; };
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; }; D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; };
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; }; D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; };
D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; }; D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; };
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */; }; D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */; };
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; };
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; }; D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; };
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.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 */; }; D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; }; D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
@ -330,7 +337,7 @@
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; }; D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
@ -565,6 +572,7 @@
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; }; D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; }; D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
@ -631,15 +639,19 @@
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableImageGalleryContentViewController.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>"; };
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.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>"; }; 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>"; }; D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = "<group>"; };
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableVideoGalleryContentViewController.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>"; };
D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.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>"; }; 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>"; }; 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>"; }; 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>"; }; D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
@ -766,7 +778,7 @@
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; }; D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = "<group>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
@ -816,6 +828,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D630C4252BC7845800208903 /* WebURL in Frameworks */,
D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */, D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */,
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */, D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */, D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
@ -852,6 +865,7 @@
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */, D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
); );
@ -887,9 +901,13 @@
children = ( children = (
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */, D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */,
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */, D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */,
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */, D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */,
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */,
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */, D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */, D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */,
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */,
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */,
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */,
); );
path = Gallery; path = Gallery;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1472,7 +1490,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLabel.swift */, D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1609,6 +1627,7 @@
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */, D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
@ -1777,6 +1796,7 @@
D630C3E02BC61C6700208903 /* UserAccounts */, D630C3E02BC61C6700208903 /* UserAccounts */,
D630C3E42BC6313400208903 /* Pachyderm */, D630C3E42BC6313400208903 /* Pachyderm */,
D630C4222BC7842C00208903 /* HTMLStreamer */, D630C4222BC7842C00208903 /* HTMLStreamer */,
D630C4242BC7845800208903 /* WebURL */,
D62220462C7EA8DF003E43B7 /* TuskerPreferences */, D62220462C7EA8DF003E43B7 /* TuskerPreferences */,
); );
productName = NotificationExtension; productName = NotificationExtension;
@ -1828,6 +1848,7 @@
); );
name = Tusker; name = Tusker;
packageProductDependencies = ( packageProductDependencies = (
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */, D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */, D63CC701290EC0B8000E19DE /* Sentry */,
@ -1954,6 +1975,7 @@
); );
mainGroup = D6D4DDC3212518A000E1C4BB; mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = ( packageReferences = (
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */, D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */, D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
@ -2103,6 +2125,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */, D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */, D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
@ -2149,7 +2172,8 @@
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */, D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */, D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */, D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */, D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */, D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
@ -2169,6 +2193,7 @@
D6D94955298963A900C59229 /* Colors.swift in Sources */, D6D94955298963A900C59229 /* Colors.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */, D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */, D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */,
@ -2196,6 +2221,7 @@
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */, D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
@ -2280,7 +2306,7 @@
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */, D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
@ -2318,7 +2344,7 @@
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */, D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */, D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */, D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
@ -2364,6 +2390,7 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */, D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
@ -2520,7 +2547,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2553,7 +2579,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2585,7 +2610,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2676,7 +2700,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2720,7 +2743,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2743,7 +2766,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2771,7 +2793,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2800,7 +2821,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2829,7 +2849,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2985,7 +3004,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3018,7 +3036,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3083,7 +3100,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3103,7 +3120,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3126,7 +3143,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3151,7 +3167,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3267,6 +3282,14 @@
minimumVersion = 1.0.1; minimumVersion = 1.0.1;
}; };
}; };
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/karwa/swift-url";
requirement = {
kind = exactVersion;
version = 0.4.2;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -3304,6 +3327,11 @@
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
productName = HTMLStreamer; productName = HTMLStreamer;
}; };
D630C4242BC7845800208903 /* WebURL */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURL;
};
D635237029B78A7D009ED5E7 /* TuskerComponents */ = { D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = TuskerComponents; productName = TuskerComponents;
@ -3322,6 +3350,11 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = TTTKit; productName = TTTKit;
}; };
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURLFoundationExtras;
};
D674A50827F9128D00BA03AC /* Pachyderm */ = { D674A50827F9128D00BA03AC /* Pachyderm */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;

View File

@ -8,7 +8,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os
struct DiskCacheTransformer<T> { struct DiskCacheTransformer<T> {
let toData: (T) throws -> Data let toData: (T) throws -> Data
@ -22,7 +21,7 @@ class DiskCache<T> {
let defaultExpiry: CacheExpiry let defaultExpiry: CacheExpiry
let transformer: DiskCacheTransformer<T> let transformer: DiskCacheTransformer<T>
private var fileStates = OSAllocatedUnfairLock(initialState: [String: FileState]()) private var fileStates = MultiThreadDictionary<String, FileState>()
init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws { init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws {
self.defaultExpiry = defaultExpiry self.defaultExpiry = defaultExpiry
@ -60,9 +59,7 @@ class DiskCache<T> {
} }
private func fileState(forKey key: String) -> FileState { private func fileState(forKey key: String) -> FileState {
return fileStates.withLock { return fileStates[key] ?? .unknown
$0[key] ?? .unknown
}
} }
func setObject(_ object: T, forKey key: String) throws { func setObject(_ object: T, forKey key: String) throws {
@ -71,17 +68,13 @@ class DiskCache<T> {
guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else { guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else {
throw Error.couldNotCreateFile throw Error.couldNotCreateFile
} }
fileStates.withLock { fileStates[key] = .exists
$0[key] = .exists
}
} }
func removeObject(forKey key: String) throws { func removeObject(forKey key: String) throws {
let path = makeFilePath(for: key) let path = makeFilePath(for: key)
try fileManager.removeItem(atPath: path) try fileManager.removeItem(atPath: path)
fileStates.withLock { fileStates[key] = .doesNotExist
$0[key] = .doesNotExist
}
} }
func existsObject(forKey key: String) throws -> Bool { func existsObject(forKey key: String) throws -> Bool {
@ -112,9 +105,7 @@ class DiskCache<T> {
} }
guard date.timeIntervalSinceNow >= 0 else { guard date.timeIntervalSinceNow >= 0 else {
try fileManager.removeItem(atPath: path) try fileManager.removeItem(atPath: path)
fileStates.withLock { fileStates[key] = .doesNotExist
$0[key] = .doesNotExist
}
throw Error.expired throw Error.expired
} }

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
@objc(FollowedHashtag) @objc(FollowedHashtag)
public final class FollowedHashtag: NSManagedObject { public final class FollowedHashtag: NSManagedObject {
@ -32,6 +33,6 @@ extension FollowedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context) self.init(context: context)
self.name = hashtag.name self.name = hashtag.name
self.url = hashtag.url self.url = URL(hashtag.url)!
} }
} }

View File

@ -375,14 +375,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Se
} }
} }
func addAll(notifications: [Pachyderm.Notification], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) { func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext backgroundContext.perform {
context.perform {
let statuses = notifications.compactMap { $0.status } let statuses = notifications.compactMap { $0.status }
let accounts = notifications.map { $0.account } let accounts = notifications.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: context) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, in: context) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: context) self.save(context: self.backgroundContext)
completion?() completion?()
statuses.forEach { self.statusSubject.send($0.id) } statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) }

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import UserAccounts import UserAccounts
@objc(SavedHashtag) @objc(SavedHashtag)
@ -41,6 +42,6 @@ extension SavedHashtag {
self.init(context: context) self.init(context: context)
self.accountID = account.id self.accountID = account.id
self.name = hashtag.name self.name = hashtag.name
self.url = hashtag.url self.url = URL(hashtag.url)!
} }
} }

View File

@ -10,6 +10,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
@objc(StatusMO) @objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol { public final class StatusMO: NSManagedObject, StatusProtocol {
@ -135,7 +136,7 @@ extension StatusMO {
self.sensitive = status.sensitive self.sensitive = status.sensitive
self.spoilerText = status.spoilerText self.spoilerText = status.spoilerText
self.uri = status.uri self.uri = status.uri
self.url = status.url self.url = status.url != nil ? URL(status.url!) : nil
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false

View File

@ -76,10 +76,17 @@ func fromTimelineKind(_ kind: String) -> Timeline {
} else if kind == "direct" { } else if kind == "direct" {
return .direct return .direct
} else if kind.starts(with: "hashtag:") { } else if kind.starts(with: "hashtag:") {
return .tag(hashtag: String(kind.trimmingPrefix("hashtag:"))) return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
} else if kind.starts(with: "list:") { } else if kind.starts(with: "list:") {
return .list(id: String(kind.trimmingPrefix("list:"))) return .list(id: String(trimmingPrefix("list:", of: kind)))
} else { } else {
fatalError("invalid timeline kind \(kind)") fatalError("invalid timeline kind \(kind)")
} }
} }
// replace with Collection.trimmingPrefix
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
}

View File

@ -36,12 +36,19 @@ private struct AppGroupedListBackground: ViewModifier {
} }
func body(content: Content) -> some View { func body(content: Content) -> some View {
if colorScheme == .dark, !pureBlackDarkMode { if #available(iOS 16.0, *) {
content if colorScheme == .dark, !pureBlackDarkMode {
.scrollContentBackground(.hidden) content
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all)) .scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
content
}
} else { } else {
content content
.onAppear {
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
}
} }
} }
} }

View File

@ -8,7 +8,8 @@
import UIKit import UIKit
import HTMLStreamer import HTMLStreamer
import Pachyderm import WebURL
import WebURLFoundationExtras
class HTMLConverter { class HTMLConverter {
@ -44,7 +45,18 @@ extension HTMLConverter {
// note: this is duplicated in NotificationExtension // note: this is duplicated in NotificationExtension
struct Callbacks: HTMLConversionCallbacks { struct Callbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? { static func makeURL(string: String) -> URL? {
try? URL.ParseStrategy().parse(string) // 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 #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 {
URL(string: string)
}
} }
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View 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

View File

@ -274,7 +274,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else { } else {
mainVC = MainSplitViewController(mastodonController: mastodonController) mainVC = MainSplitViewController(mastodonController: mastodonController)
} }
if UIDevice.current.userInterfaceIdiom == .phone { if UIDevice.current.userInterfaceIdiom == .phone,
#available(iOS 16.0, *) {
// TODO: maybe the duckable container should be outside the account switching container // TODO: maybe the duckable container should be outside the account switching container
return DuckableContainerViewController(child: mainVC) return DuckableContainerViewController(child: mainVC)
} else { } else {

View File

@ -86,7 +86,7 @@ struct AddReactionView: View {
} }
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
.presentationDetents([.medium, .large]) .mediumPresentationDetentIfAvailable()
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in .alertWithData("Error Adding Reaction", data: $error, actions: { _ in
Button("OK") {} Button("OK") {}
}, message: { error in }, message: { error in
@ -171,6 +171,17 @@ private struct AddReactionButton<Label: View>: View {
} }
private extension View { private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func mediumPresentationDetentIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.presentationDetents([.medium, .large])
} else {
self
}
}
@available(iOS, obsoleted: 17.1) @available(iOS, obsoleted: 17.1)
@available(visionOS 1.0, *) @available(visionOS 1.0, *)
@ViewBuilder @ViewBuilder

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
class AnnouncementContentTextView: ContentTextView { class AnnouncementContentTextView: ContentTextView {
@ -29,7 +30,7 @@ class AnnouncementContentTextView: ContentTextView {
override func getMention(for url: URL, text: String) -> Mention? { override func getMention(for url: URL, text: String) -> Mention? {
announcement?.mentions.first { announcement?.mentions.first {
$0.url == url URL($0.url) == url
}.map { }.map {
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id) Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
} }
@ -37,7 +38,7 @@ class AnnouncementContentTextView: ContentTextView {
override func getHashtag(for url: URL, text: String) -> Hashtag? { override func getHashtag(for url: URL, text: String) -> Hashtag? {
announcement?.tags.first { announcement?.tags.first {
$0.url == url URL($0.url) == url
} }
} }

View File

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
import WebURLFoundationExtras
struct AnnouncementListRow: View { struct AnnouncementListRow: View {
@Binding var announcement: Announcement @Binding var announcement: Announcement
@ -19,10 +20,14 @@ struct AnnouncementListRow: View {
@State private var isShowingAddReactionSheet = false @State private var isShowingAddReactionSheet = false
var body: some View { var body: some View {
mostOfTheBody if #available(iOS 16.0, *) {
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in mostOfTheBody
dimension[.leading] .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
}) dimension[.leading]
})
} else {
mostOfTheBody
}
} }
private var mostOfTheBody: some View { private var mostOfTheBody: some View {
@ -49,7 +54,11 @@ struct AnnouncementListRow: View {
Label { Label {
Text("Add Reaction") Text("Add Reaction")
} icon: { } icon: {
Image("face.smiling.badge.plus") if #available(iOS 16.0, *) {
Image("face.smiling.badge.plus")
} else {
Image(systemName: "face.smiling")
}
} }
} }
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
@ -115,8 +124,8 @@ struct AnnouncementListRow: View {
let url: URL? let url: URL?
let staticURL: URL? let staticURL: URL?
if case .custom(let emoji) = reaction { if case .custom(let emoji) = reaction {
url = emoji.url url = URL(emoji.url)
staticURL = emoji.staticURL staticURL = URL(emoji.staticURL)
} else { } else {
url = nil url = nil
staticURL = nil staticURL = nil

View File

@ -8,6 +8,8 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
import WebURLFoundationExtras
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}") private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool { private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
@ -227,10 +229,10 @@ class ConversationViewController: UIViewController {
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") { let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location effectiveURL = location
} else { } else {
effectiveURL = url.formatted(.url.fragment(.never)) effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
} }
} else { } else {
effectiveURL = url.formatted(.url.fragment(.never)) effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
} }
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
@ -482,11 +484,3 @@ extension ConversationViewController: StatusBarTappableViewController {
} }
} }
} }
extension ConversationViewController: RefreshableViewController {
func refresh() {
Task {
await refreshContext()
}
}
}

View File

@ -9,6 +9,20 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
@available(iOS, obsoleted: 16.0)
struct AddHashtagPinnedTimelineRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<AddHashtagPinnedTimelineView>
@Binding var pinnedTimelines: [PinnedTimeline]
func makeUIViewController(context: Context) -> UIHostingController<AddHashtagPinnedTimelineView> {
return UIHostingController(rootView: AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines))
}
func updateUIViewController(_ uiViewController: UIHostingController<AddHashtagPinnedTimelineView>, context: Context) {
}
}
struct AddHashtagPinnedTimelineView: View { struct AddHashtagPinnedTimelineView: View {
@EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ -35,6 +49,9 @@ struct AddHashtagPinnedTimelineView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
list list
#if !os(visionOS)
.appGroupedListBackground(container: AddHashtagPinnedTimelineRepresentable.UIViewControllerType.self)
#endif
.listStyle(.grouped) .listStyle(.grouped)
.navigationTitle("Add Hashtag") .navigationTitle("Add Hashtag")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@ -36,8 +36,15 @@ struct CustomizeTimelinesList: View {
} }
var body: some View { var body: some View {
NavigationStack { if #available(iOS 16.0, *) {
navigationBody NavigationStack {
navigationBody
}
} else {
NavigationView {
navigationBody
}
.navigationViewStyle(.stack)
} }
} }

View File

@ -149,7 +149,7 @@ struct EditFilterView: View {
} }
.appGroupedListBackground(container: UIHostingController<CustomizeTimelinesList>.self) .appGroupedListBackground(container: UIHostingController<CustomizeTimelinesList>.self)
#if !os(visionOS) #if !os(visionOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboardInteractivelyIfAvailable()
#endif #endif
.navigationTitle(create ? "Add Filter" : "Edit Filter") .navigationTitle(create ? "Add Filter" : "Edit Filter")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -226,6 +226,18 @@ private struct FilterContextToggleStyle: ToggleStyle {
} }
} }
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}
//struct EditFilterView_Previews: PreviewProvider { //struct EditFilterView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {
// EditFilterView() // EditFilterView()

View File

@ -115,8 +115,18 @@ struct PinnedTimelinesModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.sheet(isPresented: $isShowingAddHashtagSheet, content: { .sheet(isPresented: $isShowingAddHashtagSheet, content: {
#if os(visionOS)
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines) AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
.edgesIgnoringSafeArea(.bottom) .edgesIgnoringSafeArea(.bottom)
#else
if #available(iOS 16.0, *) {
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
.edgesIgnoringSafeArea(.bottom)
} else {
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
.edgesIgnoringSafeArea(.bottom)
}
#endif
}) })
.sheet(isPresented: $isShowingAddInstanceSheet, content: { .sheet(isPresented: $isShowingAddInstanceSheet, content: {
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines) AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)

View File

@ -10,6 +10,7 @@ import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
import CoreData import CoreData
import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController { class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
@ -559,7 +560,10 @@ extension ExploreViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity) provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
provider = NSItemProvider(object: hashtag.url as NSURL) guard let url = URL(hashtag.url) else {
return []
}
provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -41,7 +41,9 @@ class InlineTrendsViewController: UIViewController {
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
navigationItem.preferredSearchBarPlacement = .stacked if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
let trends = TrendsViewController(mastodonController: mastodonController) let trends = TrendsViewController(mastodonController: mastodonController)
trends.view.translatesAutoresizingMaskIntoConstraints = false trends.view.translatesAutoresizingMaskIntoConstraints = false

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import Combine import Combine
class TrendingHashtagsViewController: UIViewController, CollectionViewController { class TrendingHashtagsViewController: UIViewController, CollectionViewController {
@ -276,10 +277,11 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
extension TrendingHashtagsViewController: UICollectionViewDragDelegate { extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath), guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else { case let .tag(hashtag) = item,
let url = URL(hashtag.url) else {
return [] return []
} }
let provider = NSItemProvider(object: hashtag.url as NSURL) let provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -9,6 +9,7 @@
#if !os(visionOS) #if !os(visionOS)
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer import HTMLStreamer
class TrendingLinkCardCollectionViewCell: UICollectionViewCell { class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
@ -70,7 +71,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
self.card = card self.card = card
self.thumbnailView.image = nil self.thumbnailView.image = nil
thumbnailView.update(for: card.image, blurhash: card.blurhash) thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash)
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title titleLabel.text = title

View File

@ -9,17 +9,22 @@
#if os(visionOS) #if os(visionOS)
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer import HTMLStreamer
struct TrendingLinkCardView: View { struct TrendingLinkCardView: View {
let card: Card let card: Card
private var imageURL: URL? { private var imageURL: URL? {
card.image if let image = card.image {
URL(image)
} else {
nil
}
} }
private var descriptionText: String { private var descriptionText: String {
let converter = TextConverter(configuration: .init(insertNewlines: false)) var converter = TextConverter(configuration: .init(insertNewlines: false))
return converter.convert(html: card.description) return converter.convert(html: card.description)
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import SafariServices import SafariServices
import Combine import Combine
#if os(visionOS) #if os(visionOS)
@ -292,19 +293,21 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
} }
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url) else {
return return
} }
selected(url: card.url) selected(url: url)
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url),
let cell = collectionView.cellForItem(at: indexPath) else { let cell = collectionView.cellForItem(at: indexPath) else {
return nil return nil
} }
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: card.url) let vc = SFSafariViewController(url: url)
#if !os(visionOS) #if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif #endif
@ -321,10 +324,11 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
extension TrendingLinksViewController: UICollectionViewDragDelegate { extension TrendingLinksViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url) else {
return [] return []
} }
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
} }
} }

View File

@ -513,7 +513,9 @@ extension TrendsViewController: UICollectionViewDelegate {
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case let .link(card): case let .link(card):
selected(url: card.url) if let url = URL(card.url) {
selected(url: url)
}
case let .status(id, state): case let .status(id, state):
selected(status: id, state: state.copy()) selected(status: id, state: state.copy())
@ -523,12 +525,12 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { @available(iOS, obsoleted: 16.0)
guard indexPaths.count == 1, @available(visionOS 1.0, *)
let item = dataSource.itemIdentifier(for: indexPaths[0]) else { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil return nil
} }
let indexPath = indexPaths[0]
switch item { switch item {
case .loadingIndicator, .confirmLoadMoreStatuses(_): case .loadingIndicator, .confirmLoadMoreStatuses(_):
@ -542,9 +544,12 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
case let .link(card): case let .link(card):
guard let url = URL(card.url) else {
return nil
}
let cell = collectionView.cellForItem(at: indexPath)! let cell = collectionView.cellForItem(at: indexPath)!
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: card.url) let vc = SFSafariViewController(url: url)
#if !os(visionOS) #if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif #endif
@ -579,6 +584,15 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
} }
// implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16
@available(iOS 16.0, visionOS 1.0, *)
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
guard indexPaths.count == 1 else {
return nil
}
return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point)
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
} }
@ -619,7 +633,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return [] return []
case let .tag(hashtag): case let .tag(hashtag):
let provider = NSItemProvider(object: hashtag.url as NSURL) guard let url = URL(hashtag.url) else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
@ -627,7 +644,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
case let .link(card): case let .link(card):
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] guard let url = URL(card.url) else {
return []
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
case let .status(id, _): case let .status(id, _):
guard let status = mastodonController.persistentContainer.status(for: id), guard let status = mastodonController.persistentContainer.status(for: id),

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView { class FastSwitchingAccountView: UIView {
@ -130,7 +131,11 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) { private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username usernameLabel.text = account.username
instanceLabel.text = account.instanceURL.host(percentEncoded: false) if let domain = WebURL.Domain(account.instanceURL.host!) {
instanceLabel.text = domain.render(.uncheckedUnicodeString)
} else {
instanceLabel.text = account.instanceURL.host!
}
let controller = MastodonController.getForAccount(account) let controller = MastodonController.getForAccount(account)
avatarTask = Task { avatarTask = Task {
guard let account = try? await controller.getOwnAccount(), guard let account = try? await controller.getOwnAccount(),

View File

@ -1,13 +1,15 @@
// //
// FallbackGalleryContentViewController.swift // FallbackGalleryContentViewController.swift
// GalleryVC // Tusker
// //
// Created by Shadowfacts on 3/18/24. // Created by Shadowfacts on 3/18/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import GalleryVC
import QuickLook import QuickLook
import Pachyderm
private class FallbackGalleryContentViewController: QLPreviewController { private class FallbackGalleryContentViewController: QLPreviewController {
private let previewItem = GalleryPreviewItem() private let previewItem = GalleryPreviewItem()
@ -50,40 +52,40 @@ extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
} }
} }
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController { class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
public init(url: URL) { init(url: URL) {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
self.viewControllers = [FallbackGalleryContentViewController(url: url)] self.viewControllers = [FallbackGalleryContentViewController(url: url)]
} }
public override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
container?.disableGalleryScrollAndZoom() container?.disableGalleryScrollAndZoom()
} }
public required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
public weak var container: (any GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public var contentSize: CGSize { var contentSize: CGSize {
.zero .zero
} }
public var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
[] []
} }
public var caption: String? { var caption: String? {
nil nil
} }
public var presentationAnimation: GalleryContentPresentationAnimation { var canAnimateFromSourceView: Bool {
.fade false
} }
} }

View File

@ -69,8 +69,4 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
[VideoActivityItemSource(asset: controller.item.asset, url: url)] [VideoActivityItemSource(asset: controller.item.asset, url: url)]
} }
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceViewWithoutSnapshot
}
} }

View File

@ -1,60 +0,0 @@
//
// 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 []
}
}
}

View File

@ -1,83 +0,0 @@
//
// 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)
}
}
}

View File

@ -1,22 +1,22 @@
// //
// ImageGalleryContentViewController.swift // ImageGalleryContentViewController.swift
// GalleryVC // Tusker
// //
// Created by Shadowfacts on 3/17/24. // Created by Shadowfacts on 3/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import GalleryVC
import Pachyderm
import TuskerComponents import TuskerComponents
@preconcurrency import VisionKit @preconcurrency import VisionKit
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController { class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
public let caption: String? let url: URL
public var image: UIImage { let caption: String?
didSet { let originalData: Data?
imageView?.image = image let image: UIImage
}
}
let gifController: GIFController? let gifController: GIFController?
private var imageView: GIFImageView! private var imageView: GIFImageView!
@ -27,8 +27,12 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
@available(iOS 16.0, macCatalyst 17.0, *) @available(iOS 16.0, macCatalyst 17.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction } private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
public init(image: UIImage, caption: String?, gifController: GIFController?) { private var isGrayscale = false
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
self.url = url
self.caption = caption self.caption = caption
self.originalData = originalData
self.image = image self.image = image
self.gifController = gifController self.gifController = gifController
@ -37,14 +41,21 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
preferredContentSize = image.size preferredContentSize = image.size
} }
public required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
public override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
imageView = GIFImageView(image: image) isGrayscale = Preferences.shared.grayscaleImages
let maybeGrayscaleImage = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: image) ?? image
} else {
image
}
imageView = GIFImageView(image: maybeGrayscaleImage)
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true imageView.isUserInteractionEnabled = true
@ -75,9 +86,11 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
} }
} }
} }
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
public override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
if let gifController { if let gifController {
@ -85,23 +98,37 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
} }
} }
@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 // MARK: GalleryContentViewController
public weak var container: (any GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public var contentSize: CGSize { var contentSize: CGSize {
image.size image.size
} }
open var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
return [image] if let data = originalData ?? image.pngData() {
return [ImageActivityItemSource(data: data, url: url, image: image)]
} else {
return []
}
} }
public var presentationAnimation: GalleryContentPresentationAnimation { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
}
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if #available(iOS 16.0, macCatalyst 17.0, *), if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction { let analysisInteraction {
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated) analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
@ -111,7 +138,7 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
@available(iOS 16.0, macCatalyst 17.0, *) @available(iOS 16.0, macCatalyst 17.0, *)
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate { extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool { func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
return container?.galleryControlsVisible ?? true return container?.galleryControlsVisible ?? true
} }
} }

View File

@ -34,7 +34,7 @@ class ImageGalleryDataSource: GalleryDataSource {
} else { } else {
nil nil
} }
return GrayscalableImageGalleryContentViewController( return ImageGalleryContentViewController(
url: url, url: url,
caption: nil, caption: nil,
originalData: entry.data, originalData: entry.data,
@ -52,7 +52,7 @@ class ImageGalleryDataSource: GalleryDataSource {
} else { } else {
nil nil
} }
return GrayscalableImageGalleryContentViewController( return ImageGalleryContentViewController(
url: self.url, url: self.url,
caption: nil, caption: nil,
originalData: data, originalData: data,

View File

@ -7,42 +7,43 @@
// //
import UIKit import UIKit
import GalleryVC
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController { class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
private let fallbackCaption: String? private let fallbackCaption: String?
private let provider: () async -> (any GalleryContentViewController)? private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)! private var wrapped: (any GalleryContentViewController)!
public weak var container: GalleryContentViewControllerContainer? weak var container: GalleryContentViewControllerContainer?
public var contentSize: CGSize { var contentSize: CGSize {
wrapped?.contentSize ?? .zero wrapped?.contentSize ?? .zero
} }
public var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
wrapped?.activityItemsForSharing ?? [] wrapped?.activityItemsForSharing ?? []
} }
public var caption: String? { var caption: String? {
wrapped?.caption ?? fallbackCaption wrapped?.caption ?? fallbackCaption
} }
public var presentationAnimation: GalleryContentPresentationAnimation { var canAnimateFromSourceView: Bool {
wrapped?.presentationAnimation ?? .fade wrapped?.canAnimateFromSourceView ?? true
} }
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) { init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
self.fallbackCaption = caption self.fallbackCaption = caption
self.provider = provider self.provider = provider
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
public required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
public override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
container?.setGalleryContentLoading(true) container?.setGalleryContentLoading(true)
@ -80,7 +81,7 @@ public class LoadingGalleryContentViewController: UIViewController, GalleryConte
let label = UILabel() let label = UILabel()
label.text = "Error Loading" label.text = "Error Loading"
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0) label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.textColor = .secondaryLabel label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true label.adjustsFontForContentSizeCategory = true
@ -101,15 +102,15 @@ public class LoadingGalleryContentViewController: UIViewController, GalleryConte
]) ])
} }
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction) wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
} }
public func galleryContentDidAppear() { func galleryContentDidAppear() {
wrapped?.galleryContentDidAppear() wrapped?.galleryContentDidAppear()
} }
public func galleryContentWillDisappear() { func galleryContentWillDisappear() {
wrapped?.galleryContentWillDisappear() wrapped?.galleryContentWillDisappear()
} }

View File

@ -33,7 +33,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
case .image: case .image:
if let view = attachmentView(for: attachment), if let view = attachmentView(for: attachment),
let image = view.attachmentImage { let image = view.attachmentImage {
return GrayscalableImageGalleryContentViewController( return ImageGalleryContentViewController(
url: attachment.url, url: attachment.url,
caption: attachment.description, caption: attachment.description,
originalData: view.originalData, originalData: view.originalData,
@ -49,7 +49,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
} else { } else {
nil nil
} }
return GrayscalableImageGalleryContentViewController( return ImageGalleryContentViewController(
url: attachment.url, url: attachment.url,
caption: attachment.description, caption: attachment.description,
originalData: entry.data, originalData: entry.data,
@ -68,7 +68,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
} else { } else {
nil nil
} }
return GrayscalableImageGalleryContentViewController( return ImageGalleryContentViewController(
url: attachment.url, url: attachment.url,
caption: attachment.description, caption: attachment.description,
originalData: data, originalData: data,
@ -91,10 +91,10 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
} }
return GifvGalleryContentViewController(controller: controller, url: attachment.url, caption: attachment.description) return GifvGalleryContentViewController(controller: controller, url: attachment.url, caption: attachment.description)
case .video: case .video:
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description) return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
case .audio: case .audio:
// TODO: use separate content VC with audio visualization? // TODO: use separate content VC with audio visualization?
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description) return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
case .unknown: case .unknown:
return LoadingGalleryContentViewController(caption: nil) { return LoadingGalleryContentViewController(caption: nil) {
do { do {

View File

@ -1,6 +1,6 @@
// //
// VideoControlsViewController.swift // VideoControlsViewController.swift
// GalleryVC // Tusker
// //
// Created by Shadowfacts on 3/21/24. // Created by Shadowfacts on 3/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
@ -18,41 +18,41 @@ class VideoControlsViewController: UIViewController {
}() }()
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private lazy var muteButton: MuteButton = { private lazy var muteButton = MuteButton().configure {
let button = MuteButton() $0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside) $0.setMuted(false, animated: false)
button.setMuted(false, animated: false) }
return button
}()
private let timestampLabel: UILabel = { private let timestampLabel = UILabel().configure {
let label = UILabel() $0.text = "0:00"
label.text = "0:00" $0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular)) }
return label
}()
private lazy var scrubbingControl: VideoScrubbingControl = { private lazy var scrubbingControl = VideoScrubbingControl().configure {
let control = VideoScrubbingControl() $0.heightAnchor.constraint(equalToConstant: 44).isActive = true
control.heightAnchor.constraint(equalToConstant: 44).isActive = true $0.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin) $0.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged) $0.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd) }
return control
}()
private let timeRemainingLabel: UILabel = { private let timeRemainingLabel = UILabel().configure {
let label = UILabel() $0.text = "-0:00"
label.text = "-0:00" $0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular)) }
return label
}()
private lazy var optionsButton = MenuButton { [unowned self] in private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String let imageName: String
#if os(visionOS)
let playbackSpeed = player.defaultRate
#else
let playbackSpeed = self.playbackSpeed
#endif
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
switch player.defaultRate { switch playbackSpeed {
case 0.5: case 0.5:
imageName = "gauge.with.dots.needle.0percent" imageName = "gauge.with.dots.needle.0percent"
case 1: case 1:
@ -68,8 +68,12 @@ class VideoControlsViewController: UIViewController {
imageName = "speedometer" imageName = "speedometer"
} }
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 { if self.player.rate > 0 {
self.player.rate = speed.rate self.player.rate = speed.rate
} }
@ -78,19 +82,17 @@ class VideoControlsViewController: UIViewController {
return UIMenu(children: [speedMenu]) return UIMenu(children: [speedMenu])
} }
private lazy var hStack: UIStackView = { private lazy var hStack = UIStackView(arrangedSubviews: [
let stack = UIStackView(arrangedSubviews: [ muteButton,
muteButton, timestampLabel,
timestampLabel, scrubbingControl,
scrubbingControl, timeRemainingLabel,
timeRemainingLabel, optionsButton,
optionsButton, ]).configure {
]) $0.axis = .horizontal
stack.axis = .horizontal $0.spacing = 8
stack.spacing = 8 $0.alignment = .center
stack.alignment = .center }
return stack
}()
private var timestampObserverToken: Any? private var timestampObserverToken: Any?
private var scrubberObserverToken: Any? private var scrubberObserverToken: Any?
@ -99,11 +101,20 @@ class VideoControlsViewController: UIViewController {
private var scrubbingTargetTime: CMTime? private var scrubbingTargetTime: CMTime?
private var isSeeking = false private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) { init(player: AVPlayer) {
self.player = player self.player = player
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -187,7 +198,11 @@ class VideoControlsViewController: UIViewController {
@objc private func scrubbingEnded() { @objc private func scrubbingEnded() {
scrubbingChanged() scrubbingChanged()
if wasPlayingWhenScrubbingStarted { if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }

View File

@ -1,53 +1,73 @@
// //
// VideoGalleryContentViewController.swift // VideoGalleryContentViewController.swift
// GalleryVC // Tusker
// //
// Created by Shadowfacts on 3/19/24. // Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import GalleryVC
import AVFoundation import AVFoundation
import CoreImage import CoreImage
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController { class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
public let url: URL private let url: URL
public let caption: String? let caption: String?
public private(set) var item: AVPlayerItem private var item: AVPlayerItem
public let player: AVPlayer let player: AVPlayer
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
#endif
private var isGrayscale: Bool
private var presentationSizeObservation: NSKeyValueObservation? private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation? private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem? private var hideControlsWorkItem: DispatchWorkItem?
private var isShowingError = false private var audioSessionToken: AudioSessionCoordinator.Token?
public init(url: URL, caption: String?) { init(url: URL, caption: String?) {
self.url = url self.url = url
self.caption = caption self.caption = caption
self.isGrayscale = Preferences.shared.grayscaleImages
let asset = AVAsset(url: url) let asset = AVAsset(url: url)
self.item = Self.createItem(asset: asset) self.item = VideoGalleryContentViewController.createItem(asset: asset)
self.player = AVPlayer(playerItem: item) self.player = AVPlayer(playerItem: item)
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
public required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
open class func createItem(asset: AVAsset) -> AVPlayerItem { private static func createItem(asset: AVAsset) -> AVPlayerItem {
return AVPlayerItem(asset: asset) 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
} }
public func replaceCurrentItem(with item: AVPlayerItem) { override func viewDidLoad() {
self.item = item
player.replaceCurrentItem(with: item)
updateItemObservations()
}
public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
container?.setGalleryContentLoading(true) container?.setGalleryContentLoading(true)
@ -72,17 +92,19 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
scheduleControlsHide() scheduleControlsHide()
} }
}) })
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
private func updateItemObservations() { private func updateItemObservations() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
MainActor.assumeIsolated { MainActor.runUnsafely {
self.preferredContentSize = item.presentationSize self.preferredContentSize = item.presentationSize
self.container?.galleryContentChanged() self.container?.galleryContentChanged()
} }
}) })
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
MainActor.assumeIsolated { MainActor.runUnsafely {
if item.status == .readyToPlay { if item.status == .readyToPlay {
self.container?.setGalleryContentLoading(false) self.container?.setGalleryContentLoading(false)
self.statusObservation = nil self.statusObservation = nil
@ -91,22 +113,19 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
self.container?.setGalleryContentLoading(false) self.container?.setGalleryContentLoading(false)
self.showErrorView(error) self.showErrorView(error)
self.statusObservation = nil self.statusObservation = nil
self.overlayVC.setVisible(false)
} }
} }
}) })
} }
private func showErrorView(_ error: any Error) { private func showErrorView(_ error: any Error) {
isShowingError = true
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!) let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit image.contentMode = .scaleAspectFit
let label = UILabel() let label = UILabel()
label.text = "Error Loading" label.text = "Error Loading"
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0) label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.textColor = .secondaryLabel label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true label.adjustsFontForContentSizeCategory = true
@ -134,9 +153,26 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
]) ])
} }
@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 {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed
#endif
}
}
}
private func scheduleControlsHide() { private func scheduleControlsHide() {
hideControlsWorkItem = DispatchWorkItem { [weak self] in hideControlsWorkItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated { MainActor.runUnsafely {
guard let self, guard let self,
let container = self.container, let container = self.container,
container.galleryControlsVisible else { container.galleryControlsVisible else {
@ -150,32 +186,33 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public var contentSize: CGSize { var contentSize: CGSize {
item.presentationSize item.presentationSize
} }
open var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
// [VideoActivityItemSource(asset: item.asset, url: url)] [VideoActivityItemSource(asset: item.asset, url: url)]
[]
}
public var presentationAnimation: GalleryContentPresentationAnimation {
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
} }
#if os(visionOS)
private lazy var overlayVC = VideoOverlayViewController(player: player) private lazy var overlayVC = VideoOverlayViewController(player: player)
public var contentOverlayAccessoryViewController: UIViewController? { #else
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
var contentOverlayAccessoryViewController: UIViewController? {
overlayVC overlayVC
} }
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player) #if os(visionOS)
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if !isShowingError { overlayVC.setVisible(visible)
overlayVC.setVisible(visible)
}
if !visible { if !visible {
hideControlsWorkItem?.cancel() hideControlsWorkItem?.cancel()
@ -185,11 +222,25 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
} }
} }
open func galleryContentDidAppear() { func galleryContentDidAppear() {
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
}
}
}
} }
open func galleryContentWillDisappear() { func galleryContentWillDisappear() {
player.pause() player.pause()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
} }
} }
@ -218,9 +269,9 @@ private class PlayerView: UIView {
playerLayer.player = player playerLayer.player = player
playerLayer.videoGravity = .resizeAspect playerLayer.videoGravity = .resizeAspect
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in
MainActor.assumeIsolated { MainActor.runUnsafely {
self?.invalidateIntrinsicContentSize() self.invalidateIntrinsicContentSize()
} }
}) })
} }

View File

@ -1,6 +1,6 @@
// //
// VideoOverlayViewController.swift // VideoOverlayViewController.swift
// GalleryVC // Tusker
// //
// Created by Shadowfacts on 3/26/24. // Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved. // Copyright © 2024 Shadowfacts. All rights reserved.
@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController {
private static let pauseImage = UIImage(systemName: "pause.fill")! private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private var dimmingView: UIView! private var dimmingView: UIView!
private var controlsStack: UIStackView! private var controlsStack: UIStackView!
@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController {
private var rateObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation?
#if os(visionOS)
init(player: AVPlayer) { init(player: AVPlayer) {
self.player = player self.player = player
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -79,7 +90,7 @@ class VideoOverlayViewController: UIViewController {
]) ])
rateObservation = player.observe(\.rate, changeHandler: { player, _ in rateObservation = player.observe(\.rate, changeHandler: { player, _ in
MainActor.assumeIsolated { MainActor.runUnsafely {
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
} }
}) })
@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController {
if player.currentTime() >= player.currentItem!.duration { if player.currentTime() >= player.currentItem!.duration {
player.seek(to: .zero) player.seek(to: .zero)
} }
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }

View File

@ -100,13 +100,28 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
navigationItem.preferredSearchBarPlacement = .stacked if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
navigationItem.renameDelegate = self navigationItem.renameDelegate = self
navigationItem.titleMenuProvider = { [unowned self] suggested in navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested var children = suggested
children.append(contentsOf: self.listSettingsMenuElements()) children.append(contentsOf: self.listSettingsMenuElements())
return UIMenu(children: children) 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)
})
]))
} }
} }

View File

@ -151,22 +151,6 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
return false return false
#endif // !os(visionOS) #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 { extension BaseMainTabBarViewController: TuskerNavigationDelegate {

View File

@ -220,19 +220,6 @@ class MainSplitViewController: UISplitViewController {
compose(editing: nil) 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)
}
}
} }
extension MainSplitViewController: UISplitViewControllerDelegate { extension MainSplitViewController: UISplitViewControllerDelegate {

View File

@ -34,13 +34,6 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController {
@Box fileprivate var myProfileCell: UIView? @Box fileprivate var myProfileCell: UIView?
private var sidebarTapRecognizer: UITapGestureRecognizer? 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -461,20 +454,15 @@ extension NewMainTabBarViewController {
extension NewMainTabBarViewController: UITabBarControllerDelegate { extension NewMainTabBarViewController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool { func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
if tab.identifier == Tab.compose.rawValue { if tab.identifier == Tab.compose.rawValue {
if #unavailable(iOS 18.1) { let currentTab = selectedTab
let currentTab = selectedTab // returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254)
// returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254) // returning false and then setting selectedTab=tab and selectedTab=currentTab seems to leave things in a bad state (currentTab's VC is on screen but in the disappeared state)
// returning false and then setting selectedTab=tab and selectedTab=currentTab seems to leave things in a bad state (currentTab's VC is on screen but in the disappeared state) // so return true, and then after the tab bar VC has finished updating, go back to currentTab
// so return true, and then after the tab bar VC has finished updating, go back to currentTab DispatchQueue.main.async {
DispatchQueue.main.async { self.selectedTab = currentTab
self.selectedTab = currentTab
}
compose(editing: nil)
return true
} else {
compose(editing: nil)
return false
} }
compose(editing: nil)
return true
} else if let selectedTab, } else if let selectedTab,
selectedTab == tab, selectedTab == tab,
let nav = selectedViewController as? any NavigationControllerProtocol { let nav = selectedViewController as? any NavigationControllerProtocol {
@ -520,6 +508,13 @@ 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, *) @available(iOS 18.0, *)
extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) { func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {

View File

@ -41,8 +41,15 @@ struct MuteAccountView: View {
@State private var error: Error? @State private var error: Error?
var body: some View { var body: some View {
NavigationStack { if #available(iOS 16.0, *) {
navigationViewContent NavigationStack {
navigationViewContent
}
} else {
NavigationView {
navigationViewContent
}
.navigationViewStyle(.stack)
} }
} }

View File

@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
fetchCustomEmojiImage?.1.cancel() fetchCustomEmojiImage?.1.cancel()
case .emojiReaction(let emojiOrShortcode, let url): case .emojiReaction(let emojiOrShortcode, let url):
iconImageView.image = nil iconImageView.image = nil
if let url, if let url = url.flatMap({ URL($0) }),
fetchCustomEmojiImage?.0 != url { fetchCustomEmojiImage?.0 != url {
fetchCustomEmojiImage?.1.cancel() fetchCustomEmojiImage?.1.cancel()
let task = Task { let task = Task {

View File

@ -101,8 +101,14 @@ extension FollowRequestNotificationViewController: UICollectionViewDelegate {
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
] ]
let acceptRejectMenu: UIMenu
if #available(iOS 16.0, *) {
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
} else {
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
}
return UIMenu(children: [ return UIMenu(children: [
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren), acceptRejectMenu,
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
]) ])
} }

View File

@ -48,9 +48,7 @@ class NotificationLoadingViewController: UIViewController {
do { do {
let (notification, _) = try await mastodonController.run(request) let (notification, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
let container = mastodonController.persistentContainer mastodonController.persistentContainer.addAll(notifications: [notification]) {
let context = container.viewContext
container.addAll(notifications: [notification], in: context) {
continuation.resume() continuation.resume()
} }
} }

View File

@ -700,8 +700,14 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
] ]
let acceptRejectMenu: UIMenu
if #available(iOS 16.0, *) {
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
} else {
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
}
return UIMenu(children: [ return UIMenu(children: [
UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren), acceptRejectMenu,
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
]) ])
} }
@ -740,7 +746,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
return cell.dragItemsForBeginning(session: session) return cell.dragItemsForBeginning(session: session)
case .poll, .update: case .poll, .update:
let status = group.notifications.first!.status! let status = group.notifications.first!.status!
let provider = NSItemProvider(object: status.url! as NSURL) let provider = NSItemProvider(object: URL(status.url!)! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -180,9 +180,3 @@ extension NotificationsPageViewController: StateRestorableViewController {
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id) return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
} }
} }
extension NotificationsPageViewController: RefreshableViewController {
func refresh() {
(currentViewController as? RefreshableViewController)?.refresh()
}
}

View File

@ -96,7 +96,9 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController.searchBar.placeholder = "Search or enter a URL" searchController.searchBar.placeholder = "Search or enter a URL"
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
navigationItem.preferredSearchBarPlacement = .stacked if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
definesPresentationContext = true definesPresentationContext = true
urlHandler = urlCheckerSubject urlHandler = urlCheckerSubject

View File

@ -91,10 +91,14 @@ struct AboutView: View {
@ViewBuilder @ViewBuilder
private var iconOrGame: some View { private var iconOrGame: some View {
FlipView { if #available(iOS 16.0, *) {
FlipView {
appIcon
} back: {
TTTView()
}
} else {
appIcon appIcon
} back: {
TTTView()
} }
} }

View File

@ -27,7 +27,14 @@ struct AppearancePrefsView: View {
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
var image: UIImage? var image: UIImage?
if let color = color.color { if let color = color.color {
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal) if #available(iOS 16.0, *) {
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)
} else {
image = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { context in
color.setFill()
context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: 20, height: 20))
}
}
} }
return (color, image) return (color, image)
} }

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURL
struct MockStatusView: View { struct MockStatusView: View {
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
@ -135,8 +136,8 @@ private struct MockStatusCardView: UIViewRepresentable {
let view = StatusCardView() let view = StatusCardView()
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
let card = StatusCardView.CardData( let card = StatusCardView.CardData(
url: URL(string: "https://vaccor.space/tusker")!, url: WebURL("https://vaccor.space/tusker")!,
image: URL(string: "https://vaccor.space/tusker/img/icon.png")!, image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
title: "Tusker", title: "Tusker",
description: "Tusker is an iOS app for Mastodon" description: "Tusker is an iOS app for Mastodon"
) )

View File

@ -36,7 +36,12 @@ struct NotificationsPrefsView: View {
if #available(iOS 15.4, *) { if #available(iOS 15.4, *) {
Section { Section {
Button { Button {
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { let str = if #available(iOS 16.0, *) {
UIApplication.openNotificationSettingsURLString
} else {
UIApplicationOpenNotificationSettingsURLString
}
if let url = URL(string: str) {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
} label: { } label: {

View File

@ -34,7 +34,7 @@ struct PushInstanceSettingsView: View {
HStack { HStack {
PrefsAccountView(account: account) PrefsAccountView(account: account)
Spacer() Spacer()
AsyncToggle("\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:)) AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
.labelsHidden() .labelsHidden()
} }
PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription) PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)

View File

@ -43,9 +43,21 @@ struct OppositeCollapseKeywordsView: View {
.listStyle(.grouped) .listStyle(.grouped)
.appGroupedListBackground(container: PreferencesNavigationController.self) .appGroupedListBackground(container: PreferencesNavigationController.self)
} }
#if !os(visionOS)
.onAppear(perform: updateAppearance)
#endif
.navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords") .navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords")
} }
@available(iOS, obsoleted: 16.0)
private func updateAppearance() {
if #available(iOS 16.0, *) {
// no longer necessary
} else {
UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive
}
}
private func commitExisting(at index: Int) -> () -> Void { private func commitExisting(at index: Int) -> () -> Void {
return { return {
if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import WebURL
struct PrefsAccountView: View { struct PrefsAccountView: View {
let account: UserAccountInfo let account: UserAccountInfo
@ -18,7 +19,12 @@ struct PrefsAccountView: View {
VStack(alignment: .prefsAvatar) { VStack(alignment: .prefsAvatar) {
Text(verbatim: account.username) Text(verbatim: account.username)
.foregroundColor(.primary) .foregroundColor(.primary)
Text(verbatim: account.instanceURL.host(percentEncoded: false)!) let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
domain.render(.uncheckedUnicodeString)
} else {
account.instanceURL.host!
}
Text(verbatim: instance)
.font(.caption) .font(.caption)
.foregroundColor(.primary) .foregroundColor(.primary)
} }

Some files were not shown because too many files have changed in this diff Show More