Compare commits

..

No commits in common. "381f3ee737ade12066987c34526869987e32162e" and "5f6699749ca9806cbf6eb5585750199a4b0db433" have entirely different histories.

74 changed files with 1035 additions and 663 deletions

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

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

View File

@ -30,14 +30,20 @@ enum ToolbarElement {
} }
private struct FocusedComposeInput: FocusedValueKey { private struct FocusedComposeInput: FocusedValueKey {
typealias Value = any ComposeInput typealias Value = (any ComposeInput)?
} }
extension FocusedValues { extension FocusedValues {
var composeInput: (any ComposeInput)? { // This double optional is unfortunate, but avoiding it requires iOS 16 API
fileprivate var _composeInput: (any ComposeInput)?? {
get { self[FocusedComposeInput.self] } get { self[FocusedComposeInput.self] }
set { self[FocusedComposeInput.self] = newValue } set { self[FocusedComposeInput.self] = newValue }
} }
var composeInput: (any ComposeInput)? {
get { _composeInput ?? nil }
set { _composeInput = newValue }
}
} }
@propertyWrapper @propertyWrapper
@ -66,6 +72,6 @@ struct FocusedInputModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.environment(\.composeInputBox, box) .environment(\.composeInputBox, box)
.focusedValue(\.composeInput, box.wrappedValue) .focusedValue(\._composeInput, box.wrappedValue)
} }
} }

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

@ -181,7 +181,7 @@ extension EnvironmentValues {
} }
} }
struct GIFViewWrapper: UIViewRepresentable { private struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView typealias UIViewType = GIFImageView
@State var controller: GIFController @State var controller: GIFController

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)
} }
@ -328,6 +330,10 @@ public final class ComposeController: ViewController {
ControllerView(controller: { controller.toolbarController }) ControllerView(controller: { controller.toolbarController })
#endif #endif
} }
#if !os(visionOS)
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
#endif
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
} }
} }
@ -436,7 +442,7 @@ public final class ComposeController: ViewController {
} }
.listStyle(.plain) .listStyle(.plain)
#if !os(visionOS) #if !os(visionOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboardInteractivelyIfAvailable()
#endif #endif
.disabled(controller.isPosting) .disabled(controller.isPosting)
} }
@ -487,6 +493,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
}
}
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

@ -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,174 +6,12 @@
// //
import SwiftUI import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentRowView: View { struct AttachmentRowView: View {
@ObservedObject var attachment: DraftAttachment @ObservedObject var attachment: DraftAttachment
@State private var isRecognizingText = false
@State private var textRecognitionError: (any Error)?
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 4) { Text(attachment.id.uuidString)
thumbnailView
descriptionView
}
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
// TODO: attachments missing descriptions feature
private var thumbnailView: some View {
AttachmentThumbnailView(attachment: attachment)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: thumbnailSize, height: thumbnailSize)
.contextMenu {
EditDrawingButton(attachment: attachment)
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
DeleteButton(attachment: attachment)
} preview: {
// TODO: need to fix flash of preview changing size
AttachmentThumbnailView(attachment: attachment)
}
}
@ViewBuilder
private var descriptionView: some View {
if isRecognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
}
}
}
private struct EditDrawingButton: View {
@ObservedObject var attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if attachment.drawingData != nil {
Button(action: editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
}
private func editDrawing() {
if case .drawing(let drawing) = attachment.data {
presentDrawing?(drawing) {
attachment.drawing = $0
}
}
}
}
private struct RecognizeTextButton: View {
@ObservedObject var attachment: DraftAttachment
@Binding var isRecognizingText: Bool
@Binding var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
if attachment.type == .image {
Button {
Task {
await recognizeText()
}
} label: {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
}
private func recognizeText() async {
isRecognizingText = true
defer { isRecognizingText = false }
do {
let data = try await getAttachmentData()
let observations = try await runRecognizeTextRequest(data: data)
if let observations {
var text = ""
for observation in observations {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
} catch let error as NSError where error.domain == VNErrorDomain && error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
self.error = error
}
}
private func getAttachmentData() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
attachment.getData(features: instanceFeatures) { result in
switch result {
case .success(let (data, _)):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
return try await withCheckedThrowingContinuation { continuation in
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
try? handler.perform([request])
}
}
}
}
private struct DeleteButton: View {
let attachment: DraftAttachment
var body: some View {
Button(role: .destructive, action: removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
private func removeAttachment() {
let draft = attachment.draft
var array = draft.draftAttachments
guard let index = array.firstIndex(of: attachment) else {
return
}
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
} }
} }

View File

@ -1,114 +0,0 @@
//
// AttachmentThumbnailView.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import TuskerComponents
import AVFoundation
import Photos
struct AttachmentThumbnailView: View {
let attachment: DraftAttachment
let contentMode: ContentMode = .fit
@State private var mode: Mode = .empty
@EnvironmentObject private var composeController: ComposeController
var body: some View {
switch mode {
case .empty:
Image(systemName: "photo")
.task {
await loadThumbnail()
}
case .image(let image):
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
case .gifController(let controller):
GIFViewWrapper(controller: controller)
}
}
private func loadThumbnail() async {
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
if let image = await composeController.fetchAttachment(url) {
self.mode = .image(image)
}
case .video, .gifv:
await loadVideoThumbnail(url: url)
case .audio, .unknown:
break
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
let isGIF = PHAssetResource.assetResources(for: asset).contains {
$0.uniformTypeIdentifier == UTType.gif.identifier
}
if isGIF {
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.mode = .gifController(GIFController(gifData: data))
} else if let image = UIImage(data: data) {
self.mode = .image(image)
}
}
} else {
let size = CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
if let image {
self.mode = .image(image)
}
}
}
case .drawing(let drawing):
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
case .file(let url, let type):
if type.conforms(to: .movie) {
await loadVideoThumbnail(url: url)
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.mode = .gifController(GIFController(gifData: data))
} else if type.conforms(to: .image) {
if let image = UIImage(data: data),
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
let prepared = await image.byPreparingForDisplay() {
self.mode = .image(prepared)
}
}
}
case .none:
break
}
}
private func loadVideoThumbnail(url: URL) async {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
}
enum Mode {
case empty
case image(UIImage)
case gifController(GIFController)
}
}

