Compare commits

...

18 Commits

Author SHA1 Message Date
cb535196e2 Fix signing into API-restricted instances
Closes #556
2024-12-28 23:43:50 -05:00
238f246d64 Bump build number and update changelog 2024-12-26 18:31:07 -05:00
20e7d9ed10 Fix compiling on visionOS 2024-12-26 17:44:41 -05:00
7c43261f9c Revert "Raise min deployment target to iOS 16"
This reverts commit f4b51c06c1107cd8149a8e07a0f652ac6816ee3a.
2024-12-26 17:33:13 -05:00
a35b72d256 Revert "Replace WebURL with URL.ParseStrategy"
This reverts commit adaf8dc217f0857bef9b8dcc7fae0efc3c08bc3d.
2024-12-26 17:31:18 -05:00
666d2c468a Fix gallery controls being positioned incorrectly in landscape 2024-12-26 17:25:49 -05:00
4ea61542a0 Fix gallery buttons changing position during dismiss animation
Closes #554
2024-12-26 17:25:31 -05:00
5ce9892a9b Bump build number and update changelog 2024-12-16 20:17:44 -05:00
54376ac585 Handle empty urls in OptionalURLDecoder
Closes #553
2024-12-16 19:10:23 -05:00
26c483fc9a Bump build number and update changelog 2024-12-15 20:28:08 -05:00
a68d2ce952 Fix compiling for visionOS 2024-12-15 17:07:38 -05:00
adaf8dc217 Replace WebURL with URL.ParseStrategy
Closes #170
2024-12-15 14:20:48 -05:00
572c5a0824 Fix NotificationGroupTests not compiling 2024-12-15 13:52:26 -05:00
e469d207b4 Make "no alt" badge all caps 2024-12-15 13:31:03 -05:00
82ec120871 Include rate limit reset date in error message
Closes #548
2024-12-15 13:27:09 -05:00
242c60d74d Workaround for tab bar content VC not being in responder chain almost ever
Closes #544, #179
2024-12-15 13:18:47 -05:00
20692b0630 Fix links in profile field values not at the beginning of the string not being tappable
Fixes #501
2024-12-07 13:00:54 -05:00
9990d50e3e Revert "Use text view for profile field value view"
This reverts commit c88076eec0599d90b80d059a69f7703863d8fed9.

Closes #521
2024-12-07 13:00:13 -05:00
83 changed files with 1034 additions and 318 deletions

View File

@ -1,3 +1,13 @@
## 2024.5
Features/Improvements:
- Improve gallery animations
Bugfixes:
- Handle right-to-left text in display names
- Fix crash during gifv playback
- iPadOS: Fix app becoming unresponsive when switching accounts
- iPadOS/macOS: Fix Cmd+R shortcuts not working
## 2024.4 ## 2024.4
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements. This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.

View File

@ -1,5 +1,22 @@
# Changelog # Changelog
## 2024.5 (141)
Bugfixes:
- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices
- Fix gallery controls being positioned incorrectly in landscape orientations
## 2024.5 (139)
Bugfixes:
- Fix error decoding certain posts
## 2024.5 (138)
Bugfixes:
- Fix potential crash when displaying certain attachments
- Fix potential crash due to race condition when opening push notification in app
- Fix misaligned text between profile field values/labels
- Fix rate limited error message not including reset timestamp
- iPadOS/macOS: Fix Cmd+R shortcut not working
## 2024.5 (137) ## 2024.5 (137)
Features/Improvements: Features/Improvements:
- Improve gallery presentation/dismissal transitions - Improve gallery presentation/dismissal transitions

View File

@ -371,13 +371,14 @@ private struct HTMLCallbacks: HTMLConversionCallbacks {
// Converting WebURL to URL is a small but non-trivial expense (since it works by // Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again), // serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip. // so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) { if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url url
} else if let web = WebURL(string), } else if let web = WebURL(string),
let url = URL(web) { let url = URL(web) {
url url
} else { } else {
nil URL(string: string)
} }
} }

View File

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

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

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

View File

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

View File

