1import ExpoModulesCore
2import UIKit
3
4private struct ComposerTokenPayload: Decodable {
5 let type: String
6 let source: String
7 let label: String
8 let iconUri: String?
9 let accent: String?
10 let symbol: String?
11 let detail: String?
12 let start: Int
13 let end: Int
14}
15
16private struct ComposerSelectionPayload: Decodable {
17 let start: Int
18 let end: Int
19}
20
21private struct ComposerControlledDocumentPayload: Decodable {
22 let value: String
23 let selection: ComposerSelectionPayload?
24 let tokensJson: String
25 let mostRecentEventCount: Int
26 let isNativeEcho: Bool
27}
28
29private struct ComposerThemePayload: Decodable {
30 let text: String
31 let placeholder: String
32 let chipBackground: String
33 let chipBorder: String
34 let chipText: String
35 let skillBackground: String
36 let skillBorder: String
37 let skillText: String
38 let fileTint: String
39}
40
41private struct ComposerChipStyle {
42 let tint: UIColor
43 let backgroundColor: UIColor
44 let borderColor: UIColor
45 let textColor: UIColor
46}
47
48private enum ComposerEnterBehavior: String {
49 case send
50 case newline
51}
52
53private final class ComposerTextAttachment: NSTextAttachment {
54 let source: String
55 let label: String
56
57 init(source: String, label: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) {
58 self.source = source
59 self.label = label
60 super.init(data: nil, ofType: nil)
61 self.image = image
62 bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height)
63 }
64
65 required init?(coder: NSCoder) {
66 nil
67 }
68}
69
70private final class ComposerContextAccessibilityElement: UIAccessibilityElement {
71 var activate: (() -> Bool)?
72
73 override func accessibilityActivate() -> Bool {
74 activate?() ?? false
75 }
76}
77
78private final class ComposerTextView: UITextView {
79 private static let pastedImageDirectoryName = "t3-composer-paste"
80 private static let stalePastedImageAge: TimeInterval = 60 * 60
81 private static let readOnlyActions = Set([
82 "cut:",
83 "delete:",
84 "paste:",
85 "redo:",
86 "toggleBoldface:",
87 "toggleItalics:",
88 "toggleUnderline:",
89 "undo:",
90 ])
91
92 var onPasteImages: (([String]) -> Void)?
93 var onPasteContext: (([String: String]) -> Void)?
94 var onPasteText: ((String, NSRange) -> Void)?
95 var clipboardFragment = ""
96 var onAttributedMutation: (() -> Void)?
97 var onSubmit: ((Bool) -> Void)?
98 var isReadOnly = false
99 var textPasteThresholdBytes = 0
100 var maxInputChars = Int.max
101 var enterBehavior: ComposerEnterBehavior = .send
102 /// Shortcut HUD titles. JS supplies what the two sends actually do right now
103 /// ("Queue Message" / "Steer Message"), so the iPad Command-hold list names
104 /// the outcome rather than a generic "Send".
105 var submitTitle = "Send Message"
106 var alternateSubmitTitle = "Send Message"
107 private var bypassTextPasteInterception = false
108
109 override var keyCommands: [UIKeyCommand]? {
110 var commands = super.keyCommands ?? []
111 guard !isReadOnly, markedTextRange == nil else { return commands }
112 // The plainer chord always performs the configured follow-up behavior and
113 // the more-modified one performs its opposite, so Command is the "other
114 // way" modifier whichever Return behavior is configured.
115 if enterBehavior == .send {
116 let submitOnReturn = UIKeyCommand(
117 input: "\r",
118 modifierFlags: [],
119 action: #selector(submitMessage(_:))
120 )
121 submitOnReturn.discoverabilityTitle = submitTitle
122 submitOnReturn.wantsPriorityOverSystemBehavior = true
123 commands.append(submitOnReturn)
124
125 let submitAlternate = UIKeyCommand(
126 input: "\r",
127 modifierFlags: .command,
128 action: #selector(submitMessageAlternate(_:))
129 )
130 submitAlternate.discoverabilityTitle = alternateSubmitTitle
131 submitAlternate.wantsPriorityOverSystemBehavior = true
132 commands.append(submitAlternate)
133
134 let newline = UIKeyCommand(
135 input: "\r",
136 modifierFlags: .shift,
137 action: #selector(insertNewline(_:))
138 )
139 newline.discoverabilityTitle = "New Line"
140 newline.wantsPriorityOverSystemBehavior = true
141 commands.append(newline)
142 } else {
143 let submit = UIKeyCommand(
144 input: "\r",
145 modifierFlags: .command,
146 action: #selector(submitMessage(_:))
147 )
148 submit.discoverabilityTitle = submitTitle
149 submit.wantsPriorityOverSystemBehavior = true
150 commands.append(submit)
151
152 let submitAlternate = UIKeyCommand(
153 input: "\r",
154 modifierFlags: [.command, .shift],
155 action: #selector(submitMessageAlternate(_:))
156 )
157 submitAlternate.discoverabilityTitle = alternateSubmitTitle
158 submitAlternate.wantsPriorityOverSystemBehavior = true
159 commands.append(submitAlternate)
160 }
161 if textPasteThresholdBytes > 0 {
162 let pasteAsText = UIKeyCommand(
163 input: "v",
164 modifierFlags: [.command, .shift],
165 action: #selector(pasteInline(_:))
166 )
167 pasteAsText.discoverabilityTitle = "Paste as Text"
168 pasteAsText.wantsPriorityOverSystemBehavior = true
169 commands.append(pasteAsText)
170 }
171 return commands
172 }
173
174 @objc private func submitMessage(_ sender: UIKeyCommand) {
175 guard !isReadOnly, markedTextRange == nil else { return }
176 onSubmit?(false)
177 }
178
179 @objc private func submitMessageAlternate(_ sender: UIKeyCommand) {
180 guard !isReadOnly, markedTextRange == nil else { return }
181 onSubmit?(true)
182 }
183
184 @objc private func insertNewline(_ sender: UIKeyCommand) {
185 guard !isReadOnly, markedTextRange == nil else { return }
186 insertText("\n")
187 }
188
189 @objc private func pasteInline(_ sender: UIKeyCommand) {
190 guard !isReadOnly else {
191 return
192 }
193 bypassTextPasteInterception = true
194 defer { bypassTextPasteInterception = false }
195 paste(sender)
196 }
197
198 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
199 if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) {
200 return isEditable && !isReadOnly && markedTextRange == nil
201 }
202 if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
203 return false
204 }
205 if action == #selector(paste(_:)) {
206 let pasteboard = UIPasteboard.general
207 if pasteboard.hasImages ||
208 pasteboard.itemProviders.contains(where: {
209 $0.canLoadObject(ofClass: UIImage.self)
210 }) {
211 return true
212 }
213 }
214 return super.canPerformAction(action, withSender: sender)
215 }
216
217 override func paste(_ sender: Any?) {
218 guard !isReadOnly else {
219 return
220 }
221 let pasteboard = UIPasteboard.general
222 let context = T3ComposerClipboard.read()
223 if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") {
224 onPasteContext?(context)
225 return
226 }
227 let imageProviders = pasteboard.itemProviders.filter {
228 $0.canLoadObject(ofClass: UIImage.self)
229 }
230 if !imageProviders.isEmpty {
231 loadImages(from: imageProviders)
232 return
233 }
234
235 let images = pasteboard.images ?? []
236 if !images.isEmpty {
237 let urls = images.compactMap(Self.writeTemporaryImage)
238 if !urls.isEmpty {
239 onPasteImages?(urls)
240 return
241 }
242 }
243 if !bypassTextPasteInterception,
244 let text = pasteboard.string, shouldInterceptTextPaste(text) {
245 onPasteText?(text, selectedRange)
246 return
247 }
248 super.paste(sender)
249 }
250
251 private func shouldInterceptTextPaste(_ text: String) -> Bool {
252 guard textPasteThresholdBytes > 0, !text.isEmpty else { return false }
253 let pastedLength = (text as NSString).length
254 if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes {
255 return true
256 }
257 // Chips occupy one display character but expand to their source in the
258 // submitted message. Measure that source, including the replaced selection.
259 let sourceLength = sourceOffset(forDisplayOffset: attributedText.length)
260 let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) -
261 sourceOffset(forDisplayOffset: selectedRange.location)
262 return sourceLength - selectedLength + pastedLength > maxInputChars
263 }
264
265 override func deleteBackward() {
266 guard !isReadOnly else {
267 return
268 }
269 guard selectedRange.length == 0, selectedRange.location > 0 else {
270 super.deleteBackward()
271 return
272 }
273
274 let previousOffset = selectedRange.location - 1
275 if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil)
276 is ComposerTextAttachment {
277 replaceDisplayRange(NSRange(location: previousOffset, length: 1))
278 return
279 }
280
281 super.deleteBackward()
282 }
283
284 private func replaceDisplayRange(_ range: NSRange) {
285 guard let start = position(from: beginningOfDocument, offset: range.location),
286 let end = position(from: start, offset: range.length),
287 let textRange = textRange(from: start, to: end) else {
288 return
289 }
290 replace(textRange, withText: "")
291 }
292
293 func loadImages(from providers: [NSItemProvider]) {
294 let group = DispatchGroup()
295 let lock = NSLock()
296 var images = [UIImage?](repeating: nil, count: providers.count)
297
298 for (index, provider) in providers.enumerated() {
299 group.enter()
300 provider.loadObject(ofClass: UIImage.self) { object, _ in
301 defer { group.leave() }
302 guard let image = object as? UIImage else {
303 return
304 }
305 lock.lock()
306 images[index] = image
307 lock.unlock()
308 }
309 }
310
311 group.notify(queue: .main) { [weak self] in
312 guard let self, !self.isReadOnly else {
313 return
314 }
315 let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage)
316 if !urls.isEmpty {
317 self.onPasteImages?(urls)
318 }
319 }
320 }
321
322 override func copy(_ sender: Any?) {
323 guard selectedRange.length > 0 else {
324 return super.copy(sender)
325 }
326 T3ComposerClipboard.write(text: serializedText(in: selectedRange), fragment: clipboardFragment)
327 }
328
329 override func cut(_ sender: Any?) {
330 guard !isReadOnly else {
331 return
332 }
333 guard isEditable, selectedRange.length > 0 else {
334 return super.cut(sender)
335 }
336 copy(sender)
337 textStorage.replaceCharacters(in: selectedRange, with: "")
338 selectedRange = NSRange(location: selectedRange.location, length: 0)
339 onAttributedMutation?()
340 }
341
342 func serializedText() -> String {
343 serializedText(in: NSRange(location: 0, length: attributedText.length))
344 }
345
346 func serializedText(in range: NSRange) -> String {
347 guard range.length > 0 else {
348 return ""
349 }
350
351 let source = NSMutableString()
352 let nsString = attributedText.string as NSString
353 var cursor = range.location
354 let end = NSMaxRange(range)
355 attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in
356 if attachmentRange.location > cursor {
357 source.append(
358 nsString.substring(
359 with: NSRange(location: cursor, length: attachmentRange.location - cursor)
360 )
361 )
362 }
363 if let attachment = value as? ComposerTextAttachment {
364 source.append(attachment.source)
365 } else {
366 source.append(nsString.substring(with: attachmentRange))
367 }
368 cursor = NSMaxRange(attachmentRange)
369 }
370 if cursor < end {
371 source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor)))
372 }
373 return source as String
374 }
375
376 func sourceOffset(forDisplayOffset displayOffset: Int) -> Int {
377 let boundedOffset = max(0, min(attributedText.length, displayOffset))
378 if boundedOffset == 0 {
379 return 0
380 }
381
382 var sourceOffset = 0
383 let range = NSRange(location: 0, length: boundedOffset)
384 attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in
385 if let attachment = value as? ComposerTextAttachment {
386 sourceOffset += (attachment.source as NSString).length
387 } else {
388 sourceOffset += attributeRange.length
389 }
390 }
391 return sourceOffset
392 }
393
394 private static func writeTemporaryImage(_ image: UIImage) -> String? {
395 guard let data = image.pngData() else {
396 return nil
397 }
398 let directory = FileManager.default.temporaryDirectory
399 .appendingPathComponent(pastedImageDirectoryName, isDirectory: true)
400 do {
401 try FileManager.default.createDirectory(
402 at: directory,
403 withIntermediateDirectories: true
404 )
405 removeStaleTemporaryImages(in: directory)
406 let url = directory.appendingPathComponent("\(UUID().uuidString).png")
407 try data.write(to: url, options: .atomic)
408 return url.absoluteString
409 } catch {
410 return nil
411 }
412 }
413
414 private static func removeStaleTemporaryImages(in directory: URL) {
415 let cutoff = Date().addingTimeInterval(-stalePastedImageAge)
416 guard let urls = try? FileManager.default.contentsOfDirectory(
417 at: directory,
418 includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
419 options: [.skipsHiddenFiles]
420 ) else {
421 return
422 }
423
424 for url in urls {
425 guard
426 let values = try? url.resourceValues(
427 forKeys: [.contentModificationDateKey, .isRegularFileKey]
428 ),
429 values.isRegularFile == true,
430 let modifiedAt = values.contentModificationDate,
431 modifiedAt < cutoff
432 else {
433 continue
434 }
435 try? FileManager.default.removeItem(at: url)
436 }
437 }
438}
439
440public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate, UIGestureRecognizerDelegate {
441 private let textView = ComposerTextView()
442 private let placeholderLabel = UILabel()
443 private var value = ""
444 private var tokensJson = "[]"
445 private var tokens: [ComposerTokenPayload] = []
446 private var requestedSelection: ComposerSelectionPayload?
447 private var theme = ComposerThemePayload(
448 text: "#262626",
449 placeholder: "#8e8e93",
450 chipBackground: "#f2f2f7",
451 chipBorder: "#dedee3",
452 chipText: "#262626",
453 skillBackground: "#f9e8fb",
454 skillBorder: "#e5a6eb",
455 skillText: "#a21caf",
456 fileTint: "#737373"
457 )
458 private var fontFamily = "DMSans-Regular"
459 private var fontSize: CGFloat = 14
460 private var lineHeight: CGFloat = 20
461 private var contentInsetVertical: CGFloat = 0
462 private var shouldAutoFocus = false
463 private var didAutoFocus = false
464 private var isReadOnly = false
465 private var isApplyingControlledValue = false
466 private var nativeEventCount = 0
467 private var lastContentSize = CGSize.zero
468 private var iconImages: [String: UIImage] = [:]
469 private var pendingIconUris = Set<String>()
470 private var tokensNeedRebuild = false
471 private var chipsNeedMeasuredWidth = false
472
473 let onComposerChange = EventDispatcher()
474 let onComposerSelectionChange = EventDispatcher()
475 let onComposerFocus = EventDispatcher()
476 let onComposerBlur = EventDispatcher()
477 let onComposerSubmit = EventDispatcher()
478 let onComposerPasteImages = EventDispatcher()
479 let onComposerContextPress = EventDispatcher()
480 let onComposerPasteContext = EventDispatcher()
481 let onComposerPasteText = EventDispatcher()
482 let onComposerContentSizeChange = EventDispatcher()
483
484 public required init(appContext: AppContext? = nil) {
485 super.init(appContext: appContext)
486
487 clipsToBounds = false
488 textView.delegate = self
489 textView.textDropDelegate = self
490 textView.backgroundColor = .clear
491 textView.textContainerInset = .zero
492 textView.textContainer.lineFragmentPadding = 0
493 textView.keyboardDismissMode = .interactive
494 textView.alwaysBounceVertical = false
495 textView.showsVerticalScrollIndicator = true
496 textView.adjustsFontForContentSizeCategory = true
497 textView.onPasteImages = { [weak self] urls in
498 self?.onComposerPasteImages(["uris": urls])
499 }
500 textView.onPasteContext = { [weak self] context in
501 guard let self else { return }
502 let selection = self.sourceSelection()
503 self.nativeEventCount += 1
504 var payload: [String: Any] = context
505 payload["value"] = self.textView.serializedText()
506 payload["eventCount"] = self.nativeEventCount
507 payload["selection"] = ["start": selection.start, "end": selection.end]
508 self.onComposerPasteContext(payload)
509 }
510 textView.onPasteText = { [weak self] text, _ in
511 guard let self else { return }
512 let selection = self.sourceSelection()
513 self.nativeEventCount += 1
514 self.onComposerPasteText([
515 "value": self.textView.serializedText(),
516 "eventCount": self.nativeEventCount,
517 "text": text,
518 "selection": ["start": selection.start, "end": selection.end],
519 ])
520 }
521 textView.onAttributedMutation = { [weak self] in
522 self?.emitTextChange()
523 }
524 textView.onSubmit = { [weak self] alternate in
525 self?.onComposerSubmit(["alternate": alternate])
526 }
527 let contextTap = UITapGestureRecognizer(target: self, action: #selector(openContext(_:)))
528 contextTap.cancelsTouchesInView = false
529 contextTap.delegate = self
530 textView.addGestureRecognizer(contextTap)
531 addSubview(textView)
532
533 placeholderLabel.numberOfLines = 0
534 placeholderLabel.adjustsFontForContentSizeCategory = true
535 addSubview(placeholderLabel)
536 applyTypography()
537 applyTheme()
538 }
539
540 @objc private func openContext(_ recognizer: UITapGestureRecognizer) {
541 guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else {
542 textView.becomeFirstResponder()
543 return
544 }
545 openContext(index: index, attachment: attachment)
546 }
547
548 private func openContext(index: Int, attachment: ComposerTextAttachment) {
549 let start = textView.sourceOffset(forDisplayOffset: index)
550 onComposerContextPress(["source": attachment.source, "start": start, "end": start + (attachment.source as NSString).length])
551 }
552
553 public override var accessibilityElements: [Any]? {
554 get {
555 var elements: [Any] = [textView]
556 let layout = textView.layoutManager
557 textView.textStorage.enumerateAttribute(.attachment, in: NSRange(location: 0, length: textView.textStorage.length)) { value, range, _ in
558 guard let attachment = value as? ComposerTextAttachment else { return }
559 let glyphRange = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
560 let rect = layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
561 .offsetBy(dx: textView.textContainerInset.left, dy: textView.textContainerInset.top)
562 guard rect.intersects(textView.bounds) else { return }
563 let element = ComposerContextAccessibilityElement(accessibilityContainer: self)
564 element.accessibilityLabel = attachment.label
565 element.accessibilityTraits = .button
566 element.accessibilityFrameInContainerSpace = textView.convert(rect, to: self)
567 element.activate = { [weak self] in
568 guard let self, range.location < self.textView.textStorage.length,
569 self.textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? ComposerTextAttachment === attachment else { return false }
570 self.openContext(index: range.location, attachment: attachment)
571 return true
572 }
573 elements.append(element)
574 }
575 return elements
576 }
577 set { super.accessibilityElements = newValue }
578 }
579
580 public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
581 true
582 }
583
584 public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
585 true
586 }
587
588 public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
589 // A chip is text context, not an image to save to the camera roll.
590 !(textAttachment is ComposerTextAttachment)
591 }
592
593 private func contextAttachment(at point: CGPoint) -> (Int, ComposerTextAttachment)? {
594 let containerPoint = CGPoint(x: point.x - textView.textContainerInset.left, y: point.y - textView.textContainerInset.top)
595 let layout = textView.layoutManager
596 let index = layout.characterIndex(for: containerPoint, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
597 guard index < textView.textStorage.length,
598 let attachment = textView.textStorage.attribute(.attachment, at: index, effectiveRange: nil) as? ComposerTextAttachment else { return nil }
599 let glyphRange = layout.glyphRange(forCharacterRange: NSRange(location: index, length: 1), actualCharacterRange: nil)
600 guard layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer).contains(containerPoint) else { return nil }
601 return (index, attachment)
602 }
603
604 public override func layoutSubviews() {
605 super.layoutSubviews()
606 textView.frame = bounds
607 if chipsNeedMeasuredWidth, bounds.width > 0 {
608 chipsNeedMeasuredWidth = false
609 applyControlledDocument(force: true)
610 }
611 let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding
612 let placeholderY = textView.textContainerInset.top
613 let placeholderWidth = max(
614 0,
615 bounds.width - placeholderX - textView.textContainerInset.right -
616 textView.textContainer.lineFragmentPadding
617 )
618 placeholderLabel.frame = CGRect(
619 x: placeholderX,
620 y: placeholderY,
621 width: placeholderWidth,
622 height: max(lineHeight, placeholderLabel.font.lineHeight)
623 )
624 emitContentSizeIfNeeded()
625 }
626
627 func setClipboardFragment(_ fragment: String) {
628 textView.clipboardFragment = fragment
629 }
630
631 public override func didMoveToWindow() {
632 super.didMoveToWindow()
633 guard window != nil, shouldAutoFocus, !didAutoFocus else {
634 return
635 }
636 didAutoFocus = true
637 DispatchQueue.main.async { [weak self] in
638 self?.textView.becomeFirstResponder()
639 }
640 }
641
642 func setControlledDocumentJson(_ documentJson: String) {
643 guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson),
644 document.mostRecentEventCount >= nativeEventCount else {
645 return
646 }
647 if document.isNativeEcho && textView.serializedText() != document.value {
648 return
649 }
650 if tokensJson != document.tokensJson {
651 tokensJson = document.tokensJson
652 tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? []
653 tokensNeedRebuild = true
654 }
655 value = document.value
656 requestedSelection = document.selection
657 applyControlledDocument(force: tokensNeedRebuild)
658 applyRequestedSelection()
659 if tokensMatchCurrentValue() {
660 tokensNeedRebuild = false
661 }
662 }
663
664 func setThemeJson(_ themeJson: String) {
665 guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else {
666 return
667 }
668 theme = nextTheme
669 applyTheme()
670 applyControlledDocument(force: true)
671 }
672
673 func setPlaceholder(_ placeholder: String) {
674 placeholderLabel.text = placeholder
675 setNeedsLayout()
676 }
677
678 func setFontFamily(_ fontFamily: String) {
679 self.fontFamily = fontFamily
680 applyTypography()
681 applyControlledDocument(force: true)
682 }
683
684 func setFontSize(_ fontSize: CGFloat) {
685 self.fontSize = fontSize
686 applyTypography()
687 applyControlledDocument(force: true)
688 }
689
690 func setLineHeight(_ lineHeight: CGFloat) {
691 self.lineHeight = lineHeight
692 applyTypography()
693 applyControlledDocument(force: true)
694 }
695
696 func setContentInsetVertical(_ contentInsetVertical: CGFloat) {
697 self.contentInsetVertical = contentInsetVertical
698 textView.textContainerInset = UIEdgeInsets(
699 top: contentInsetVertical,
700 left: 0,
701 bottom: contentInsetVertical,
702 right: 0
703 )
704 setNeedsLayout()
705 }
706
707 func setEditable(_ editable: Bool) {
708 textView.isEditable = editable
709 }
710
711 func setReadOnly(_ readOnly: Bool) {
712 isReadOnly = readOnly
713 textView.isReadOnly = readOnly
714 }
715
716 func setScrollEnabled(_ scrollEnabled: Bool) {
717 textView.isScrollEnabled = scrollEnabled
718 }
719
720 func setAutoFocus(_ autoFocus: Bool) {
721 shouldAutoFocus = autoFocus
722 }
723
724 func setAutoCorrect(_ autoCorrect: Bool) {
725 textView.autocorrectionType = autoCorrect ? .yes : .no
726 }
727
728 func setSpellCheck(_ spellCheck: Bool) {
729 textView.spellCheckingType = spellCheck ? .yes : .no
730 }
731
732 func setEnterBehavior(_ behavior: String) {
733 textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
734 }
735
736 func setSubmitTitle(_ title: String) {
737 textView.submitTitle = title
738 }
739
740 func setAlternateSubmitTitle(_ title: String) {
741 textView.alternateSubmitTitle = title
742 }
743
744 func setTextPasteThresholdBytes(_ threshold: Int) {
745 textView.textPasteThresholdBytes = threshold
746 }
747
748 func setMaxInputChars(_ maxInputChars: Int) {
749 textView.maxInputChars = maxInputChars
750 }
751
752 func focusEditor() {
753 textView.becomeFirstResponder()
754 }
755
756 func blurEditor() {
757 textView.resignFirstResponder()
758 }
759
760 func setSelection(start: Int, end: Int) {
761 requestedSelection = ComposerSelectionPayload(start: start, end: end)
762 applyRequestedSelection()
763 }
764
765 public func textViewDidChange(_ textView: UITextView) {
766 emitTextChange()
767 }
768
769 public func textViewDidChangeSelection(_ textView: UITextView) {
770 guard !isApplyingControlledValue else {
771 return
772 }
773 restoreBaseTypingAttributes()
774 // UIKit moves the selection before textViewDidChange runs. Emitting here
775 // would pair the post-edit text with a pre-edit revision counter, so let
776 // the change event that follows carry both; only pure caret moves emit.
777 guard self.textView.serializedText() == value else {
778 return
779 }
780 emitSelection()
781 }
782
783 public func textView(
784 _ textView: UITextView,
785 shouldChangeTextIn range: NSRange,
786 replacementText text: String
787 ) -> Bool {
788 restoreBaseTypingAttributes()
789 return !isReadOnly
790 }
791
792 public func textDroppableView(
793 _ textDroppableView: UIView & UITextDroppable,
794 proposalForDrop drop: UITextDropRequest
795 ) -> UITextDropProposal {
796 guard !isReadOnly else {
797 return UITextDropProposal(operation: .cancel)
798 }
799 guard droppedImageProviders(in: drop) != nil else {
800 return drop.suggestedProposal
801 }
802
803 // The composer owns image drops so UIKit does not insert NSTextAttachments
804 // that the controlled plain-text value cannot represent.
805 let proposal = UITextDropProposal(operation: .copy)
806 proposal.dropAction = .insert
807 proposal.dropPerformer = .delegate
808 return proposal
809 }
810
811 public func textDroppableView(
812 _ textDroppableView: UIView & UITextDroppable,
813 willPerformDrop drop: UITextDropRequest
814 ) {
815 guard !isReadOnly else {
816 return
817 }
818 guard let imageProviders = droppedImageProviders(in: drop) else {
819 return
820 }
821 textView.loadImages(from: imageProviders)
822 }
823
824 private func droppedImageProviders(in drop: UITextDropRequest) -> [NSItemProvider]? {
825 let providers = drop.dropSession.items.map(\.itemProvider)
826 guard !providers.isEmpty,
827 providers.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) else {
828 return nil
829 }
830 return providers
831 }
832
833 public func textViewDidBeginEditing(_ textView: UITextView) {
834 onComposerFocus()
835 }
836
837 public func textViewDidEndEditing(_ textView: UITextView) {
838 onComposerBlur()
839 }
840
841 private func applyControlledDocument(force: Bool = false) {
842 let currentSource = textView.serializedText()
843 guard force || currentSource != value || !documentMatchesExpectedTokens() else {
844 updatePlaceholderVisibility()
845 return
846 }
847
848 let previousSelection = sourceSelection()
849 isApplyingControlledValue = true
850 textView.attributedText = makeAttributedDocument()
851 let targetSelection = requestedSelection ?? previousSelection
852 requestedSelection = nil
853 textView.selectedRange = displayRange(for: targetSelection)
854 restoreBaseTypingAttributes()
855 isApplyingControlledValue = false
856 updatePlaceholderVisibility()
857 emitContentSizeIfNeeded()
858 }
859
860 private func makeAttributedDocument() -> NSAttributedString {
861 let result = NSMutableAttributedString()
862 let source = value as NSString
863 var cursor = 0
864 let validTokens = tokens.filter {
865 $0.start >= cursor &&
866 $0.end > $0.start &&
867 $0.end <= source.length &&
868 source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
869 }
870
871 for token in validTokens {
872 if token.start < cursor {
873 continue
874 }
875 if token.start > cursor {
876 appendPlainText(
877 source.substring(with: NSRange(location: cursor, length: token.start - cursor)),
878 to: result
879 )
880 }
881 result.append(makeAttachmentString(token))
882 cursor = token.end
883 }
884 if cursor < source.length {
885 appendPlainText(
886 source.substring(with: NSRange(location: cursor, length: source.length - cursor)),
887 to: result
888 )
889 }
890 return result
891 }
892
893 private func appendPlainText(_ text: String, to result: NSMutableAttributedString) {
894 result.append(NSAttributedString(string: text, attributes: baseAttributes()))
895 }
896
897 private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString {
898 let isSkill = token.type == "skill"
899 let accent = token.accent.flatMap { UIColor(composerHex: $0) }
900 let foreground = UIColor(composerHex: theme.chipText) ?? .label
901 let border = UIColor(composerHex: theme.chipBorder) ?? .separator
902 let tint = accent.map { blend($0, over: foreground, weight: 0.22) }
903 ?? UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel
904 let iconName = token.symbol ?? (isSkill ? "cube" : "doc")
905 let iconImage = token.iconUri.flatMap(iconImage(for:))
906 let style = ComposerChipStyle(
907 tint: tint,
908 backgroundColor: accent?.withAlphaComponent(0.11) ?? UIColor(
909 composerHex: isSkill ? theme.skillBackground : theme.chipBackground
910 ) ?? .secondarySystemFill,
911 borderColor: accent.map { blend($0, over: border, weight: 0.34) } ?? UIColor(
912 composerHex: isSkill ? theme.skillBorder : theme.chipBorder
913 ) ?? .separator,
914 textColor: tint
915 )
916 let image = renderChip(
917 label: token.label,
918 detail: token.detail,
919 iconName: iconName,
920 iconImage: iconImage,
921 style: style
922 )
923 let font = UIFont(name: fontFamily, size: fontSize)
924 ?? UIFont.systemFont(ofSize: fontSize)
925 let baselineOffset = floor((font.capHeight - image.size.height) / 2)
926 let attachment = ComposerTextAttachment(
927 source: token.source,
928 label: token.label,
929 image: image,
930 size: image.size,
931 baselineOffset: baselineOffset
932 )
933 let attributedAttachment = NSMutableAttributedString(attachment: attachment)
934 attributedAttachment.addAttributes(
935 baseAttributes(),
936 range: NSRange(location: 0, length: attributedAttachment.length)
937 )
938 return attributedAttachment
939 }
940
941 /// Chip glyphs with no SF Symbol that reads correctly. A pull request would otherwise land on
942 /// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request", so it is
943 /// drawn from the same lucide geometry web and Android use.
944 private static func vectorIcon(named name: String, size: CGFloat, color: UIColor) -> UIImage? {
945 guard name == "git-pull-request" else { return nil }
946 return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in
947 let s = size / 24 // lucide authors on a 24pt grid.
948 let path = UIBezierPath()
949 for centre in [CGPoint(x: 18 * s, y: 18 * s), CGPoint(x: 6 * s, y: 6 * s)] {
950 path.append(UIBezierPath(arcCenter: centre, radius: 3 * s, startAngle: 0,
951 endAngle: .pi * 2, clockwise: true))
952 }
953 path.move(to: CGPoint(x: 13 * s, y: 6 * s))
954 path.addLine(to: CGPoint(x: 16 * s, y: 6 * s))
955 path.addCurve(to: CGPoint(x: 18 * s, y: 8 * s),
956 controlPoint1: CGPoint(x: 17.1 * s, y: 6 * s),
957 controlPoint2: CGPoint(x: 18 * s, y: 6.9 * s))
958 path.addLine(to: CGPoint(x: 18 * s, y: 15 * s))
959 path.move(to: CGPoint(x: 6 * s, y: 9 * s))
960 path.addLine(to: CGPoint(x: 6 * s, y: 21 * s))
961 path.lineWidth = 2 * s
962 path.lineCapStyle = .round
963 path.lineJoinStyle = .round
964 color.setStroke()
965 path.stroke()
966 }
967 }
968
969 private func renderChip(
970 label: String,
971 detail: String?,
972 iconName: String,
973 iconImage: UIImage?,
974 style: ComposerChipStyle
975 ) -> UIImage {
976 // Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and
977 // the same chip drawn in a sent message have to be the same picture.
978 let chipFontSize = fontSize * 0.86
979 let font = UIFont(name: "DMSans-Medium", size: chipFontSize)
980 ?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium)
981 let fallbackIcon = Self.vectorIcon(named: iconName, size: 14, color: style.textColor)
982 ?? UIImage(
983 systemName: iconName,
984 withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)
985 )
986 let icon = iconImage ?? fallbackIcon
987 // The size reads as metadata, not part of the name, so it renders a step down from the
988 // label the way the web chip does.
989 let paragraph = NSMutableParagraphStyle()
990 paragraph.alignment = .left
991 let detailFont = UIFont(name: "DMSans-Medium", size: chipFontSize * 0.84)
992 ?? UIFont.systemFont(ofSize: chipFontSize * 0.84, weight: .medium)
993 let attributedLabel = NSMutableAttributedString(
994 string: label,
995 attributes: [.font: font, .foregroundColor: style.textColor, .paragraphStyle: paragraph]
996 )
997 if let detail, !detail.isEmpty {
998 attributedLabel.append(
999 NSAttributedString(
1000 string: " \(detail)",
1001 attributes: [
1002 .font: detailFont,
1003 .foregroundColor: style.textColor,
1004 .paragraphStyle: paragraph,
1005 ]
1006 )
1007 )
1008 }
1009 let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17
1010 let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33
1011 let padding = chipFontSize * 0.5
1012 let height = ceil(chipFontSize * 1.41)
1013 // A long path would otherwise draw a chip wider than the composer and clip. Cap the label
1014 // to the text the editor can actually show and truncate inside it, as Android's
1015 // `maximumWidth` does, so the chip always fits the line it sits on.
1016 let availableWidth = textView.textContainer.size.width > 0
1017 ? textView.textContainer.size.width - textView.textContainer.lineFragmentPadding * 2
1018 : (textView.window?.bounds.width ?? bounds.width)
1019 if availableWidth <= 0 {
1020 chipsNeedMeasuredWidth = true
1021 }
1022 let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap)
1023 paragraph.lineBreakMode = .byTruncatingMiddle
1024 attributedLabel.addAttribute(
1025 .paragraphStyle,
1026 value: paragraph,
1027 range: NSRange(location: 0, length: attributedLabel.length)
1028 )
1029 let measured = attributedLabel.size()
1030 let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height)
1031 let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width)
1032 let format = UIGraphicsImageRendererFormat.preferred()
1033 format.opaque = false
1034 let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format)
1035 return renderer.image { context in
1036 let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
1037 let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: chipFontSize * 0.5)
1038 style.backgroundColor.setFill()
1039 path.fill()
1040 style.borderColor.setStroke()
1041 path.lineWidth = 1
1042 path.stroke()
1043
1044 var x = padding
1045 if let icon {
1046 let renderedIcon = iconImage == nil
1047 ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal)
1048 : icon
1049 renderedIcon.draw(
1050 in: CGRect(x: x, y: (height - iconWidth) / 2, width: iconWidth, height: iconWidth)
1051 )
1052 x += iconWidth + iconGap
1053 }
1054 // Exactly the measured width: a spare pixel here would let a capped label draw past
1055 // the cap instead of truncating inside it.
1056 attributedLabel.draw(
1057 in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height)
1058 )
1059 context.cgContext.setAllowsAntialiasing(true)
1060 }
1061 }
1062
1063 private func blend(_ accent: UIColor, over base: UIColor, weight: CGFloat) -> UIColor {
1064 var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0
1065 var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0
1066 accent.getRed(&ar, green: &ag, blue: &ab, alpha: &aa)
1067 base.getRed(&br, green: &bg, blue: &bb, alpha: &ba)
1068 return UIColor(
1069 red: ar * weight + br * (1 - weight),
1070 green: ag * weight + bg * (1 - weight),
1071 blue: ab * weight + bb * (1 - weight),
1072 alpha: aa * weight + ba * (1 - weight)
1073 )
1074 }
1075
1076 private func iconImage(for uri: String) -> UIImage? {
1077 if let image = iconImages[uri] {
1078 return image
1079 }
1080 guard !pendingIconUris.contains(uri), let url = URL(string: uri) else {
1081 return nil
1082 }
1083
1084 if url.isFileURL, let image = UIImage(contentsOfFile: url.path) {
1085 iconImages[uri] = image
1086 return image
1087 }
1088
1089 pendingIconUris.insert(uri)
1090 URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
1091 guard let self, let data, let image = UIImage(data: data) else {
1092 DispatchQueue.main.async {
1093 self?.pendingIconUris.remove(uri)
1094 }
1095 return
1096 }
1097 DispatchQueue.main.async {
1098 self.pendingIconUris.remove(uri)
1099 self.iconImages[uri] = image
1100 self.applyControlledDocument(force: true)
1101 }
1102 }.resume()
1103 return nil
1104 }
1105
1106 private func baseAttributes() -> [NSAttributedString.Key: Any] {
1107 let font = UIFont(name: fontFamily, size: fontSize)
1108 ?? UIFont.systemFont(ofSize: fontSize)
1109 let paragraph = NSMutableParagraphStyle()
1110 paragraph.minimumLineHeight = lineHeight
1111 paragraph.maximumLineHeight = lineHeight
1112 return [
1113 .font: font,
1114 .foregroundColor: UIColor(composerHex: theme.text) ?? .label,
1115 .paragraphStyle: paragraph,
1116 ]
1117 }
1118
1119 private func applyTypography() {
1120 let font = UIFont(name: fontFamily, size: fontSize)
1121 ?? UIFont.systemFont(ofSize: fontSize)
1122 textView.font = font
1123 restoreBaseTypingAttributes()
1124 placeholderLabel.font = font
1125 setNeedsLayout()
1126 }
1127
1128 private func restoreBaseTypingAttributes() {
1129 guard textView.markedTextRange == nil else {
1130 return
1131 }
1132 textView.typingAttributes = baseAttributes()
1133 }
1134
1135 private func applyTheme() {
1136 textView.textColor = UIColor(composerHex: theme.text) ?? .label
1137 placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText
1138 tintColor = UIColor.systemBlue
1139 }
1140
1141 private func emitTextChange() {
1142 guard !isApplyingControlledValue else {
1143 return
1144 }
1145 value = textView.serializedText()
1146 let selection = sourceSelection()
1147 nativeEventCount += 1
1148 onComposerChange([
1149 "value": value,
1150 "selection": ["start": selection.start, "end": selection.end],
1151 "eventCount": nativeEventCount,
1152 ])
1153 updatePlaceholderVisibility()
1154 emitContentSizeIfNeeded()
1155 }
1156
1157 private func emitSelection() {
1158 // Caret moves advance the revision counter like text edits do: a
1159 // controlled payload computed before this move is stale and must fail the
1160 // revision guard instead of yanking the caret back mid-typing.
1161 let currentValue = textView.serializedText()
1162 let selection = sourceSelection()
1163 nativeEventCount += 1
1164 onComposerSelectionChange([
1165 "value": currentValue,
1166 "selection": ["start": selection.start, "end": selection.end],
1167 "eventCount": nativeEventCount,
1168 ])
1169 }
1170
1171 private func sourceSelection() -> ComposerSelectionPayload {
1172 ComposerSelectionPayload(
1173 start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location),
1174 end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange))
1175 )
1176 }
1177
1178 private func displayRange(for selection: ComposerSelectionPayload) -> NSRange {
1179 let start = displayOffset(forSourceOffset: selection.start)
1180 let end = displayOffset(forSourceOffset: selection.end)
1181 return NSRange(location: start, length: max(0, end - start))
1182 }
1183
1184 private func displayOffset(forSourceOffset sourceOffset: Int) -> Int {
1185 let boundedOffset = max(0, min((value as NSString).length, sourceOffset))
1186 var collapsedLength = 0
1187 for token in tokens where token.end <= boundedOffset {
1188 collapsedLength += max(0, token.end - token.start - 1)
1189 }
1190 if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) {
1191 return token.start - collapsedLength + 1
1192 }
1193 return boundedOffset - collapsedLength
1194 }
1195
1196 private func applyRequestedSelection() {
1197 guard let requestedSelection else {
1198 return
1199 }
1200 let nextRange = displayRange(for: requestedSelection)
1201 guard nextRange.location <= textView.attributedText.length,
1202 NSMaxRange(nextRange) <= textView.attributedText.length else {
1203 return
1204 }
1205 self.requestedSelection = nil
1206 // Programmatically assigning selectedRange resets the keyboard's
1207 // autocorrect and predictive-text context even when the range is
1208 // unchanged, so a no-op assignment must be skipped.
1209 guard !NSEqualRanges(nextRange, textView.selectedRange) else {
1210 return
1211 }
1212 isApplyingControlledValue = true
1213 textView.selectedRange = nextRange
1214 isApplyingControlledValue = false
1215 }
1216
1217 private func updatePlaceholderVisibility() {
1218 placeholderLabel.isHidden = !value.isEmpty
1219 }
1220
1221 private func emitContentSizeIfNeeded() {
1222 let nextSize = textView.contentSize
1223 guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
1224 abs(nextSize.height - lastContentSize.height) > 0.5 else {
1225 return
1226 }
1227 lastContentSize = nextSize
1228 onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height])
1229 }
1230
1231 private func decode<T: Decodable>(_ type: T.Type, from json: String) -> T? {
1232 guard let data = json.data(using: .utf8) else {
1233 return nil
1234 }
1235 return try? JSONDecoder().decode(type, from: data)
1236 }
1237
1238 private func tokensMatchCurrentValue() -> Bool {
1239 let source = value as NSString
1240 return tokens.allSatisfy {
1241 $0.start >= 0 &&
1242 $0.end > $0.start &&
1243 $0.end <= source.length &&
1244 source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
1245 }
1246 }
1247
1248 private func documentMatchesExpectedTokens() -> Bool {
1249 let source = value as NSString
1250 let expectedSources = tokens.compactMap { token -> String? in
1251 guard token.start >= 0,
1252 token.end > token.start,
1253 token.end <= source.length,
1254 source.substring(
1255 with: NSRange(location: token.start, length: token.end - token.start)
1256 ) == token.source else {
1257 return nil
1258 }
1259 return token.source
1260 }
1261 var renderedSources: [String] = []
1262 textView.attributedText.enumerateAttribute(
1263 .attachment,
1264 in: NSRange(location: 0, length: textView.attributedText.length)
1265 ) { value, _, _ in
1266 if let attachment = value as? ComposerTextAttachment {
1267 renderedSources.append(attachment.source)
1268 }
1269 }
1270 return renderedSources == expectedSources
1271 }
1272}
1273
1274private extension UIColor {
1275 convenience init?(composerHex hex: String?) {
1276 guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
1277 return nil
1278 }
1279 if value.hasPrefix("#") {
1280 value.removeFirst()
1281 }
1282 guard value.count == 6 || value.count == 8,
1283 let raw = UInt64(value, radix: 16) else {
1284 return nil
1285 }
1286 if value.count == 8 {
1287 self.init(
1288 red: CGFloat((raw >> 24) & 0xff) / 255,
1289 green: CGFloat((raw >> 16) & 0xff) / 255,
1290 blue: CGFloat((raw >> 8) & 0xff) / 255,
1291 alpha: CGFloat(raw & 0xff) / 255
1292 )
1293 } else {
1294 self.init(
1295 red: CGFloat((raw >> 16) & 0xff) / 255,
1296 green: CGFloat((raw >> 8) & 0xff) / 255,
1297 blue: CGFloat(raw & 0xff) / 255,
1298 alpha: 1
1299 )
1300 }
1301 }
1302}