View File

@ -28,15 +28,95 @@ struct AttachmentsListView: View {
} }
var body: some View { var body: some View {
WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: callbacks, canAddAttachment: canAddAttachment) if #available(iOS 16.0, *) {
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: callbacks, canAddAttachment: canAddAttachment)
// view from laying out, and leaving the intrinsic content size at zero too. // Impose a minimum height, because otherwise it defaults to zero which prevents the collection
.frame(minHeight: 50) // view from laying out, and leaving the intrinsic content size at zero too.
.padding(.horizontal, -8) .frame(minHeight: 50)
.environmentObject(instanceFeatures) .padding(.horizontal, -8)
} else {
LegacyAttachmentsList(draft: draft, callbacks: callbacks, canAddAttachment: canAddAttachment)
}
} }
} }
@available(iOS, obsoleted: 16.0)
private struct LegacyAttachmentsList: View {
@ObservedObject var draft: Draft
let callbacks: Callbacks
let canAddAttachment: Bool
@State private var attachmentHeights = [NSManagedObjectID: CGFloat]()
private var totalHeight: CGFloat {
let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding)
let rowHeights = draft.attachments.compactMap {
attachmentHeights[($0 as! NSManagedObject).objectID]
}.reduce(0) { partialResult, height in
partialResult + height + AttachmentsListPaddingModifier.cellPadding
}
return buttonsHeight + rowHeights
}
var body: some View {
List {
content
}
.listStyle(.plain)
.frame(height: totalHeight)
.scrollDisabledIfAvailable(true)
}
@ViewBuilder
private var content: some View {
ForEach(draft.draftAttachments) { attachment in
AttachmentRowView(attachment: attachment)
.modifier(AttachmentRowHeightModifier(height: $attachmentHeights[attachment.objectID]))
}
.onMove(perform: self.moveAttachments)
.onDelete(perform: self.removeAttachments)
AddPhotoButton(canAddAttachment: canAddAttachment, draft: draft, insertAttachments: self.insertAttachments)
AddDrawingButton(canAddAttachment: canAddAttachment)
TogglePollButton(poll: draft.poll)
}
// TODO: move this to Callbacks
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
guard self.canAddAttachment else {
return
}
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
// TODO: move this to Callbacks
private func moveAttachments(from source: IndexSet, to destination: Int) {
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
// results in the order switching back to the previous order and then to the correct one
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func removeAttachments(at indices: IndexSet) {
for index in indices {
callbacks.removeAttachment(at: index)
}
}
}
private struct Callbacks { private struct Callbacks {
let draft: Draft let draft: Draft
let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
@ -63,11 +143,6 @@ private struct Callbacks {
draft.attachments = NSMutableOrderedSet(array: array) draft.attachments = NSMutableOrderedSet(array: array)
} }
func reorderAttachments(with difference: CollectionDifference<DraftAttachment>) {
let array = draft.draftAttachments.applying(difference)!
draft.attachments = NSMutableOrderedSet(array: array)
}
func addPhoto() { func addPhoto() {
presentAssetPicker?() { presentAssetPicker?() {
insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider)) insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
@ -82,6 +157,104 @@ private struct Callbacks {
} }
} }
private struct AttachmentRowHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct AttachmentRowHeightModifier: ViewModifier {
@Binding var height: CGFloat?
func body(content: Content) -> some View {
content
.background {
GeometryReader { proxy in
Color.clear
// do the preference dance because onChange(of:inital:_:) is iOS 17+ :/
.preference(key: AttachmentRowHeightPreferenceKey.self, value: proxy.size.height)
.onPreferenceChange(AttachmentRowHeightPreferenceKey.self) { newValue in
height = newValue
}
}
}
}
}
private struct AttachmentsListPaddingModifier: ViewModifier {
static let cellPadding: CGFloat = 12
func body(content: Content) -> some View {
content
.listRowInsets(EdgeInsets(top: Self.cellPadding / 2, leading: Self.cellPadding / 2, bottom: Self.cellPadding / 2, trailing: Self.cellPadding / 2))
}
}
private struct AttachmentsListButton<Label: View>: View {
let action: () -> Void
@ViewBuilder let label: Label
var body: some View {
Button(action: action) {
label
}
.foregroundStyle(.tint)
.frame(height: 40)
.modifier(AttachmentsListPaddingModifier())
}
}
private struct AddPhotoButton: View {
let canAddAttachment: Bool
let draft: Draft
let insertAttachments: (Int, [NSItemProvider]) -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
var body: some View {
AttachmentsListButton {
presentAssetPicker?() { results in
insertAttachments(draft.attachments.count, results.map(\.itemProvider))
}
} label: {
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
}
.disabled(!canAddAttachment)
}
}
private struct AddDrawingButton: View {
let canAddAttachment: Bool
var body: some View {
AttachmentsListButton {
fatalError("TODO")
} label: {
Label("Draw something", systemImage: "hand.draw")
}
.disabled(!canAddAttachment)
}
}
private struct TogglePollButton: View {
let poll: Poll?
var canAddPoll: Bool {
// TODO
true
}
var body: some View {
AttachmentsListButton {
fatalError("TODO")
} label: {
Label(poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(!canAddPoll)
}
}
@available(iOS 16.0, *) @available(iOS 16.0, *)
private struct WrappedCollectionView: UIViewRepresentable { private struct WrappedCollectionView: UIViewRepresentable {
let attachments: [DraftAttachment] let attachments: [DraftAttachment]
@ -106,31 +279,6 @@ private struct WrappedCollectionView: UIViewRepresentable {
context.coordinator.dataSource = dataSource context.coordinator.dataSource = dataSource
view.delegate = context.coordinator view.delegate = context.coordinator
view.isScrollEnabled = false view.isScrollEnabled = false
dataSource.reorderingHandlers.canReorderItem = {
if case .attachment(_) = $0 {
true
} else {
false
}
}
dataSource.reorderingHandlers.didReorder = { transaction in
let attachmentsChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentsChanges)!
callbacks.reorderAttachments(with: attachmentsDiff)
}
let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized))
longPressRecognizer.delegate = context.coordinator
view.addGestureRecognizer(longPressRecognizer)
return view return view
} }
@ -219,7 +367,7 @@ private final class IntrinsicContentSizeCollectionView: UICollectionView {
} }
@available(iOS 16.0, *) @available(iOS 16.0, *)
private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate, UIGestureRecognizerDelegate { private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate {
var callbacks: Callbacks var callbacks: Callbacks
var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)? var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)?
@ -231,7 +379,7 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration { cell.contentConfiguration = UIHostingConfiguration {
AttachmentRowView(attachment: item) Text(item.id.uuidString)
} }
} }
@ -308,41 +456,6 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView
callbacks.togglePoll() callbacks.togglePoll()
} }
} }
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let attachmentsSection = snapshot.indexOfSection(.attachments)!
if proposedIndexPath.section != attachmentsSection {
return IndexPath(item: snapshot.itemIdentifiers(inSection: .attachments).count - 1, section: attachmentsSection)
} else {
return proposedIndexPath
}
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location) else {
return false
}
return collectionView.beginInteractiveMovementForItem(at: indexPath)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
collectionView.updateInteractiveMovementTargetPosition(recognizer.location(in: collectionView))
case .ended:
collectionView.endInteractiveMovement()
case .cancelled:
collectionView.cancelInteractiveMovement()
default:
break
}
}
} }