@ -9,6 +9,15 @@
import UIKit import UIKit
import AVFoundation import AVFoundation
@propertyWrapper
final class Box<T> {
var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
class VideoControlsViewController: UIViewController { class VideoControlsViewController: UIViewController {
private static let formatter: DateComponentsFormatter = { private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter() let f = DateComponentsFormatter()
@ -18,6 +27,9 @@ class VideoControlsViewController: UIViewController {
}() }()
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private lazy var muteButton: MuteButton = { private lazy var muteButton: MuteButton = {
let button = MuteButton() let button = MuteButton()
@ -51,8 +63,13 @@ class VideoControlsViewController: UIViewController {
private lazy var optionsButton = MenuButton { [unowned self] in private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String let imageName: String
#if os(visionOS)
let playbackSpeed = player.defaultRate
#else
let playbackSpeed = self.playbackSpeed
#endif
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
switch player.defaultRate { switch playbackSpeed {
case 0.5: case 0.5:
imageName = "gauge.with.dots.needle.0percent" imageName = "gauge.with.dots.needle.0percent"
case 1: case 1:
@ -68,8 +85,12 @@ class VideoControlsViewController: UIViewController {
imageName = "speedometer" imageName = "speedometer"
} }
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 { if self.player.rate > 0 {
self.player.rate = speed.rate self.player.rate = speed.rate
} }
@ -99,11 +120,20 @@ class VideoControlsViewController: UIViewController {
private var scrubbingTargetTime: CMTime? private var scrubbingTargetTime: CMTime?
private var isSeeking = false private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) { init(player: AVPlayer) {
self.player = player self.player = player
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -187,7 +217,11 @@ class VideoControlsViewController: UIViewController {
@objc private func scrubbingEnded() { @objc private func scrubbingEnded() {
scrubbingChanged() scrubbingChanged()
if wasPlayingWhenScrubbingStarted { if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }

View File

@ -16,6 +16,11 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
public private(set) var item: AVPlayerItem public private(set) var item: AVPlayerItem
public let player: AVPlayer public let player: AVPlayer
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
#endif
private var presentationSizeObservation: NSKeyValueObservation? private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation? private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation?
@ -165,12 +170,20 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
isShowingError ? .fade : .fromSourceViewWithoutSnapshot isShowingError ? .fade : .fromSourceViewWithoutSnapshot
} }
#if os(visionOS)
private lazy var overlayVC = VideoOverlayViewController(player: player) private lazy var overlayVC = VideoOverlayViewController(player: player)
#else
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public var contentOverlayAccessoryViewController: UIViewController? { public var contentOverlayAccessoryViewController: UIViewController? {
overlayVC overlayVC
} }
#if os(visionOS)
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player) public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if !isShowingError { if !isShowingError {

View File

@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController {
private static let pauseImage = UIImage(systemName: "pause.fill")! private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private var dimmingView: UIView! private var dimmingView: UIView!
private var controlsStack: UIStackView! private var controlsStack: UIStackView!
@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController {
private var rateObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation?
#if os(visionOS)
init(player: AVPlayer) { init(player: AVPlayer) {
self.player = player self.player = player
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController {
if player.currentTime() >= player.currentItem!.duration { if player.currentTime() >= player.currentItem!.duration {
player.seek(to: .zero) player.seek(to: .zero)
} }
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }

View File

@ -11,6 +11,7 @@ import AVFoundation
@MainActor @MainActor
protocol GalleryItemViewControllerDelegate: AnyObject { protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool func isGalleryBeingPresented() -> Bool
func isGalleryBeingDismissed() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController) func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
@ -376,13 +377,27 @@ class GalleryItemViewController: UIViewController {
} }
private func updateTopControlsInsets() { private func updateTopControlsInsets() {
guard delegate?.isGalleryBeingDismissed() != true else {
return
}
let notchedDeviceTopInsets: [CGFloat] = [ let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max 44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11 48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus 47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini 50, // iPhone 12 mini, 13 mini
] ]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) { let topInset: CGFloat
switch view.window?.windowScene?.interfaceOrientation {
case .portraitUpsideDown:
topInset = view.safeAreaInsets.bottom
case .landscapeLeft:
topInset = view.safeAreaInsets.right
case .landscapeRight:
topInset = view.safeAreaInsets.left
default:
topInset = view.safeAreaInsets.top
}
if notchedDeviceTopInsets.contains(topInset) {
// the notch width is not the same for the iPhones 13, // the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges // but what we actually want is the same offset from the edges
// since the corner radius didn't change // since the corner radius didn't change
@ -391,7 +406,7 @@ class GalleryItemViewController: UIViewController {
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2 let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset closeButtonTrailingConstraint.constant = offset
} else if view.safeAreaInsets.top == 0 { } else if topInset == 0 {
// square corner devices // square corner devices
shareButtonLeadingConstraint.constant = 8 shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8 shareButtonTopConstraint.constant = 8

View File

@ -139,6 +139,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
isBeingPresented isBeingPresented
} }
func isGalleryBeingDismissed() -> Bool {
isBeingDismissed
}
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) { func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
presentationAnimationCompletionHandlers.append(block) presentationAnimationCompletionHandlers.append(block)
} }

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Pachyderm", name: "Pachyderm",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v15),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -25,27 +25,30 @@ public struct Client: Sendable {
public var timeoutInterval: TimeInterval = 60 public var timeoutInterval: TimeInterval = 60
static let decoder: JSONDecoder = { private static let dateFormatter: DateFormatter = {
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")
let iso8601 = ISO8601DateFormatter() return formatter
}()
private static let iso8601Formatter = ISO8601DateFormatter()
private static func decodeDate(string: String) -> Date? {
// for the next time mastodon accidentally changes date formats >.>
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
}
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) in 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)
// for the next time mastodon accidentally changes date formats >.> if let date = Self.decodeDate(string: str) {
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
}() }()
@ -105,6 +108,15 @@ 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
@ -575,6 +587,8 @@ 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))"
} }
} }
} }
@ -585,6 +599,7 @@ 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

@ -11,9 +11,15 @@ import Foundation
public struct NodeInfo: Decodable, Sendable, Equatable { public struct NodeInfo: Decodable, Sendable, Equatable {
public let version: String public let version: String
public let software: Software public let software: Software
public let metadata: Metadata
public struct Software: Decodable, Sendable, Equatable { public struct Software: Decodable, Sendable, Equatable {
public let name: String public let name: String
public let version: String public let version: String
} }
public struct Metadata: Decodable, Sendable, Equatable {
public let nodeName: String
public let nodeDescription: String
}
} }

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])!]) XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
} }
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])!, NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB])!, NotificationGroup(notifications: [likeB], kind: .favourite)!,
]) ])
} }
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])!, NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB])!, NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2])!, NotificationGroup(notifications: [likeA2], kind: .favourite)!,
]) ])
} }
func testMergeSimpleGroups() { func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1])! let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeA2])! let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
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])! NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
]) ])
} }
func testMergeGroupsWithOtherGroupableInBetween() { func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])! let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeB])! let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
let group3 = NotificationGroup(notifications: [likeA2])! let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
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])!, NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB])!, NotificationGroup(notifications: [likeB], kind: .favourite)!,
]) ])
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])!, NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB])!, NotificationGroup(notifications: [likeB], kind: .favourite)!,
]) ])
let group4 = NotificationGroup(notifications: [likeB2])! let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
let group5 = NotificationGroup(notifications: [mentionB])! let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
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]), NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
group3 group3
]) ])
} }
func testDontMergeWithUngroupableInBetween() { func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])! let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [mentionB])! let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
let group3 = NotificationGroup(notifications: [likeA2])! let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
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])!, NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB])!, NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2])!, NotificationGroup(notifications: [likeA2], kind: .favourite)!,
]) ])
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -141,6 +141,7 @@
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; }; D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; }; D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; }; D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
@ -332,7 +333,7 @@
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; }; D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
@ -567,6 +568,7 @@
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; }; D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; }; D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
@ -768,7 +770,7 @@
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; }; D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = "<group>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
@ -1476,7 +1478,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLabel.swift */, D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1613,6 +1615,7 @@
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */, D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
@ -2287,7 +2290,7 @@
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */, D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
@ -2371,6 +2374,7 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */, D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
@ -2527,7 +2531,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2560,7 +2563,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2592,7 +2594,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2683,7 +2684,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2727,7 +2727,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2750,7 +2750,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2778,7 +2777,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2807,7 +2805,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2836,7 +2833,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2992,7 +2988,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3025,7 +3020,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3090,7 +3084,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3110,7 +3104,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3133,7 +3127,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3158,7 +3151,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

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

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

