Compare commits
No commits in common. "f775527d63fab4f13d4d26e31976b459a27aabe3" and "bda8fdb1b9218f0f3badf2b8db6df2be6875ba15" have entirely different histories.
f775527d63
...
bda8fdb1b9
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -1,32 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2023.5 (81)
|
|
||||||
Features/Improvements:
|
|
||||||
- Add expanded attachment view on Compose screen
|
|
||||||
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
|
|
||||||
- Expanded attachment view allows you to view the attachment larger while writing the description
|
|
||||||
- Plays back videos while writing the description
|
|
||||||
- iOS 16: Allow zooming in to expanded attachment view
|
|
||||||
- Add language picker to Compose screen
|
|
||||||
- Persist sidebar visibility across app launches
|
|
||||||
- Align link verification checkmarks to link rather than creen edge
|
|
||||||
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
|
|
||||||
- Remove Automatically Save Drafts preference
|
|
||||||
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix share sheet extension being unavailable on iOS 15
|
|
||||||
- Fix crash when loading draft with poll from share sheet extension
|
|
||||||
- Fix active draft being deleted when Compose screen ducked
|
|
||||||
- Fix restored, ducked Compose screen lacking title
|
|
||||||
- Fix error when reloading empty profile
|
|
||||||
- Fix local attachments not being deleted upon draft deletion
|
|
||||||
- Fix GIFs being converted to still images on upload
|
|
||||||
- Fix crash on deleting draft with attachments in share extension
|
|
||||||
- Fix deleted attachments in Compose screen reappearing
|
|
||||||
- Fix spinner on Send Report button being misplaced
|
|
||||||
- Fix crash on launch loop when migrating from previous version in certain circumstances
|
|
||||||
|
|
||||||
## 2023.5 (80)
|
## 2023.5 (80)
|
||||||
This build adds a Share Sheet extension and introduces further Compose screen refactors.
|
This build adds a Share Sheet extension and introduces further Compose screen refactors.
|
||||||
|
|
||||||
|
|
|
@ -19,14 +19,13 @@ let package = Package(
|
||||||
.package(path: "../Pachyderm"),
|
.package(path: "../Pachyderm"),
|
||||||
.package(path: "../InstanceFeatures"),
|
.package(path: "../InstanceFeatures"),
|
||||||
.package(path: "../TuskerComponents"),
|
.package(path: "../TuskerComponents"),
|
||||||
.package(path: "../MatchedGeometryPresentation"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ComposeUITests",
|
name: "ComposeUITests",
|
||||||
dependencies: ["ComposeUI"]),
|
dependencies: ["ComposeUI"]),
|
||||||
|
|
|
@ -48,7 +48,7 @@ class PostService: ObservableObject {
|
||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
spoilerText: contentWarning,
|
spoilerText: contentWarning,
|
||||||
visibility: draft.visibility,
|
visibility: draft.visibility,
|
||||||
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
language: nil,
|
||||||
pollOptions: draft.poll?.pollOptions.map(\.text),
|
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||||
pollMultiple: draft.poll?.multiple,
|
pollMultiple: draft.poll?.multiple,
|
||||||
|
|
|
@ -7,11 +7,9 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import UIKit
|
|
||||||
|
|
||||||
protocol ComposeInput: AnyObject, ObservableObject {
|
protocol ComposeInput: AnyObject, ObservableObject {
|
||||||
var toolbarElements: [ToolbarElement] { get }
|
var toolbarElements: [ToolbarElement] { get }
|
||||||
var textInputMode: UITextInputMode? { get }
|
|
||||||
|
|
||||||
var autocompleteState: AutocompleteState? { get }
|
var autocompleteState: AutocompleteState? { get }
|
||||||
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
|
||||||
|
|
|
@ -26,6 +26,7 @@ public struct ComposeUIConfig {
|
||||||
// Preferences
|
// Preferences
|
||||||
public var useTwitterKeyboard = false
|
public var useTwitterKeyboard = false
|
||||||
public var contentType = StatusContentType.plain
|
public var contentType = StatusContentType.plain
|
||||||
|
public var automaticallySaveDrafts = false
|
||||||
public var requireAttachmentDescriptions = false
|
public var requireAttachmentDescriptions = false
|
||||||
|
|
||||||
// Host callbacks
|
// Host callbacks
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import TuskerComponents
|
import TuskerComponents
|
||||||
import Vision
|
import Vision
|
||||||
import MatchedGeometryPresentation
|
|
||||||
|
|
||||||
class AttachmentRowController: ViewController {
|
class AttachmentRowController: ViewController {
|
||||||
let parent: ComposeController
|
let parent: ComposeController
|
||||||
|
@ -16,16 +15,12 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||||
@Published var textRecognitionError: Error?
|
@Published var textRecognitionError: Error?
|
||||||
@Published var focusAttachmentOnTextEditorUnfocus = false
|
|
||||||
|
|
||||||
let thumbnailController: AttachmentThumbnailController
|
|
||||||
|
|
||||||
private var descriptionObservation: NSKeyValueObservation?
|
private var descriptionObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.thumbnailController = AttachmentThumbnailController(attachment: attachment)
|
|
||||||
|
|
||||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||||
|
@ -62,11 +57,6 @@ class AttachmentRowController: ViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func focusAttachment() {
|
|
||||||
focusAttachmentOnTextEditorUnfocus = false
|
|
||||||
parent.focusedAttachment = (attachment, thumbnailController)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recognizeText() {
|
private func recognizeText() {
|
||||||
descriptionMode = .recognizingText
|
descriptionMode = .recognizingText
|
||||||
|
|
||||||
|
@ -119,7 +109,6 @@ class AttachmentRowController: ViewController {
|
||||||
struct AttachmentView: View {
|
struct AttachmentView: View {
|
||||||
@ObservedObject private var attachment: DraftAttachment
|
@ObservedObject private var attachment: DraftAttachment
|
||||||
@EnvironmentObject private var controller: AttachmentRowController
|
@EnvironmentObject private var controller: AttachmentRowController
|
||||||
@FocusState private var textEditorFocused: Bool
|
|
||||||
|
|
||||||
init(attachment: DraftAttachment) {
|
init(attachment: DraftAttachment) {
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
|
@ -127,19 +116,9 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
AttachmentThumbnailView(attachment: attachment, fullSize: false)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
|
|
||||||
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
|
|
||||||
.overlay {
|
|
||||||
thumbnailFocusedOverlay
|
|
||||||
}
|
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
.onTapGesture {
|
.cornerRadius(8)
|
||||||
textEditorFocused = false
|
|
||||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
|
||||||
controller.focusAttachmentOnTextEditorUnfocus = true
|
|
||||||
}
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if attachment.drawingData != nil {
|
if attachment.drawingData != nil {
|
||||||
Button(action: controller.editDrawing) {
|
Button(action: controller.editDrawing) {
|
||||||
|
@ -155,14 +134,16 @@ class AttachmentRowController: ViewController {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
} previewIfAvailable: {
|
} previewIfAvailable: {
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
AttachmentThumbnailView(attachment: attachment, fullSize: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch controller.descriptionMode {
|
switch controller.descriptionMode {
|
||||||
case .allowEntry:
|
case .allowEntry:
|
||||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
|
AttachmentDescriptionTextView(
|
||||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
text: $attachment.attachmentDescription,
|
||||||
.focused($textEditorFocused)
|
placeholder: Text("Describe for the visually impaired…"),
|
||||||
|
minHeight: 80
|
||||||
|
)
|
||||||
|
|
||||||
case .recognizingText:
|
case .recognizingText:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
@ -175,25 +156,7 @@ class AttachmentRowController: ViewController {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
}
|
}
|
||||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||||
.onChange(of: textEditorFocused) { newValue in
|
|
||||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
|
||||||
controller.focusAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var thumbnailFocusedOverlay: some View {
|
|
||||||
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.black.opacity(0.35))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
|
|
||||||
.opacity(textEditorFocused ? 1 : 0)
|
|
||||||
.animation(.linear(duration: 0.1), value: textEditorFocused)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,166 +0,0 @@
|
||||||
//
|
|
||||||
// AttachmentThumbnailController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/10/21.
|
|
||||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Photos
|
|
||||||
import TuskerComponents
|
|
||||||
|
|
||||||
class AttachmentThumbnailController: ViewController {
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
|
|
||||||
@Published private var image: UIImage?
|
|
||||||
@Published private var gifController: GIFController?
|
|
||||||
@Published private var fullSize: Bool = false
|
|
||||||
|
|
||||||
init(attachment: DraftAttachment) {
|
|
||||||
self.attachment = attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadImageIfNecessary(fullSize: Bool) {
|
|
||||||
if (gifController != nil) || (image != nil && self.fullSize) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.fullSize = fullSize
|
|
||||||
|
|
||||||
switch attachment.data {
|
|
||||||
case .asset(let id):
|
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $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.gifController = GIFController(gifData: data)
|
|
||||||
} else {
|
|
||||||
let image = UIImage(data: data)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let size: CGSize
|
|
||||||
if fullSize {
|
|
||||||
size = PHImageManagerMaximumSize
|
|
||||||
} else {
|
|
||||||
// currently only used as thumbnail in ComposeAttachmentRow
|
|
||||||
size = CGSize(width: 80, height: 80)
|
|
||||||
}
|
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .drawing(let drawing):
|
|
||||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
|
||||||
|
|
||||||
case .file(let url, let type):
|
|
||||||
if type.conforms(to: .movie) {
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
|
||||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
|
||||||
self.image = UIImage(cgImage: cgImage)
|
|
||||||
}
|
|
||||||
} else if let data = try? Data(contentsOf: url) {
|
|
||||||
if type == .gif {
|
|
||||||
self.gifController = GIFController(gifData: data)
|
|
||||||
} else if type.conforms(to: .image),
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
if fullSize {
|
|
||||||
image.prepareForDisplay { prepared in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = prepared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.image = prepared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some SwiftUI.View {
|
|
||||||
View()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct View: SwiftUI.View {
|
|
||||||
@EnvironmentObject private var controller: AttachmentThumbnailController
|
|
||||||
@Environment(\.attachmentThumbnailConfiguration) private var config
|
|
||||||
|
|
||||||
var body: some SwiftUI.View {
|
|
||||||
content
|
|
||||||
.onAppear {
|
|
||||||
controller.loadImageIfNecessary(fullSize: config.fullSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var content: some SwiftUI.View {
|
|
||||||
if let gifController = controller.gifController {
|
|
||||||
GIFViewWrapper(controller: gifController)
|
|
||||||
} else if let image = controller.image {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentThumbnailConfiguration {
|
|
||||||
let aspectRatio: CGFloat?
|
|
||||||
let contentMode: ContentMode
|
|
||||||
let fullSize: Bool
|
|
||||||
|
|
||||||
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
|
|
||||||
self.aspectRatio = aspectRatio
|
|
||||||
self.contentMode = contentMode
|
|
||||||
self.fullSize = fullSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue = AttachmentThumbnailConfiguration()
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
|
|
||||||
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
|
|
||||||
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct GIFViewWrapper: UIViewRepresentable {
|
|
||||||
typealias UIViewType = GIFImageView
|
|
||||||
|
|
||||||
@State var controller: GIFController
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> GIFImageView {
|
|
||||||
let view = GIFImageView()
|
|
||||||
controller.attach(to: view)
|
|
||||||
controller.startAnimating()
|
|
||||||
view.contentMode = .scaleAspectFit
|
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -76,9 +76,7 @@ class AttachmentsListController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteAttachments(at indices: IndexSet) {
|
private func deleteAttachments(at indices: IndexSet) {
|
||||||
var array = draft.draftAttachments
|
draft.attachments.removeObjects(at: indices)
|
||||||
array.remove(atOffsets: indices)
|
|
||||||
draft.attachments = NSMutableOrderedSet(array: array)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
||||||
|
@ -150,7 +148,6 @@ class AttachmentsListController: ViewController {
|
||||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
.id(attachment.id)
|
|
||||||
}
|
}
|
||||||
.onMove(perform: controller.moveAttachments)
|
.onMove(perform: controller.moveAttachments)
|
||||||
.onDelete(perform: controller.deleteAttachments)
|
.onDelete(perform: controller.deleteAttachments)
|
||||||
|
|
|
@ -9,7 +9,6 @@ import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import TuskerComponents
|
import TuskerComponents
|
||||||
import MatchedGeometryPresentation
|
|
||||||
|
|
||||||
public final class ComposeController: ViewController {
|
public final class ComposeController: ViewController {
|
||||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||||
|
@ -30,7 +29,6 @@ public final class ComposeController: ViewController {
|
||||||
|
|
||||||
@Published public var currentAccount: (any AccountProtocol)?
|
@Published public var currentAccount: (any AccountProtocol)?
|
||||||
@Published public var showToolbar = true
|
@Published public var showToolbar = true
|
||||||
@Published public var deleteDraftOnDisappear = true
|
|
||||||
|
|
||||||
@Published var autocompleteController: AutocompleteController!
|
@Published var autocompleteController: AutocompleteController!
|
||||||
@Published var toolbarController: ToolbarController!
|
@Published var toolbarController: ToolbarController!
|
||||||
|
@ -39,8 +37,6 @@ public final class ComposeController: ViewController {
|
||||||
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
||||||
// updates when it changes, because changes to it may alter postButtonEnabled
|
// updates when it changes, because changes to it may alter postButtonEnabled
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
|
|
||||||
let scrollToAttachment = PassthroughSubject<UUID, Never>()
|
|
||||||
@Published var contentWarningBecomeFirstResponder = false
|
@Published var contentWarningBecomeFirstResponder = false
|
||||||
@Published var mainComposeTextViewBecomeFirstResponder = false
|
@Published var mainComposeTextViewBecomeFirstResponder = false
|
||||||
@Published var currentInput: (any ComposeInput)? = nil
|
@Published var currentInput: (any ComposeInput)? = nil
|
||||||
|
@ -50,7 +46,6 @@ public final class ComposeController: ViewController {
|
||||||
@Published var poster: PostService?
|
@Published var poster: PostService?
|
||||||
@Published var postError: PostService.Error?
|
@Published var postError: PostService.Error?
|
||||||
@Published public private(set) var didPostSuccessfully = false
|
@Published public private(set) var didPostSuccessfully = false
|
||||||
@Published var hasChangedLanguageSelection = false
|
|
||||||
|
|
||||||
var isPosting: Bool {
|
var isPosting: Bool {
|
||||||
poster != nil
|
poster != nil
|
||||||
|
@ -75,15 +70,6 @@ public final class ComposeController: ViewController {
|
||||||
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||||
}
|
}
|
||||||
|
|
||||||
public var navigationTitle: String {
|
|
||||||
if let id = draft.inReplyToID,
|
|
||||||
let status = fetchStatus(id) {
|
|
||||||
return "Reply to @\(status.account.acct)"
|
|
||||||
} else {
|
|
||||||
return "New Post"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
draft: Draft,
|
draft: Draft,
|
||||||
config: ComposeUIConfig,
|
config: ComposeUIConfig,
|
||||||
|
@ -108,10 +94,6 @@ public final class ComposeController: ViewController {
|
||||||
self.autocompleteController = AutocompleteController(parent: self)
|
self.autocompleteController = AutocompleteController(parent: self)
|
||||||
self.toolbarController = ToolbarController(parent: self)
|
self.toolbarController = ToolbarController(parent: self)
|
||||||
self.attachmentsListController = AttachmentsListController(parent: self)
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||||
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: some View {
|
public var view: some View {
|
||||||
|
@ -153,17 +135,23 @@ public final class ComposeController: ViewController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if draft.hasContent {
|
if config.automaticallySaveDrafts {
|
||||||
isShowingSaveDraftSheet = true
|
|
||||||
} else {
|
|
||||||
deleteDraftOnDisappear = true
|
|
||||||
config.dismiss(.cancel)
|
config.dismiss(.cancel)
|
||||||
|
} else {
|
||||||
|
if draft.hasContent {
|
||||||
|
isShowingSaveDraftSheet = true
|
||||||
|
} else {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cancel(deleteDraft: Bool) {
|
func cancel(deleteDraft: Bool) {
|
||||||
deleteDraftOnDisappear = true
|
if deleteDraft {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
}
|
||||||
config.dismiss(.cancel)
|
config.dismiss(.cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -217,7 +205,7 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func onDisappear() {
|
func onDisappear() {
|
||||||
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully) {
|
if !draft.hasContent || didPostSuccessfully {
|
||||||
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
}
|
}
|
||||||
DraftsPersistentContainer.shared.save()
|
DraftsPersistentContainer.shared.save()
|
||||||
|
@ -230,16 +218,6 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
@objc private func currentInputModeChanged() {
|
|
||||||
guard let mode = currentInput?.textInputMode,
|
|
||||||
let code = LanguagePicker.codeFromInputMode(mode),
|
|
||||||
!hasChangedLanguageSelection && !draft.hasContent else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.language = code.identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@OptionalObservedObject var poster: PostService?
|
@OptionalObservedObject var poster: PostService?
|
||||||
@EnvironmentObject var controller: ComposeController
|
@EnvironmentObject var controller: ComposeController
|
||||||
|
@ -256,24 +234,12 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
|
||||||
navRoot
|
|
||||||
}
|
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var navRoot: some View {
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
||||||
config.backgroundColor
|
config.backgroundColor
|
||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
ScrollViewReader { proxy in
|
mainList
|
||||||
mainList
|
|
||||||
.onReceive(controller.scrollToAttachment) { id in
|
|
||||||
proxy.scrollTo(id, anchor: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let poster = poster {
|
if let poster = poster {
|
||||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
|
@ -313,26 +279,17 @@ public final class ComposeController: ViewController {
|
||||||
}, message: { error in
|
}, message: { error in
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
})
|
})
|
||||||
.matchedGeometryPresentation(id: Binding(get: {
|
|
||||||
controller.focusedAttachment?.0.id
|
|
||||||
}, set: {
|
|
||||||
if $0 == nil {
|
|
||||||
controller.focusedAttachment = nil
|
|
||||||
} else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}), backgroundColor: .black) {
|
|
||||||
ControllerView(controller: {
|
|
||||||
FocusedAttachmentController(
|
|
||||||
parent: controller,
|
|
||||||
attachment: controller.focusedAttachment!.0,
|
|
||||||
thumbnailController: controller.focusedAttachment!.1
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.onDisappear(perform: controller.onDisappear)
|
.onDisappear(perform: controller.onDisappear)
|
||||||
.navigationTitle(controller.navigationTitle)
|
.navigationTitle(navTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
}
|
||||||
|
|
||||||
|
private var navTitle: String {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = controller.fetchStatus(id) {
|
||||||
|
return "Reply to @\(status.account.acct)"
|
||||||
|
} else {
|
||||||
|
return "New Post"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainList: some View {
|
private var mainList: some View {
|
||||||
|
|
|
@ -134,10 +134,9 @@ private struct DraftRow: View {
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(draft.draftAttachments) { attachment in
|
ForEach(draft.draftAttachments) { attachment in
|
||||||
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment) })
|
AttachmentThumbnailView(attachment: attachment, fullSize: false)
|
||||||
.aspectRatio(contentMode: .fit)
|
.frame(width: 50, height: 50)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.cornerRadius(5)
|
||||||
.frame(height: 50)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
//
|
|
||||||
// FocusedAttachmentController.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/29/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import MatchedGeometryPresentation
|
|
||||||
import AVKit
|
|
||||||
|
|
||||||
class FocusedAttachmentController: ViewController {
|
|
||||||
|
|
||||||
unowned let parent: ComposeController
|
|
||||||
let attachment: DraftAttachment
|
|
||||||
let thumbnailController: AttachmentThumbnailController
|
|
||||||
private let player: AVPlayer?
|
|
||||||
|
|
||||||
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
|
|
||||||
self.parent = parent
|
|
||||||
self.attachment = attachment
|
|
||||||
self.thumbnailController = thumbnailController
|
|
||||||
|
|
||||||
if case let .file(url, type) = attachment.data,
|
|
||||||
type.conforms(to: .movie) {
|
|
||||||
self.player = AVPlayer(url: url)
|
|
||||||
self.player!.isMuted = true
|
|
||||||
} else {
|
|
||||||
self.player = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
FocusedAttachmentView(attachment: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FocusedAttachmentView: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
@EnvironmentObject private var controller: FocusedAttachmentController
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@FocusState private var textEditorFocused: Bool
|
|
||||||
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
if let player = controller.player {
|
|
||||||
VideoPlayer(player: player)
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
.onAppear {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
} else if #available(iOS 16.0, *) {
|
|
||||||
ZoomableScrollView {
|
|
||||||
attachmentView
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
attachmentView
|
|
||||||
.matchedGeometryDestination(id: attachment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
FocusedAttachmentDescriptionView(attachment: attachment)
|
|
||||||
.environment(\.colorScheme, .dark)
|
|
||||||
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
|
|
||||||
.frame(height: 150)
|
|
||||||
.focused($textEditorFocused)
|
|
||||||
}
|
|
||||||
.background(.black)
|
|
||||||
.overlay(alignment: .topLeading, content: {
|
|
||||||
Button {
|
|
||||||
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
|
|
||||||
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
|
|
||||||
if textEditorFocused {
|
|
||||||
matchedGeomState.mode = .dismissing
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
|
|
||||||
}
|
|
||||||
.buttonStyle(DismissFocusedAttachmentButtonStyle())
|
|
||||||
.padding([.top, .leading], 4)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private var attachmentView: some View {
|
|
||||||
ControllerView(controller: { controller.thumbnailController })
|
|
||||||
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(.black.opacity(0.5))
|
|
||||||
|
|
||||||
configuration.label
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.imageScale(.large)
|
|
||||||
}
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AttachmentDescriptionTextViewID: Hashable {
|
|
||||||
let attachmentID: UUID
|
|
||||||
|
|
||||||
init(_ attachment: DraftAttachment) {
|
|
||||||
self.attachmentID = attachment.id
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(attachmentID)
|
|
||||||
hasher.combine("descriptionTextView")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -79,11 +79,6 @@ class ToolbarController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if #available(iOS 16.0, *),
|
|
||||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.frame(minWidth: minWidth)
|
.frame(minWidth: minWidth)
|
||||||
|
|
|
@ -26,7 +26,6 @@ public class Draft: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var id: UUID
|
@NSManaged public var id: UUID
|
||||||
@NSManaged public var initialText: String
|
@NSManaged public var initialText: String
|
||||||
@NSManaged public var inReplyToID: String?
|
@NSManaged public var inReplyToID: String?
|
||||||
@NSManaged public var language: String? // ISO 639 language code
|
|
||||||
@NSManaged public var lastModified: Date
|
@NSManaged public var lastModified: Date
|
||||||
@NSManaged public var localOnly: Bool
|
@NSManaged public var localOnly: Bool
|
||||||
@NSManaged public var text: String
|
@NSManaged public var text: String
|
||||||
|
|
|
@ -58,13 +58,6 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
case file(URL, UTType)
|
case file(URL, UTType)
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func prepareForDeletion() {
|
|
||||||
super.prepareForDeletion()
|
|
||||||
if let fileURL {
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DraftAttachment {
|
extension DraftAttachment {
|
||||||
|
@ -203,8 +196,7 @@ extension DraftAttachment {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if type != .gif,
|
if type.conforms(to: .image) {
|
||||||
type.conforms(to: .image) {
|
|
||||||
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
|
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
|
||||||
completion(.success(result))
|
completion(.success(result))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="initialText" attributeType="String"/>
|
<attribute name="initialText" attributeType="String"/>
|
||||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="language" optional="YES" attributeType="String"/>
|
|
||||||
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="text" attributeType="String" defaultValueString=""/>
|
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||||
|
|
|
@ -16,6 +16,16 @@ public class Poll: NSManagedObject {
|
||||||
@NSManaged public var draft: Draft
|
@NSManaged public var draft: Draft
|
||||||
@NSManaged public var options: NSMutableOrderedSet
|
@NSManaged public var options: NSMutableOrderedSet
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext) {
|
||||||
|
super.init(entity: context.persistentStoreCoordinator!.managedObjectModel.entitiesByName["Poll"]!, insertInto: context)
|
||||||
|
self.multiple = false
|
||||||
|
self.duration = 24 * 60 * 60 // 1 day
|
||||||
|
self.options = [
|
||||||
|
PollOption(context: context),
|
||||||
|
PollOption(context: context),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
public var pollOptions: [PollOption] {
|
public var pollOptions: [PollOption] {
|
||||||
get {
|
get {
|
||||||
options.array as! [PollOption]
|
options.array as! [PollOption]
|
||||||
|
@ -25,18 +35,6 @@ public class Poll: NSManagedObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func awakeFromInsert() {
|
|
||||||
super.awakeFromInsert()
|
|
||||||
self.multiple = false
|
|
||||||
self.duration = 24 * 60 * 60 // 1 day
|
|
||||||
if let managedObjectContext {
|
|
||||||
self.options = [
|
|
||||||
PollOption(context: managedObjectContext),
|
|
||||||
PollOption(context: managedObjectContext),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Poll {
|
extension Poll {
|
||||||
|
|
|
@ -10,13 +10,7 @@ import Combine
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
class KeyboardReader: ObservableObject {
|
class KeyboardReader: ObservableObject {
|
||||||
// @Published var isVisible = false
|
@Published var isVisible = false
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
|
||||||
|
|
||||||
var isVisible: Bool {
|
|
||||||
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
|
|
||||||
keyboardHeight > 72
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||||
|
@ -24,16 +18,12 @@ class KeyboardReader: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func willShow(_ notification: Foundation.Notification) {
|
@objc func willShow(_ notification: Foundation.Notification) {
|
||||||
|
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
|
||||||
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||||
// isVisible = endFrame.height > 72
|
isVisible = endFrame.height > 72
|
||||||
keyboardHeight = endFrame.height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func willHide() {
|
@objc func willHide() {
|
||||||
// sometimes willHide is called during a SwiftUI view update
|
isVisible = false
|
||||||
DispatchQueue.main.async {
|
|
||||||
// self.isVisible = false
|
|
||||||
self.keyboardHeight = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,24 +7,22 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private var placeholder: some View {
|
struct AttachmentDescriptionTextView: View {
|
||||||
Text("Describe for the visually impaired…")
|
@Binding private var text: String
|
||||||
}
|
private let placeholder: Text?
|
||||||
|
|
||||||
struct InlineAttachmentDescriptionView: View {
|
|
||||||
@ObservedObject private var attachment: DraftAttachment
|
|
||||||
private let minHeight: CGFloat
|
private let minHeight: CGFloat
|
||||||
|
|
||||||
@State private var height: CGFloat?
|
@State private var height: CGFloat?
|
||||||
|
|
||||||
init(attachment: DraftAttachment, minHeight: CGFloat) {
|
init(text: Binding<String>, placeholder: Text?, minHeight: CGFloat) {
|
||||||
self.attachment = attachment
|
self._text = text
|
||||||
|
self.placeholder = placeholder
|
||||||
self.minHeight = minHeight
|
self.minHeight = minHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if attachment.attachmentDescription.isEmpty {
|
if text.isEmpty, let placeholder {
|
||||||
placeholder
|
placeholder
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -32,9 +30,9 @@ struct InlineAttachmentDescriptionView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedTextView(
|
WrappedTextView(
|
||||||
text: $attachment.attachmentDescription,
|
text: $text,
|
||||||
backgroundColor: .clear,
|
textDidChange: self.textDidChange,
|
||||||
textDidChange: self.textDidChange
|
font: .preferredFont(forTextStyle: .body)
|
||||||
)
|
)
|
||||||
.frame(height: height ?? minHeight)
|
.frame(height: height ?? minHeight)
|
||||||
}
|
}
|
||||||
|
@ -45,43 +43,20 @@ struct InlineAttachmentDescriptionView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FocusedAttachmentDescriptionView: View {
|
|
||||||
@ObservedObject var attachment: DraftAttachment
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
WrappedTextView(
|
|
||||||
text: $attachment.attachmentDescription,
|
|
||||||
backgroundColor: .secondarySystemBackground,
|
|
||||||
textDidChange: nil
|
|
||||||
)
|
|
||||||
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
|
|
||||||
|
|
||||||
if attachment.attachmentDescription.isEmpty {
|
|
||||||
placeholder
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.offset(x: 4, y: 8)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WrappedTextView: UIViewRepresentable {
|
private struct WrappedTextView: UIViewRepresentable {
|
||||||
typealias UIViewType = UITextView
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let backgroundColor: UIColor
|
let textDidChange: ((UITextView) -> Void)
|
||||||
let textDidChange: (((UITextView) -> Void))?
|
let font: UIFont
|
||||||
|
|
||||||
@Environment(\.isEnabled) private var isEnabled
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let view = UITextView()
|
let view = UITextView()
|
||||||
view.delegate = context.coordinator
|
view.delegate = context.coordinator
|
||||||
view.backgroundColor = backgroundColor
|
view.backgroundColor = .clear
|
||||||
view.font = .preferredFont(forTextStyle: .body)
|
view.font = font
|
||||||
view.adjustsFontForContentSizeCategory = true
|
view.adjustsFontForContentSizeCategory = true
|
||||||
view.textContainer.lineBreakMode = .byWordWrapping
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
return view
|
return view
|
||||||
|
@ -93,12 +68,10 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
context.coordinator.textView = uiView
|
context.coordinator.textView = uiView
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.didChange = textDidChange
|
context.coordinator.didChange = textDidChange
|
||||||
if let textDidChange {
|
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
// the text view knows its new content size
|
||||||
// the text view knows its new content size
|
DispatchQueue.main.async {
|
||||||
DispatchQueue.main.async {
|
self.textDidChange(uiView)
|
||||||
textDidChange(uiView)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,10 +82,10 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||||
weak var textView: UITextView?
|
weak var textView: UITextView?
|
||||||
var text: Binding<String>
|
var text: Binding<String>
|
||||||
var didChange: ((UITextView) -> Void)?
|
var didChange: (UITextView) -> Void
|
||||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
init(text: Binding<String>, didChange: @escaping (UITextView) -> Void) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.didChange = didChange
|
self.didChange = didChange
|
||||||
|
|
||||||
|
@ -131,7 +104,7 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
text.wrappedValue = textView.text
|
text.wrappedValue = textView.text
|
||||||
didChange?(textView)
|
didChange(textView)
|
||||||
|
|
||||||
ensureCursorVisible(textView: textView)
|
ensureCursorVisible(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
//
|
||||||
|
// AttachmentThumbnailView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/10/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct AttachmentThumbnailView: View {
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
let fullSize: Bool
|
||||||
|
|
||||||
|
@State private var gifData: Data? = nil
|
||||||
|
@State private var image: UIImage? = nil
|
||||||
|
@State private var imageContentMode: ContentMode = .fill
|
||||||
|
@State private var imageBackgroundColor: Color = .black
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let gifData {
|
||||||
|
GIFViewWrapper(gifData: gifData)
|
||||||
|
} else if let image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: imageContentMode)
|
||||||
|
.background(imageBackgroundColor)
|
||||||
|
} else {
|
||||||
|
Image(systemName: placeholderImageName)
|
||||||
|
.onAppear(perform: self.loadImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderImageName: String {
|
||||||
|
switch colorScheme {
|
||||||
|
case .light:
|
||||||
|
return "photo"
|
||||||
|
case .dark:
|
||||||
|
return "photo.fill"
|
||||||
|
@unknown default:
|
||||||
|
return "photo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
switch attachment.data {
|
||||||
|
case .asset(let id):
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let size: CGSize
|
||||||
|
if fullSize {
|
||||||
|
size = PHImageManagerMaximumSize
|
||||||
|
} else {
|
||||||
|
// currently only used as thumbnail in ComposeAttachmentRow
|
||||||
|
size = CGSize(width: 80, height: 80)
|
||||||
|
}
|
||||||
|
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||||
|
if isGIF {
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
if typeIdentifier == UTType.gif.identifier {
|
||||||
|
self.gifData = data
|
||||||
|
} else if let data {
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case let .drawing(drawing):
|
||||||
|
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
imageContentMode = .fit
|
||||||
|
imageBackgroundColor = .white
|
||||||
|
|
||||||
|
case .file(let url, let type):
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.image = UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
} else if let data = try? Data(contentsOf: url) {
|
||||||
|
if type == .gif {
|
||||||
|
self.gifData = data
|
||||||
|
} else if type.conforms(to: .image),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
if fullSize {
|
||||||
|
image.prepareForDisplay {
|
||||||
|
self.image = $0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
image.prepareThumbnail(of: CGSize(width: 80, height: 80)) {
|
||||||
|
self.image = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GIFViewWrapper: UIViewRepresentable {
|
||||||
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
|
@State private var controller: GIFController
|
||||||
|
|
||||||
|
init(gifData: Data) {
|
||||||
|
self._controller = State(wrappedValue: GIFController(gifData: gifData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> GIFImageView {
|
||||||
|
let view = GIFImageView()
|
||||||
|
controller.attach(to: view)
|
||||||
|
controller.startAnimating()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -123,10 +123,6 @@ struct EmojiTextField: UIViewRepresentable {
|
||||||
|
|
||||||
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
|
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
|
||||||
|
|
||||||
var textInputMode: UITextInputMode? {
|
|
||||||
textField?.textInputMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyFormat(_ format: StatusFormat) {
|
func applyFormat(_ format: StatusFormat) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,192 +0,0 @@
|
||||||
//
|
|
||||||
// LanguagePicker.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 5/4/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
struct LanguagePicker: View {
|
|
||||||
@Binding var draftLanguage: String?
|
|
||||||
@Binding var hasChangedSelection: Bool
|
|
||||||
@State private var isShowingSheet = false
|
|
||||||
|
|
||||||
private var codeFromDraft: Locale.LanguageCode? {
|
|
||||||
draftLanguage.map(Locale.LanguageCode.init(_:))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var codeFromActiveInputMode: Locale.LanguageCode? {
|
|
||||||
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
|
||||||
guard let bcp47Lang = mode.primaryLanguage else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
|
|
||||||
if maybeIso639Code.last == "-" {
|
|
||||||
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
|
||||||
}
|
|
||||||
let code = Locale.LanguageCode(String(maybeIso639Code))
|
|
||||||
if code.isISOLanguage {
|
|
||||||
return code
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
|
||||||
if let identifier = Locale.preferredLanguages.first {
|
|
||||||
let code = Locale.LanguageCode(identifier)
|
|
||||||
if code.isISOLanguage {
|
|
||||||
return code
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var languageCode: Binding<Locale.LanguageCode> {
|
|
||||||
Binding {
|
|
||||||
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
|
|
||||||
} set: { newValue in
|
|
||||||
draftLanguage = newValue.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button {
|
|
||||||
isShowingSheet = true
|
|
||||||
} label: {
|
|
||||||
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Post Language")
|
|
||||||
.sheet(isPresented: $isShowingSheet) {
|
|
||||||
NavigationStack {
|
|
||||||
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
private struct LanguagePickerList: View {
|
|
||||||
@Binding var languageCode: Locale.LanguageCode
|
|
||||||
@Binding var hasChangedSelection: Bool
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
@State private var recentLangs: [Lang] = []
|
|
||||||
@State private var langs: [Lang] = []
|
|
||||||
@State private var filteredLangs: [Lang]?
|
|
||||||
@State private var query = ""
|
|
||||||
|
|
||||||
private var defaults: UserDefaults {
|
|
||||||
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recentIdentifiers: [String] {
|
|
||||||
get {
|
|
||||||
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
|
|
||||||
}
|
|
||||||
nonmutating set {
|
|
||||||
defaults.set(newValue, forKey: "LanguagePickerRecents")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section {
|
|
||||||
ForEach(recentLangs) { lang in
|
|
||||||
button(for: lang)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Recently Used")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
ForEach(filteredLangs ?? langs) { lang in
|
|
||||||
button(for: lang)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("All Languages")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $query)
|
|
||||||
.navigationTitle("Post Language")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
// make sure recents always contains the currently selected lang
|
|
||||||
let recents = addRecentLang(languageCode)
|
|
||||||
recentLangs = recents
|
|
||||||
.map { Lang(code: .init($0)) }
|
|
||||||
.sorted { $0.name < $1.name }
|
|
||||||
|
|
||||||
langs = Locale.LanguageCode.isoLanguageCodes
|
|
||||||
.map { Lang(code: $0) }
|
|
||||||
.sorted { $0.name < $1.name }
|
|
||||||
}
|
|
||||||
.onChange(of: query) { newValue in
|
|
||||||
if newValue.isEmpty {
|
|
||||||
filteredLangs = nil
|
|
||||||
} else {
|
|
||||||
filteredLangs = langs.filter {
|
|
||||||
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
|
|
||||||
var recents = recentIdentifiers
|
|
||||||
if !recents.contains(languageCode.identifier) {
|
|
||||||
recents.insert(languageCode.identifier, at: 0)
|
|
||||||
if recents.count > 5 {
|
|
||||||
recents = Array(recents[..<5])
|
|
||||||
}
|
|
||||||
recentIdentifiers = recents
|
|
||||||
}
|
|
||||||
return recents
|
|
||||||
}
|
|
||||||
|
|
||||||
private func button(for lang: Lang) -> some View {
|
|
||||||
Button {
|
|
||||||
languageCode = lang.code
|
|
||||||
hasChangedSelection = true
|
|
||||||
isPresented = false
|
|
||||||
addRecentLang(lang.code)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(lang.name)
|
|
||||||
Spacer()
|
|
||||||
if lang.code == languageCode {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Lang: Identifiable {
|
|
||||||
let code: Locale.LanguageCode
|
|
||||||
let name: String
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
code.identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
init(code: Locale.LanguageCode) {
|
|
||||||
self.code = code
|
|
||||||
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -256,10 +256,6 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
[.emojiPicker, .formattingButtons]
|
[.emojiPicker, .formattingButtons]
|
||||||
}
|
}
|
||||||
|
|
||||||
var textInputMode: UITextInputMode? {
|
|
||||||
textView?.textInputMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func autocomplete(with string: String) {
|
func autocomplete(with string: String) {
|
||||||
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,111 +0,0 @@
|
||||||
//
|
|
||||||
// ZoomableScrollView.swift
|
|
||||||
// ComposeUI
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/29/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
|
||||||
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
init(@ViewBuilder content: () -> Content) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> Controller {
|
|
||||||
return Controller(content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
|
||||||
uiViewController.host.rootView = content
|
|
||||||
}
|
|
||||||
|
|
||||||
class Controller: UIViewController, UIScrollViewDelegate {
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let host: UIHostingController<Content>
|
|
||||||
|
|
||||||
private var lastIntrinsicSize: CGSize?
|
|
||||||
private var contentViewTopConstraint: NSLayoutConstraint!
|
|
||||||
private var contentViewLeadingConstraint: NSLayoutConstraint!
|
|
||||||
private var hostBoundsObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(content: Content) {
|
|
||||||
self.host = UIHostingController(rootView: content)
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
scrollView.delegate = self
|
|
||||||
scrollView.bouncesZoom = true
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
|
|
||||||
host.sizingOptions = .intrinsicContentSize
|
|
||||||
host.view.backgroundColor = .clear
|
|
||||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addChild(host)
|
|
||||||
scrollView.addSubview(host.view)
|
|
||||||
host.didMove(toParent: self)
|
|
||||||
|
|
||||||
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
|
|
||||||
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
|
|
||||||
contentViewLeadingConstraint,
|
|
||||||
contentViewTopConstraint,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
if !host.view.intrinsicContentSize.equalTo(.zero),
|
|
||||||
host.view.intrinsicContentSize != lastIntrinsicSize {
|
|
||||||
self.lastIntrinsicSize = host.view.intrinsicContentSize
|
|
||||||
|
|
||||||
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
|
|
||||||
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
|
|
||||||
let heightScale = maxHeight / host.view.intrinsicContentSize.height
|
|
||||||
let widthScale = maxWidth / host.view.intrinsicContentSize.width
|
|
||||||
let minScale = min(widthScale, heightScale)
|
|
||||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
|
||||||
scrollView.minimumZoomScale = minScale
|
|
||||||
scrollView.maximumZoomScale = maxScale
|
|
||||||
scrollView.zoomScale = minScale
|
|
||||||
}
|
|
||||||
|
|
||||||
centerImage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
||||||
return host.view
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
||||||
centerImage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func centerImage() {
|
|
||||||
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
|
|
||||||
contentViewTopConstraint.constant = yOffset
|
|
||||||
|
|
||||||
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
|
|
||||||
contentViewLeadingConstraint.constant = xOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,8 +10,6 @@ import UIKit
|
||||||
public protocol DuckableViewController: UIViewController {
|
public protocol DuckableViewController: UIViewController {
|
||||||
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||||
|
|
||||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
|
||||||
|
|
||||||
func duckableViewControllerMayAttemptToDuck()
|
func duckableViewControllerMayAttemptToDuck()
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
||||||
|
@ -20,7 +18,6 @@ public protocol DuckableViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DuckableViewController {
|
extension DuckableViewController {
|
||||||
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
|
|
||||||
public func duckableViewControllerMayAttemptToDuck() {}
|
public func duckableViewControllerMayAttemptToDuck() {}
|
||||||
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||||
|
@ -30,12 +27,6 @@ public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
func duckableViewControllerWillDismiss(animated: Bool)
|
func duckableViewControllerWillDismiss(animated: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DuckAttemptAction {
|
|
||||||
case duck
|
|
||||||
case dismiss
|
|
||||||
case block
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||||
|
|
|
@ -63,9 +63,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
presented.view.layer.opacity = 0
|
presented.view.layer.opacity = 0
|
||||||
}
|
}
|
||||||
fadeAnimator.addCompletion { _ in
|
|
||||||
presented.view.layer.opacity = 1
|
|
||||||
}
|
|
||||||
fadeAnimator.startAnimation(afterDelay: 0.3)
|
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
@ -83,7 +80,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
presented.view.layer.opacity = 0
|
presented.view.layer.opacity = 0
|
||||||
}
|
}
|
||||||
fadeAnimator.addCompletion { _ in
|
fadeAnimator.addCompletion { _ in
|
||||||
presented.view.layer.opacity = 1
|
|
||||||
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
transitionContext.completeTransition(true)
|
transitionContext.completeTransition(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,9 +88,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
|
|
||||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
viewController.duckableDelegate = self
|
viewController.duckableDelegate = self
|
||||||
viewController.modalPresentationStyle = .custom
|
let nav = UINavigationController(rootViewController: viewController)
|
||||||
viewController.transitioningDelegate = self
|
nav.modalPresentationStyle = .custom
|
||||||
present(viewController, animated: animated) {
|
nav.transitioningDelegate = self
|
||||||
|
present(nav, animated: animated) {
|
||||||
self.configureChildForDuckedPlaceholder()
|
self.configureChildForDuckedPlaceholder()
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
|
@ -135,18 +136,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch viewController.duckableViewControllerShouldDuck() {
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||||
case .duck:
|
state = .ducked(viewController, placeholder: placeholder)
|
||||||
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
configureChildForDuckedPlaceholder()
|
||||||
state = .ducked(viewController, placeholder: placeholder)
|
dismiss(animated: true)
|
||||||
configureChildForDuckedPlaceholder()
|
|
||||||
dismiss(animated: true)
|
|
||||||
case .block:
|
|
||||||
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
|
||||||
case .dismiss:
|
|
||||||
duckableViewControllerWillDismiss(animated: true)
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureChildForDuckedPlaceholder() {
|
private func configureChildForDuckedPlaceholder() {
|
||||||
|
@ -243,3 +236,4 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -111,10 +111,6 @@ public class InstanceFeatures: ObservableObject {
|
||||||
instanceType.isMastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
public var createStatusWithLanguage: Bool {
|
|
||||||
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,15 +199,6 @@ extension InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isMastodon(_ subtype: MastodonType) -> Bool {
|
|
||||||
if case .mastodon(let t, _) = self,
|
|
||||||
t.equalsIgnoreVersion(subtype) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPleroma: Bool {
|
var isPleroma: Bool {
|
||||||
if case .pleroma(_) = self {
|
if case .pleroma(_) = self {
|
||||||
return true
|
return true
|
||||||
|
@ -219,50 +206,17 @@ extension InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPleroma(_ subtype: PleromaType) -> Bool {
|
|
||||||
if case .pleroma(let t) = self,
|
|
||||||
t.equalsIgnoreVersion(subtype) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MastodonType {
|
enum MastodonType {
|
||||||
case vanilla
|
case vanilla
|
||||||
case hometown(Version?)
|
case hometown(Version?)
|
||||||
case glitch
|
case glitch
|
||||||
|
|
||||||
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
|
|
||||||
switch (self, other) {
|
|
||||||
case (.vanilla, .vanilla):
|
|
||||||
return true
|
|
||||||
case (.hometown(_), .hometown(_)):
|
|
||||||
return true
|
|
||||||
case (.glitch, .glitch):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PleromaType {
|
enum PleromaType {
|
||||||
case vanilla(Version?)
|
case vanilla(Version?)
|
||||||
case akkoma(Version?)
|
case akkoma(Version?)
|
||||||
|
|
||||||
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
|
|
||||||
switch (self, other) {
|
|
||||||
case (.vanilla(_), .vanilla(_)):
|
|
||||||
return true
|
|
||||||
case (.akkoma(_), .akkoma(_)):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
/.build
|
|
||||||
/Packages
|
|
||||||
/*.xcodeproj
|
|
||||||
xcuserdata/
|
|
||||||
DerivedData/
|
|
||||||
.swiftpm/config/registries.json
|
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
|
||||||
.netrc
|
|
|
@ -1,26 +0,0 @@
|
||||||
// swift-tools-version: 5.8
|
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
|
||||||
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "MatchedGeometryPresentation",
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v15),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
|
||||||
.library(
|
|
||||||
name: "MatchedGeometryPresentation",
|
|
||||||
targets: ["MatchedGeometryPresentation"]),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
|
||||||
.target(
|
|
||||||
name: "MatchedGeometryPresentation"),
|
|
||||||
// .testTarget(
|
|
||||||
// name: "MatchedGeometryPresentationTests",
|
|
||||||
// dependencies: ["MatchedGeometryPresentation"]),
|
|
||||||
]
|
|
||||||
)
|
|
|
@ -1,125 +0,0 @@
|
||||||
//
|
|
||||||
// MatchedGeometryModifiers.swift
|
|
||||||
// MatchGeom
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/24/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
|
|
||||||
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
|
|
||||||
}
|
|
||||||
|
|
||||||
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
|
|
||||||
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
|
|
||||||
}
|
|
||||||
|
|
||||||
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
|
|
||||||
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
|
|
||||||
@Binding var id: ID?
|
|
||||||
let backgroundColor: UIColor
|
|
||||||
let presented: Presented
|
|
||||||
@StateObject private var state = MatchedGeometryState()
|
|
||||||
|
|
||||||
private var isPresented: Binding<Bool> {
|
|
||||||
Binding {
|
|
||||||
id != nil
|
|
||||||
} set: {
|
|
||||||
if $0 {
|
|
||||||
fatalError()
|
|
||||||
} else {
|
|
||||||
id = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.environmentObject(state)
|
|
||||||
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in
|
|
||||||
Color.clear
|
|
||||||
.presentViewController(makeVC(allSources: sources), isPresented: isPresented)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController {
|
|
||||||
return {
|
|
||||||
// force unwrap is safe, this closure is only called when being presented so we must have an id
|
|
||||||
let id = AnyHashable(id!)
|
|
||||||
return MatchedGeometryViewController(
|
|
||||||
presentationID: id,
|
|
||||||
content: presented,
|
|
||||||
state: state,
|
|
||||||
backgroundColor: backgroundColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchedGeometrySourceModifier: ViewModifier {
|
|
||||||
let id: AnyHashable
|
|
||||||
let presentationID: AnyHashable
|
|
||||||
let matched: () -> AnyView
|
|
||||||
@EnvironmentObject private var state: MatchedGeometryState
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
|
||||||
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
|
||||||
if let newValue {
|
|
||||||
state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.opacity(state.animating && state.presentationID == presentationID ? 0 : 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
|
|
||||||
let id: AnyHashable
|
|
||||||
let matched: Matched
|
|
||||||
@EnvironmentObject private var state: MatchedGeometryState
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.background(GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
|
||||||
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
|
||||||
if let newValue,
|
|
||||||
// ignore intermediate layouts that may happen while the dismiss animation is happening
|
|
||||||
state.mode != .dismissing {
|
|
||||||
state.destinations[id] = (AnyView(matched), newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.opacity(state.animating ? 0 : 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchedGeometryDestinationFrameKey: PreferenceKey {
|
|
||||||
static let defaultValue: CGRect? = nil
|
|
||||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchedGeometrySourcesKey: PreferenceKey {
|
|
||||||
static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:]
|
|
||||||
static func reduce(value: inout Value, nextValue: () -> Value) {
|
|
||||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SourceKey: Hashable {
|
|
||||||
let presentationID: AnyHashable
|
|
||||||
let matchedID: AnyHashable
|
|
||||||
}
|
|
|
@ -1,238 +0,0 @@
|
||||||
//
|
|
||||||
// MatchedGeometryViewController.swift
|
|
||||||
// MatchGeom
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/24/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
private let mass: CGFloat = 1
|
|
||||||
private let presentStiffness: CGFloat = 300
|
|
||||||
private let presentDamping: CGFloat = 20
|
|
||||||
private let dismissStiffness: CGFloat = 200
|
|
||||||
private let dismissDamping: CGFloat = 20
|
|
||||||
|
|
||||||
public class MatchedGeometryState: ObservableObject {
|
|
||||||
@Published var presentationID: AnyHashable?
|
|
||||||
@Published var animating: Bool = false
|
|
||||||
@Published public var mode: Mode = .presenting
|
|
||||||
@Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:]
|
|
||||||
@Published var currentFrames: [AnyHashable: CGRect] = [:]
|
|
||||||
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
|
|
||||||
|
|
||||||
public enum Mode: Equatable {
|
|
||||||
case presenting
|
|
||||||
case idle
|
|
||||||
case dismissing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
|
|
||||||
|
|
||||||
let presentationID: AnyHashable
|
|
||||||
let content: Content
|
|
||||||
let state: MatchedGeometryState
|
|
||||||
let backgroundColor: UIColor
|
|
||||||
var contentHost: UIHostingController<ContentContainerView>!
|
|
||||||
var matchedHost: UIHostingController<MatchedContainerView>!
|
|
||||||
|
|
||||||
init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) {
|
|
||||||
self.presentationID = presentationID
|
|
||||||
self.content = content
|
|
||||||
self.state = state
|
|
||||||
self.backgroundColor = backgroundColor
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
|
|
||||||
modalPresentationStyle = .custom
|
|
||||||
transitioningDelegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state))
|
|
||||||
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
contentHost.view.frame = view.bounds
|
|
||||||
contentHost.view.backgroundColor = backgroundColor
|
|
||||||
addChild(contentHost)
|
|
||||||
view.addSubview(contentHost.view)
|
|
||||||
contentHost.didMove(toParent: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
state.presentationID = presentationID
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] {
|
|
||||||
Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) })
|
|
||||||
}
|
|
||||||
|
|
||||||
func addMatchedHostingController() {
|
|
||||||
let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) }
|
|
||||||
matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state))
|
|
||||||
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
matchedHost.view.frame = view.bounds
|
|
||||||
matchedHost.view.backgroundColor = .clear
|
|
||||||
matchedHost.view.layer.zPosition = 100
|
|
||||||
addChild(matchedHost)
|
|
||||||
view.addSubview(matchedHost.view)
|
|
||||||
matchedHost.didMove(toParent: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentContainerView: View {
|
|
||||||
let content: Content
|
|
||||||
let state: MatchedGeometryState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content
|
|
||||||
.environmentObject(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MatchedContainerView: View {
|
|
||||||
let sources: [(id: AnyHashable, view: () -> AnyView)]
|
|
||||||
@ObservedObject var state: MatchedGeometryState
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
ForEach(sources, id: \.id) { (id, view) in
|
|
||||||
matchedView(id: id, source: view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func matchedView(id: AnyHashable, source: () -> AnyView) -> some View {
|
|
||||||
if let frame = state.currentFrames[id],
|
|
||||||
let dest = state.destinations[id]?.0 {
|
|
||||||
ZStack {
|
|
||||||
source()
|
|
||||||
dest
|
|
||||||
.opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1))
|
|
||||||
}
|
|
||||||
.frame(width: frame.width, height: frame.height)
|
|
||||||
.position(x: frame.midX, y: frame.midY)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: UIViewControllerTransitioningDelegate
|
|
||||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
||||||
return MatchedGeometryPresentationAnimationController<Content>()
|
|
||||||
}
|
|
||||||
|
|
||||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
||||||
return MatchedGeometryDismissAnimationController<Content>()
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
|
||||||
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
|
||||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
|
||||||
return 0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
||||||
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
|
|
||||||
let container = transitionContext.containerView
|
|
||||||
|
|
||||||
// add the VC to the container, which kicks off layout out the content hosting controller
|
|
||||||
matchedGeomVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
matchedGeomVC.view.frame = container.bounds
|
|
||||||
container.addSubview(matchedGeomVC.view)
|
|
||||||
|
|
||||||
// layout out the content hosting controller and having enough destinations may take a while
|
|
||||||
// so listen for when it's ready, rather than trying to guess at the timing
|
|
||||||
let cancellable = matchedGeomVC.state.$destinations
|
|
||||||
.filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } }
|
|
||||||
.first()
|
|
||||||
.sink { destinations in
|
|
||||||
matchedGeomVC.addMatchedHostingController()
|
|
||||||
|
|
||||||
// setup the initial state for the animation
|
|
||||||
matchedGeomVC.matchedHost.view.isHidden = true
|
|
||||||
matchedGeomVC.state.mode = .presenting
|
|
||||||
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
|
||||||
|
|
||||||
// wait one runloop iteration for the matched hosting controller to be setup
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
matchedGeomVC.matchedHost.view.isHidden = false
|
|
||||||
matchedGeomVC.state.animating = true
|
|
||||||
// get the now-current destinations, in case they've changed since the sunk value was published
|
|
||||||
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedGeomVC.contentHost.view.layer.opacity = 0
|
|
||||||
let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
|
|
||||||
animator.addAnimations {
|
|
||||||
matchedGeomVC.contentHost.view.layer.opacity = 1
|
|
||||||
}
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
transitionContext.completeTransition(true)
|
|
||||||
matchedGeomVC.state.animating = false
|
|
||||||
matchedGeomVC.state.mode = .idle
|
|
||||||
|
|
||||||
matchedGeomVC.matchedHost?.view.removeFromSuperview()
|
|
||||||
matchedGeomVC.matchedHost?.removeFromParent()
|
|
||||||
cancellable.cancel()
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
|
||||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
|
||||||
return 0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
||||||
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
|
|
||||||
|
|
||||||
// recreate the matched host b/c using the current destinations doesn't seem to update the existing one
|
|
||||||
matchedGeomVC.addMatchedHostingController()
|
|
||||||
matchedGeomVC.matchedHost.view.isHidden = true
|
|
||||||
matchedGeomVC.state.mode = .dismissing
|
|
||||||
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
matchedGeomVC.matchedHost.view.isHidden = false
|
|
||||||
matchedGeomVC.state.animating = true
|
|
||||||
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero)
|
|
||||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring)
|
|
||||||
animator.addAnimations {
|
|
||||||
matchedGeomVC.contentHost.view.layer.opacity = 0
|
|
||||||
}
|
|
||||||
animator.addCompletion { _ in
|
|
||||||
transitionContext.completeTransition(true)
|
|
||||||
matchedGeomVC.state.animating = false
|
|
||||||
}
|
|
||||||
animator.startAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatchedGeometryPresentationController: UIPresentationController {
|
|
||||||
override func dismissalTransitionWillBegin() {
|
|
||||||
super.dismissalTransitionWillBegin()
|
|
||||||
delegate?.presentationControllerWillDismiss?(self)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
//
|
|
||||||
// View+PresentViewController.swift
|
|
||||||
// MatchGeom
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 4/24/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding<Bool>) -> some View {
|
|
||||||
self
|
|
||||||
.background(
|
|
||||||
ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ViewControllerPresenter: UIViewControllerRepresentable {
|
|
||||||
let makeVC: () -> UIViewController
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIViewController {
|
|
||||||
return UIViewController()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
|
||||||
if isPresented {
|
|
||||||
if uiViewController.presentedViewController == nil {
|
|
||||||
let presented = makeVC()
|
|
||||||
presented.presentationController!.delegate = context.coordinator
|
|
||||||
uiViewController.present(presented, animated: true)
|
|
||||||
context.coordinator.didPresent = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if context.coordinator.didPresent,
|
|
||||||
let presentedViewController = uiViewController.presentedViewController,
|
|
||||||
!presentedViewController.isBeingDismissed {
|
|
||||||
uiViewController.dismiss(animated: true)
|
|
||||||
context.coordinator.didPresent = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
return Coordinator(isPresented: $isPresented)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
var didPresent = false
|
|
||||||
|
|
||||||
init(isPresented: Binding<Bool>) {
|
|
||||||
self._isPresented = isPresented
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -385,7 +385,7 @@ public class Client {
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Visibility? = nil,
|
visibility: Visibility? = nil,
|
||||||
language: String? = nil, // language supported by mastodon and akkoma
|
language: String? = nil,
|
||||||
pollOptions: [String]? = nil,
|
pollOptions: [String]? = nil,
|
||||||
pollExpiresIn: Int? = nil,
|
pollExpiresIn: Int? = nil,
|
||||||
pollMultiple: Bool? = nil,
|
pollMultiple: Bool? = nil,
|
||||||
|
|
|
@ -48,12 +48,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.uri = try container.decode(String.self, forKey: .uri)
|
self.uri = try container.decode(String.self, forKey: .uri)
|
||||||
do {
|
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
||||||
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
|
||||||
} catch {
|
|
||||||
let s = (try? container.decode(String.self, forKey: .url)) ?? "<failed to decode string>"
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s)', reason: \(String(describing: error))")
|
|
||||||
}
|
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
||||||
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
||||||
|
|
|
@ -63,6 +63,7 @@ public class Preferences: Codable, ObservableObject {
|
||||||
|
|
||||||
self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility)
|
self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility)
|
||||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||||
|
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
|
||||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||||
|
@ -119,6 +120,7 @@ public class Preferences: Codable, ObservableObject {
|
||||||
|
|
||||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||||
|
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
|
||||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||||
|
@ -170,6 +172,7 @@ public class Preferences: Codable, ObservableObject {
|
||||||
// MARK: Composing
|
// MARK: Composing
|
||||||
@Published public var defaultPostVisibility = Visibility.public
|
@Published public var defaultPostVisibility = Visibility.public
|
||||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||||
|
@Published public var automaticallySaveDrafts = true
|
||||||
@Published public var requireAttachmentDescriptions = false
|
@Published public var requireAttachmentDescriptions = false
|
||||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||||
@Published public var mentionReblogger = false
|
@Published public var mentionReblogger = false
|
||||||
|
@ -233,6 +236,7 @@ public class Preferences: Codable, ObservableObject {
|
||||||
|
|
||||||
case defaultPostVisibility
|
case defaultPostVisibility
|
||||||
case defaultReplyVisibility
|
case defaultReplyVisibility
|
||||||
|
case automaticallySaveDrafts
|
||||||
case requireAttachmentDescriptions
|
case requireAttachmentDescriptions
|
||||||
case contentWarningCopyMode
|
case contentWarningCopyMode
|
||||||
case mentionReblogger
|
case mentionReblogger
|
||||||
|
|
|
@ -74,6 +74,8 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
||||||
var config = ComposeUIConfig()
|
var config = ComposeUIConfig()
|
||||||
config.allowSwitchingDrafts = false
|
config.allowSwitchingDrafts = false
|
||||||
config.textSelectionStartsAtBeginning = true
|
config.textSelectionStartsAtBeginning = true
|
||||||
|
// note: in the share sheet, we ignore this preference
|
||||||
|
config.automaticallySaveDrafts = false
|
||||||
|
|
||||||
config.backgroundColor = Color(uiColor: .appBackground)
|
config.backgroundColor = Color(uiColor: .appBackground)
|
||||||
config.groupedBackgroundColor = Color(uiColor: .appGroupedBackground)
|
config.groupedBackgroundColor = Color(uiColor: .appGroupedBackground)
|
||||||
|
|
|
@ -33,16 +33,17 @@ class ShareViewController: UIViewController {
|
||||||
|
|
||||||
let context = ShareMastodonContext(accountInfo: account)
|
let context = ShareMastodonContext(accountInfo: account)
|
||||||
let host = ShareHostingController(draft: draft, mastodonContext: context)
|
let host = ShareHostingController(draft: draft, mastodonContext: context)
|
||||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
let nav = UINavigationController(rootViewController: host)
|
||||||
addChild(host)
|
self.addChild(nav)
|
||||||
self.view.addSubview(host.view)
|
nav.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.view.addSubview(nav.view)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
host.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
nav.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
||||||
host.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
nav.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
||||||
host.view.topAnchor.constraint(equalTo: self.view.topAnchor),
|
nav.view.topAnchor.constraint(equalTo: self.view.topAnchor),
|
||||||
host.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
nav.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
||||||
])
|
])
|
||||||
host.didMove(toParent: self)
|
nav.didMove(toParent: self)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state = .notLoggedIn
|
state = .notLoggedIn
|
||||||
|
|
|
@ -634,7 +634,6 @@
|
||||||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MatchedGeometryPresentation; path = Packages/MatchedGeometryPresentation; sourceTree = "<group>"; };
|
|
||||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1458,7 +1457,6 @@
|
||||||
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
||||||
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
||||||
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
||||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
|
||||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
|
@ -2382,7 +2380,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2448,7 +2446,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2474,12 +2472,12 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2503,12 +2501,12 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2532,12 +2530,12 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2687,7 +2685,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2718,7 +2716,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2824,7 +2822,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2850,7 +2848,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 81;
|
CURRENT_PROJECT_VERSION = 80;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
|
|
@ -60,10 +60,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure the persistent container is initialized on the main thread
|
|
||||||
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
|
|
||||||
_ = DraftsPersistentContainer.shared
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||||
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
|
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||||
|
|
|
@ -61,9 +61,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
|
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
||||||
composeVC.delegate = self
|
composeVC.delegate = self
|
||||||
|
let nav = EnhancedNavigationViewController(rootViewController: composeVC)
|
||||||
|
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
window!.rootViewController = composeVC
|
window!.rootViewController = nav
|
||||||
window!.makeKeyAndVisible()
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
updateTitle(draft: composeVC.controller.draft)
|
updateTitle(draft: composeVC.controller.draft)
|
||||||
|
|
|
@ -28,6 +28,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
let controller: ComposeController
|
let controller: ComposeController
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
|
|
||||||
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
|
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
|
||||||
private var drawingCompletion: ((PKDrawing) -> Void)?
|
private var drawingCompletion: ((PKDrawing) -> Void)?
|
||||||
|
|
||||||
|
@ -55,9 +56,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
pasteConfiguration = UIPasteConfiguration(forAccepting: DraftAttachment.self)
|
pasteConfiguration = UIPasteConfiguration(forAccepting: DraftAttachment.self)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
// set an initial title immediately, in case we're starting ducked
|
|
||||||
self.navigationItem.title = self.controller.navigationTitle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
@ -79,6 +77,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
|
|
||||||
config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard
|
config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard
|
||||||
config.contentType = Preferences.shared.statusContentType
|
config.contentType = Preferences.shared.statusContentType
|
||||||
|
config.automaticallySaveDrafts = Preferences.shared.automaticallySaveDrafts
|
||||||
config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions
|
config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions
|
||||||
|
|
||||||
config.dismiss = { [unowned self] in self.dismiss(mode: $0) }
|
config.dismiss = { [unowned self] in self.dismiss(mode: $0) }
|
||||||
|
@ -137,17 +136,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
|
|
||||||
// MARK: Duckable
|
// MARK: Duckable
|
||||||
|
|
||||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction {
|
|
||||||
if controller.draft.hasContent {
|
|
||||||
return .duck
|
|
||||||
} else {
|
|
||||||
return .dismiss
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
||||||
controller.deleteDraftOnDisappear = false
|
|
||||||
|
|
||||||
withAnimation(.linear(duration: duration).delay(delay)) {
|
withAnimation(.linear(duration: duration).delay(delay)) {
|
||||||
controller.showToolbar = false
|
controller.showToolbar = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,15 +24,6 @@ class MainSplitViewController: UISplitViewController {
|
||||||
viewController(for: .secondary) as? SplitNavigationController
|
viewController(for: .secondary) as? SplitNavigationController
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sidebarVisibile: Bool {
|
|
||||||
get {
|
|
||||||
(UserDefaults.standard.object(forKey: "MainSplitViewControllerSidebarVisible") as? Bool) ?? true
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
UserDefaults.standard.set(newValue, forKey: "MainSplitViewControllerSidebarVisible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
@ -54,11 +45,6 @@ class MainSplitViewController: UISplitViewController {
|
||||||
sidebar.sidebarDelegate = self
|
sidebar.sidebarDelegate = self
|
||||||
setViewController(sidebar, for: .primary)
|
setViewController(sidebar, for: .primary)
|
||||||
primaryBackgroundStyle = .sidebar
|
primaryBackgroundStyle = .sidebar
|
||||||
if sidebarVisibile {
|
|
||||||
show(.primary)
|
|
||||||
} else {
|
|
||||||
hide(.primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
let splitNav = SplitNavigationController()
|
let splitNav = SplitNavigationController()
|
||||||
setViewController(splitNav, for: .secondary)
|
setViewController(splitNav, for: .secondary)
|
||||||
|
@ -369,18 +355,6 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitViewController(_ svc: UISplitViewController, willHide column: UISplitViewController.Column) {
|
|
||||||
if column == .primary {
|
|
||||||
sidebarVisibile = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitViewController(_ svc: UISplitViewController, willShow column: UISplitViewController.Column) {
|
|
||||||
if column == .primary {
|
|
||||||
sidebarVisibile = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
||||||
|
|
|
@ -58,6 +58,9 @@ struct ComposingPrefsView: View {
|
||||||
|
|
||||||
var composingSection: some View {
|
var composingSection: some View {
|
||||||
Section(header: Text("Composing")) {
|
Section(header: Text("Composing")) {
|
||||||
|
Toggle(isOn: $preferences.automaticallySaveDrafts) {
|
||||||
|
Text("Automatically Save Drafts")
|
||||||
|
}
|
||||||
Toggle(isOn: $preferences.requireAttachmentDescriptions) {
|
Toggle(isOn: $preferences.requireAttachmentDescriptions) {
|
||||||
Text("Require Attachment Descriptions")
|
Text("Require Attachment Descriptions")
|
||||||
}
|
}
|
||||||
|
|
|
@ -266,11 +266,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
collectionView.contentInset = .zero
|
collectionView.contentInset = .zero
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadInitial() async {
|
|
||||||
state = .unloaded
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tryLoadPinned() async {
|
private func tryLoadPinned() async {
|
||||||
do {
|
do {
|
||||||
try await loadPinned()
|
try await loadPinned()
|
||||||
|
@ -358,15 +353,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
if newer == nil {
|
// TODO: coalesce these data source updates
|
||||||
// no statuses were loaded initially, so reload the initial batch
|
// TODO: refresh profile
|
||||||
await reloadInitial()
|
await controller.loadNewer()
|
||||||
} else {
|
await tryLoadPinned()
|
||||||
// TODO: coalesce these data source updates
|
|
||||||
// TODO: refresh profile
|
|
||||||
await controller.loadNewer()
|
|
||||||
await tryLoadPinned()
|
|
||||||
}
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -143,16 +143,14 @@ struct ReportView: View {
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
|
|
||||||
Button(action: self.sendReport) {
|
Button(action: self.sendReport) {
|
||||||
let label = Text(isReporting ? "Sending Report" : "Send Report")
|
if isReporting {
|
||||||
HStack {
|
Text("Sending Report")
|
||||||
label
|
|
||||||
Spacer()
|
Spacer()
|
||||||
if isReporting {
|
ProgressView()
|
||||||
ProgressView()
|
.progressViewStyle(.circular)
|
||||||
.progressViewStyle(.circular)
|
} else {
|
||||||
}
|
Text("Send Report")
|
||||||
}
|
}
|
||||||
.accessibilityLabel(label)
|
|
||||||
}
|
}
|
||||||
.disabled(isReporting)
|
.disabled(isReporting)
|
||||||
.appGroupedListRowBackground()
|
.appGroupedListRowBackground()
|
||||||
|
|
|
@ -103,8 +103,21 @@ extension TuskerNavigationDelegate {
|
||||||
presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
present(compose, animated: animated)
|
let nav = UINavigationController(rootViewController: compose)
|
||||||
|
// TODO: is this still necessary?
|
||||||
|
// nav.presentationController?.delegate = compose
|
||||||
|
present(nav, animated: animated)
|
||||||
}
|
}
|
||||||
|
// let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
|
// if #available(iOS 16.0, *),
|
||||||
|
// presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
||||||
|
// return
|
||||||
|
// } else {
|
||||||
|
// let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
|
// let nav = UINavigationController(rootViewController: compose)
|
||||||
|
// nav.presentationController?.delegate = compose
|
||||||
|
// present(nav, animated: animated)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -201,7 +201,6 @@ private class ProfileFieldValueView: UIView {
|
||||||
|
|
||||||
if field.verifiedAt != nil {
|
if field.verifiedAt != nil {
|
||||||
var config = UIButton.Configuration.plain()
|
var config = UIButton.Configuration.plain()
|
||||||
config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium)
|
|
||||||
config.image = UIImage(systemName: "checkmark")
|
config.image = UIImage(systemName: "checkmark")
|
||||||
config.baseForegroundColor = .systemGreen
|
config.baseForegroundColor = .systemGreen
|
||||||
let icon = UIButton(configuration: config)
|
let icon = UIButton(configuration: config)
|
||||||
|
@ -212,10 +211,10 @@ private class ProfileFieldValueView: UIView {
|
||||||
icon.isPointerInteractionEnabled = true
|
icon.isPointerInteractionEnabled = true
|
||||||
icon.accessibilityLabel = "Verified link"
|
icon.accessibilityLabel = "Verified link"
|
||||||
addSubview(icon)
|
addSubview(icon)
|
||||||
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor, constant: -4)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
icon.lastBaselineAnchor.constraint(equalTo: textView.lastBaselineAnchor),
|
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
icon.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||||
|
|
Loading…
Reference in New Issue