View File

@ -41,7 +41,7 @@ struct ComposeToolbarView: View {
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures) VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController) LocalOnlyButton(enabled: $draft.contentWarningEnabled, mastodonController: mastodonController)
InsertEmojiButton() InsertEmojiButton()
@ -74,7 +74,7 @@ private struct ToolbarScrollView<Content: View>: View {
} }
} }
} }
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0) .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background { .background {
GeometryReader { proxy in GeometryReader { proxy in
@ -246,7 +246,8 @@ private struct LangaugeButton: View {
@State private var hasChanged = false @State private var hasChanged = false
var body: some View { var body: some View {
if instanceFeatures.createStatusWithLanguage { if #available(iOS 16.0, *),
instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged) LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged) .onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
.onChange(of: draft.id) { _ in .onChange(of: draft.id) { _ in

View File

@ -15,8 +15,15 @@ struct ComposeView: View {
@EnvironmentObject private var controller: ComposeController @EnvironmentObject private var controller: ComposeController
var body: some View { var body: some View {
NavigationStack { if #available(iOS 16.0, *) {
navigationRoot NavigationStack {
navigationRoot
}
} else {
NavigationView {
navigationRoot
}
.navigationViewStyle(.stack)
} }
} }
@ -25,7 +32,7 @@ struct ComposeView: View {
ScrollView(.vertical) { ScrollView(.vertical) {
scrollContent scrollContent
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboardInteractivelyIfAvailable()
#if !os(visionOS) && !targetEnvironment(macCatalyst) #if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier()) .modifier(ToolbarSafeAreaInsetModifier())
#endif #endif

View File

@ -18,6 +18,7 @@ struct NewMainTextView: View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder) NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder)
.focused($focusedField, equals: .body) .focused($focusedField, equals: .body)
.modifier(FocusedInputModifier()) .modifier(FocusedInputModifier())
.modifier(HeightExpandingModifier(minHeight: Self.minHeight))
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if value.isEmpty { if value.isEmpty {
PlaceholderView() PlaceholderView()
@ -36,10 +37,18 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard @Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
// TODO: test textSelectionStartsAtBeginning // TODO: test textSelectionStartsAtBeginning
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning @Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
#if !os(visionOS)
@Environment(\.textViewContentHeight) @Binding private var textViewContentHeight
#endif
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let view: UITextView
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = WrappedTextView(usingTextLayoutManager: true) if #available(iOS 16.0, *) {
view = WrappedTextView(usingTextLayoutManager: true)
} else {
view = WrappedTextView()
}
view.delegate = context.coordinator view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping view.textContainer.lineBreakMode = .byWordWrapping
@ -83,6 +92,16 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
becomeFirstResponder = false becomeFirstResponder = false
} }
} }
#if !os(visionOS)
if #unavailable(iOS 16.0) {
DispatchQueue.main.async {
let targetSize = CGSize(width: uiView.bounds.width, height: UIView.layoutFittingCompressedSize.height)
let fittingSize = uiView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultHigh)
textViewContentHeight = fittingSize.height
}
}
#endif
} }
func makeCoordinator() -> WrappedTextViewCoordinator { func makeCoordinator() -> WrappedTextViewCoordinator {
@ -101,9 +120,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
} }
} }
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
private final class WrappedTextViewCoordinator: NSObject { private final class WrappedTextViewCoordinator: NSObject {
private static let attachment: NSTextAttachment = { private static let attachment: NSTextAttachment = {
let font = UIFont.systemFont(ofSize: 20) let font = UIFont.systemFont(ofSize: 20)
@ -133,8 +149,13 @@ private final class WrappedTextViewCoordinator: NSObject {
let str = NSMutableAttributedString(string: text) let str = NSMutableAttributedString(string: text)
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length)) let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() { for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) let range: NSRange
let range = NSRange(location: match.range.location, length: match.range.length + 1) if #available(iOS 16.0, *) {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
range = NSRange(location: match.range.location, length: match.range.length + 1)
} else {
range = match.range
}
str.addAttributes([ str.addAttributes([
.mention: true, .mention: true,
.foregroundColor: UIColor.tintColor, .foregroundColor: UIColor.tintColor,
@ -152,7 +173,6 @@ private final class WrappedTextViewCoordinator: NSObject {
private func updateAttributes(in textView: UITextView) { private func updateAttributes(in textView: UITextView) {
let str = NSMutableAttributedString(attributedString: textView.attributedText!) let str = NSMutableAttributedString(attributedString: textView.attributedText!)
var changed = false var changed = false
var cursorOffset = 0
// remove existing mentions that aren't valid // remove existing mentions that aren't valid
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
@ -161,46 +181,44 @@ private final class WrappedTextViewCoordinator: NSObject {
if hasTextAttachment { if hasTextAttachment {
substr = String(substr.dropFirst()) substr = String(substr.dropFirst())
} }
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 { if CharacterCounter.mention.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true changed = true
str.removeAttribute(.mention, range: range) str.removeAttribute(.mention, range: range)
str.removeAttribute(.foregroundColor, range: range) str.removeAttribute(.foregroundColor, range: range)
if hasTextAttachment { if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1)) str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1
} }
} }
} }
// add mentions for those missing // add mentions for those missing
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length)) let mentionMatches = CharacterCounter.mention.matches(in: str.string, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() { for match in mentionMatches.reversed() {
var attributeRange = NSRange() var attributeRange = NSRange()
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange) let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
// the attribute range should always be one greater than the match range, to account for the text attachment // the attribute range should always be one greater than the match range, to account for the text attachment
if attribute == nil || attributeRange.length <= match.range.length { if attribute == nil || attributeRange.length <= match.range.length {
changed = true changed = true
let newAttributeRange: NSRange if #available(iOS 16.0, *) {
if attribute == nil { let newAttributeRange: NSRange
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) if attribute == nil {
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1) str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
cursorOffset += 1 newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
} else {
newAttributeRange = match.range
}
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: newAttributeRange)
} else { } else {
newAttributeRange = match.range str.addAttribute(.foregroundColor, value: UIColor.tintColor, range: match.range)
} }
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: newAttributeRange)
} }
} }
if changed { if changed {
let selection = textView.selectedRange
textView.attributedText = str textView.attributedText = str
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
} }
} }
} }
@ -258,3 +276,38 @@ private struct PlaceholderView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private struct HeightExpandingModifier: ViewModifier {
let minHeight: CGFloat
@State private var height: CGFloat?
private var effectiveHeight: CGFloat {
height.map { max($0, minHeight) } ?? minHeight
}
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
} else {
content
.frame(height: effectiveHeight)
.environment(\.textViewContentHeight, $height)
}
}
}
@available(iOS, obsoleted: 16.0)
private struct TextViewContentHeightKey: EnvironmentKey {
static var defaultValue: Binding<CGFloat?> { .constant(nil) }
}
@available(iOS, obsoleted: 16.0)
private extension EnvironmentValues {
var textViewContentHeight: Binding<CGFloat?> {
get { self[TextViewContentHeightKey.self] }
set { self[TextViewContentHeightKey.self] = newValue }
}
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,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.
@ -26,15 +26,9 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "WebURL", package: "swift-url"), .product(name: "WebURL", package: "swift-url"),
.product(name: "WebURLFoundationExtras", package: "swift-url"), .product(name: "WebURLFoundationExtras", package: "swift-url"),
],
swiftSettings: [
.swiftLanguageMode(.v5)
]), ]),
.testTarget( .testTarget(
name: "PachydermTests", name: "PachydermTests",
dependencies: ["Pachyderm"], dependencies: ["Pachyderm"]),
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */; };
@ -336,7 +337,7 @@
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; }; D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
@ -571,6 +572,7 @@
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; }; D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; }; D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
@ -776,7 +778,7 @@
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; }; D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = "<group>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
@ -1488,7 +1490,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLabel.swift */, D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1625,6 +1627,7 @@
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */, D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
@ -2303,7 +2306,7 @@
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */, D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
@ -2387,6 +2390,7 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */, D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
@ -2543,7 +2547,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2576,7 +2579,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2608,7 +2610,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2699,7 +2700,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2743,7 +2743,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2766,7 +2766,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2794,7 +2793,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2823,7 +2821,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2852,7 +2849,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3008,7 +3004,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3041,7 +3036,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3106,7 +3100,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3126,7 +3120,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist; INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3149,7 +3143,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3174,7 +3167,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

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

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

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