@ -48,13 +48,14 @@ extension HTMLConverter {
// Converting WebURL to URL is a small but non-trivial expense (since it works by // Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again), // serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip. // so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) { if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url url
} else if let web = WebURL(string), } else if let web = WebURL(string),
let url = URL(web) { let url = URL(web) {
url url
} else { } else {
nil URL(string: string)
} }
} }

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

@ -20,10 +20,14 @@ struct AnnouncementListRow: View {
@State private var isShowingAddReactionSheet = false @State private var isShowingAddReactionSheet = false
var body: some View { var body: some View {
mostOfTheBody if #available(iOS 16.0, *) {
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in mostOfTheBody
dimension[.leading] .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
}) dimension[.leading]
})
} else {
mostOfTheBody
}
} }
private var mostOfTheBody: some View { private var mostOfTheBody: some View {
@ -50,7 +54,11 @@ struct AnnouncementListRow: View {
Label { Label {
Text("Add Reaction") Text("Add Reaction")
} icon: { } icon: {
Image("face.smiling.badge.plus") if #available(iOS 16.0, *) {
Image("face.smiling.badge.plus")
} else {
Image(systemName: "face.smiling")
}
} }
} }
.labelStyle(.iconOnly) .labelStyle(.iconOnly)

View File

@ -484,3 +484,11 @@ 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

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

@ -16,15 +16,11 @@ struct TrendingLinkCardView: View {
let card: Card let card: Card
private var imageURL: URL? { private var imageURL: URL? {
if let image = card.image { card.image.flatMap { URL($0) }
URL(image)
} else {
nil
}
} }
private var descriptionText: String { private var descriptionText: String {
var converter = TextConverter(configuration: .init(insertNewlines: false)) let converter = TextConverter(configuration: .init(insertNewlines: false))
return converter.convert(html: card.description) return converter.convert(html: card.description)
} }

View File

@ -525,12 +525,12 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { @available(iOS, obsoleted: 16.0)
guard indexPaths.count == 1, @available(visionOS 1.0, *)
let item = dataSource.itemIdentifier(for: indexPaths[0]) else { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil return nil
} }
let indexPath = indexPaths[0]
switch item { switch item {
case .loadingIndicator, .confirmLoadMoreStatuses(_): case .loadingIndicator, .confirmLoadMoreStatuses(_):
@ -584,6 +584,15 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
} }
// implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16
@available(iOS 16.0, visionOS 1.0, *)
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
guard indexPaths.count == 1 else {
return nil
}
return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point)
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
} }

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.titleMenuProvider = { [unowned self] suggested in navigationItem.renameDelegate = self
var children = suggested navigationItem.titleMenuProvider = { [unowned self] suggested in
children.append(contentsOf: self.listSettingsMenuElements()) var children = suggested
return UIMenu(children: children) children.append(contentsOf: self.listSettingsMenuElements())
return UIMenu(children: children)
}
} else {
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
// uncached so that menu always reflects the current state of the list
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
var elements = self.listSettingsMenuElements()
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
self.present($0, animated: true)
}).run()
}), at: 0)
elementHandler(elements)
})
]))
} }
} }

View File

@ -151,6 +151,22 @@ 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

@ -219,6 +219,19 @@ class MainSplitViewController: UISplitViewController {
@objc func handleComposeKeyCommand() { @objc func handleComposeKeyCommand() {
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)
}
}
} }

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

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

