Compare commits

..

25 Commits

Author SHA1 Message Date
ad5f45c620 Edit attachments gallery for non-drawings 2024-12-07 12:54:41 -05:00
c564bb4112 Merge branch 'develop' into compose-redesign 2024-12-03 13:55:23 -05:00
ec9673f6c0 Use gallery VC for editing attachment descriptions 2024-11-23 10:47:58 -05:00
8cc9849b36 Merge branch 'develop' into compose-redesign 2024-11-21 19:29:03 -05:00
8006b0add9 Prune DraftAttachment managed objects from drafts persistent store 2024-11-20 00:46:13 -05:00
b9e3d8ec5e Compose attachment section 2024-11-20 00:45:21 -05:00
2fb76e322a Start new compose design 2024-11-17 13:14:23 -05:00
57990f8339 Go back to using one List for everything in compose 2024-11-12 10:29:48 -05:00
381f3ee737 Attachment row view 2024-10-14 22:57:39 -04:00
5be80d8e68 Merge branch 'develop' into compose-rewrite 2024-10-14 18:26:55 -04:00
02fd724b0b Attachment reordering 2024-09-13 11:20:10 -04:00
7d47f1f259 Fix detecting mentions while typing 2024-09-13 10:52:49 -04:00
cad074bcc3 Remove pre-iOS 16 code 2024-09-12 15:57:05 -04:00
8243e06e95 Merge branch 'develop' into compose-rewrite
# Conflicts:
#	Packages/ComposeUI/Package.swift
#	Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift
2024-09-12 15:51:22 -04:00
5f6699749c Delete attachment swipe action 2024-09-12 00:16:17 -04:00
ec50dd6bb6 Merge branch 'develop' into compose-rewrite 2024-09-11 21:49:20 -04:00
5d9974ddf8 WIP attachments list 2024-08-19 11:29:16 -04:00
f001e8edcd iOS 15 fixes 2024-08-15 23:40:07 -07:00
17c67a3d5d WIP rewritten main text view 2024-08-15 21:56:23 -07:00
54fadaa270 Fix enabling feature flags on iOS 15 2024-08-15 21:52:58 -07:00
ff433c4270 Fix compiling with Xcode 16 2024-08-11 21:28:05 -07:00
71fd804fd7 Update Sentry and swift-url 2024-08-11 21:27:33 -07:00
198b201a51 WIP compose rewrite 2024-08-11 10:14:10 -07:00
66626c8f62 Compose: assign account synchronously if possible 2024-08-10 10:39:18 -07:00
727f28e39f Add compose rewrite feature flag 2024-08-10 10:39:00 -07:00
118 changed files with 2920 additions and 1118 deletions

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "ComposeUI",
platforms: [
.iOS(.v15),
.iOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
@ -20,13 +20,24 @@ let package = Package(
.package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"),
.package(path: "../MatchedGeometryPresentation"),
.package(path: "../TuskerPreferences"),
.package(path: "../UserAccounts"),
.package(path: "../GalleryVC"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
dependencies: [
"Pachyderm",
"InstanceFeatures",
"TuskerComponents",
"MatchedGeometryPresentation",
"TuskerPreferences",
"UserAccounts",
"GalleryVC",
],
swiftSettings: [
.swiftLanguageMode(.v5)
]),

View File

@ -11,7 +11,7 @@ import Pachyderm
import UniformTypeIdentifiers
@MainActor
class PostService: ObservableObject {
final class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig
private let draft: Draft

View File

@ -12,7 +12,7 @@ import InstanceFeatures
public struct CharacterCounter {
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
let mentionsRemoved = removeMentions(in: text)

View File

@ -8,6 +8,7 @@
import Foundation
import Combine
import UIKit
import SwiftUI
protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get }
@ -27,3 +28,44 @@ enum ToolbarElement {
case emojiPicker
case formattingButtons
}
private struct FocusedComposeInput: FocusedValueKey {
typealias Value = any ComposeInput
}
extension FocusedValues {
var composeInput: (any ComposeInput)? {
get { self[FocusedComposeInput.self] }
set { self[FocusedComposeInput.self] = newValue }
}
}
@propertyWrapper
final class MutableObservableBox<Value>: ObservableObject {
@Published var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
private struct FocusedComposeInputBox: EnvironmentKey {
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
}
extension EnvironmentValues {
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
get { self[FocusedComposeInputBox.self] }
set { self[FocusedComposeInputBox.self] = newValue }
}
}
struct FocusedInputModifier: ViewModifier {
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
func body(content: Content) -> some View {
content
.environment(\.composeInputBox, box)
.focusedValue(\.composeInput, box.wrappedValue)
}
}

View File

@ -9,6 +9,7 @@ import Foundation
import Pachyderm
import InstanceFeatures
import UserAccounts
import SwiftUI
public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get }
@ -26,4 +27,6 @@ public protocol ComposeMastodonContext {
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
func fetchStatus(id: String) -> (any StatusProtocol)?
}

View File

@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
Button(role: .destructive, action: controller.removeAttachment) {
Label("Delete", systemImage: "trash")
}
} previewIfAvailable: {
} preview: {
ControllerView(controller: { controller.thumbnailController })
}
@ -221,16 +221,3 @@ extension AttachmentRowController {
case allowEntry, recognizingText
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
}
}

View File

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

View File

@ -131,7 +131,6 @@ class AttachmentsListController: ViewController {
@EnvironmentObject private var controller: AttachmentsListController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
attachmentsList
@ -214,48 +213,10 @@ fileprivate extension View {
self
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
@available(iOS 16.0, *)
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
}
@available(visionOS 1.0, *)
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
struct AttachmentButtonLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultLabelStyle().makeBody(configuration: configuration)
.foregroundStyle(.white)

View File

@ -125,18 +125,22 @@ public final class ComposeController: ViewController {
self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self)
if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
}
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)
}
public var view: some View {
ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config)
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
.environment(\.currentAccount, currentAccount)
.environment(\.composeUIConfig, config)
} else {
ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config)
}
}
@MainActor
@ -324,10 +328,6 @@ public final class ComposeController: ViewController {
ControllerView(controller: { controller.toolbarController })
#endif
}
#if !os(visionOS)
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
#endif
.transition(.move(edge: .bottom))
}
}
@ -436,7 +436,7 @@ public final class ComposeController: ViewController {
}
.listStyle(.plain)
#if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable()
.scrollDismissesKeyboard(.interactively)
#endif
.disabled(controller.isPosting)
}
@ -487,31 +487,6 @@ public final class ComposeController: ViewController {
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return ToolbarController.height
} else {
return 0
}
}
#endif
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}

