diff --git a/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Packages/Models/Sources/Models/Alias/HTMLString.swift index f085be027..26f729a20 100644 --- a/Packages/Models/Sources/Models/Alias/HTMLString.swift +++ b/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -52,7 +52,7 @@ public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable { asMarkdown = "" do { - let document: Document = try SwiftSoup.parse(htmlValue) + let document: SwiftSoup.Document = try SwiftSoup.parse(htmlValue) var listCounters: [Int] = [] handleNode(node: document, listCounters: &listCounters) @@ -136,7 +136,7 @@ public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable { try container.encode(hadTrailingTags, forKey: .hadTrailingTags) } - private mutating func removeTrailingTags(doc: Document) { + private mutating func removeTrailingTags(doc: SwiftSoup.Document) { // Fast bail-outs if !asMarkdown.contains("#") { return } diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/Representable.swift b/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/Representable.swift index 08c1e4270..fb78a12b5 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/Representable.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/Representable.swift @@ -1,7 +1,7 @@ import SwiftUI extension TextView { - struct Representable: UIViewRepresentable { + struct Representable: UIViewControllerRepresentable { @Binding var text: NSMutableAttributedString @Binding var calculatedHeight: CGFloat @Environment(\.sizeCategory) var sizeCategory @@ -9,16 +9,17 @@ extension TextView { let keyboard: UIKeyboardType var getTextView: ((UITextView) -> Void)? - func makeUIView(context: Context) -> UIKitTextView { - context.coordinator.textView + func makeUIViewController(context: Context) -> TextViewController { + TextViewController(textView: context.coordinator.textView) } - func updateUIView(_: UIKitTextView, context: Context) { + func updateUIViewController(_: TextViewController, context: Context) { context.coordinator.update(representable: self) - if !context.coordinator.didBecomeFirstResponder { - context.coordinator.textView.becomeFirstResponder() - context.coordinator.didBecomeFirstResponder = true - } + // Deliberately no automatic becomeFirstResponder here: on iOS 26/27 a + // focused UITextView in this sheet deadlocks SwiftUI's AttributeGraph + // when the device rotates ("cycle detected" spam until the system kills + // the app). Tap the field to start typing; TextViewController drops focus + // in viewWillTransition, *before* the rotation begins. } @discardableResult func makeCoordinator() -> Coordinator { diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/TextView.swift b/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/TextView.swift index 72d0065aa..3dd746924 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/TextView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/UITextView/TextView.swift @@ -60,7 +60,62 @@ public struct TextView: View { } } +/// Hosts the editor's `UIKitTextView` so the editor gets a `viewWillTransition` +/// hook on the owning view controller. +/// +/// iOS 26/27: a focused `UITextView` inside the compose sheet deadlocks +/// SwiftUI's AttributeGraph when the interface rotates ("cycle detected" spam +/// until the watchdog kills the app). `viewWillTransition(to:with:)` fires +/// *before* the rotation animation begins, so resigning first responder here +/// dismisses the keyboard in time to prevent the cycle from ever forming — the +/// device-orientation observer in `UIKitTextView` only lands afterwards and so +/// can't. It stays as a post-rotation backstop. +final class TextViewController: UIViewController { + private let textView: UIKitTextView + + init(textView: UIKitTextView) { + self.textView = textView + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + // The text view *is* the controller's view, so SwiftUI sizes it exactly as + // it did when the representable vended the UITextView directly. + view = textView + } + + override func viewWillTransition( + to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator + ) { + if textView.isFirstResponder { + textView.resignFirstResponder() + } + super.viewWillTransition(to: size, with: coordinator) + } +} + final class UIKitTextView: UITextView { + override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + // iOS 26/27 backstop: dropping focus before rotation happens in + // TextViewController.viewWillTransition. This post-rotation observer only + // helps settle layout if a transition was left half-finished. + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + NotificationCenter.default.addObserver( + self, selector: #selector(deviceOrientationDidChange), + name: UIDevice.orientationDidChangeNotification, object: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override var keyCommands: [UIKeyCommand]? { (super.keyCommands ?? []) + [ UIKeyCommand( @@ -71,4 +126,15 @@ final class UIKitTextView: UITextView { @objc private func escape(_: Any) { resignFirstResponder() } + + @objc private func deviceOrientationDidChange() { + // This lands only after the interface has already switched orientation, so + // it can't prevent the iOS 26 rotation deadlock — but if we were focused + // through a rotation and the transition was left half-finished, dropping + // focus here lets UIKit settle the layout and resume touch delivery. + guard isFirstResponder, + UIDevice.current.orientation.isValidInterfaceOrientation + else { return } + resignFirstResponder() + } }