Compare commits
No commits in common. "develop" and "public-beta" have entirely different histories.
develop
...
public-bet
@ -175,7 +175,6 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
} else {
|
||||
conversationIdentifier = nil
|
||||
}
|
||||
content.threadIdentifier = conversationIdentifier ?? ""
|
||||
|
||||
let account: Account?
|
||||
switch notification.kind {
|
||||
@ -213,7 +212,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: notificationContent,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: nil,
|
||||
conversationIdentifier: conversationIdentifier,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
|
@ -19,6 +19,7 @@ let package = Package(
|
||||
.package(path: "../Pachyderm"),
|
||||
.package(path: "../InstanceFeatures"),
|
||||
.package(path: "../TuskerComponents"),
|
||||
.package(path: "../MatchedGeometryPresentation"),
|
||||
.package(path: "../TuskerPreferences"),
|
||||
.package(path: "../UserAccounts"),
|
||||
.package(path: "../GalleryVC"),
|
||||
@ -32,6 +33,7 @@ let package = Package(
|
||||
"Pachyderm",
|
||||
"InstanceFeatures",
|
||||
"TuskerComponents",
|
||||
"MatchedGeometryPresentation",
|
||||
"TuskerPreferences",
|
||||
"UserAccounts",
|
||||
"GalleryVC",
|
||||
|
@ -25,9 +25,9 @@ final class PostService: ObservableObject {
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
func post() async throws(Error) -> Status {
|
||||
func post() async throws(Error) {
|
||||
guard draft.hasContent || draft.editedStatusID != nil else {
|
||||
throw .noContent
|
||||
return
|
||||
}
|
||||
|
||||
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||
@ -105,7 +105,7 @@ final class PostService: ObservableObject {
|
||||
do {
|
||||
let (status, _) = try await mastodonController.run(request)
|
||||
currentStep += 1
|
||||
return status
|
||||
mastodonController.storeCreatedStatus(status)
|
||||
} catch {
|
||||
throw Error.posting(error)
|
||||
}
|
||||
@ -197,7 +197,6 @@ final class PostService: ObservableObject {
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case noContent
|
||||
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
||||
case attachmentMissingMimeType(index: Int, type: UTType)
|
||||
case attachmentUpload(index: Int, cause: Client.Error)
|
||||
@ -205,8 +204,6 @@ final class PostService: ObservableObject {
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .noContent:
|
||||
return "No content"
|
||||
case let .attachmentData(index: index, cause: cause):
|
||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||
case let .attachmentMissingMimeType(index: index, type: type):
|
||||
|
@ -70,31 +70,3 @@ struct FocusedInputModifier: ViewModifier {
|
||||
.focusedValue(\.composeInput, box.wrappedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// In the input accessory view toolbar, we get the focused input through the box injected from the ComposeView.
|
||||
// Otherwise we get it from @FocusedValue (which doesn't seem to work via the hacks we use for the input accessory).
|
||||
// This property wrapper abstracts over them both.
|
||||
@propertyWrapper
|
||||
struct FocusedInput: DynamicProperty {
|
||||
@Environment(\.toolbarInjectedFocusedInputBox) private var box
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@StateObject private var updater = Updater()
|
||||
|
||||
var wrappedValue: (any ComposeInput)? {
|
||||
box?.wrappedValue ?? input ?? nil
|
||||
}
|
||||
|
||||
func update() {
|
||||
updater.update(box: box)
|
||||
}
|
||||
|
||||
private class Updater: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
func update(box: MutableObservableBox<(any ComposeInput)?>?) {
|
||||
cancellable = box?.objectWillChange.sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,5 +26,7 @@ public protocol ComposeMastodonContext {
|
||||
@MainActor
|
||||
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||
|
||||
func storeCreatedStatus(_ status: Status)
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)?
|
||||
}
|
||||
|
@ -6,10 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
public enum DismissMode {
|
||||
case cancel
|
||||
case edit(Status)
|
||||
case post(Status)
|
||||
case cancel, post
|
||||
}
|
||||
|
30
Packages/ComposeUI/Sources/ComposeUI/ViewController.swift
Normal file
30
Packages/ComposeUI/Sources/ComposeUI/ViewController.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public protocol ViewController: ObservableObject {
|
||||
associatedtype ContentView: View
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
var view: ContentView { get }
|
||||
}
|
||||
|
||||
public struct ControllerView<Controller: ViewController>: View {
|
||||
@StateObject private var controller: Controller
|
||||
|
||||
public init(controller: @escaping () -> Controller) {
|
||||
self._controller = StateObject(wrappedValue: controller())
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
controller.view
|
||||
.environmentObject(controller)
|
||||
}
|
||||
}
|
@ -0,0 +1,151 @@
|
||||
//
|
||||
// AttachmentDescriptionTextView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 3/12/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private var placeholder: some View {
|
||||
Text("Describe for the visually impaired…")
|
||||
}
|
||||
|
||||
struct InlineAttachmentDescriptionView: View {
|
||||
@ObservedObject private var attachment: DraftAttachment
|
||||
private let minHeight: CGFloat
|
||||
|
||||
@State private var height: CGFloat?
|
||||
|
||||
init(attachment: DraftAttachment, minHeight: CGFloat) {
|
||||
self.attachment = attachment
|
||||
self.minHeight = minHeight
|
||||
}
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
placeholder
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.offset(placeholderOffset)
|
||||
}
|
||||
|
||||
WrappedTextView(
|
||||
text: $attachment.attachmentDescription,
|
||||
backgroundColor: .clear,
|
||||
textDidChange: self.textDidChange
|
||||
)
|
||||
.frame(height: height ?? minHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func textDidChange(_ textView: UITextView) {
|
||||
height = max(minHeight, textView.contentSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
let backgroundColor: UIColor
|
||||
let textDidChange: (((UITextView) -> Void))?
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let view = UITextView()
|
||||
view.delegate = context.coordinator
|
||||
view.backgroundColor = backgroundColor
|
||||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.textContainer.lineBreakMode = .byWordWrapping
|
||||
#if os(visionOS)
|
||||
view.borderStyle = .roundedRect
|
||||
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
||||
#endif
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
uiView.text = text
|
||||
uiView.isEditable = isEnabled
|
||||
context.coordinator.textView = uiView
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
if let textDidChange {
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
textDidChange(uiView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text, didChange: textDidChange)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: ((UITextView) -> Void)?
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
||||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||
self.text = text
|
||||
self.didChange = didChange
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardDidShow() {
|
||||
guard let textView,
|
||||
textView.isFirstResponder else {
|
||||
return
|
||||
}
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
didChange?(textView)
|
||||
|
||||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
}
|
||||
}
|
@ -41,19 +41,17 @@ private struct AttachmentOptionsMenu: View {
|
||||
@Binding var recognizingText: Bool
|
||||
|
||||
var body: some View {
|
||||
if attachment.drawingData != nil || attachment.type == .image {
|
||||
Menu {
|
||||
if attachment.drawingData != nil {
|
||||
EditDrawingButton(attachment: attachment)
|
||||
} else if attachment.type == .image {
|
||||
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
|
||||
}
|
||||
} label: {
|
||||
Label("Options", systemImage: "ellipsis.circle.fill")
|
||||
Menu {
|
||||
if attachment.drawingData != nil {
|
||||
EditDrawingButton(attachment: attachment)
|
||||
} else if attachment.type == .image {
|
||||
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
|
||||
}
|
||||
.buttonStyle(AttachmentOverlayButtonStyle())
|
||||
.padding([.top, .leading], 2)
|
||||
} label: {
|
||||
Label("Options", systemImage: "ellipsis.circle.fill")
|
||||
}
|
||||
.buttonStyle(AttachmentOverlayButtonStyle())
|
||||
.padding([.top, .leading], 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,12 +118,6 @@ private struct ContentWarningButton: View {
|
||||
}
|
||||
|
||||
private struct VisibilityButton: View {
|
||||
private static var allOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||
Pachyderm.Visibility.allCases.map { vis in
|
||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||
|
||||
@ -139,10 +133,14 @@ private struct VisibilityButton: View {
|
||||
}
|
||||
|
||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||
let visibilities: [Pachyderm.Visibility]
|
||||
if !instanceFeatures.composeDirectStatuses {
|
||||
Self.allOptions.filter { $0.value != .direct }
|
||||
visibilities = [.public, .unlisted, .private]
|
||||
} else {
|
||||
Self.allOptions
|
||||
visibilities = Pachyderm.Visibility.allCases
|
||||
}
|
||||
return visibilities.map { vis in
|
||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,9 +152,6 @@ private struct VisibilityButton: View {
|
||||
}
|
||||
|
||||
private struct LocalOnlyButton: View {
|
||||
private static let localOnlyImage = UIImage(named: "link.broken")!
|
||||
private static let federatedImage = UIImage(systemName: "link")!
|
||||
|
||||
@Binding var enabled: Bool
|
||||
var mastodonController: any ComposeMastodonContext
|
||||
@ObservedObject private var instanceFeatures: InstanceFeatures
|
||||
@ -170,8 +165,8 @@ private struct LocalOnlyButton: View {
|
||||
private var options: [MenuPicker<Bool>.Option] {
|
||||
let domain = mastodonController.accountInfo!.instanceURL.host!
|
||||
return [
|
||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: Self.localOnlyImage),
|
||||
.init(value: false, title: "Federated", image: Self.federatedImage),
|
||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||
]
|
||||
}
|
||||
|
||||
@ -183,11 +178,11 @@ private struct LocalOnlyButton: View {
|
||||
}
|
||||
|
||||
private struct InsertEmojiButton: View {
|
||||
@FocusedInput private var input
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
var body: some View {
|
||||
if input?.toolbarElements.contains(.emojiPicker) == true {
|
||||
if input??.toolbarElements.contains(.emojiPicker) == true {
|
||||
Button(action: beginAutocompletingEmoji) {
|
||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||
}
|
||||
@ -200,16 +195,16 @@ private struct InsertEmojiButton: View {
|
||||
}
|
||||
|
||||
private func beginAutocompletingEmoji() {
|
||||
input?.beginAutocompletingEmoji()
|
||||
input??.beginAutocompletingEmoji()
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormatButtons: View {
|
||||
@FocusedInput private var input
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@PreferenceObserving(\.$statusContentType) private var contentType
|
||||
|
||||
var body: some View {
|
||||
if let input,
|
||||
if let input = input.flatMap(\.self),
|
||||
input.toolbarElements.contains(.formattingButtons),
|
||||
contentType != .plain {
|
||||
|
||||
@ -242,16 +237,6 @@ private struct FormatButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct InputAccessoryToolbarHost: EnvironmentKey {
|
||||
static var defaultValue: UIView? { nil }
|
||||
}
|
||||
extension EnvironmentValues {
|
||||
var inputAccessoryToolbarHost: UIView? {
|
||||
get { self[InputAccessoryToolbarHost.self] }
|
||||
set { self[InputAccessoryToolbarHost.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// ComposeToolbarView()
|
||||
//}
|
||||
|
@ -31,7 +31,6 @@ public struct ComposeView: View {
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
let currentAccount: (any AccountProtocol)?
|
||||
let config: ComposeUIConfig
|
||||
@FocusState private var focusedField: FocusableField?
|
||||
|
||||
public init(
|
||||
state: ComposeViewState,
|
||||
@ -50,12 +49,10 @@ public struct ComposeView: View {
|
||||
draft: state.draft,
|
||||
mastodonController: mastodonController,
|
||||
state: state,
|
||||
setDraft: self.setDraft,
|
||||
focusedField: $focusedField
|
||||
setDraft: self.setDraft
|
||||
)
|
||||
.environment(\.composeUIConfig, config)
|
||||
.environment(\.currentAccount, currentAccount)
|
||||
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||
}
|
||||
|
||||
@ -85,8 +82,8 @@ private struct ComposeViewBody: View {
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@ObservedObject var state: ComposeViewState
|
||||
let setDraft: (Draft) -> Void
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
@State private var postError: PostService.Error?
|
||||
@FocusState private var focusedField: FocusableField?
|
||||
@State private var isShowingDrafts = false
|
||||
@State private var isDismissing = false
|
||||
@State private var userConfirmedDelete = false
|
||||
@ -157,20 +154,11 @@ private struct ComposeViewBody: View {
|
||||
.overlay(alignment: .bottom, content: {
|
||||
// This needs to be in an overlay, ignoring the keyboard safe area
|
||||
// doesn't work with the safeAreaInset modifier.
|
||||
|
||||
// When we're using the input accessory toolbar, hide the overlay toolbar so that,
|
||||
// on iPad, we don't get two toolbars showing.
|
||||
let showOverlayToolbar = if #available(iOS 16.0, *) {
|
||||
focusedField == nil
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
if config.showToolbar,
|
||||
showOverlayToolbar {
|
||||
if config.showToolbar {
|
||||
toolbarView
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.modifier(IgnoreKeyboardSafeAreaIfUsingInputAccessory())
|
||||
// TODO: use a input accessory view (controller) for the toolbar
|
||||
// .ignoresSafeArea(.keyboard)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.snappy, value: config.showToolbar)
|
||||
}
|
||||
@ -235,7 +223,7 @@ private struct ComposeViewBody: View {
|
||||
state.poster = poster
|
||||
|
||||
do {
|
||||
let status = try await poster.post()
|
||||
try await poster.post()
|
||||
|
||||
isDismissing = true
|
||||
state.didPostSuccessfully = true
|
||||
@ -245,11 +233,7 @@ private struct ComposeViewBody: View {
|
||||
|
||||
// don't unset the poster, so the ui remains disabled while dismissing
|
||||
|
||||
if draft.editedStatusID != nil {
|
||||
config.dismiss(.edit(status))
|
||||
} else {
|
||||
config.dismiss(.post(status))
|
||||
}
|
||||
config.dismiss(.post)
|
||||
} catch {
|
||||
self.postError = error
|
||||
state.poster = nil
|
||||
@ -307,119 +291,6 @@ private struct ToolbarSafeAreaInsetModifier: ViewModifier {
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct IgnoreKeyboardSafeAreaIfUsingInputAccessory: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
content
|
||||
.ignoresSafeArea(.keyboard)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func injectInputAccessoryHost(
|
||||
state: ComposeViewState,
|
||||
mastodonController: any ComposeMastodonContext,
|
||||
focusedField: FocusState<FocusableField?>.Binding
|
||||
) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField))
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private struct InputAccessoryHostInjector: ViewModifier {
|
||||
// This is in a StateObject so we can use the autoclosure StateObject initializer.
|
||||
@StateObject private var factory: ViewFactory
|
||||
@FocusedValue(\.composeInput) private var composeInput
|
||||
|
||||
init(
|
||||
state: ComposeViewState,
|
||||
mastodonController: any ComposeMastodonContext,
|
||||
focusedField: FocusState<FocusableField?>.Binding
|
||||
) {
|
||||
self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField))
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.inputAccessoryToolbarHost, factory.view)
|
||||
.onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in
|
||||
factory.focusedInput = newValue.input ?? nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class ViewFactory: ObservableObject {
|
||||
let view: UIView
|
||||
@MutableObservableBox var focusedInput: (any ComposeInput)?
|
||||
|
||||
init(
|
||||
state: ComposeViewState,
|
||||
mastodonController: any ComposeMastodonContext,
|
||||
focusedField: FocusState<FocusableField?>.Binding
|
||||
) {
|
||||
self._focusedInput = MutableObservableBox(wrappedValue: nil)
|
||||
let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput)
|
||||
let controller = UIHostingController(rootView: view)
|
||||
controller.sizingOptions = .intrinsicContentSize
|
||||
controller.view.autoresizingMask = .flexibleHeight
|
||||
self.view = controller.view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComposeInputEquatableBox: Equatable {
|
||||
let input: (any ComposeInput)?
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.input === rhs.input
|
||||
}
|
||||
}
|
||||
|
||||
/// FocusedValue doesn't seem to work through the hacks we're doing for the input accessory.
|
||||
private struct FocusedInputBoxEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: MutableObservableBox<(any ComposeInput)?>? { nil }
|
||||
}
|
||||
extension EnvironmentValues {
|
||||
var toolbarInjectedFocusedInputBox: MutableObservableBox<(any ComposeInput)?>? {
|
||||
get { self[FocusedInputBoxEnvironmentKey.self] }
|
||||
set { self[FocusedInputBoxEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct InputAccessoryToolbarView: View {
|
||||
@ObservedObject var state: ComposeViewState
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
let focusedInputBox: MutableObservableBox<(any ComposeInput)?>
|
||||
@PreferenceObserving(\.$accentColor) private var accentColor
|
||||
|
||||
init(
|
||||
state: ComposeViewState,
|
||||
mastodonController: any ComposeMastodonContext,
|
||||
focusedField: FocusState<FocusableField?>.Binding,
|
||||
focusedInputBox: MutableObservableBox<(any ComposeInput)?>
|
||||
) {
|
||||
self.state = state
|
||||
self.mastodonController = mastodonController
|
||||
self._focusedField = focusedField
|
||||
self.focusedInputBox = focusedInputBox
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField)
|
||||
.environment(\.toolbarInjectedFocusedInputBox, focusedInputBox)
|
||||
.tint(accentColor.color.map(Color.init(uiColor:)))
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// ComposeView()
|
||||
//}
|
||||
|
@ -87,26 +87,11 @@ private struct LanguageButtonStyle: ButtonStyle {
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 4)
|
||||
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
|
||||
.modifier(LanguageButtonStyleAnimationModifier(isPressed: configuration.isPressed))
|
||||
.animation(.linear(duration: 0.1), value: configuration.isPressed)
|
||||
.padding(2)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
private struct LanguageButtonStyleAnimationModifier: ViewModifier {
|
||||
let isPressed: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// This looks weird while the button is being pressed on iOS 15 for some reason.
|
||||
if #available(iOS 16.0, *) {
|
||||
content
|
||||
.animation(.linear(duration: 0.1), value: isPressed)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TogglePollButton: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
|
@ -13,7 +13,6 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.composeInputBox) private var inputBox
|
||||
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
|
||||
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
@ -65,10 +64,6 @@ struct EmojiTextField: UIViewRepresentable {
|
||||
#if !os(visionOS)
|
||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
|
||||
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
|
||||
uiView.inputAccessoryView = inputAccessoryToolbarHost
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
|
@ -19,7 +19,6 @@ struct NewMainTextView: View {
|
||||
|
||||
var body: some View {
|
||||
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
|
||||
.frame(minHeight: Self.minHeight)
|
||||
.focused($focusedField, equals: .body)
|
||||
.modifier(FocusedInputModifier())
|
||||
.overlay(alignment: .topLeading) {
|
||||
@ -41,7 +40,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
@PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard
|
||||
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
||||
@PreferenceObserving(\.$statusContentType) private var statusContentType
|
||||
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
|
||||
|
||||
func makeUIView(context: Context) -> WrappedTextView {
|
||||
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||
@ -60,10 +58,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||
]
|
||||
view.backgroundColor = nil
|
||||
// on iOS 15, this is needed to prevent the text view from growing horizontally
|
||||
if #unavailable(iOS 16.0) {
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
}
|
||||
|
||||
// view.layer.cornerRadius = 5
|
||||
// view.layer.cornerCurve = .continuous
|
||||
@ -94,10 +88,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||
// #endif
|
||||
|
||||
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
|
||||
uiView.inputAccessoryView = inputAccessoryToolbarHost
|
||||
}
|
||||
|
||||
// Trying to set this with the @FocusState binding in onAppear results in the
|
||||
// keyboard not appearing until after the sheet presentation animation completes :/
|
||||
if becomeFirstResponder {
|
||||
|
9
Packages/MatchedGeometryPresentation/.gitignore
vendored
Normal file
9
Packages/MatchedGeometryPresentation/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
29
Packages/MatchedGeometryPresentation/Package.swift
Normal file
29
Packages/MatchedGeometryPresentation/Package.swift
Normal file
@ -0,0 +1,29 @@
|
||||
// swift-tools-version: 6.0
|
||||
// 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",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
// .testTarget(
|
||||
// name: "MatchedGeometryPresentationTests",
|
||||
// dependencies: ["MatchedGeometryPresentation"]),
|
||||
]
|
||||
)
|
@ -0,0 +1,125 @@
|
||||
//
|
||||
// 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
|
||||
}
|
@ -0,0 +1,239 @@
|
||||
//
|
||||
// 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
|
||||
matchedGeomVC.state.mode = .idle
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
class MatchedGeometryPresentationController: UIPresentationController {
|
||||
override func dismissalTransitionWillBegin() {
|
||||
super.dismissalTransitionWillBegin()
|
||||
delegate?.presentationControllerWillDismiss?(self)
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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
|
||||
didPresent = false
|
||||
}
|
||||
}
|
||||
}
|
@ -25,7 +25,6 @@ public struct Card: Codable, Sendable {
|
||||
public let blurhash: String?
|
||||
/// Only present when returned from the trending links endpoint
|
||||
public let history: [History]?
|
||||
public let authors: [Author]
|
||||
|
||||
public init(
|
||||
url: WebURL,
|
||||
@ -41,8 +40,7 @@ public struct Card: Codable, Sendable {
|
||||
width: Int? = nil,
|
||||
height: Int? = nil,
|
||||
blurhash: String? = nil,
|
||||
history: [History]? = nil,
|
||||
authors: [Author] = []
|
||||
history: [History]? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
@ -58,7 +56,6 @@ public struct Card: Codable, Sendable {
|
||||
self.height = height
|
||||
self.blurhash = blurhash
|
||||
self.history = history
|
||||
self.authors = authors
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@ -78,7 +75,6 @@ public struct Card: Codable, Sendable {
|
||||
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
|
||||
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
|
||||
self.history = try? container.decodeIfPresent([History].self, forKey: .history)
|
||||
self.authors = try container.decodeIfPresent([Author].self, forKey: .authors) ?? []
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
@ -107,7 +103,6 @@ public struct Card: Codable, Sendable {
|
||||
case height
|
||||
case blurhash
|
||||
case history
|
||||
case authors
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,29 +114,3 @@ extension Card {
|
||||
case rich
|
||||
}
|
||||
}
|
||||
|
||||
extension Card {
|
||||
public struct Author: Decodable, Sendable {
|
||||
public let name: String
|
||||
public let url: WebURL?
|
||||
public let account: Account?
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case name
|
||||
case url
|
||||
case account
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
let s = try container.decode(String.self, forKey: .url)
|
||||
if s.isEmpty {
|
||||
self.url = nil
|
||||
} else {
|
||||
self.url = WebURL(s)
|
||||
}
|
||||
self.account = try container.decodeIfPresent(Account.self, forKey: .account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
||||
switch mode {
|
||||
case .cancel:
|
||||
extensionContext.cancelRequest(withError: Error.cancelled)
|
||||
case .post(_), .edit(_):
|
||||
case .post:
|
||||
extensionContext.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
@ -72,6 +72,9 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
||||
return []
|
||||
}
|
||||
|
||||
func storeCreatedStatus(_ status: Status) {
|
||||
}
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||
return nil
|
||||
}
|
||||
|
@ -299,7 +299,6 @@
|
||||
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
|
||||
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
|
||||
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
|
||||
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
|
||||
@ -366,7 +365,6 @@
|
||||
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
|
||||
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
||||
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
||||
D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */; };
|
||||
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
|
||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
||||
@ -687,6 +685,7 @@
|
||||
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>"; };
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
|
||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; 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>"; };
|
||||
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
@ -730,7 +729,6 @@
|
||||
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
|
||||
D6C4532E2BCB873400E26A0E /* MockStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStatusView.swift; sourceTree = "<group>"; };
|
||||
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Resolve.swift"; sourceTree = "<group>"; };
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
|
||||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
|
||||
@ -807,7 +805,6 @@
|
||||
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
||||
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
|
||||
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardMO.swift; sourceTree = "<group>"; };
|
||||
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
|
||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
|
||||
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
||||
@ -1034,7 +1031,6 @@
|
||||
children = (
|
||||
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
|
||||
D60E2F232442372B005F8713 /* StatusMO.swift */,
|
||||
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */,
|
||||
D60E2F252442372B005F8713 /* AccountMO.swift */,
|
||||
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
||||
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
||||
@ -1245,6 +1241,7 @@
|
||||
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
||||
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
||||
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
||||
D642E83D2BA7AD0F004BFD6A /* GalleryVC */,
|
||||
D65A26242BC39A02005EB5D8 /* PushNotifications */,
|
||||
);
|
||||
@ -1746,7 +1743,6 @@
|
||||
children = (
|
||||
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
|
||||
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */,
|
||||
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */,
|
||||
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
|
||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
||||
@ -2167,7 +2163,6 @@
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
|
||||
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */,
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
|
||||
@ -2282,7 +2277,6 @@
|
||||
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
|
||||
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
||||
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
|
||||
D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */,
|
||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */,
|
||||
|
@ -1,63 +0,0 @@
|
||||
//
|
||||
// MastodonController+Resolve.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/23/25.
|
||||
// Copyright © 2025 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
import Pachyderm
|
||||
|
||||
extension MastodonController {
|
||||
@MainActor
|
||||
func resolveRemoteStatus(url: URL) async throws -> StatusMO {
|
||||
let effectiveURL: String
|
||||
if isLikelyMastodonRemoteStatus(url: url) {
|
||||
var request = URLRequest(url: url)
|
||||
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
|
||||
request.addValue("application/activity+json", forHTTPHeaderField: "accept")
|
||||
if let (_, response) = try? await URLSession.appDefault.data(for: request, delegate: RedirectBlocker()),
|
||||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||
effectiveURL = location
|
||||
} else {
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
} else {
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
|
||||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
|
||||
let (results, _) = try await run(request)
|
||||
let statuses = results.statuses.compactMap(\.value)
|
||||
// Don't try to exactly match effective URL because the URL form Mastodon
|
||||
// uses for the ActivityPub redirect doesn't match what's returned by the API.
|
||||
// Instead we just assume that, if only one status was returned, it worked.
|
||||
guard statuses.count == 1 else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
let status = statuses[0]
|
||||
return persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
|
||||
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
|
||||
let path = url.path
|
||||
let range = NSRange(location: 0, length: path.utf16.count)
|
||||
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
|
||||
}
|
||||
|
||||
private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UnableToResolveError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
"Unable to resolve status from URL"
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
//
|
||||
// StatusCardMO.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/25/25.
|
||||
// Copyright © 2025 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
@objc(StatusCardMO)
|
||||
public final class StatusCardMO: NSManagedObject {
|
||||
|
||||
@NSManaged public var url: URL
|
||||
@NSManaged public var title: String
|
||||
@NSManaged public var cardDescription: String
|
||||
@NSManaged private var kindString: String
|
||||
@NSManaged public var image: URL?
|
||||
@NSManaged public var blurhash: String?
|
||||
|
||||
@NSManaged public var authors: NSSet
|
||||
@NSManaged public var status: StatusMO
|
||||
|
||||
public var authorAccounts: Set<AccountMO> {
|
||||
authors as! Set<AccountMO>
|
||||
}
|
||||
|
||||
public var kind: Card.Kind {
|
||||
get { .init(rawValue: kindString) ?? .link }
|
||||
set { kindString = newValue.rawValue }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusCardMO {
|
||||
convenience init(apiCard card: Card, status: StatusMO, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
|
||||
self.init(context: context)
|
||||
self.updateFrom(apiCard: card, container: container)
|
||||
self.status = status
|
||||
}
|
||||
|
||||
func updateFrom(apiCard card: Card, container: MastodonCachePersistentStore) {
|
||||
guard let context = managedObjectContext else {
|
||||
return
|
||||
}
|
||||
|
||||
self.url = URL(card.url)!
|
||||
self.title = card.title
|
||||
self.cardDescription = card.description
|
||||
self.kind = card.kind
|
||||
self.image = card.image.flatMap { URL($0) }
|
||||
self.blurhash = card.blurhash
|
||||
|
||||
let authors = NSMutableSet()
|
||||
for account in card.authors.compactMap(\.account) {
|
||||
if let existing = container.account(for: account.id, in: context) {
|
||||
authors.add(existing)
|
||||
} else {
|
||||
let new = AccountMO(apiAccount: account, container: container, context: context)
|
||||
authors.add(new)
|
||||
}
|
||||
}
|
||||
self.authors = authors
|
||||
}
|
||||
}
|
@ -50,14 +50,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||
@NSManaged public var url: URL?
|
||||
@NSManaged private var visibilityString: String
|
||||
@NSManaged private var pollData: Data?
|
||||
@NSManaged public var account: AccountMO
|
||||
@NSManaged public var reblog: StatusMO?
|
||||
@NSManaged public var localOnly: Bool
|
||||
@NSManaged public var lastFetchedAt: Date?
|
||||
@NSManaged public var language: String?
|
||||
|
||||
@NSManaged public var account: AccountMO
|
||||
@NSManaged public var reblog: StatusMO?
|
||||
@NSManaged public var card: StatusCardMO?
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
||||
public var attachments: [Attachment]
|
||||
|
||||
@ -70,9 +68,8 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
|
||||
public var mentions: [Mention]
|
||||
|
||||
// The card deserialized from cardData. This is only kept around for when migrating forward to the version that added StatusCardMO.
|
||||
@LazilyDecoding(from: \StatusMO.cardData, fallback: nil)
|
||||
public var deprecatedCard: Card?
|
||||
public var card: Card?
|
||||
|
||||
@LazilyDecoding(from: \StatusMO.pollData, fallback: nil)
|
||||
public var poll: Poll?
|
||||
@ -120,6 +117,7 @@ extension StatusMO {
|
||||
self.applicationName = status.application?.name
|
||||
self.attachments = status.attachments
|
||||
self.bookmarkedInternal = status.bookmarked ?? false
|
||||
self.card = status.card
|
||||
self.content = status.content
|
||||
self.createdAt = status.createdAt
|
||||
self.editedAt = status.editedAt
|
||||
@ -160,19 +158,5 @@ extension StatusMO {
|
||||
} else {
|
||||
self.reblog = nil
|
||||
}
|
||||
if let card = status.card {
|
||||
if let existing = self.card {
|
||||
existing.updateFrom(apiCard: card, container: container)
|
||||
} else {
|
||||
let new = StatusCardMO(apiCard: card, status: self, container: container, context: context)
|
||||
self.card = new
|
||||
}
|
||||
self.deprecatedCard = nil
|
||||
} else {
|
||||
if let existing = self.card {
|
||||
context.delete(existing)
|
||||
self.card = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D70" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
@ -20,7 +20,6 @@
|
||||
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="url" attributeType="URI"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="authoredCards" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StatusCard" inverseName="authors" inverseEntity="StatusCard"/>
|
||||
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
||||
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
|
||||
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/>
|
||||
@ -126,7 +125,6 @@
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
||||
<relationship name="card" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="StatusCard" inverseName="status" inverseEntity="StatusCard"/>
|
||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/>
|
||||
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
|
||||
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
|
||||
@ -136,16 +134,6 @@
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="StatusCard" representedClassName="StatusCardMO" syncable="YES">
|
||||
<attribute name="blurhash" optional="YES" attributeType="String"/>
|
||||
<attribute name="cardDescription" attributeType="String"/>
|
||||
<attribute name="image" optional="YES" attributeType="URI"/>
|
||||
<attribute name="kindString" attributeType="String"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="url" attributeType="URI"/>
|
||||
<relationship name="authors" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Account" inverseName="authoredCards" inverseEntity="Account"/>
|
||||
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="card" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
|
||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
|
||||
@ -174,6 +162,5 @@
|
||||
<memberEntity name="List"/>
|
||||
<memberEntity name="Account"/>
|
||||
<memberEntity name="ActiveInstance"/>
|
||||
<memberEntity name="StatusCard"/>
|
||||
</configuration>
|
||||
</model>
|
@ -121,7 +121,7 @@ extension ComposeSceneDelegate: ComposeHostingControllerDelegate {
|
||||
switch mode {
|
||||
case .cancel:
|
||||
animation = .decline
|
||||
case .post(_), .edit(_):
|
||||
case .post:
|
||||
animation = .commit
|
||||
}
|
||||
closeWindow(animation: animation)
|
||||
|
@ -145,41 +145,10 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||
if delegate?.dismissCompose(mode: mode) == true {
|
||||
return
|
||||
} else {
|
||||
switch mode {
|
||||
case .edit(let status), .post(let status):
|
||||
if let presentingViewController,
|
||||
let host = findNavDelegate(in: presentingViewController) {
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
var config = ToastConfiguration(title: "Posted Successfully")
|
||||
config.actionTitle = "View"
|
||||
config.systemImageName = "checkmark"
|
||||
config.action = { toast in
|
||||
host.selected(status: status.id)
|
||||
toast.dismissToast(animated: true)
|
||||
}
|
||||
host.showToast(configuration: config, animated: true)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func findNavDelegate(in vc: UIViewController) -> (any TuskerNavigationDelegate)? {
|
||||
if let toastable = vc as? any TuskerNavigationDelegate {
|
||||
return toastable
|
||||
} else {
|
||||
for child in vc.children {
|
||||
if let navDelegate = findNavDelegate(in: child) {
|
||||
return navDelegate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAssetPicker(completion: @MainActor @escaping ([PHPickerResult]) -> Void) {
|
||||
self.assetPickerCompletion = completion
|
||||
|
||||
@ -311,6 +280,10 @@ extension MastodonController: ComposeMastodonContext {
|
||||
return results
|
||||
}
|
||||
|
||||
func storeCreatedStatus(_ status: Status) {
|
||||
persistentContainer.addOrUpdate(status: status)
|
||||
}
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||
return persistentContainer.status(for: id)
|
||||
}
|
||||
|
@ -11,6 +11,13 @@ import Pachyderm
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
|
||||
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
|
||||
let path = url.path
|
||||
let range = NSRange(location: 0, length: path.utf16.count)
|
||||
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
|
||||
}
|
||||
|
||||
class ConversationViewController: UIViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
@ -208,8 +215,38 @@ class ConversationViewController: UIViewController {
|
||||
indicator.startAnimating()
|
||||
state = .loading(indicator)
|
||||
|
||||
let effectiveURL: String
|
||||
final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
if isLikelyMastodonRemoteStatus(url: url) {
|
||||
var request = URLRequest(url: url)
|
||||
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
|
||||
request.addValue("application/activity+json", forHTTPHeaderField: "accept")
|
||||
if let (_, response) = try? await URLSession.appDefault.data(for: request, delegate: RedirectBlocker()),
|
||||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||
effectiveURL = location
|
||||
} else {
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
} else {
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
|
||||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
do {
|
||||
let status = try await mastodonController.resolveRemoteStatus(url: url)
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
let statuses = results.statuses.compactMap(\.value)
|
||||
// Don't try to exactly match effective URL because the URL form Mastodon
|
||||
// uses for the ActivityPub redirect doesn't match what's returned by the API.
|
||||
// Instead we just assume that, if only one status was returned, it worked.
|
||||
guard statuses.count == 1 else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
let status = statuses[0]
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
mode = .localID(status.id)
|
||||
return status.id
|
||||
} catch {
|
||||
@ -274,6 +311,7 @@ class ConversationViewController: UIViewController {
|
||||
guard case .displaying(_) = state else {
|
||||
return nil
|
||||
}
|
||||
let error = error as! Client.Error
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
@ -330,7 +368,9 @@ class ConversationViewController: UIViewController {
|
||||
subtitle.adjustsFontForContentSizeCategory = true
|
||||
subtitle.numberOfLines = 0
|
||||
subtitle.textAlignment = .center
|
||||
if let error = error as? Client.Error {
|
||||
if let error = error as? UnableToResolveError {
|
||||
subtitle.text = error.localizedDescription
|
||||
} else if let error = error as? Client.Error {
|
||||
subtitle.text = error.localizedDescription
|
||||
} else {
|
||||
subtitle.text = error.localizedDescription
|
||||
@ -393,6 +433,11 @@ extension ConversationViewController {
|
||||
}
|
||||
|
||||
extension ConversationViewController {
|
||||
struct UnableToResolveError: Error {
|
||||
var localizedDescription: String {
|
||||
"Unable to resolve status from URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationViewController {
|
||||
|
@ -178,8 +178,9 @@ extension BaseMainTabBarViewController: StateRestorableViewController {
|
||||
var activity: NSUserActivity?
|
||||
if let presentedNav = presentedViewController as? UINavigationController,
|
||||
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
|
||||
let draft = compose.state.draft
|
||||
activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
|
||||
// TODO: this
|
||||
// let draft = compose.controller.draft
|
||||
// activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
|
||||
} else if let vc = (selectedViewController as? any NavigationControllerProtocol)?.topViewController as? StateRestorableViewController {
|
||||
activity = vc.stateRestorationActivity()
|
||||
}
|
||||
|
@ -490,18 +490,6 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate {
|
||||
}
|
||||
|
||||
func tabBarController(_ tabBarController: UITabBarController, didSelectTab newTab: UITab, previousTab: UITab?) {
|
||||
// Workaround for FB16476036 (visionOS: tabBarController(_:shouldSelectTab:) delegate method not called)
|
||||
#if os(visionOS)
|
||||
if let previousTab,
|
||||
newTab.identifier == Tab.compose.rawValue {
|
||||
compose(editing: nil)
|
||||
DispatchQueue.main.async {
|
||||
self.selectedTab = previousTab
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
self.updateViewControllerSafeAreaInsets(newTab.viewController!)
|
||||
|
||||
// All tabs in a tab group deliberately share the same view controller, so we have to do this ourselves.
|
||||
|
@ -254,6 +254,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||
|
||||
private func showRecommendationsError(_ error: Client.ErrorType) {
|
||||
let footer = UITableViewHeaderFooterView()
|
||||
footer.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -46,6 +46,7 @@ struct MockStatusView: View {
|
||||
|
||||
if preferences.showLinkPreviews {
|
||||
MockStatusCardView()
|
||||
.frame(height: StatusContentContainer.cardViewHeight)
|
||||
}
|
||||
|
||||
MockAttachmentsContainerView()
|
||||
@ -135,8 +136,8 @@ private struct MockStatusCardView: UIViewRepresentable {
|
||||
let view = StatusCardView()
|
||||
view.isUserInteractionEnabled = false
|
||||
let card = StatusCardView.CardData(
|
||||
url: URL(string: "https://vaccor.space/tusker")!,
|
||||
image: URL(string: "https://vaccor.space/tusker/img/icon.png")!,
|
||||
url: WebURL("https://vaccor.space/tusker")!,
|
||||
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
|
||||
title: "Tusker",
|
||||
description: "Tusker is an iOS app for Mastodon"
|
||||
)
|
||||
|
@ -354,9 +354,6 @@ extension ProfileViewController: TuskerNavigationDelegate {
|
||||
}
|
||||
|
||||
extension ProfileViewController: ToastableViewController {
|
||||
var toastScrollView: UIScrollView? {
|
||||
currentViewController.collectionView
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||
|
@ -79,7 +79,9 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||
$0.isSelectable = false
|
||||
}
|
||||
|
||||
private let cardView = StatusCardView()
|
||||
private let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
private let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
|
@ -723,7 +723,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||
} else {
|
||||
var config = ToastConfiguration(title: "Sync Position")
|
||||
config.edge = .top
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
config.dismissAutomaticallyAfter = 5
|
||||
config.systemImageName = "arrow.triangle.2.circlepath"
|
||||
config.action = { [unowned self] toast in
|
||||
toast.isUserInteractionEnabled = false
|
||||
@ -861,7 +861,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||
var config = ToastConfiguration(title: "Jump to Present")
|
||||
config.edge = .top
|
||||
config.systemImageName = "arrow.up"
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
config.dismissAutomaticallyAfter = 4
|
||||
config.action = { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
|
||||
|
@ -123,23 +123,6 @@ extension MenuActionProvider {
|
||||
]
|
||||
}
|
||||
|
||||
func actionsForResolvingStatusURL(_ url: URL, source: PopoverSource) -> [UIMenuElement] {
|
||||
guard let mastodonController else {
|
||||
return actionsForURL(url, source: source)
|
||||
}
|
||||
return [
|
||||
UIDeferredMenuElement({ completionHandler in
|
||||
Task {
|
||||
if let status = try? await mastodonController.resolveRemoteStatus(url: url) {
|
||||
completionHandler(self.actionsForStatus(status, source: source))
|
||||
} else {
|
||||
completionHandler(self.actionsForURL(url, source: source))
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
|
||||
var actionsSection: [UIMenuElement] = []
|
||||
if let mastodonController = mastodonController,
|
||||
@ -426,29 +409,6 @@ extension MenuActionProvider {
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
func contextMenuConfigurationForURL(_ url: URL, source: PopoverSource) -> UIContextMenuConfiguration {
|
||||
if let mastodonController,
|
||||
isLikelyResolvableAsStatus(url) {
|
||||
return UIContextMenuConfiguration {
|
||||
ConversationViewController(resolving: url, mastodonController: mastodonController)
|
||||
} actionProvider: { _ in
|
||||
let actions = self.actionsForResolvingStatusURL(url, source: source)
|
||||
return UIMenu(children: actions)
|
||||
}
|
||||
} else {
|
||||
return UIContextMenuConfiguration {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
return vc
|
||||
} actionProvider: { _ in
|
||||
let actions = self.actionsForURL(url, source: source)
|
||||
return UIMenu(children: actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createAction(identifier: String, title: String, systemImageName: String?, handler: @escaping (UIAction) -> Void) -> UIAction {
|
||||
let image: UIImage?
|
||||
|
@ -216,7 +216,7 @@ private let statusPathRegex = try! NSRegularExpression(
|
||||
options: .caseInsensitive
|
||||
)
|
||||
|
||||
func isLikelyResolvableAsStatus(_ url: URL) -> Bool {
|
||||
private func isLikelyResolvableAsStatus(_ url: URL) -> Bool {
|
||||
let path = url.path
|
||||
let range = NSRange(location: 0, length: path.utf16.count)
|
||||
return statusPathRegex.numberOfMatches(in: path, range: range) == 1
|
||||
|
@ -273,33 +273,20 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
||||
// Store the previewed link range for use in the previewForHighlighting method
|
||||
currentPreviewedLinkRange = range
|
||||
|
||||
let preview: UIContextMenuContentPreviewProvider
|
||||
let actions: UIContextMenuActionProvider
|
||||
if let mastodonController,
|
||||
isLikelyResolvableAsStatus(link) {
|
||||
preview = {
|
||||
ConversationViewController(resolving: link, mastodonController: mastodonController)
|
||||
}
|
||||
actions = { _ in
|
||||
let actions = self.actionsForResolvingStatusURL(link, source: .view(self))
|
||||
return UIMenu(children: actions)
|
||||
}
|
||||
} else {
|
||||
preview = {
|
||||
self.getViewController(forLink: link, inRange: range)
|
||||
}
|
||||
actions = { (_) in
|
||||
let text = (self.text as NSString).substring(with: range)
|
||||
let actions: [UIMenuElement]
|
||||
if let mention = self.getMention(for: link, text: text) {
|
||||
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
|
||||
} else if let tag = self.getHashtag(for: link, text: text) {
|
||||
actions = self.actionsForHashtag(tag, source: .view(self))
|
||||
} else {
|
||||
actions = self.actionsForURL(link, source: .view(self))
|
||||
}
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||
let preview: UIContextMenuContentPreviewProvider = {
|
||||
self.getViewController(forLink: link, inRange: range)
|
||||
}
|
||||
let actions: UIContextMenuActionProvider = { (_) in
|
||||
let text = (self.text as NSString).substring(with: range)
|
||||
let actions: [UIMenuElement]
|
||||
if let mention = self.getMention(for: link, text: text) {
|
||||
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
|
||||
} else if let tag = self.getHashtag(for: link, text: text) {
|
||||
actions = self.actionsForHashtag(tag, source: .view(self))
|
||||
} else {
|
||||
actions = self.actionsForURL(link, source: .view(self))
|
||||
}
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||
}
|
||||
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions)
|
||||
|
@ -183,7 +183,15 @@ extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionPro
|
||||
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
|
||||
}
|
||||
} else {
|
||||
return self.contextMenuConfigurationForURL(url, source: .view(self))
|
||||
return UIContextMenuConfiguration {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
return vc
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForURL(url, source: .view(self)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -151,7 +151,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||
|
||||
private var translateButton: TranslateButton?
|
||||
|
||||
let cardView = StatusCardView()
|
||||
let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
|
@ -15,8 +15,6 @@ import HTMLStreamer
|
||||
|
||||
class StatusCardView: UIView {
|
||||
|
||||
fileprivate static var cardHeight: CGFloat { 80 }
|
||||
|
||||
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||
weak var actionProvider: MenuActionProvider?
|
||||
|
||||
@ -37,10 +35,6 @@ class StatusCardView: UIView {
|
||||
private var placeholderImageView: UIImageView!
|
||||
private var leadingSpacer: UIView!
|
||||
private var trailingSpacer: UIView!
|
||||
private var authorContainerVStack: UIStackView!
|
||||
private var authorHStack: UIStackView?
|
||||
private var authorAvatarImageView: CachedImageView?
|
||||
private var authorDisplayNameLabel: EmojiLabel?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -53,14 +47,25 @@ class StatusCardView: UIView {
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
self.layer.shadowColor = UIColor.black.cgColor
|
||||
self.layer.shadowRadius = 5
|
||||
self.layer.shadowOpacity = 0.2
|
||||
self.layer.shadowOffset = .zero
|
||||
|
||||
self.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
|
||||
titleLabel = UILabel()
|
||||
titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
titleLabel.numberOfLines = 2
|
||||
titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
|
||||
descriptionLabel = UILabel()
|
||||
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
|
||||
descriptionLabel.adjustsFontForContentSizeCategory = true
|
||||
|
||||
descriptionLabel.numberOfLines = 3
|
||||
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
|
||||
domainLabel = UILabel()
|
||||
domainLabel.font = .preferredFont(forTextStyle: .caption2)
|
||||
domainLabel.adjustsFontForContentSizeCategory = true
|
||||
@ -78,8 +83,7 @@ class StatusCardView: UIView {
|
||||
])
|
||||
vStack.axis = .vertical
|
||||
vStack.alignment = .leading
|
||||
vStack.distribution = .equalSpacing
|
||||
vStack.spacing = 2
|
||||
vStack.spacing = 0
|
||||
|
||||
imageView = StatusCardImageView(cache: .attachments)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
@ -96,38 +100,29 @@ class StatusCardView: UIView {
|
||||
vStack,
|
||||
trailingSpacer,
|
||||
])
|
||||
hStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
hStack.axis = .horizontal
|
||||
hStack.alignment = .center
|
||||
hStack.distribution = .fill
|
||||
hStack.spacing = 4
|
||||
hStack.clipsToBounds = true
|
||||
hStack.layer.borderWidth = 0.5
|
||||
hStack.layer.cornerRadius = 5
|
||||
hStack.layer.cornerCurve = .continuous
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
updateBorderColor()
|
||||
|
||||
addSubview(hStack)
|
||||
hStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped)))
|
||||
hStack.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
|
||||
|
||||
placeholderImageView = UIImageView(image: UIImage(systemName: "doc.text"))
|
||||
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
placeholderImageView.contentMode = .scaleAspectFit
|
||||
placeholderImageView.tintColor = .gray
|
||||
placeholderImageView.isHidden = true
|
||||
|
||||
addSubview(placeholderImageView)
|
||||
|
||||
authorContainerVStack = UIStackView(arrangedSubviews: [
|
||||
hStack,
|
||||
])
|
||||
authorContainerVStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorContainerVStack.axis = .vertical
|
||||
authorContainerVStack.alignment = .leading
|
||||
authorContainerVStack.spacing = 4
|
||||
addSubview(authorContainerVStack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.heightAnchor.constraint(equalToConstant: Self.cardHeight),
|
||||
imageView.heightAnchor.constraint(equalTo: heightAnchor),
|
||||
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
|
||||
|
||||
vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8),
|
||||
@ -135,40 +130,31 @@ class StatusCardView: UIView {
|
||||
leadingSpacer.widthAnchor.constraint(equalToConstant: 4),
|
||||
trailingSpacer.widthAnchor.constraint(equalToConstant: 4),
|
||||
|
||||
hStack.heightAnchor.constraint(equalToConstant: Self.cardHeight),
|
||||
hStack.widthAnchor.constraint(equalTo: widthAnchor),
|
||||
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
hStack.topAnchor.constraint(equalTo: topAnchor),
|
||||
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
placeholderImageView.widthAnchor.constraint(equalToConstant: 30),
|
||||
placeholderImageView.heightAnchor.constraint(equalToConstant: 30),
|
||||
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
|
||||
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
|
||||
|
||||
authorContainerVStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
authorContainerVStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
authorContainerVStack.topAnchor.constraint(equalTo: topAnchor),
|
||||
authorContainerVStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
updateContentSize()
|
||||
|
||||
#if os(visionOS)
|
||||
registerForTraitChanges([UITraitPreferredContentSizeCategory.self], action: #selector(updateContentSize))
|
||||
#endif
|
||||
}
|
||||
|
||||
// Unneeded on visionOS because there is no light/dark mode
|
||||
#if !os(visionOS)
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
|
||||
updateBorderColor()
|
||||
}
|
||||
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
|
||||
updateContentSize()
|
||||
}
|
||||
updateBorderColor()
|
||||
}
|
||||
#endif
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
hStack.layer.cornerRadius = 0.1 * bounds.height
|
||||
}
|
||||
|
||||
private func updateBorderColor() {
|
||||
if traitCollection.userInterfaceStyle == .dark {
|
||||
hStack.layer.borderColor = UIColor.darkGray.cgColor
|
||||
@ -177,21 +163,8 @@ class StatusCardView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateContentSize() {
|
||||
let category = traitCollection.preferredContentSizeCategory
|
||||
titleLabel.numberOfLines = if category > .extraExtraExtraLarge {
|
||||
2
|
||||
} else if category > .extraLarge {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
descriptionLabel.isHidden = category > .extraExtraExtraLarge || (descriptionLabel.text?.isEmpty ?? true)
|
||||
domainLabel.isHidden = category > .accessibilityMedium
|
||||
}
|
||||
|
||||
func updateUI(status: StatusMO, persistentContainer: MastodonCachePersistentStore) {
|
||||
let newData = status.card.map { CardData(card: $0) } ?? status.deprecatedCard.map { CardData(card: $0) }
|
||||
func updateUI(status: StatusMO) {
|
||||
let newData = status.card.map { CardData(card: $0) }
|
||||
guard self.card != newData else {
|
||||
return
|
||||
}
|
||||
@ -203,13 +176,6 @@ class StatusCardView: UIView {
|
||||
}
|
||||
|
||||
updateUI(card: newData, sensitive: status.sensitive)
|
||||
|
||||
if let authorID = newData.authorID,
|
||||
let account = persistentContainer.account(for: authorID) {
|
||||
createCardAuthorView(account: account)
|
||||
} else {
|
||||
authorHStack?.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
// This method is internal for use by MockStatusView
|
||||
@ -218,14 +184,14 @@ class StatusCardView: UIView {
|
||||
if sensitive {
|
||||
if let blurhash = card.blurhash {
|
||||
imageView.blurImage = false
|
||||
imageView.showOnlyBlurHash(blurhash, for: image)
|
||||
imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
|
||||
} else {
|
||||
// if we don't have a blurhash, load the image and show it behind a blur
|
||||
imageView.blurImage = true
|
||||
imageView.update(for: image, blurhash: nil)
|
||||
imageView.update(for: URL(image), blurhash: nil)
|
||||
}
|
||||
} else {
|
||||
imageView.update(for: image, blurhash: card.blurhash)
|
||||
imageView.update(for: URL(image), blurhash: card.blurhash)
|
||||
}
|
||||
imageView.isHidden = false
|
||||
leadingSpacer.isHidden = true
|
||||
@ -242,124 +208,66 @@ class StatusCardView: UIView {
|
||||
let converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||
let description = converter.convert(html: card.description)
|
||||
descriptionLabel.text = description
|
||||
descriptionLabel.isHidden = description.isEmpty || traitCollection.preferredContentSizeCategory > .extraExtraExtraLarge
|
||||
descriptionLabel.isHidden = description.isEmpty
|
||||
|
||||
if let host = card.url.host {
|
||||
domainLabel.text = host
|
||||
domainLabel.text = host.serialized
|
||||
domainLabel.isHidden = false
|
||||
} else {
|
||||
domainLabel.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private func createCardAuthorView(account: AccountMO) {
|
||||
if let authorHStack {
|
||||
authorHStack.isHidden = false
|
||||
} else {
|
||||
let moreFromLabel = UILabel()
|
||||
moreFromLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
moreFromLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
moreFromLabel.adjustsFontForContentSizeCategory = true
|
||||
moreFromLabel.text = "More from"
|
||||
moreFromLabel.textColor = .secondaryLabel
|
||||
|
||||
let avatarImageView = CachedImageView(cache: .avatars)
|
||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarImageView.layer.cornerCurve = .continuous
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 25
|
||||
authorAvatarImageView = avatarImageView
|
||||
|
||||
let displayNameLabel = EmojiLabel()
|
||||
displayNameLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
displayNameLabel.font = .preferredFont(forTextStyle: .subheadline).withTraits(.traitBold)
|
||||
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||
displayNameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
authorDisplayNameLabel = displayNameLabel
|
||||
|
||||
let disclosureIndicator = UIImageView()
|
||||
disclosureIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
disclosureIndicator.image = UIImage(systemName: "chevron.forward")
|
||||
disclosureIndicator.tintColor = .secondaryLabel
|
||||
disclosureIndicator.preferredSymbolConfiguration = .init(weight: .light)
|
||||
|
||||
let hStack = UIStackView(arrangedSubviews: [
|
||||
moreFromLabel,
|
||||
avatarImageView,
|
||||
displayNameLabel,
|
||||
disclosureIndicator,
|
||||
])
|
||||
hStack.axis = .horizontal
|
||||
hStack.spacing = 8
|
||||
hStack.alignment = .center
|
||||
authorHStack = hStack
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
avatarImageView.widthAnchor.constraint(equalToConstant: 25),
|
||||
avatarImageView.heightAnchor.constraint(equalToConstant: 25),
|
||||
])
|
||||
|
||||
authorContainerVStack.addArrangedSubview(hStack)
|
||||
|
||||
authorHStack?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(authorTapped)))
|
||||
}
|
||||
|
||||
authorAvatarImageView!.update(for: account.avatar)
|
||||
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||
authorDisplayNameLabel!.text = account.displayNameWithoutCustomEmoji
|
||||
authorDisplayNameLabel!.removeEmojis()
|
||||
} else {
|
||||
authorDisplayNameLabel!.text = account.displayOrUserName
|
||||
authorDisplayNameLabel!.setEmojis(account.emojis, identifier: account.id)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cardTapped() {
|
||||
if let card, let navigationDelegate {
|
||||
navigationDelegate.selected(url: card.url)
|
||||
let titleHeight = titleLabel.isHidden ? 0 : titleLabel.sizeThatFits(CGSize(width: titleLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
let descriptionHeight = descriptionLabel.isHidden ? 0 : descriptionLabel.sizeThatFits(CGSize(width: descriptionLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
let domainLabel = domainLabel.isHidden ? 0 : domainLabel.sizeThatFits(CGSize(width: domainLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
if titleHeight + descriptionHeight + domainLabel > vStack.bounds.height {
|
||||
descriptionLabel.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func authorTapped() {
|
||||
if let card,
|
||||
let authorID = card.authorID,
|
||||
let navigationDelegate {
|
||||
navigationDelegate.selected(account: authorID)
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = StatusCardView.activeBackgroundColor
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
setNeedsDisplay()
|
||||
|
||||
if let card = card, let delegate = navigationDelegate {
|
||||
delegate.selected(url: URL(card.url)!)
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
struct CardData: Equatable {
|
||||
let url: URL
|
||||
let image: URL?
|
||||
let url: WebURL
|
||||
let image: WebURL?
|
||||
let title: String
|
||||
let description: String
|
||||
let blurhash: String?
|
||||
let authorID: String?
|
||||
|
||||
init(card: Card) {
|
||||
self.url = URL(card.url)!
|
||||
self.image = card.image.flatMap { URL($0) }
|
||||
self.title = card.title
|
||||
self.description = card.description
|
||||
self.blurhash = card.blurhash
|
||||
self.authorID = nil
|
||||
}
|
||||
|
||||
init(card: StatusCardMO) {
|
||||
self.url = card.url
|
||||
self.image = card.image
|
||||
self.title = card.title
|
||||
self.description = card.cardDescription
|
||||
self.description = card.description
|
||||
self.blurhash = card.blurhash
|
||||
self.authorID = card.authorAccounts.sorted(by: { $0.id < $1.id }).first?.id
|
||||
}
|
||||
|
||||
init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) {
|
||||
init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) {
|
||||
self.url = url
|
||||
self.image = image
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.blurhash = blurhash
|
||||
self.authorID = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,7 +277,16 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let card = card else { return nil }
|
||||
|
||||
return self.actionProvider?.contextMenuConfigurationForURL(card.url, source: .view(self))
|
||||
return UIContextMenuConfiguration(identifier: nil) {
|
||||
let vc = SFSafariViewController(url: URL(card.url)!)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
return vc
|
||||
} actionProvider: { (_) in
|
||||
let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? []
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||
}
|
||||
}
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
@ -393,12 +310,6 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusCardView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
Self.cardHeight
|
||||
}
|
||||
}
|
||||
|
||||
private class StatusCardImageView: CachedImageView {
|
||||
@Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
|
||||
var blurImage = false {
|
||||
|
@ -102,7 +102,7 @@ extension StatusCollectionViewCell {
|
||||
pollView.delegate = delegate
|
||||
pollView.updateUI(status: status, poll: status.poll)
|
||||
if Preferences.shared.showLinkPreviews {
|
||||
cardView.updateUI(status: status, persistentContainer: mastodonController.persistentContainer)
|
||||
cardView.updateUI(status: status)
|
||||
cardView.isHidden = status.card == nil
|
||||
cardView.navigationDelegate = delegate
|
||||
cardView.actionProvider = delegate
|
||||
|
@ -9,6 +9,9 @@
|
||||
import UIKit
|
||||
|
||||
class StatusContentContainer: UIView {
|
||||
// TODO: this is a weird place for this
|
||||
static var cardViewHeight: CGFloat { 90 }
|
||||
|
||||
private var arrangedSubviews: [any StatusContentView]
|
||||
|
||||
private var isHiddenObservations: [NSKeyValueObservation] = []
|
||||
@ -203,6 +206,12 @@ extension ContentTextView: StatusContentView {
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusCardView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
StatusContentContainer.cardViewHeight
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentsContainerView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
effectiveWidth / aspectRatio
|
||||
|
@ -196,7 +196,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||
$0.emojiFont = TimelineStatusCollectionViewCell.contentFont
|
||||
}
|
||||
|
||||
let cardView = StatusCardView()
|
||||
let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
|
@ -106,23 +106,3 @@ extension ToastableViewController {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UITabBarController: ToastableViewController {
|
||||
var toastParentView: UIView {
|
||||
(selectedViewController as? ToastableViewController)?.toastParentView ?? self.view
|
||||
}
|
||||
|
||||
var toastScrollView: UIScrollView? {
|
||||
(selectedViewController as? ToastableViewController)?.toastScrollView
|
||||
}
|
||||
}
|
||||
|
||||
extension UINavigationController: ToastableViewController {
|
||||
var toastParentView: UIView {
|
||||
(topViewController as? ToastableViewController)?.toastParentView ?? self.view
|
||||
}
|
||||
|
||||
var toastScrollView: UIScrollView? {
|
||||
(topViewController as? ToastableViewController)?.toastScrollView
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user