View File

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

View File

@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
Text("Happy π day!")
} else if components.month == 4 && components.day == 1 {
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
Text("April Fools!").rotationEffect(.radians(.pi), anchor: .center)
} else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
Text("Any questions?")
}
} else {
Text("What's on your mind?")
Text("Whats on your mind?")
}
}
@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
}
// exists to provide access to the type alias since the @State property needs it to be explicit
private protocol PlaceholderViewProvider {
protocol PlaceholderViewProvider {
associatedtype PlaceholderView: View
@ViewBuilder
static func makePlaceholderView() -> PlaceholderView

View File

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

View File

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

View File

@ -170,17 +170,26 @@ public class DraftsPersistentContainer: NSPersistentContainer {
return
}
performBackgroundTask { context in
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
do {
try context.execute(deleteReq)
} catch {
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
}
let allAttachmentsReq = DraftAttachment.fetchRequest()
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
return
}
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphaned {
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphanedFiles {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
}
}
completion()

View File

@ -0,0 +1,49 @@
//
// Environment.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import Pachyderm
//@propertyWrapper
//struct RequiredEnvironment<Value>: DynamicProperty {
// private let keyPath: KeyPath<EnvironmentValues, Value?>
// @Environment private var value: Value?
//
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
// self.keyPath = keyPath
// self._value = Environment(keyPath)
// }
//
// var wrappedValue: Value {
// guard let value else {
// preconditionFailure("Missing required environment value for \(keyPath)")
// }
// return value
// }
//}
private struct ComposeMastodonContextKey: EnvironmentKey {
static let defaultValue: (any ComposeMastodonContext)? = nil
}
extension EnvironmentValues {
var mastodonController: (any ComposeMastodonContext)? {
get { self[ComposeMastodonContextKey.self] }
set { self[ComposeMastodonContextKey.self] = newValue }
}
}
private struct CurrentAccountKey: EnvironmentKey {
static let defaultValue: (any AccountProtocol)? = nil
}
extension EnvironmentValues {
var currentAccount: (any AccountProtocol)? {
get { self[CurrentAccountKey.self] }
set { self[CurrentAccountKey.self] = newValue }
}
}

View File

@ -10,9 +10,7 @@
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
// @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0
var isVisible: Bool {
@ -27,14 +25,12 @@ class KeyboardReader: ObservableObject {
@objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// isVisible = endFrame.height > 72
keyboardHeight = endFrame.height
}
@objc func willHide() {
// sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async {
// self.isVisible = false
self.keyboardHeight = 0
}
}

View File

@ -9,9 +9,13 @@
import UIKit
import Pachyderm
enum StatusFormat: Int, CaseIterable {
enum StatusFormat: Int, CaseIterable, Identifiable {
case bold, italics, strikethrough, code
var id: some Hashable {
rawValue
}
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
switch contentType {
case .plain:

View File

@ -0,0 +1,11 @@
//
// Preferences.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import Foundation
import TuskerPreferences
typealias Preferences = TuskerPreferences.Preferences

View File

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

@ -11,6 +11,7 @@ import Combine
public protocol ViewController: ObservableObject {
associatedtype ContentView: View
@MainActor
@ViewBuilder
var view: ContentView { get }
}

View File

@ -0,0 +1,182 @@
//
// AttachmentRowView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/18/24.
//
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentRowView: View {
@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 {
HStack(alignment: .center, spacing: 4) {
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)
}
}
//#Preview {
// AttachmentRowView()
//}

View File

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

@ -0,0 +1,154 @@
//
// AttachmentCollectionViewCell.swift
// ComposeUI
//
// Created by Shadowfacts on 11/20/24.
//
import UIKit
import SwiftUI
final class AttachmentCollectionViewCell: UICollectionViewCell {
let attachmentView: UIView & UIContentView
override init(frame: CGRect) {
attachmentView = UIHostingConfiguration(content: {
AttachmentCollectionViewCellView(attachment: nil)
}).makeContentView()
super.init(frame: frame)
attachmentView.frame = bounds
addSubview(attachmentView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(attachment: DraftAttachment) {
attachmentView.configuration = UIHostingConfiguration(content: {
AttachmentCollectionViewCellView(attachment: attachment)
}).margins(.all, .zero)
}
}
private struct AttachmentCollectionViewCellView: View {
let attachment: DraftAttachment?
var body: some View {
if let attachment {
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
.squareFrame()
.background {
RoundedSquare(cornerRadius: 5)
.fill(.quaternary)
}
.overlay(alignment: .bottom) {
AttachmentDescriptionLabel(attachment: attachment)
}
.overlay(alignment: .topTrailing) {
AttachmentRemoveButton(attachment: attachment)
}
.clipShape(RoundedSquare(cornerRadius: 5))
}
}
}
private struct AttachmentRemoveButton: View {
let attachment: DraftAttachment
var body: some View {
Button("Remove", systemImage: "xmark.circle.fill") {
let draft = attachment.draft
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
attachments.remove(attachment)
draft.attachments = attachments
DraftsPersistentContainer.shared.viewContext.delete(attachment)
}
.labelStyle(.iconOnly)
.imageScale(.large)
.foregroundStyle(.white)
.shadow(radius: 2)
.padding([.top, .trailing], 2)
}
}
private struct AttachmentDescriptionLabel: View {
@ObservedObject var attachment: DraftAttachment
var body: some View {
ZStack(alignment: .bottomLeading) {
LinearGradient(
stops: [.init(color: .clear, location: 0), .init(color: .clear, location: 0.6), .init(color: .black.opacity(0.5), location: 1)],
startPoint: .top,
endPoint: .bottom
)
label
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 1)
.padding([.horizontal, .bottom], 4)
}
}
@ViewBuilder
private var label: some View {
if attachment.attachmentDescription.isEmpty {
Label("Add alt", systemImage: "pencil")
.labelStyle(NarrowSpacingLabelStyle())
.font(.callout)
.lineLimit(1)
} else {
Text(attachment.attachmentDescription)
.font(.caption)
.lineLimit(2)
}
}
}
private struct NarrowSpacingLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon
configuration.title
}
}
}
private struct RoundedSquare: Shape {
let cornerRadius: CGFloat
nonisolated func path(in rect: CGRect) -> Path {
let minDimension = min(rect.width, rect.height)
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
}
}
private struct SquareFrame: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
precondition(subviews.count == 1)
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
let minDimension = min(size.width, size.height)
return CGSize(width: minDimension, height: minDimension)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
precondition(subviews.count == 1)
let subviewSize = subviews[0].sizeThatFits(proposal)
let minDimension = min(bounds.width, bounds.height)
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
}
}
private extension View {
func squareFrame() -> some View {
SquareFrame {
self
}
}
}

View File

@ -0,0 +1,90 @@
//
// AttachmentGalleryDataSource.swift
// ComposeUI
//
// Created by Shadowfacts on 11/21/24.
//
import UIKit
import GalleryVC
import TuskerComponents
import Photos
struct AttachmentsGalleryDataSource: GalleryDataSource {
let collectionView: UICollectionView
let attachmentAtIndex: (Int) -> DraftAttachment?
func galleryItemsCount() -> Int {
collectionView.numberOfItems(inSection: 0) - 1
}
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
let attachment = attachmentAtIndex(index)!
let content: any GalleryContentViewController
switch attachment.data {
case .editing(_, _, _):
fatalError("TODO")
case .asset(let id):
content = LoadingGalleryContentViewController(caption: nil) {
if let (image, gifData) = await fetchImageAndGIFData(assetID: id) {
let gifController = gifData.map(GIFController.init)
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return nil
}
}
case .drawing(let drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
case .file(let url, let type):
if type.conforms(to: .movie) {
content = VideoGalleryContentViewController(url: url, caption: nil)
} else if type.conforms(to: .image),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
let gifController = type == .gif ? GIFController(gifData: data) : nil
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
case .none:
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
return EditAttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
}
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
collectionView.cellForItem(at: IndexPath(item: index, section: 0))
}
private func fetchImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return nil
}
let (type, data) = await withCheckedContinuation { continuation in
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
continuation.resume(returning: (typeIdentifier, data))
}
}
guard let data,
let image = UIImage(data: data) else {
return nil
}
if type == UTType.gif.identifier {
return (image, data)
} else {
return (image, nil)
}
}
}