@ -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))),
]) ])
} }

View File

@ -180,3 +180,9 @@ 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

@ -75,13 +75,14 @@ class InstanceSelectorTableViewController: UITableViewController {
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item { switch item {
case let .selected(_, instance): case let .selected(_, info):
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
cell.updateUI(instance: instance) cell.updateUI(info: info)
return cell return cell
case let .recommended(instance): case let .recommended(instance):
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
cell.updateUI(instance: instance) let info = Info(host: instance.domain, description: instance.description, thumbnail: instance.proxiedThumbnailURL, adult: instance.category == "adult")
cell.updateUI(info: info)
return cell return cell
} }
}) })
@ -96,7 +97,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
@ -162,22 +165,20 @@ class InstanceSelectorTableViewController: UITableViewController {
return return
} }
let client = Client(baseURL: url, session: .appDefault) checkSpecificInstance(url: url) { (info) in
let request = Client.getInstanceV1()
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteSections([.selected]) snapshot.deleteSections([.selected])
} }
if case let .success(instance, _) = response { if let info {
if snapshot.indexOfSection(.recommendedInstances) != nil { if snapshot.indexOfSection(.recommendedInstances) != nil {
snapshot.insertSections([.selected], beforeSection: .recommendedInstances) snapshot.insertSections([.selected], beforeSection: .recommendedInstances)
} else { } else {
snapshot.appendSections([.selected]) snapshot.appendSections([.selected])
} }
snapshot.appendItems([.selected(url, instance)], toSection: .selected) snapshot.appendItems([.selected(url, info)], toSection: .selected)
DispatchQueue.main.async { DispatchQueue.main.async {
self.dataSource.apply(snapshot) { self.dataSource.apply(snapshot) {
@ -192,6 +193,29 @@ class InstanceSelectorTableViewController: UITableViewController {
} }
} }
private func checkSpecificInstance(url: URL, completionHandler: @escaping (Info?) -> Void) {
let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstanceV1()
client.run(request) { response in
switch response {
case .success(let instance, _):
let host = url.host ?? URLComponents(string: instance.uri)?.host ?? instance.uri
let info = Info(host: host, description: instance.shortDescription ?? instance.description, thumbnail: instance.thumbnail, adult: false)
completionHandler(info)
case .failure(_):
Task {
do {
let nodeInfo = try await client.nodeInfo()
let info = Info(host: url.host!, description: nodeInfo.metadata.nodeDescription, thumbnail: nil, adult: false)
completionHandler(info)
} catch {
completionHandler(nil)
}
}
}
}
}
private func loadRecommendedInstances() { private func loadRecommendedInstances() {
InstanceSelector.getInstances(category: nil) { (response) in InstanceSelector.getInstances(category: nil) { (response) in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -310,13 +334,13 @@ extension InstanceSelectorTableViewController {
case recommendedInstances case recommendedInstances
} }
enum Item: Equatable, Hashable, Sendable { enum Item: Equatable, Hashable, Sendable {
case selected(URL, InstanceV1) case selected(URL, Info)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.selected(urlA, instanceA), .selected(urlB, instanceB)): case let (.selected(urlA, _), .selected(urlB, _)):
return urlA == urlB && instanceA.uri == instanceB.uri return urlA == urlB
case let (.recommended(a), .recommended(b)): case let (.recommended(a), .recommended(b)):
return a.domain == b.domain return a.domain == b.domain
default: default:
@ -326,16 +350,21 @@ extension InstanceSelectorTableViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .selected(url, instance): case let .selected(url, _):
hasher.combine(0) hasher.combine(0)
hasher.combine(url) hasher.combine(url)
hasher.combine(instance.uri)
case let .recommended(instance): case let .recommended(instance):
hasher.combine(1) hasher.combine(1)
hasher.combine(instance.domain) hasher.combine(instance.domain)
} }
} }
} }
struct Info: Hashable {
let host: String
let description: String
let thumbnail: URL?
let adult: Bool
}
} }
extension InstanceSelectorTableViewController: UISearchResultsUpdating { extension InstanceSelectorTableViewController: UISearchResultsUpdating {

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

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

@ -393,3 +393,9 @@ extension ProfileViewController: StatusBarTappableViewController {
return currentViewController.handleStatusBarTapped(xPosition: xPosition) return currentViewController.handleStatusBarTapped(xPosition: xPosition)
} }
} }
extension ProfileViewController: RefreshableViewController {
func refresh() {
currentViewController.refresh()
}
}

View File

@ -69,30 +69,34 @@ private struct ScrollBackgroundModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View { func body(content: Content) -> some View {
content if #available(iOS 16.0, *) {
.scrollContentBackground(.hidden) content
.background { .scrollContentBackground(.hidden)
// otherwise the pureBlackDarkMode isn't propagated, for some reason? .background {
// even though it is for ReportSelectRulesView?? // otherwise the pureBlackDarkMode isn't propagated, for some reason?
let traits: UITraitCollection = { // even though it is for ReportSelectRulesView??
var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) let traits: UITraitCollection = {
#if os(visionOS) var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light)
t = t.modifyingTraits({ mutableTraits in #if os(visionOS)
mutableTraits.pureBlackDarkMode = true
})
#else
if #available(iOS 17.0, *) {
t = t.modifyingTraits({ mutableTraits in t = t.modifyingTraits({ mutableTraits in
mutableTraits.pureBlackDarkMode = true mutableTraits.pureBlackDarkMode = true
}) })
} else { #else
t.obsoletePureBlackDarkMode = true if #available(iOS 17.0, *) {
} t = t.modifyingTraits({ mutableTraits in
#endif mutableTraits.pureBlackDarkMode = true
return t })
}() } else {
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits)) t.obsoletePureBlackDarkMode = true
.edgesIgnoringSafeArea(.all) }
} #endif
return t
}()
Color(uiColor: .appGroupedBackground.resolvedColor(with: traits))
.edgesIgnoringSafeArea(.all)
}
} else {
content
}
} }
} }