@ -18,6 +18,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().configure { private lazy var muteButton = MuteButton().configure {
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
@ -43,8 +46,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:
@ -60,8 +68,12 @@ class VideoControlsViewController: UIViewController {
imageName = "speedometer" imageName = "speedometer"
} }
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 { if self.player.rate > 0 {
self.player.rate = speed.rate self.player.rate = speed.rate
} }
@ -89,11 +101,20 @@ class VideoControlsViewController: UIViewController {
private var scrubbingTargetTime: CMTime? private var scrubbingTargetTime: CMTime?
private var isSeeking = false private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) { init(player: AVPlayer) {
self.player = player self.player = player
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -177,7 +198,11 @@ class VideoControlsViewController: UIViewController {
@objc private func scrubbingEnded() { @objc private func scrubbingEnded() {
scrubbingChanged() scrubbingChanged()
if wasPlayingWhenScrubbingStarted { if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }

View File

@ -17,6 +17,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private var item: AVPlayerItem private var item: AVPlayerItem
let player: AVPlayer let player: AVPlayer
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
#endif
private var isGrayscale: Bool private var isGrayscale: Bool
private var presentationSizeObservation: NSKeyValueObservation? private var presentationSizeObservation: NSKeyValueObservation?
@ -156,7 +161,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
player.replaceCurrentItem(with: item) player.replaceCurrentItem(with: item)
updateItemObservations() updateItemObservations()
if isPlaying { if isPlaying {
#if os(visionOS)
player.play() player.play()
#else
player.rate = playbackSpeed
#endif
} }
} }
} }
@ -187,12 +196,20 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
[VideoActivityItemSource(asset: item.asset, url: url)] [VideoActivityItemSource(asset: item.asset, url: url)]
} }
#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
var contentOverlayAccessoryViewController: UIViewController? { var contentOverlayAccessoryViewController: UIViewController? {
overlayVC overlayVC
} }
#if os(visionOS)
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player) private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
overlayVC.setVisible(visible) overlayVC.setVisible(visible)

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