View File

@ -0,0 +1,309 @@
//
// AttachmentsSection.swift
// ComposeUI
//
// Created by Shadowfacts on 11/17/24.
//
import SwiftUI
import PhotosUI
import PencilKit
import GalleryVC
struct AttachmentsSection: View {
@ObservedObject var draft: Draft
var body: some View {
WrappedCollectionView(
draft: draft,
spacing: 8,
minItemSize: 100
)
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
// view from laying out, and leaving the intrinsic content size at zero too.
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
.frame(minHeight: 104)
}
}
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
private struct WrappedCollectionView: UIViewControllerRepresentable {
@ObservedObject var draft: Draft
let spacing: CGFloat
let minItemSize: CGFloat
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
WrappedCollectionViewController(spacing: spacing, minItemSize: minItemSize)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
uiViewController.draft = draft
uiViewController.addAttachment = {
DraftsPersistentContainer.shared.viewContext.insert($0)
$0.draft = draft
draft.attachments.add($0)
}
uiViewController.updateAttachments()
}
}
private class WrappedCollectionViewController: UIViewController {
let spacing: CGFloat
let minItemSize: CGFloat
var draft: Draft!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
fileprivate var currentInteractiveMoveCell: AttachmentCollectionViewCell?
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
init(spacing: CGFloat, minItemSize: CGFloat) {
self.spacing = spacing
self.minItemSize = minItemSize
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
let (itemSize, itemsPerRow) = self.itemSize(width: environment.container.contentSize.width)
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), repeatingSubitem: item, count: itemsPerRow)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
return section
}
let attachmentCell = UICollectionView.CellRegistration<AttachmentCollectionViewCell, DraftAttachment> { cell, indexPath, attachment in
cell.updateUI(attachment: attachment)
}
let addButtonCell = UICollectionView.CellRegistration<UICollectionViewCell, Bool> { [unowned self] cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration(content: {
AddAttachmentButton(viewController: self, enabled: item)
}).margins(.all, .zero)
}
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
self.view = collectionView
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .addButton:
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: true)
}
}
dataSource.reorderingHandlers.canReorderItem = { item in
switch item {
case .attachment(_):
true
case .addButton:
false
}
}
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
let attachmentChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentChanges)!
let array = draft.draftAttachments.applying(attachmentsDiff)!
draft.attachments = NSMutableOrderedSet(array: array)
}
collectionView.isScrollEnabled = false
collectionView.clipsToBounds = false
collectionView.delegate = self
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
longPressRecognizer.delegate = self
collectionView.addGestureRecognizer(longPressRecognizer)
}
private func itemSize(width: CGFloat) -> (CGFloat, Int) {
// The maximum item size is 2*minItemSize + spacing - 1,
// in the case where one item fits in the row but we are one pt short of
// adding a second item.
var itemSize = minItemSize
var fittingCount = floor((width + spacing) / (itemSize + spacing))
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
var remainingSpace = width - usedSpaceForFittingCount
if fittingCount == 1 && remainingSpace > minItemSize / 2 {
// If there's only one item that would fit at min size, and giving
// it the rest of the space would increase it by at least 50%,
// add a second item anywyas.
itemSize = (width - spacing) / 2
fittingCount = 2
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
remainingSpace = width - usedSpaceForFittingCount
}
itemSize = itemSize + remainingSpace / fittingCount
return (itemSize, Int(fittingCount))
}
func updateAttachments() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.all])
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
snapshot.appendItems([.addButton])
dataSource.apply(snapshot)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
var pos = recognizer.location(in: collectionView)
if let currentInteractiveMoveStartOffsetInCell {
pos.x -= currentInteractiveMoveStartOffsetInCell.x
pos.y -= currentInteractiveMoveStartOffsetInCell.y
}
collectionView.updateInteractiveMovementTargetPosition(pos)
case .ended:
collectionView.endInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
case .cancelled:
collectionView.cancelInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
default:
break
}
}
enum Section {
case all
}
enum Item: Hashable {
case attachment(DraftAttachment)
case addButton
}
}
extension WrappedCollectionViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location),
let cell = collectionView.cellForItem(at: indexPath) as? AttachmentCollectionViewCell else {
return false
}
UIView.animate(withDuration: 0.2) {
cell.attachmentView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}
currentInteractiveMoveCell = cell
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
return collectionView.beginInteractiveMovementForItem(at: indexPath)
}
}
extension WrappedCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let items = snapshot.itemIdentifiers(inSection: .all).count
if proposedIndexPath.row == items - 1 {
return IndexPath(item: items - 2, section: proposedIndexPath.section)
} else {
return proposedIndexPath
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let dataSource = AttachmentsGalleryDataSource(collectionView: collectionView) { [dataSource] in
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
switch item {
case .attachment(let attachment):
return attachment
default:
return nil
}
}
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
galleryVC.showShareButton = false
present(galleryVC, animated: true)
}
}
private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero
override var intrinsicContentSize: CGSize {
_intrinsicContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
private struct AddAttachmentButton: View {
unowned let viewController: WrappedCollectionViewController
let enabled: Bool
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
Menu {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker {
let draft = viewController.draft!
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
}
}
}
if let presentDrawing {
Button("Draw something", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let draft = viewController.draft!
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
} label: {
Image(systemName: "photo.badge.plus")
.imageScale(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(.tint.opacity(0.1))
RoundedRectangle(cornerRadius: 5)
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
}
}
.disabled(!enabled)
}
}