View File

@ -49,12 +49,26 @@ struct ReportSelectRulesView: View {
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
.scrollContentBackground(.hidden) .withAppBackgroundIfAvailable()
.background(Color.appGroupedBackground)
.navigationTitle("Rules") .navigationTitle("Rules")
} }
} }
private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func withAppBackgroundIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground)
} else {
self
}
}
}
//struct ReportSelectRulesView_Previews: PreviewProvider { //struct ReportSelectRulesView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {
// ReportSelectRulesView() // ReportSelectRulesView()

View File

@ -27,11 +27,18 @@ struct ReportView: View {
} }
var body: some View { var body: some View {
NavigationStack { if #available(iOS 16.0, *) {
navigationViewContent NavigationStack {
#if !os(visionOS) navigationViewContent
.scrollDismissesKeyboard(.interactively) #if !os(visionOS)
#endif .scrollDismissesKeyboard(.interactively)
#endif
}
} else {
NavigationView {
navigationViewContent
}
.navigationViewStyle(.stack)
} }
} }

View File

@ -39,7 +39,9 @@ class MastodonSearchController: UISearchController {
searchResultsUpdater = searchResultsController searchResultsUpdater = searchResultsController
automaticallyShowsSearchResultsController = false automaticallyShowsSearchResultsController = false
showsSearchResultsController = true showsSearchResultsController = true
scopeBarActivation = .onSearchActivation if #available(iOS 16.0, *) {
scopeBarActivation = .onSearchActivation
}
searchBar.autocapitalizationType = .none searchBar.autocapitalizationType = .none
searchBar.delegate = self searchBar.delegate = self
@ -76,8 +78,12 @@ class MastodonSearchController: UISearchController {
if searchText != defaultLanguage, if searchText != defaultLanguage,
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) { let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let identifier = (searchText as NSString).substring(with: match.range(at: 1)) let identifier = (searchText as NSString).substring(with: match.range(at: 1))
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) { if #available(iOS 16.0, *) {
langSuggestions.append("language:\(identifier)") if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
langSuggestions.append("language:\(identifier)")
}
} else if searchText != "en" {
langSuggestions.append("language:\(searchText)")
} }
} }
suggestions.append((.language, langSuggestions)) suggestions.append((.language, langSuggestions))

View File

@ -212,3 +212,9 @@ extension TimelinesPageViewController: StateRestorableViewController {
return (currentViewController as? TimelineViewController)?.stateRestorationActivity() return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
} }
} }
extension TimelinesPageViewController: RefreshableViewController {
func refresh() {
(currentViewController as? RefreshableViewController)?.refresh()
}
}

View File

