Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 382 additions & 0 deletions apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers

/// The composer's text entry is a UIKit text view because SwiftUI's text
/// inputs expose no paste hook on iOS: the long-press Paste menu can never
/// offer an image. Bridging `UITextView` buys the native paste menu, image
/// paste, and internal scrolling once the draft outgrows its viewport cap.
struct FeatureComposerTextInput: UIViewRepresentable {
@Binding var text: String
// A plain binding, not `FocusState`: SwiftUI ignores writes to a
// `FocusState` that no `.focused()` view registers with, and a
// representable cannot register. The UIKit responder state is the source
// of truth and this binding mirrors it for the hosts.
@Binding var focused: Bool
let placeholder: String
let acceptsImages: Bool
let selectionRequest: FeatureComposerTextSelectionRequest?
let onPasteImages: ([NSItemProvider]) -> Void
let onDismissKeyboard: (() -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> FeatureComposerUITextView {
let textView = FeatureComposerUITextView()
textView.delegate = context.coordinator
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard
if onDismissKeyboard != nil {
textView.installDismissPanRecognizer()
}
textView.backgroundColor = .clear
textView.textColor = T3Colors.uiTextPrimary
textView.tintColor = T3Colors.uiAccent
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
// Match the metrics of the SwiftUI text field this view replaced so
// the swap is invisible: the surrounding paddings stay in SwiftUI.
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = true
// Deliberately not `keyboardDismissMode = .interactive`: the capped
// input sits directly above the keyboard, so scrolling up through a
// long draft drags into the keyboard's frame and yanks it around.
// Dismissal belongs to the pan recognizer below, which only fires
// for a drag that begins with the draft at its top.
Comment thread
cursor[bot] marked this conversation as resolved.
textView.accessibilityIdentifier = "message-composer"
updateAccessibility(textView)
return textView
}

func updateUIView(_ textView: FeatureComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.acceptsImages = acceptsImages
textView.onPasteImages = onPasteImages
textView.onDismissKeyboard = onDismissKeyboard

let shouldApplySelection = selectionRequest.map {
context.coordinator.lastAppliedSelectionRequestID != $0.id
} ?? false
if textView.text != text {
let previousText = textView.text ?? ""
let selectedRange = textView.selectedRange
textView.text = text
if !shouldApplySelection {
let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate(
previousText: previousText,
newText: text,
selectedLocation: selectedRange.location
)
let length = previousText.isEmpty
? 0
: min(selectedRange.length, text.utf16.count - location)
textView.selectedRange = NSRange(location: location, length: length)
textView.scrollRangeToVisible(textView.selectedRange)
}
}
if shouldApplySelection, let selectionRequest {
let location = min(selectionRequest.location, textView.text.utf16.count)
textView.selectedRange = NSRange(location: location, length: 0)
textView.scrollRangeToVisible(textView.selectedRange)
context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id
}
updateAccessibility(textView)

if context.coordinator.lastAppliedFocus != focused {
context.coordinator.lastAppliedFocus = focused
if focused, !textView.isFirstResponder {
textView.becomeFirstResponderWhenAttached()
} else if !focused {
textView.cancelPendingFirstResponder()
if textView.isFirstResponder {
textView.resignFirstResponder()
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FeatureComposerUITextView,
context: Context
) -> CGSize? {
guard let width = proposal.width, width.isFinite else { return nil }
let fittingSize = uiView.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(
width: width,
height: FeatureComposerTextInputSizing.height(
fittingHeight: fittingSize.height,
lineHeight: uiView.font?.lineHeight ?? 22
)
)
}

private func updateAccessibility(_ textView: FeatureComposerUITextView) {
textView.accessibilityLabel = "Message agent"
textView.accessibilityHint = acceptsImages
? "Enter a message or paste images to attach them."
: "Enter a message."
textView.accessibilityValue = text.isEmpty ? placeholder : nil
}

final class Coordinator: NSObject, UITextViewDelegate {
var parent: FeatureComposerTextInput
var lastAppliedFocus: Bool?
var lastAppliedSelectionRequestID: UUID?

init(_ parent: FeatureComposerTextInput) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard parent.text != textView.text else { return }
parent.text = textView.text
}

func textViewDidBeginEditing(_ textView: UITextView) {
lastAppliedFocus = true
if !parent.focused {
parent.focused = true
}
}

func textViewDidEndEditing(_ textView: UITextView) {
lastAppliedFocus = false
if parent.focused {
parent.focused = false
}
}
}
}

/// Advertises image support to the paste menu and routes image pastes out to
/// the attachment pipeline. Text-only pastes fall through to UIKit untouched.
final class FeatureComposerUITextView: UITextView {
var acceptsImages = false {
didSet {
guard oldValue != acceptsImages else { return }
pasteConfiguration = acceptsImages
? UIPasteConfiguration(
acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.text.identifier,
]
)
: nil
}
}
var onPasteImages: (([NSItemProvider]) -> Void)?
var onDismissKeyboard: (() -> Void)?
private var wantsFirstResponderOnAttach = false

/// Programmatic focus can arrive before the view joins a window (a host
/// refocusing right as the composer expands); retry once attached. The
/// pending request is cancelled if focus clears again before the view
/// attaches, so a stale request can never raise the keyboard.
func becomeFirstResponderWhenAttached() {
if window != nil {
becomeFirstResponder()
} else {
wantsFirstResponderOnAttach = true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

func cancelPendingFirstResponder() {
wantsFirstResponderOnAttach = false
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil, wantsFirstResponderOnAttach {
wantsFirstResponderOnAttach = false
becomeFirstResponder()
}
}

// A SwiftUI drag gesture on the composer never sees drags that start
// inside this view: the text interaction's own recognizers claim them at
// the UIKit level. This observing pan reproduces the host's
// drag-to-dismiss there: it recognizes alongside everything, cancels
// nothing, and dismisses a scrollable draft only when the drag begins at
// its top.
private let dismissPanDelegate = FeatureComposerDismissPanDelegate()

func installDismissPanRecognizer() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan))
pan.cancelsTouchesInView = false
pan.delegate = dismissPanDelegate
addGestureRecognizer(pan)
}

private var dismissPanBeganAtTop = false

@objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) {
// A fast flick can jump straight from .began to .ended without a
// .changed in between, so the end state is evaluated too. The at-top
// check is latched at .began: a drag that merely reaches the top
// mid-scroll only rubber-bands, instead of yanking the keyboard away
// the moment the offset crosses zero.
switch recognizer.state {
case .began:
dismissPanBeganAtTop = contentOffset.y <= 0
return
case .changed, .ended: break
default: return
}
guard isFirstResponder else { return }
let translation = recognizer.translation(in: self)
guard FeatureComposerDragDismissPolicy.shouldDismiss(
translationX: translation.x,
translationY: translation.y,
isScrollable: contentOverflows,
isAtTop: dismissPanBeganAtTop
) else { return }
onDismissKeyboard?()
}

// Scrolling stays enabled at every size: toggling `isScrollEnabled` off
// stops UITextView from maintaining `contentSize` on some OS versions,
// which left long drafts unscrollable on device. Overflow is computed
// fresh wherever it matters instead. A stale offset from a mid-resize
// selection change is still reset; with nothing to scroll, any offset
// clips the first line under the padding.
var contentOverflows: Bool {
contentSize.height > bounds.height + 0.5
}

override func layoutSubviews() {
super.layoutSubviews()
if !contentOverflows, contentOffset.y != 0 {
contentOffset.y = 0
}
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)),
acceptsImages,
FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) {
return true
}
return super.canPerformAction(action, withSender: sender)
}