View File

@ -0,0 +1,198 @@
//
// EditAttachmentWrapperGalleryContentViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 11/22/24.
//
import UIKit
import GalleryVC
class EditAttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
let draftAttachment: DraftAttachment
let wrapped: any GalleryContentViewController
var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
wrapped.contentSize
}
var activityItemsForSharing: [Any] {
wrapped.activityItemsForSharing
}
var caption: String? {
wrapped.caption
}
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
var bottomControlsAccessoryViewController: UIViewController? {
editDescriptionViewController
}
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
false
}
var presentationAnimation: GalleryContentPresentationAnimation {
wrapped.presentationAnimation
}
var hideControlsOnZoom: Bool {
false
}
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
wrapped.container = container
addChild(wrapped)
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wrapped.view)
NSLayoutConstraint.activate([
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
wrapped.didMove(toParent: self)
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
if !visible {
editDescriptionViewController.textView?.resignFirstResponder()
}
}
func galleryContentDidAppear() {
wrapped.galleryContentDidAppear()
}
func galleryContentWillDisappear() {
wrapped.galleryContentWillDisappear()
}
func shouldHideControls() -> Bool {
if editDescriptionViewController.textView.isFirstResponder {
editDescriptionViewController.textView.resignFirstResponder()
return false
} else {
return true
}
}
}
private class EditAttachmentDescriptionViewController: UIViewController {
private let draftAttachment: DraftAttachment
private let wrapped: UIViewController?
private(set) var textView: UITextView!
private var isShowingPlaceholder = false
private var descriptionObservation: NSKeyValueObservation?
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.overrideUserInterfaceStyle = .dark
view.backgroundColor = .secondarySystemFill
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
])
if let wrapped {
stack.addArrangedSubview(wrapped.view)
}
textView = UITextView()
textView.backgroundColor = nil
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
if draftAttachment.attachmentDescription.isEmpty {
showPlaceholder()
} else {
removePlaceholder()
textView.text = draftAttachment.attachmentDescription
}
textView.delegate = self
stack.addArrangedSubview(textView)
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
let desc = self.draftAttachment.attachmentDescription
if desc.isEmpty {
if !isShowingPlaceholder {
showPlaceholder()
}
} else {
if isShowingPlaceholder {
removePlaceholder()
}
self.textView.text = desc
}
}
}
fileprivate func showPlaceholder() {
isShowingPlaceholder = true
textView.text = "Describe for the visually impaired"
textView.textColor = .secondaryLabel
}
fileprivate func removePlaceholder() {
isShowingPlaceholder = false
textView.text = ""
textView.textColor = .label
}
}
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
if isShowingPlaceholder {
removePlaceholder()
}
}
func textViewDidEndEditing(_ textView: UITextView) {
draftAttachment.attachmentDescription = textView.text
if textView.text.isEmpty {
showPlaceholder()
}
}
}

View File

@ -0,0 +1,182 @@
//
// AttachmentsListSection.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import InstanceFeatures
import PencilKit
struct AttachmentsListSection: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.canAddAttachment) private var canAddAttachment
var body: some View {
attachmentRows
buttons
.foregroundStyle(.tint)
#if os(visionOS)
.buttonStyle(.bordered)
.labelStyle(AttachmentButtonLabelStyle())
#endif
}
private var attachmentRows: some View {
ForEach(draft.draftAttachments) { attachment in
AttachmentRowView(attachment: attachment)
}
.onMove(perform: moveAttachments)
.onDelete(perform: deleteAttachments)
.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in
Self.insertAttachments(in: draft, at: offset, itemProviders: providers)
}
}
@ViewBuilder
private var buttons: some View {
AddPhotoButton(addAttachments: self.addAttachments)
AddDrawingButton(draft: draft)
TogglePollButton(draft: draft)
}
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
}
private func addAttachments(itemProviders: [NSItemProvider]) {
Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
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 deleteAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
private struct AddPhotoButton: View {
let addAttachments: ([NSItemProvider]) -> Void
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
var body: some View {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker { results in
addAttachments(results.map(\.itemProvider))
}
}
}
}
}
private struct AddDrawingButton: View {
let draft: Draft
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if let presentDrawing {
Button("Add drawing", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
var body: some View {
Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") {
withAnimation {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
.disabled(draft.attachments.count > 0)
}
}
struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image }
&& draft.poll == nil
} else {
return true
}
}
func body(content: Content) -> some View {
content
.environment(\.canAddAttachment, canAddAttachment)
}
}
private struct CanAddAttachmentKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var canAddAttachment: Bool {
get { self[CanAddAttachmentKey.self] }
set { self[CanAddAttachmentKey.self] = newValue }
}
}
struct DropAttachmentModifier: ViewModifier {
let draft: Draft
@Environment(\.canAddAttachment) private var canAddAttachment
func body(content: Content) -> some View {
content
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
}
}
private struct AttachmentDropDelegate: DropDelegate {
let draft: Draft
let canAddAttachment: Bool
func validateDrop(info: DropInfo) -> Bool {
canAddAttachment
}
func performDrop(info: DropInfo) -> Bool {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
return true
}
}

View File

