Compare commits

..

No commits in common. "develop" and "public-beta" have entirely different histories.

47 changed files with 872 additions and 761 deletions

View File

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

View File

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

View File

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

View File

@ -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()
}
}
}
}

View File

@ -26,5 +26,7 @@ public protocol ComposeMastodonContext {
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
func fetchStatus(id: String) -> (any StatusProtocol)?
}

View File

@ -6,10 +6,7 @@
//
import Foundation
import Pachyderm
public enum DismissMode {
case cancel
case edit(Status)
case post(Status)
case cancel, post
}

View 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)
}
}

View File

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

View File

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

View File

@ -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()
//}

View File

@ -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()
//}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View 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"]),
]
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,6 +72,9 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
return []
}
func storeCreatedStatus(_ status: Status) {
}
func fetchStatus(id: String) -> (any StatusProtocol)? {
return nil
}

View File

@ -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 */,

View File

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

View File

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

View File

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

View File

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

View File

@ -121,7 +121,7 @@ extension ComposeSceneDelegate: ComposeHostingControllerDelegate {
switch mode {
case .cancel:
animation = .decline
case .post(_), .edit(_):
case .post:
animation = .commit
}
closeWindow(animation: animation)

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

@ -354,9 +354,6 @@ extension ProfileViewController: TuskerNavigationDelegate {
}
extension ProfileViewController: ToastableViewController {
var toastScrollView: UIScrollView? {
currentViewController.collectionView
}
}
extension ProfileViewController: ProfileHeaderViewDelegate {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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