@ -22,7 +22,8 @@ class EnhancedNavigationViewController: UINavigationController {
override var viewControllers: [UIViewController] { override var viewControllers: [UIViewController] {
didSet { didSet {
poppedViewControllers = [] poppedViewControllers = []
if useBrowserStyleNavigation { if #available(iOS 16.0, *),
useBrowserStyleNavigation {
// TODO: this for loop might not be necessary // TODO: this for loop might not be necessary
for vc in viewControllers { for vc in viewControllers {
configureNavItem(vc.navigationItem) configureNavItem(vc.navigationItem)
@ -39,7 +40,8 @@ class EnhancedNavigationViewController: UINavigationController {
self.interactivePushTransition = InteractivePushTransition(navigationController: self) self.interactivePushTransition = InteractivePushTransition(navigationController: self)
#endif #endif
if useBrowserStyleNavigation, if #available(iOS 16.0, *),
useBrowserStyleNavigation,
let topViewController { let topViewController {
configureNavItem(topViewController.navigationItem) configureNavItem(topViewController.navigationItem)
updateTopNavItemState() updateTopNavItemState()
@ -50,7 +52,9 @@ class EnhancedNavigationViewController: UINavigationController {
let popped = performAfterAnimating(block: { let popped = performAfterAnimating(block: {
super.popViewController(animated: animated) super.popViewController(animated: animated)
}, after: { }, after: {
self.updateTopNavItemState() if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated) }, animated: animated)
if let popped { if let popped {
poppedViewControllers.insert(popped, at: 0) poppedViewControllers.insert(popped, at: 0)
@ -62,7 +66,9 @@ class EnhancedNavigationViewController: UINavigationController {
let popped = performAfterAnimating(block: { let popped = performAfterAnimating(block: {
super.popToRootViewController(animated: animated) super.popToRootViewController(animated: animated)
}, after: { }, after: {
self.updateTopNavItemState() if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated) }, animated: animated)
if let popped { if let popped {
poppedViewControllers = popped poppedViewControllers = popped
@ -74,7 +80,9 @@ class EnhancedNavigationViewController: UINavigationController {
let popped = performAfterAnimating(block: { let popped = performAfterAnimating(block: {
super.popToViewController(viewController, animated: animated) super.popToViewController(viewController, animated: animated)
}, after: { }, after: {
self.updateTopNavItemState() if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated) }, animated: animated)
if let popped { if let popped {
poppedViewControllers.insert(contentsOf: popped, at: 0) poppedViewControllers.insert(contentsOf: popped, at: 0)
@ -89,11 +97,15 @@ class EnhancedNavigationViewController: UINavigationController {
self.poppedViewControllers = [] self.poppedViewControllers = []
} }
configureNavItem(viewController.navigationItem) if #available(iOS 16.0, *) {
configureNavItem(viewController.navigationItem)
}
super.pushViewController(viewController, animated: animated) super.pushViewController(viewController, animated: animated)
updateTopNavItemState() if #available(iOS 16.0, *) {
updateTopNavItemState()
}
} }
func pushPoppedViewController() { func pushPoppedViewController() {
@ -123,7 +135,9 @@ class EnhancedNavigationViewController: UINavigationController {
pushViewController(target, animated: true) pushViewController(target, animated: true)
}, after: { }, after: {
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1) self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
self.updateTopNavItemState() if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: true) }, animated: true)
} }

View File

@ -204,7 +204,8 @@ extension MenuActionProvider {
}), }),
] ]
if includeStatusButtonActions { if #available(iOS 16.0, *),
includeStatusButtonActions {
let favorited = status.favourited let favorited = status.favourited
// TODO: move this color into an asset catalog or something // TODO: move this color into an asset catalog or something
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")! var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
@ -367,11 +368,19 @@ extension MenuActionProvider {
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)) addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
let toggleableAndActions = toggleableSection + actionsSection if #available(iOS 16.0, *) {
return [ let toggleableAndActions = toggleableSection + actionsSection
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions), return [
UIMenu(options: .displayInline, children: shareSection), UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
] UIMenu(options: .displayInline, children: shareSection),
]
} else {
return [
UIMenu(options: .displayInline, children: shareSection),
UIMenu(options: .displayInline, children: toggleableSection),
UIMenu(options: .displayInline, children: actionsSection),
]
}
} }
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] { func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {

View File

@ -108,7 +108,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
} }
func compose(editing draft: Draft) { func compose(editing draft: Draft) {
if UIDevice.current.userInterfaceIdiom == .phone { if #available(iOS 16.0, *),
UIDevice.current.userInterfaceIdiom == .phone {
self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil) self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil)
} else { } else {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -122,7 +123,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
precondition(state > .initial) precondition(state > .initial)
navigation.run() navigation.run()
#if !os(visionOS) #if !os(visionOS)
if let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) { if #available(iOS 16.0, *),
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil) self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil)
} }
#endif #endif

View File

@ -114,7 +114,8 @@ extension TuskerNavigationDelegate {
#if os(visionOS) #if os(visionOS)
fatalError("unreachable") fatalError("unreachable")
#else #else
if presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) { if #available(iOS 16.0, *),
presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
return return
} else { } else {
present(compose, animated: animated, completion: completion) present(compose, animated: animated, completion: completion)

View File

@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -41,7 +40,7 @@ struct AccountDisplayNameView: View {
guard !matches.isEmpty else { return } guard !matches.isEmpty else { return }
let emojiSize = self.emojiSize let emojiSize = self.emojiSize
let emojiImages = OSAllocatedUnfairLock(initialState: [String: Image]()) let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup() let group = DispatchGroup()
@ -64,9 +63,7 @@ struct AccountDisplayNameView: View {
image.draw(in: CGRect(origin: .zero, size: size)) image.draw(in: CGRect(origin: .zero, size: size))
} }
emojiImages.withLock { emojiImages[emoji.shortcode] = Image(uiImage: resized)
$0[emoji.shortcode] = Image(uiImage: resized)
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
@ -81,7 +78,7 @@ struct AccountDisplayNameView: View {
// iterate backwards as to not alter the indices of earlier matches // iterate backwards as to not alter the indices of earlier matches
for match in matches.reversed() { for match in matches.reversed() {
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1)) let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
guard let image = emojiImages.withLock({ $0[shortcode] }) else { continue } guard let image = emojiImages[shortcode] else { continue }
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound)) let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))

View File

@ -263,7 +263,17 @@ class AttachmentView: GIFImageView {
let asset = AVURLAsset(url: attachment.url) let asset = AVURLAsset(url: attachment.url)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let image = try? await generator.image(at: .zero).image, let image: CGImage?
#if os(visionOS)
image = try? await generator.image(at: .zero).image
#else
if #available(iOS 16.0, *) {
image = try? await generator.image(at: .zero).image
} else {
image = try? generator.copyCGImage(at: .zero, actualTime: nil)
}
#endif
guard let image,
let prepared = await UIImage(cgImage: image).byPreparingForDisplay(), let prepared = await UIImage(cgImage: image).byPreparingForDisplay(),
!Task.isCancelled else { !Task.isCancelled else {
return return
@ -402,7 +412,7 @@ class AttachmentView: GIFImageView {
makeBadgeView(text: "ALT") makeBadgeView(text: "ALT")
} }
if badges.contains(.noAlt) { if badges.contains(.noAlt) {
makeBadgeView(text: "No ALT") makeBadgeView(text: "NO ALT")
} }
let first = stack.arrangedSubviews.first! let first = stack.arrangedSubviews.first!

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -57,7 +56,7 @@ extension BaseEmojiLabel {
return imageSizeMatchingFontSize return imageSizeMatchingFontSize
} }
let emojiImages = OSAllocatedUnfairLock(initialState: [String: UIImage]()) let emojiImages = MultiThreadDictionary<String, UIImage>()
var foundEmojis = false var foundEmojis = false
let group = DispatchGroup() let group = DispatchGroup()
@ -80,11 +79,9 @@ extension BaseEmojiLabel {
// todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache // todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache
if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)), if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)),
let cgImage = thumbnail.cgImage { let cgImage = thumbnail.cgImage {
emojiImages.withLock { // the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert
// the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert // see FB12187798
// see FB12187798 emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
$0[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
}
} }
} else { } else {
// otherwise, perform the network request // otherwise, perform the network request
@ -102,9 +99,7 @@ extension BaseEmojiLabel {
group.leave() group.leave()
return return
} }
emojiImages.withLock { emojiImages[emoji.shortcode] = transformedImage
$0[emoji.shortcode] = transformedImage
}
group.leave() group.leave()
} }
} }