@ -0,0 +1,59 @@
//
// ComposeDraftView.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct ComposeDraftView: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.currentAccount) private var currentAccount
@EnvironmentObject private var controller: ComposeController
var body: some View {
HStack(alignment: .top, spacing: 8) {
// TODO: scroll effect?
AvatarImageView(
url: currentAccount?.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
if let currentAccount {
AccountNameView(account: currentAccount)
}
ContentWarningTextField(draft: draft, focusedField: $focusedField)
DraftContentEditor(draft: draft, focusedField: $focusedField)
AttachmentsSection(draft: draft)
}
}
}
}
private struct AccountNameView: View {
let account: any AccountProtocol
@EnvironmentObject private var controller: ComposeController
var body: some View {
HStack(spacing: 4) {
controller.displayNameLabel(account, .body, 16)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}

View File

@ -0,0 +1,242 @@
//
// ComposeToolbarView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import TuskerComponents
import InstanceFeatures
import Pachyderm
import TuskerPreferences
struct ComposeToolbarView: View {
static let height: CGFloat = 44
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
#if os(visionOS)
buttons
#else
ToolbarScrollView {
buttons
.padding(.horizontal, 16)
}
.frame(height: Self.height)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.ignoresSafeArea(edges: [.leading, .trailing])
}
#endif
}
private var buttons: some View {
HStack(spacing: 0) {
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
InsertEmojiButton()
FormatButtons()
Spacer()
}
}
}
#if !os(visionOS)
private struct ToolbarScrollView<Content: View>: View {
@ViewBuilder let content: Content
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
content
.frame(minWidth: minWidth)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
realWidth = $0
}
}
}
}
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
.frame(maxWidth: .infinity)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) {
minWidth = $0
}
}
}
}
}
#endif
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
private struct ContentWarningButton: View {
@Binding var enabled: Bool
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
Button("CW", action: toggleContentWarning)
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
}
private func toggleContentWarning() {
enabled.toggle()
if enabled {
focusedField = .contentWarning
}
}
}
private struct VisibilityButton: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
}
var body: some View {
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
}
}
private struct LocalOnlyButton: View {
@Binding var enabled: Bool
var mastodonController: any ComposeMastodonContext
@ObservedObject private var instanceFeatures: InstanceFeatures
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
self._enabled = enabled
self.mastodonController = mastodonController
self.instanceFeatures = mastodonController.instanceFeatures
}
private var options: [MenuPicker<Bool>.Option] {
let domain = mastodonController.accountInfo!.instanceURL.host!
return [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
]
}
var body: some View {
if mastodonController.instanceFeatures.localOnlyPosts {
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
}
}
}
private struct InsertEmojiButton: View {
@FocusedValue(\.composeInput) private var input
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
if input?.toolbarElements.contains(.emojiPicker) == true {
Button(action: beginAutocompletingEmoji) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
private func beginAutocompletingEmoji() {
input?.beginAutocompletingEmoji()
}
}
private struct FormatButtons: View {
@FocusedValue(\.composeInput) private var input
@PreferenceObserving(\.$statusContentType) private var contentType
var body: some View {
if let input,
input.toolbarElements.contains(.formattingButtons),
contentType != .plain {
Spacer()
ForEach(StatusFormat.allCases) { format in
FormatButton(format: format, input: input)
}
}
}
}
private struct FormatButton: View {
let format: StatusFormat
let input: any ComposeInput
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
Button(action: applyFormat) {
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
private func applyFormat() {
input.applyFormat(format)
}
}
//#Preview {
// ComposeToolbarView()
//}

View File

@ -0,0 +1,237 @@
//
// ComposeView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct ComposeView: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@State private var poster: PostService? = nil
@FocusState private var focusedField: FocusableField?
@EnvironmentObject private var controller: ComposeController
var body: some View {
NavigationStack {
navigationRoot
}
.environmentObject(mastodonController.instanceFeatures)
}
private var navigationRoot: some View {
ZStack {
ScrollView {
scrollContent
}
.scrollDismissesKeyboard(.interactively)
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
#endif
}
.overlay(alignment: .top) {
if let poster {
PostProgressView(poster: poster)
.frame(alignment: .top)
}
}
#if !os(visionOS)
.overlay(alignment: .bottom, content: {
// TODO: during ducking animation, toolbar should move off the botto edge
// This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier.
toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard)
})
#endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805
.modifier(DropAttachmentModifier(draft: draft))
.modifier(AddAttachmentConditionsModifier(draft: draft))
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarActions(draft: draft, controller: controller)
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
toolbarView
}
#endif
}
}
private var toolbarView: some View {
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
}
@ViewBuilder
private var scrollContent: some View {
VStack(spacing: 8) {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
ComposeDraftView(draft: draft, focusedField: $focusedField)
}
.padding(8)
// NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
// .listRowSeparator(.hidden)
//
// ContentWarningTextField(draft: draft, focusedField: $focusedField)
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
// .listRowSeparator(.hidden)
//
// NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
// .listRowSeparator(.hidden)
//
// AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
}
private func addAttachments(_ itemProviders: [NSItemProvider]) {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
private var navigationTitle: String {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
}
func body(content: Content) -> some View {
content
.navigationTitle(navigationTitle)
.preference(key: NavigationTitlePreferenceKey.self, value: navigationTitle)
}
}
// Public preference so that the host can read the title.
public struct NavigationTitlePreferenceKey: PreferenceKey {
public static var defaultValue: String? { nil }
public static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()
}
}
private struct ToolbarActions: ToolbarContent {
@ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment form the containing view.
let controller: ComposeController
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
}
private var draftsButton: some View {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
private var postButton: some View {
// TODO: don't use the controller for this
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
#if !targetEnvironment(macCatalyst)
@ViewBuilder
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
#endif
}
private struct ToolbarCancelButton: View {
let draft: Draft
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(action: controller.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
}
enum FocusableField: Hashable {
case contentWarning
case body
case attachmentDescription(UUID)
var nextField: FocusableField? {
switch self {
case .contentWarning:
return .body
default:
return nil
}
}
}
#if !os(visionOS) && !targetEnvironment(macCatalyst)
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
@StateObject private var keyboardReader = KeyboardReader()
func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
} else {
content
.safeAreaInset(edge: .bottom) {
if !keyboardReader.isVisible {
Color.clear.frame(height: ComposeToolbarView.height)
}
}
}
}
}
#endif
//#Preview {
// ComposeView()
//}

View File

@ -0,0 +1,38 @@
//
// ContentWarningTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct ContentWarningTextField: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
if draft.contentWarningEnabled {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
// TODO: completely replace this with FocusState
becomeFirstResponder: .constant(false),
focusNextView: Binding(get: {
false
}, set: {
if $0 {
focusedField = .body
}
})
)
.focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier())
}
}
}
//#Preview {
// ContentWarningTextField()
//}

View File

@ -0,0 +1,96 @@
//
// DraftContentEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import InstanceFeatures
struct DraftContentEditor: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
VStack(spacing: 4) {
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
HStack(alignment: .firstTextBaseline) {
LanguageButton(draft: draft)
Spacer()
CharactersRemaining(draft: draft)
.padding(.trailing, 4)
}
.padding(.all.subtracting(.top), 2)
}
.background {
RoundedRectangle(cornerRadius: 5)
.fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
}
}
private func addAttachments(_ providers: [NSItemProvider]) {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
}
}
private struct CharactersRemaining: View {
@ObservedObject var draft: Draft
@EnvironmentObject var instanceFeatures: InstanceFeatures
private var charsRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
Text(verbatim: charsRemaining.description)
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
.font(.callout.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
}
}
private struct LanguageButton: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@FocusedValue(\.composeInput) private var input
@State private var hasChanged = false
var body: some View {
if instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.buttonStyle(LanguageButtonStyle())
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
.onChange(of: draft.id) { _ in
hasChanged = false
}
}
}
private func currentInputModeChanged(_ notification: Foundation.Notification) {
guard !hasChanged,
!draft.hasContent,
let mode = input?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode) else {
return
}
draft.language = code.identifier
}
}
private struct LanguageButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.callout)
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
.animation(.linear(duration: 0.1), value: configuration.isPressed)
}
}