// When the pasteboard holds images, only the images attach. Any text
// riding along (a copied web image usually brings its URL) is dropped on
// purpose: Slack and X do the same, and inserting a stray URL next to an
// attached screenshot reads as a bug.
override func paste(_ sender: Any?) {
guard acceptsImages else {
super.paste(sender)
return
}
let imageProviders = UIPasteboard.general.itemProviders.filter {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
guard !imageProviders.isEmpty else {
super.paste(sender)
return
}
onPasteImages?(imageProviders)
}
}

/// A standalone delegate (rather than the text view itself, whose scroll-view
/// superclass already takes part in gesture delegation) so the observing pan
/// reliably recognizes alongside the text interaction's own recognizers
/// instead of being cancelled by them.
private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}

/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly
/// vertical downward drag. While the draft is scrollable, scrolling back
/// through it never drops the keyboard mid-read, but a drag that *begins*
/// with the draft at its top only rubber-bands, which is unambiguous
/// dismissal intent (and the composer's only escape hatch once it and the
/// keyboard cover the transcript). `isAtTop` is the position at drag start,
/// so a scroll that reaches the top never dismisses mid-gesture.
enum FeatureComposerDragDismissPolicy {
static func shouldDismiss(
translationX: CGFloat,
translationY: CGFloat,
isScrollable: Bool,
isAtTop: Bool
) -> Bool {
(!isScrollable || isAtTop)
&& translationY > 8
&& translationY > abs(translationX)
}
}

enum FeatureComposerPasteboardPolicy {
/// `UIPasteboard.hasImages` misses formats that merely conform to image
/// (HEIC screenshots among them), so detection goes through UTType
/// conformance instead.
static func containsImage(in pasteboard: UIPasteboard) -> Bool {
pasteboard.itemProviders.contains {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}
}
}

/// A one-shot caret placement, applied by the text input exactly once per
/// request `id`. Command completion issues one so the caret lands after the
/// inserted text instead of wherever UIKit leaves it after a programmatic
/// text replacement.
struct FeatureComposerTextSelectionRequest: Equatable {
let id = UUID()
let location: Int
}

enum FeatureComposerTextSelectionPolicy {
/// UTF-16 caret location after `range` (character indices, as produced by
/// the trigger parser) is replaced with `replacement`.
static func cursorLocation(
afterReplacing range: Range<Int>,
in text: String,
with replacement: String
) -> Int {
let lower = min(max(range.lowerBound, 0), text.count)
let lowerIndex = text.index(text.startIndex, offsetBy: lower)
return text[..<lowerIndex].utf16.count + replacement.utf16.count
}

/// Caret location after the binding changed underneath the text view. A
/// restored draft (previous text empty) puts the caret at the end; any
/// other external rewrite keeps the caret where it was, clamped into the
/// new text.
static func cursorLocationAfterBindingUpdate(
previousText: String,
newText: String,
selectedLocation: Int
) -> Int {
previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count)
}
}

/// Cap-then-scroll, the way Messages and Slack grow their composers: the
/// input tracks its content until it reaches a fixed line cap, then holds
/// that height and scrolls internally. The cap deliberately ignores the
/// SwiftUI height proposal: proposals here derive from this view's own
/// previous answer, so scaling by them feeds back and collapses the input.
enum FeatureComposerTextInputSizing {
static let maximumLines: CGFloat = 12

static func height(
fittingHeight: CGFloat,
lineHeight: CGFloat
) -> CGFloat {
min(fittingHeight, lineHeight * maximumLines)
}
}
Loading
Loading