@ -100,13 +100,28 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
navigationItem.preferredSearchBarPlacement = .stacked if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
navigationItem.renameDelegate = self navigationItem.renameDelegate = self
navigationItem.titleMenuProvider = { [unowned self] suggested in navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested var children = suggested
children.append(contentsOf: self.listSettingsMenuElements()) children.append(contentsOf: self.listSettingsMenuElements())
return UIMenu(children: children) return UIMenu(children: children)
}
} else {
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
// uncached so that menu always reflects the current state of the list
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
var elements = self.listSettingsMenuElements()
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
self.present($0, animated: true)
}).run()
}), at: 0)
elementHandler(elements)
})
]))
} }
} }

View File

@ -454,20 +454,15 @@ extension NewMainTabBarViewController {
extension NewMainTabBarViewController: UITabBarControllerDelegate { extension NewMainTabBarViewController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool { func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
if tab.identifier == Tab.compose.rawValue { if tab.identifier == Tab.compose.rawValue {
if #unavailable(iOS 18.1) { let currentTab = selectedTab
let currentTab = selectedTab // returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254)
// returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254) // returning false and then setting selectedTab=tab and selectedTab=currentTab seems to leave things in a bad state (currentTab's VC is on screen but in the disappeared state)
// returning false and then setting selectedTab=tab and selectedTab=currentTab seems to leave things in a bad state (currentTab's VC is on screen but in the disappeared state) // so return true, and then after the tab bar VC has finished updating, go back to currentTab
// so return true, and then after the tab bar VC has finished updating, go back to currentTab DispatchQueue.main.async {
DispatchQueue.main.async { self.selectedTab = currentTab
self.selectedTab = currentTab
}
compose(editing: nil)
return true
} else {
compose(editing: nil)
return false
} }
compose(editing: nil)
return true
} else if let selectedTab, } else if let selectedTab,
selectedTab == tab, selectedTab == tab,
let nav = selectedViewController as? any NavigationControllerProtocol { let nav = selectedViewController as? any NavigationControllerProtocol {

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

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

View File

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

View File

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

View File

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

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

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

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

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