View File

@ -12,6 +12,7 @@ struct EmojiTextField: UIViewRepresentable {
@EnvironmentObject private var controller: ComposeController
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox
@Binding var text: String
let placeholder: String
@ -75,7 +76,11 @@ struct EmojiTextField: UIViewRepresentable {
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
DispatchQueue.main.async {
inputBox.wrappedValue = coordinator
}
return coordinator
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
@ -113,12 +118,16 @@ struct EmojiTextField: UIViewRepresentable {
}
func textFieldDidBeginEditing(_ textField: UITextField) {
controller.currentInput = self
DispatchQueue.main.async {
self.controller.currentInput = self
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidEndEditing(_ textField: UITextField) {
controller.currentInput = nil
DispatchQueue.main.async {
self.controller.currentInput = nil
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}

View File

@ -29,3 +29,19 @@ struct HeaderView: View {
}.frame(height: 50)
}
}
struct NewHeaderView: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.currentAccount) private var currentAccount
private var charactersRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
HeaderView(currentAccount: currentAccount, charsRemaining: charactersRemaining)
}
}

View File

@ -0,0 +1,282 @@
//
// NewMainTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/11/24.
//
import SwiftUI
struct NewMainTextView: View {
static var minHeight: CGFloat { 150 }
@Binding var value: String
@FocusState.Binding var focusedField: FocusableField?
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@State private var becomeFirstResponder = true
var body: some View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
.focused($focusedField, equals: .body)
.modifier(FocusedInputModifier())
.overlay(alignment: .topLeading) {
if value.isEmpty {
PlaceholderView()
}
}
}
}
private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Binding var value: String
@Binding var becomeFirstResponder: Bool
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@Environment(\.composeInputBox) private var inputBox
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
// TODO: test textSelectionStartsAtBeginning
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
func makeUIView(context: Context) -> UITextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = WrappedTextView(usingTextLayoutManager: true)
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
view.isScrollEnabled = false
view.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
view.backgroundColor = nil
// view.layer.cornerRadius = 5
// view.layer.cornerCurve = .continuous
// view.layer.shadowColor = UIColor.black.cgColor
// view.layer.shadowOpacity = 0.15
// view.layer.shadowOffset = .zero
// view.layer.shadowRadius = 1
if textSelectionStartsAtBeginning {
// Update the text immediately so that the selection isn't invalidated by the text changing.
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
context.coordinator.value = $value
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
uiView.isEditable = isEnabled
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
// #if !os(visionOS)
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
// #endif
// Trying to set this with the @FocusState binding in onAppear results in the
// keyboard not appearing until after the sheet presentation animation completes :/
if becomeFirstResponder {
uiView.becomeFirstResponder()
DispatchQueue.main.async {
becomeFirstResponder = false
}
}
}
func makeCoordinator() -> WrappedTextViewCoordinator {
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
// DispatchQueue.main.async {
// inputBox.wrappedValue = coordinator
// }
return coordinator
}
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
let width = proposal.width ?? 10
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
}
}
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
private final class WrappedTextViewCoordinator: NSObject {
private static let attachment: NSTextAttachment = {
let font = UIFont.systemFont(ofSize: 20)
let size = /*1.4 * */font.capHeight
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
let image = renderer.image { ctx in
UIColor.systemRed.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
}
let attachment = NSTextAttachment(image: image)
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
attachment.lineLayoutPadding = 1
return attachment
}()
var value: Binding<String>
var handleAttachmentDrop: ([NSItemProvider]) -> Void
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
self.value = value
self.handleAttachmentDrop = handleAttachmentDrop
}
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
}
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
let str = NSMutableAttributedString(string: text)
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
let range = NSRange(location: match.range.location, length: match.range.length + 1)
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: range)
}
return str
}
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
if text != plainTextFromAttributed(textView.attributedText) {
textView.attributedText = attributedTextFromPlain(text)
}
}
private func updateAttributes(in textView: UITextView) {
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
var changed = false
var cursorOffset = 0
// remove existing mentions that aren't valid
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
var substr = (str.string as NSString).substring(with: range)
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
if hasTextAttachment {
substr = String(substr.dropFirst())
}
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true
str.removeAttribute(.mention, range: range)
str.removeAttribute(.foregroundColor, range: range)
if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1
}
}
}
// add mentions for those missing
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
var attributeRange = NSRange()
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
// the attribute range should always be one greater than the match range, to account for the text attachment
if attribute == nil || attributeRange.length <= match.range.length {
changed = true
let newAttributeRange: NSRange
if attribute == nil {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
cursorOffset += 1
} else {
newAttributeRange = match.range
}
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: newAttributeRange)
}
}
if changed {
let selection = textView.selectedRange
textView.attributedText = str
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
}
}
}
extension WrappedTextViewCoordinator: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.isEmpty {
textView.typingAttributes = [
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
} else {
updateAttributes(in: textView)
}
let plain = plainTextFromAttributed(textView.attributedText)
if plain != value.wrappedValue {
value.wrappedValue = plain
}
}
func textViewDidChangeSelection(_ textView: UITextView) {
}
}
//extension WrappedTextViewCoordinator: ComposeInput {
//
//}
// Because of FB11790805, we can't handle drops for the entire screen.
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
// So, manually add the UIInteraction to at least handle that.
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
session.canLoadObjects(ofClass: DraftAttachment.self)
}
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
UIDropProposal(operation: .copy)
}
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
handleAttachmentDrop(session.items.map(\.itemProvider))
}
}
private final class WrappedTextView: UITextView {
}
private extension NSAttributedString.Key {
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
}
private struct PlaceholderView: View {
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
@ScaledMetric private var fontSize = 20
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
placeholder
.font(.system(size: fontSize))
.foregroundStyle(.secondary)
.offset(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}

View File

@ -0,0 +1,21 @@
//
// PostProgressView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
struct PostProgressView: View {
@ObservedObject var poster: PostService
var body: some View {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
}
}
//#Preview {
// PostProgressView()
//}

View File

@ -132,3 +132,21 @@ private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepr
}
}
}
struct NewReplyStatusView: View {
let draft: Draft
let mastodonController: any ComposeMastodonContext
var body: some View {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
ReplyStatusView(
status: status,
rowTopInset: 8,
globalFrameOutsideList: .zero
)
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,8 +15,11 @@ public protocol GalleryContentViewController: UIViewController {
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
var presentationAnimation: GalleryContentPresentationAnimation { get }
var hideControlsOnZoom: Bool { get }
func shouldHideControls() -> Bool
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func galleryContentDidAppear()
func galleryContentWillDisappear()
@ -31,10 +34,22 @@ public extension GalleryContentViewController {
nil
}
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
true
}
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceView
}
var hideControlsOnZoom: Bool {
true
}
func shouldHideControls() -> Bool {
true
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
}

View File

@ -11,7 +11,6 @@ import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func isGalleryBeingDismissed() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
@ -46,6 +45,12 @@ class GalleryItemViewController: UIViewController {
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
var showShareButton: Bool = true {
didSet {
shareButton?.isHidden = !showShareButton
}
}
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
@ -67,6 +72,10 @@ class GalleryItemViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Need to use the keyboard layout guide in some way in this VC,
// otherwise the keyboardLayoutGuide inside the bottom controls accessory view doesn't animate
_ = view.keyboardLayoutGuide
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
@ -110,6 +119,7 @@ class GalleryItemViewController: UIViewController {
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
shareButton.preferredBehavioralStyle = .pad
shareButton.isHidden = !showShareButton
shareButton.translatesAutoresizingMaskIntoConstraints = false
updateShareButton()
topControlsView.addSubview(shareButton)
@ -142,12 +152,14 @@ class GalleryItemViewController: UIViewController {
bottomControlsView.addArrangedSubview(controlsAccessory.view)
controlsAccessory.didMove(toParent: self)
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
if content.insetBottomControlsAccessoryViewControllerToSafeArea {
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
}
}
captionTextView = UITextView()
@ -211,6 +223,14 @@ class GalleryItemViewController: UIViewController {
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
@objc private func keyboardWillUpdate() {
updateZoomScale(resetZoom: true)
}
override func viewSafeAreaInsetsDidChange() {
@ -333,7 +353,7 @@ class GalleryItemViewController: UIViewController {
return
}
let heightScale = view.bounds.height / content.contentSize.height
let heightScale = (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height) / content.contentSize.height
let widthScale = view.bounds.width / content.contentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
@ -356,7 +376,7 @@ class GalleryItemViewController: UIViewController {
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
// which means it's already been scaled by the zoom factor.
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
let yOffset = max(0, (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
@ -377,27 +397,13 @@ class GalleryItemViewController: UIViewController {
}
private func updateTopControlsInsets() {
guard delegate?.isGalleryBeingDismissed() != true else {
return
}
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini
]
let topInset: CGFloat
switch view.window?.windowScene?.interfaceOrientation {
case .portraitUpsideDown:
topInset = view.safeAreaInsets.bottom
case .landscapeLeft:
topInset = view.safeAreaInsets.right
case .landscapeRight:
topInset = view.safeAreaInsets.left
default:
topInset = view.safeAreaInsets.top
}
if notchedDeviceTopInsets.contains(topInset) {
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
@ -406,7 +412,7 @@ class GalleryItemViewController: UIViewController {
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if topInset == 0 {
} else if view.safeAreaInsets.top == 0 {
// square corner devices
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
@ -447,7 +453,9 @@ class GalleryItemViewController: UIViewController {
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
if content.shouldHideControls() {
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
}
}
}
@ -566,7 +574,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else {
setControlsVisible(false, animated: true, dueToUserInteraction: true)
if content.hideControlsOnZoom {
setControlsVisible(false, animated: true, dueToUserInteraction: true)
}
}
centerContent()

View File

@ -26,6 +26,14 @@ public class GalleryViewController: UIPageViewController {
private var dismissInteraction: GalleryDismissInteraction!
private var presentationAnimationCompletionHandlers: [() -> Void] = []
public var showShareButton: Bool = true {
didSet {
if viewControllers?.isEmpty == false {
currentItemViewController.showShareButton = showShareButton
}
}
}
override public var prefersStatusBarHidden: Bool {
true
}
@ -89,7 +97,9 @@ public class GalleryViewController: UIPageViewController {
private func makeItemVC(index: Int) -> GalleryItemViewController {
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
let itemVC = GalleryItemViewController(delegate: self, itemIndex: index, content: content)
itemVC.showShareButton = showShareButton
return itemVC
}
func presentationAnimationCompleted() {
@ -139,10 +149,6 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
isBeingPresented
}
func isGalleryBeingDismissed() -> Bool {
isBeingDismissed
}
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
presentationAnimationCompletionHandlers.append(block)
}

View File

@ -14,7 +14,8 @@ extension UIView {
while let superview = view.superview {
if superview.layer.masksToBounds {
return superview
} else if superview is UIScrollView {
} else if let scrollView = superview as? UIScrollView,
scrollView.isScrollEnabled {
return self
} else {
view = superview

View File

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

View File

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

View File

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

View File

@ -25,30 +25,27 @@ public struct Client: Sendable {
public var timeoutInterval: TimeInterval = 60
private static let dateFormatter: DateFormatter = {
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private static let iso8601Formatter = ISO8601DateFormatter()
private static func decodeDate(string: String) -> Date? {
// for the next time mastodon accidentally changes date formats >.>
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
}
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let iso8601 = ISO8601DateFormatter()
decoder.dateDecodingStrategy = .custom({ (decoder) in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
if let date = Self.decodeDate(string: str) {
// for the next time mastodon accidentally changes date formats >.>
if let date = formatter.date(from: str) {
return date
} else if let date = iso8601.date(from: str) {
return date
} else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
}
})
return decoder
}()
@ -108,15 +105,6 @@ public struct Client: Sendable {
return task
}
private func error(from response: HTTPURLResponse) -> ErrorType {
if response.statusCode == 429,
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
return .rateLimited(date)
} else {
return .unexpectedStatus(response.statusCode)
}
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
@ -587,8 +575,6 @@ extension Client {
return "Invalid Model"
case .mastodonError(let code, let error):
return "Server Error (\(code)): \(error)"
case .rateLimited(let reset):
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
}
}
}
@ -599,7 +585,6 @@ extension Client {
case invalidResponse
case invalidModel(Swift.Error)
case mastodonError(Int, String)
case rateLimited(Date)
}
enum NodeInfoError: LocalizedError {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,5 +9,6 @@ import Foundation
public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation"
case composeRewrite = "compose-rewrite"
case pushNotifCustomEmoji = "push-notif-custom-emoji"
}

View File

@ -0,0 +1,37 @@
//
// PreferenceObserving.swift
// TuskerPreferences
//
// Created by Shadowfacts on 8/10/24.
//
import SwiftUI
import Combine
@propertyWrapper
public struct PreferenceObserving<Key: PreferenceKey>: DynamicProperty {
public typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
private let keyPath: PrefKeyPath
@StateObject private var observer: Observer
public init(_ keyPath: PrefKeyPath) {
self.keyPath = keyPath
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
}
public var wrappedValue: Key.Value {
Preferences.shared.getValue(preferenceKeyPath: keyPath)
}
@MainActor
private class Observer: ObservableObject {
private var cancellable: AnyCancellable?
init(keyPath: PrefKeyPath) {
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

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

View File

@ -83,4 +83,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
func storeCreatedStatus(_ status: Status) {
}
func fetchStatus(id: String) -> (any StatusProtocol)? {
return nil
}
}

View File

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

View File

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

View File

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

View File

@ -36,19 +36,12 @@ private struct AppGroupedListBackground: ViewModifier {
}
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
if colorScheme == .dark, !pureBlackDarkMode {
content
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
content
}
if colorScheme == .dark, !pureBlackDarkMode {
content
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
content
.onAppear {
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
}
}
}
}
@ -66,31 +59,3 @@ private struct AppGroupedListRowBackground: ViewModifier {
}
}
}
@propertyWrapper
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
let keyPath: PrefKeyPath
@StateObject private var observer: Observer
init(_ keyPath: PrefKeyPath) {
self.keyPath = keyPath
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
}
var wrappedValue: Key.Value {
Preferences.shared.getValue(preferenceKeyPath: keyPath)
}
@MainActor
private class Observer: ObservableObject {
private var cancellable: AnyCancellable?
init(keyPath: PrefKeyPath) {
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -50,6 +50,10 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
)
if let account = mastodonController.account {
controller.currentAccount = account
}
self.mastodonController = mastodonController
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
@ -129,7 +133,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
picker.modalPresentationStyle = .pageSheet
picker.overrideUserInterfaceStyle = .dark
// sheet detents don't play nice with PHPickerViewController, see
// let sheet = picker.sheetPresentationController!
// sheet.detents = [.medium(), .large()]
@ -151,7 +154,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
var body: some SwiftUI.View {
ControllerView(controller: { controller })
.task {
if let account = try? await mastodonController.getOwnAccount() {
if controller.currentAccount == nil,
let account = try? await mastodonController.getOwnAccount() {
controller.currentAccount = account
}
}
@ -185,6 +189,7 @@ extension ComposeHostingController: DuckableViewController {
}
#endif
// TODO: don't conform MastodonController to this protocol, use a separate type
extension MastodonController: ComposeMastodonContext {
@MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol] {
@ -227,6 +232,10 @@ extension MastodonController: ComposeMastodonContext {
func storeCreatedStatus(_ status: Status) {
persistentContainer.addOrUpdate(status: status)
}
func fetchStatus(id: String) -> (any StatusProtocol)? {
return persistentContainer.status(for: id)
}
}
extension ComposeHostingController: PHPickerViewControllerDelegate {

View File

@ -484,11 +484,3 @@ extension ConversationViewController: StatusBarTappableViewController {
}
}
}
extension ConversationViewController: RefreshableViewController {
func refresh() {
Task {
await refreshContext()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -151,22 +151,6 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
return false
#endif // !os(visionOS)
}
// MARK: Keyboard shortcuts
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
// we manually delegate to the top view controller if possible.
if action == #selector(RefreshableViewController.refresh),
let selected = selectedViewController as? NavigationControllerProtocol,
let top = selected.topViewController as? RefreshableViewController {
return top
} else {
return super.target(forAction: action, withSender: sender)
}
}
}
extension BaseMainTabBarViewController: TuskerNavigationDelegate {

View File

@ -219,19 +219,6 @@ class MainSplitViewController: UISplitViewController {
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
// we manually delegate to the top view controller if possible.
if action == #selector(RefreshableViewController.refresh),
traitCollection.horizontalSizeClass == .regular,
let top = secondaryNavController.topViewController as? RefreshableViewController {
return top
} else {
return super.target(forAction: action, withSender: sender)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,6 @@ struct AdvancedPrefsView : View {
@State private var mastodonCacheSize: Int64 = 0
@State private var cloudKitStatus: CKAccountStatus?
@State private var isShowingFeatureFlagAlert = false
@State private var featureFlagName = ""
var body: some View {
List {
@ -32,23 +31,9 @@ struct AdvancedPrefsView : View {
.listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self)
.onTapGesture(count: 3) {
featureFlagName = ""
isShowingFeatureFlagAlert = true
}
.alert("Enable Feature Flag", isPresented: $isShowingFeatureFlagAlert) {
TextField("Flag Name", text: $featureFlagName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {}
Button("Enable") {
if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag)
}
}
} message: {
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
}
.modifier(FeatureFlagAlertModifier(showing: $isShowingFeatureFlagAlert))
.navigationBarTitle(Text("Advanced"))
}
@ -252,6 +237,74 @@ extension StatusContentType {
}
}
private struct FeatureFlagAlertModifier: ViewModifier {
@Binding var showing: Bool
@ObservedObject private var preferences = Preferences.shared
@State private var featureFlagName = ""
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.onChange(of: showing) {
if $0 {
featureFlagName = ""
}
}
.alert("Enable Feature Flag", isPresented: $showing) {
textField
Button("Cancel", role: .cancel) {}
Button("Enable", action: enableFlag)
} message: {
warning
}
} else {
content
.sheet(isPresented: $showing) {
NavigationView {
List {
Section {
textField
} footer: {
warning
}
}
.navigationTitle("Enable Feature Flag")
.navigationBarTitleDisplayMode(.inline)
.listStyle(.insetGrouped)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showing = false
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Enable", action: self.enableFlag)
}
}
}
.navigationViewStyle(.stack)
}
}
}
private var textField: some View {
TextField("Flag Name", text: $featureFlagName)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
private var warning: Text {
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
}
private func enableFlag() {
if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag)
showing = false
}
}
}
#if DEBUG
struct AdvancedPrefsView_Previews : PreviewProvider {
static var previews: some View {

View File

@ -27,14 +27,7 @@ struct AppearancePrefsView: View {
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
var image: UIImage?
if let color = color.color {
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))
}
}
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)
}
return (color, image)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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