View File

@ -146,7 +146,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? { func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
if let textLayoutManager { if #available(iOS 16.0, *),
let textLayoutManager {
guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else { guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
return nil return nil
} }
@ -304,7 +305,8 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
// Determine the line rects that the link takes up in the coordinate space of this view. // Determine the line rects that the link takes up in the coordinate space of this view.
var rects = [CGRect]() var rects = [CGRect]()
if let textLayoutManager, if #available(iOS 16.0, *),
let textLayoutManager,
let contentManager = textLayoutManager.textContentManager { let contentManager = textLayoutManager.textContentManager {
// convert from NSRange to NSTextRange // convert from NSRange to NSTextRange
// i have no idea under what circumstances any of these calls could fail // i have no idea under what circumstances any of these calls could fail

View File

@ -1,5 +1,5 @@
// //
// CopyableLabel.swift // CopyableLable.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 2/4/23. // Created by Shadowfacts on 2/4/23.
@ -8,7 +8,7 @@
import UIKit import UIKit
class CopyableLabel: UILabel { class CopyableLable: UILabel {
private var _editMenuInteraction: Any! private var _editMenuInteraction: Any!
@available(iOS 16.0, *) @available(iOS 16.0, *)
@ -28,10 +28,12 @@ class CopyableLabel: UILabel {
} }
private func commonInit() { private func commonInit() {
editMenuInteraction = UIEditMenuInteraction(delegate: nil) if #available(iOS 16.0, *) {
addInteraction(editMenuInteraction) editMenuInteraction = UIEditMenuInteraction(delegate: nil)
isUserInteractionEnabled = true addInteraction(editMenuInteraction)
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed))) isUserInteractionEnabled = true
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
}
} }
override func copy(_ sender: Any?) { override func copy(_ sender: Any?) {

View File

@ -16,8 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
@IBOutlet weak var adultLabel: UILabel! @IBOutlet weak var adultLabel: UILabel!
@IBOutlet weak var descriptionTextView: ContentTextView! @IBOutlet weak var descriptionTextView: ContentTextView!
var instance: InstanceV1? private var instance: InstanceSelectorTableViewController.Info?
var selectorInstance: InstanceSelector.Instance?
private var thumbnailTask: Task<Void, Never>? private var thumbnailTask: Task<Void, Never>?
@ -44,25 +43,14 @@ class InstanceTableViewCell: UITableViewCell {
backgroundConfiguration = .appListGroupedCell(for: state) backgroundConfiguration = .appListGroupedCell(for: state)
} }
func updateUI(instance: InstanceSelector.Instance) { func updateUI(info: InstanceSelectorTableViewController.Info) {
self.selectorInstance = instance self.instance = info
self.instance = nil
domainLabel.text = instance.domain
adultLabel.isHidden = instance.category != "adult"
descriptionTextView.setBodyTextFromHTML(instance.description)
updateThumbnail(url: instance.proxiedThumbnailURL)
}
func updateUI(instance: InstanceV1) {
self.instance = instance
self.selectorInstance = nil
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri domainLabel.text = info.host
adultLabel.isHidden = true adultLabel.isHidden = !info.adult
descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description) descriptionTextView.setBodyTextFromHTML(info.description)
if let thumbnail = instance.thumbnail { if let thumbnail = info.thumbnail {
updateThumbnail(url: thumbnail) updateThumbnail(url: thumbnail)
} else { } else {
thumbnailImageView.image = nil thumbnailImageView.image = nil
@ -85,7 +73,6 @@ class InstanceTableViewCell: UITableViewCell {
thumbnailTask?.cancel() thumbnailTask?.cancel()
instance = nil instance = nil
selectorInstance = nil
} }
} }

View File

@ -12,11 +12,7 @@ import SwiftUI
import SafariServices import SafariServices
class ProfileFieldValueView: UIView { class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? { weak var navigationDelegate: TuskerNavigationDelegate?
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private static let converter = HTMLConverter( private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body), font: .preferredFont(forTextStyle: .body),
@ -28,8 +24,9 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO private let account: AccountMO
private let field: Account.Field private let field: Account.Field
private var link: (String, URL)?
private let textView = ContentTextView() private let label = EmojiLabel()
private var iconView: UIView? private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview? private var currentTargetedPreview: UITargetedPreview?
@ -42,28 +39,34 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
#if os(visionOS) converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
textView.linkTextAttributes = [ guard value != nil else { return }
.foregroundColor: UIColor.link if self.link == nil {
] self.link = (converted.attributedSubstring(from: range).string, value as! URL)
#else }
textView.linkTextAttributes = [ #if os(visionOS)
.foregroundColor: UIColor.tintColor converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
] #else
#endif converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
textView.backgroundColor = nil #endif
textView.isScrollEnabled = false // the .link attribute in a UILabel always makes the color blue >.>
textView.isSelectable = false converted.removeAttribute(.link, range: range)
textView.isEditable = false }
textView.font = .preferredFont(forTextStyle: .body)
updateTextContainerInset() if link != nil {
textView.adjustsFontForContentSizeCategory = true label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
textView.attributedText = converted label.addInteraction(UIContextMenuInteraction(delegate: self))
textView.setEmojis(account.emojis, identifier: account.id) label.isUserInteractionEnabled = true
textView.isUserInteractionEnabled = true }
textView.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false label.numberOfLines = 0
addSubview(textView) label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint let labelTrailingConstraint: NSLayoutConstraint
@ -80,20 +83,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link" icon.accessibilityLabel = "Verified link"
addSubview(icon) addSubview(icon)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor) labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor), icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
]) ])
} else { } else {
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor) labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor), label.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint, labelTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor), label.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
@ -102,36 +105,37 @@ class ProfileFieldValueView: UIView {
} }
override func sizeThatFits(_ size: CGSize) -> CGSize { override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = textView.sizeThatFits(size) var size = label.sizeThatFits(size)
if let iconView { if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
} }
return size return size
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
updateTextContainerInset()
}
}
private func updateTextContainerInset() {
// blergh
switch traitCollection.preferredContentSizeCategory {
case .extraSmall:
textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0)
case .small:
textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0)
case .medium, .large:
textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
default:
textView.textContainerInset = .zero
}
}
func setTextAlignment(_ alignment: NSTextAlignment) { func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
} }
@objc private func verifiedIconTapped() { @objc private func verifiedIconTapped() {
@ -141,7 +145,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView( let view = ProfileFieldVerificationView(
acct: account.acct, acct: account.acct,
verifiedAt: field.verifiedAt!, verifiedAt: field.verifiedAt!,
linkText: textView.text ?? "", linkText: label.text ?? "",
navigationDelegate: navigationDelegate navigationDelegate: navigationDelegate
) )
let host = UIHostingController(rootView: view) let host = UIHostingController(rootView: view)
@ -165,3 +169,49 @@ class ProfileFieldValueView: UIView {
navigationDelegate.present(toPresent, animated: true) navigationDelegate.present(toPresent, animated: true)
} }
} }
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {
return nil
}
if let hashtag {
return UIContextMenuConfiguration {
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
} actionProvider: { _ in
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
rect.origin.x = 0
rect.origin.y = (bounds.height - rect.height) / 2
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
let preview = UITargetedPreview(view: label, parameters: parameters)
currentTargetedPreview = preview
return preview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
return currentTargetedPreview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
}
}

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -124,16 +125,16 @@
</constraints> </constraints>
</stackView> </stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC"> <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
<rect key="frame" x="144" y="235" width="101.5" height="23"/> <rect key="frame" x="144" y="235" width="103.5" height="23"/>
<subviews> <subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL" customClass="CopyableLabel" customModule="Tusker" customModuleProvider="target"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL" customClass="CopyableLable" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/> <rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/> <fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC">
<rect key="frame" x="85" y="1.5" width="16.5" height="19.5"/> <rect key="frame" x="85" y="1.5" width="18.5" height="19.5"/>
<color key="tintColor" systemColor="secondaryLabelColor"/> <color key="tintColor" systemColor="secondaryLabelColor"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/> <preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/>
</imageView> </imageView>
@ -186,14 +187,14 @@
</view> </view>
</objects> </objects>
<resources> <resources>
<image name="ellipsis" catalog="system" width="32" height="32"/> <image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="lock.fill" catalog="system" width="32" height="32"/> <image name="lock.fill" catalog="system" width="125" height="128"/>
<image name="person.badge.plus" catalog="system" width="32" height="32"/> <image name="person.badge.plus" catalog="system" width="128" height="124"/>
<systemColor name="labelColor"> <systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor> </systemColor>
<systemColor name="secondaryLabelColor"> <systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor> </systemColor>
<systemColor name="systemBackgroundColor"> <systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>

View File

@ -143,7 +143,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.isEditable = false $0.isEditable = false
$0.isSelectable = true $0.isSelectable = true
$0.emojiFont = ConversationMainStatusCollectionViewCell.contentFont $0.emojiFont = ConversationMainStatusCollectionViewCell.contentFont
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber, .money, .physicalValue] $0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
$0.dataDetectorTypes.formUnion([.money, .physicalValue])
}
} }
private var translateButton: TranslateButton? private var translateButton: TranslateButton?

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.5 MARKETING_VERSION = 2024.5
CURRENT_PROJECT_VERSION = 137 CURRENT_PROJECT_VERSION = 141
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev