@opengeni/react-native 4.2.1-canary.39642758903001 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/android/build.gradle +15 -0
  2. package/android/src/main/AndroidManifest.xml +1 -0
  3. package/android/src/main/java/dev/opengeni/textselection/OpenGeniTextSelectionModule.kt +274 -0
  4. package/dist/{chunk-D3MFBDVR.js → chunk-2RNMGRTB.js} +3 -3
  5. package/dist/{chunk-D3MFBDVR.js.map → chunk-2RNMGRTB.js.map} +1 -1
  6. package/dist/{chunk-2W74OY6I.js → chunk-FHPMRXIG.js} +16 -2
  7. package/dist/chunk-FHPMRXIG.js.map +1 -0
  8. package/dist/{chunk-YTT4WSJZ.js → chunk-QFV5DISV.js} +3 -3
  9. package/dist/{chunk-YTT4WSJZ.js.map → chunk-QFV5DISV.js.map} +1 -1
  10. package/dist/{chunk-U5L2CUMJ.js → chunk-ZZNZBZWV.js} +3 -3
  11. package/dist/{chunk-U5L2CUMJ.js.map → chunk-ZZNZBZWV.js.map} +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/timeline/annotations.d.ts +81 -0
  14. package/dist/timeline/icon-nodes.generated.d.ts +5 -0
  15. package/dist/timeline/index.d.ts +2 -0
  16. package/dist/timeline/index.js +1186 -197
  17. package/dist/timeline/index.js.map +1 -1
  18. package/dist/timeline/markdown.js +5 -2
  19. package/dist/timeline/markdown.js.map +1 -1
  20. package/dist/timeline/message-timeline.d.ts +20 -2
  21. package/dist/timeline/previews.js +2 -2
  22. package/dist/timeline/queue-dock.d.ts +4 -1
  23. package/dist/timeline/text-selection.d.ts +44 -0
  24. package/expo-module.config.json +5 -2
  25. package/ios/OpenGeniTextSelectionModule.swift +560 -0
  26. package/package.json +6 -4
  27. package/src/timeline/activity.tsx +24 -15
  28. package/src/timeline/annotations.tsx +877 -0
  29. package/src/timeline/icon-nodes.generated.ts +14 -0
  30. package/src/timeline/index.ts +2 -0
  31. package/src/timeline/markdown.tsx +7 -0
  32. package/src/timeline/message-timeline.tsx +340 -174
  33. package/src/timeline/queue-dock.tsx +36 -0
  34. package/src/timeline/session-screen.tsx +122 -4
  35. package/src/timeline/sheet.tsx +3 -2
  36. package/src/timeline/text-selection.ts +115 -0
  37. package/dist/chunk-2W74OY6I.js.map +0 -1
@@ -0,0 +1,560 @@
1
+ import ExpoModulesCore
2
+ import ObjectiveC
3
+ import UIKit
4
+
5
+ /// Native text selection for timeline messages. React Native's selectable
6
+ /// Text on iOS only offers Copy for the whole paragraph. Inside a view whose
7
+ /// accessibility identifier (its React Native test id) starts with
8
+ /// `og-annotation-source:`, a long press instead selects the word under the
9
+ /// finger with the system selection handles and edit menu, so any part of the
10
+ /// paragraph can be selected, copied, looked up or shared. When JavaScript
11
+ /// listens and the identifier names a source, the menu also offers Add note,
12
+ /// which reports the selected text and its surroundings.
13
+ public class OpenGeniTextSelectionModule: Module {
14
+ public func definition() -> ModuleDefinition {
15
+ Name("OpenGeniTextSelection")
16
+
17
+ Events("onAnnotate")
18
+
19
+ OnCreate {
20
+ DispatchQueue.main.async { OpenGeniTextSelection.shared.install() }
21
+ }
22
+
23
+ OnStartObserving {
24
+ OpenGeniTextSelection.shared.emit = { [weak self] event in
25
+ self?.sendEvent("onAnnotate", event)
26
+ }
27
+ }
28
+
29
+ OnStopObserving {
30
+ OpenGeniTextSelection.shared.emit = nil
31
+ }
32
+
33
+ /// The Add note title, localized by JavaScript; nil hides Add note.
34
+ Function("setAnnotateTitle") { (title: String?) in
35
+ DispatchQueue.main.async { OpenGeniTextSelection.shared.annotateTitle = title }
36
+ }
37
+
38
+ /// Where each draft quote is drawn inside a source, in the source view's
39
+ /// coordinates: `{ x, top, rects }`, the end of its last glyph, the top of
40
+ /// that line's text and one text box per line (flattened x, y, width,
41
+ /// height), or an empty object when the quote is not drawn there. Each
42
+ /// query is `{ quote, before, after }`; the surrounding text picks between
43
+ /// repeated occurrences.
44
+ AsyncFunction("quoteAnchors") { (sourceKey: String, queries: [[String: String]]) -> [[String: Any]] in
45
+ OpenGeniTextSelection.shared.quoteAnchors(sourceKey: sourceKey, queries: queries)
46
+ }.runOnQueue(.main)
47
+ }
48
+ }
49
+
50
+ final class OpenGeniTextSelection: NSObject {
51
+ static let shared = OpenGeniTextSelection()
52
+ static let sourcePrefix = "og-annotation-source:"
53
+
54
+ var emit: (([String: Any]) -> Void)?
55
+ var annotateTitle: String?
56
+ private var installed = false
57
+ private weak var active: OpenGeniSelectionTextView?
58
+
59
+ /// Swizzles the Fabric paragraph's long-press handler once.
60
+ func install() {
61
+ guard !installed, let cls = NSClassFromString("RCTParagraphComponentView") else { return }
62
+ installed = true
63
+ let selector = NSSelectorFromString("handleLongPress:")
64
+ guard let method = class_getInstanceMethod(cls, selector) else { return }
65
+ typealias Handler = @convention(c) (AnyObject, Selector, AnyObject) -> Void
66
+ let original = unsafeBitCast(method_getImplementation(method), to: Handler.self)
67
+ let block: @convention(block) (AnyObject, AnyObject) -> Void = { target, gesture in
68
+ if let view = target as? UIView, let press = gesture as? UILongPressGestureRecognizer,
69
+ OpenGeniTextSelection.shared.handle(press, on: view)
70
+ {
71
+ return
72
+ }
73
+ original(target, selector, gesture)
74
+ }
75
+ method_setImplementation(method, imp_implementationWithBlock(block))
76
+ }
77
+
78
+ /// The source marker this paragraph sits in: nil when the paragraph is not
79
+ /// inside a timeline message, "" for a message that cannot be annotated.
80
+ private func sourceKey(for view: UIView) -> String? {
81
+ var current: UIView? = view
82
+ while let candidate = current {
83
+ if let id = candidate.accessibilityIdentifier, id.hasPrefix(Self.sourcePrefix) {
84
+ return String(id.dropFirst(Self.sourcePrefix.count))
85
+ }
86
+ current = candidate.superview
87
+ }
88
+ return nil
89
+ }
90
+
91
+ /// Starts a selection for a long press; false leaves it to React Native.
92
+ private func handle(_ press: UILongPressGestureRecognizer, on paragraph: UIView) -> Bool {
93
+ guard let key = sourceKey(for: paragraph) else { return false }
94
+ // The press keeps reporting changes while the finger stays down; its
95
+ // start opens a selection and lifting the finger shows the edit menu, as
96
+ // in any iOS text view.
97
+ if press.state == .ended {
98
+ if let current = active, current.paragraph === paragraph { current.showMenu() }
99
+ return true
100
+ }
101
+ guard press.state == .began else { return true }
102
+ if let current = active, current.paragraph === paragraph { return true }
103
+ active?.dismiss()
104
+ guard
105
+ let text = Self.attributedText(of: paragraph), text.length > 0,
106
+ let frame = Self.textFrame(of: paragraph), frame.width > 0, frame.height > 0
107
+ else { return false }
108
+ let overlay = OpenGeniSelectionTextView(
109
+ paragraph: paragraph, text: text, frame: frame, sourceKey: key)
110
+ paragraph.addSubview(overlay)
111
+ active = overlay
112
+ overlay.begin(at: press.location(in: overlay))
113
+ return true
114
+ }
115
+
116
+ static func attributedText(of paragraph: UIView) -> NSAttributedString? {
117
+ guard paragraph.responds(to: NSSelectorFromString("attributedText")) else { return nil }
118
+ return paragraph.value(forKey: "attributedText") as? NSAttributedString
119
+ }
120
+
121
+ /// Where React Native draws the paragraph's text, in its own coordinates:
122
+ /// the drawing frame of its content view where React Native exposes one,
123
+ /// else the content view's bounds (text without padding or borders).
124
+ private static func textFrame(of paragraph: UIView) -> CGRect? {
125
+ guard paragraph.responds(to: NSSelectorFromString("contentView")),
126
+ let content = paragraph.value(forKey: "contentView") as? UIView
127
+ else { return nil }
128
+ if content.responds(to: NSSelectorFromString("drawingFrame")),
129
+ let drawing = (content.value(forKey: "drawingFrame") as? NSValue)?.cgRectValue
130
+ {
131
+ return drawing.offsetBy(dx: content.frame.minX, dy: content.frame.minY)
132
+ }
133
+ return content.frame
134
+ }
135
+
136
+ func annotate(key: String, quote: String, before: String, after: String) {
137
+ emit?(["sourceKey": key, "quote": quote, "before": before, "after": after])
138
+ }
139
+
140
+ var canAnnotate: Bool { emit != nil && annotateTitle != nil }
141
+
142
+ // MARK: Quote anchors
143
+
144
+ func quoteAnchors(sourceKey: String, queries: [[String: String]]) -> [[String: Any]] {
145
+ let none = queries.map { _ in [String: Any]() }
146
+ guard let source = Self.sourceView(identifier: Self.sourcePrefix + sourceKey) else { return none }
147
+ let paragraphs = Self.paragraphs(in: source).compactMap { paragraph -> MeasuredParagraph? in
148
+ guard let text = Self.attributedText(of: paragraph), text.length > 0,
149
+ let frame = Self.textFrame(of: paragraph), frame.width > 0, frame.height > 0
150
+ else { return nil }
151
+ return MeasuredParagraph(view: paragraph, text: text, frame: frame)
152
+ }
153
+ return queries.map { query in
154
+ guard let quote = query["quote"].map(Self.trimmedQuote), !quote.isEmpty else { return [:] }
155
+ var best: (paragraph: MeasuredParagraph, range: NSRange, score: Int)?
156
+ for paragraph in paragraphs {
157
+ let string = paragraph.text.string as NSString
158
+ var search = NSRange(location: 0, length: string.length)
159
+ while search.length > 0 {
160
+ let found = string.range(of: quote, options: [], range: search)
161
+ if found.location == NSNotFound { break }
162
+ let score =
163
+ Self.commonSuffix(string.substring(to: found.location), query["before"] ?? "")
164
+ + Self.commonPrefix(string.substring(from: NSMaxRange(found)), query["after"] ?? "")
165
+ if best == nil || score > best!.score { best = (paragraph, found, score) }
166
+ let next = found.location + 1
167
+ search = NSRange(location: next, length: string.length - next)
168
+ }
169
+ }
170
+ guard let best else { return [:] }
171
+ return best.paragraph.anchor(for: best.range, in: source) ?? [:]
172
+ }
173
+ }
174
+
175
+ /// A quote's visible text: surrounding whitespace has no glyph to anchor to.
176
+ private static func trimmedQuote(_ quote: String) -> String {
177
+ quote.trimmingCharacters(in: .whitespacesAndNewlines)
178
+ }
179
+
180
+ private static func commonSuffix(_ a: String, _ b: String) -> Int {
181
+ zip(a.reversed(), b.reversed()).prefix { $0 == $1 }.count
182
+ }
183
+
184
+ private static func commonPrefix(_ a: String, _ b: String) -> Int {
185
+ zip(a, b).prefix { $0 == $1 }.count
186
+ }
187
+
188
+ private static func sourceView(identifier: String) -> UIView? {
189
+ let windows = UIApplication.shared.connectedScenes
190
+ .compactMap { $0 as? UIWindowScene }
191
+ .flatMap(\.windows)
192
+ for window in windows {
193
+ if let view = find(in: window, where: { $0.accessibilityIdentifier == identifier }) {
194
+ return view
195
+ }
196
+ }
197
+ return nil
198
+ }
199
+
200
+ private static func find(in view: UIView, where match: (UIView) -> Bool) -> UIView? {
201
+ if match(view) { return view }
202
+ for child in view.subviews {
203
+ if let found = find(in: child, where: match) { return found }
204
+ }
205
+ return nil
206
+ }
207
+
208
+ /// The paragraphs drawn inside a source, in document order.
209
+ private static func paragraphs(in view: UIView) -> [UIView] {
210
+ guard let cls = NSClassFromString("RCTParagraphComponentView") else { return [] }
211
+ var found: [UIView] = []
212
+ func walk(_ current: UIView) {
213
+ if current.isHidden { return }
214
+ if current.isKind(of: cls) {
215
+ found.append(current)
216
+ return
217
+ }
218
+ current.subviews.forEach(walk)
219
+ }
220
+ walk(view)
221
+ return found
222
+ }
223
+ }
224
+
225
+ /// A paragraph laid out the way React Native drew it, for measuring.
226
+ private struct MeasuredParagraph {
227
+ let view: UIView
228
+ let text: NSAttributedString
229
+ let frame: CGRect
230
+
231
+ func anchor(for range: NSRange, in source: UIView) -> [String: Any]? {
232
+ let end = NSMaxRange(range)
233
+ guard range.length > 0 else { return nil }
234
+ let layout = OpenGeniTextLayout(text: text, size: frame.size)
235
+ let manager = layout.manager
236
+ let storage = layout.storage
237
+ let glyphs = manager.glyphRange(
238
+ forCharacterRange: NSRange(location: end - 1, length: 1), actualCharacterRange: nil)
239
+ guard glyphs.length > 0 else { return nil }
240
+ let box = manager.boundingRect(forGlyphRange: glyphs, in: layout.container)
241
+ let (top, _) = textBox(glyph: glyphs.location, manager: manager, storage: storage)
242
+ let point = convert(CGPoint(x: box.maxX, y: top), to: source)
243
+ // One box per line, from the top of its text to the bottom of its descenders.
244
+ var rects: [Double] = []
245
+ let all = manager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
246
+ manager.enumerateLineFragments(forGlyphRange: all) { _, _, _, lineGlyphs, _ in
247
+ let part = NSIntersectionRange(all, lineGlyphs)
248
+ guard part.length > 0 else { return }
249
+ let bounds = manager.boundingRect(forGlyphRange: part, in: layout.container)
250
+ let (lineTop, lineBottom) = self.textBox(
251
+ glyph: part.location, manager: manager, storage: storage)
252
+ let origin = self.convert(CGPoint(x: bounds.minX, y: lineTop), to: source)
253
+ rects += [
254
+ Double(origin.x), Double(origin.y), Double(bounds.width), Double(lineBottom - lineTop),
255
+ ]
256
+ }
257
+ return ["x": Double(point.x), "top": Double(point.y), "rects": rects]
258
+ }
259
+
260
+ /// The top of the text and the bottom of its descenders on a glyph's line.
261
+ /// The glyph location already includes the baseline offset.
262
+ private func textBox(glyph: Int, manager: NSLayoutManager, storage: NSTextStorage)
263
+ -> (CGFloat, CGFloat)
264
+ {
265
+ let line = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
266
+ let baseline = line.minY + manager.location(forGlyphAt: glyph).y
267
+ let character = manager.characterIndexForGlyph(at: glyph)
268
+ let font = storage.attribute(.font, at: character, effectiveRange: nil) as? UIFont
269
+ let ascender = font?.ascender ?? line.height * 0.75
270
+ let descender = font?.descender ?? -line.height * 0.25
271
+ return (baseline - ascender, baseline - descender)
272
+ }
273
+
274
+ private func convert(_ point: CGPoint, to source: UIView) -> CGPoint {
275
+ view.convert(CGPoint(x: frame.minX + point.x, y: frame.minY + point.y), to: source)
276
+ }
277
+ }
278
+
279
+ /// TextKit 1 laid out like React Native lays out a paragraph: the same
280
+ /// attributed string, no line fragment padding, no font leading, clipping line
281
+ /// breaks and React Native's draw-time baseline offset.
282
+ final class OpenGeniTextLayout {
283
+ let storage: NSTextStorage
284
+ let manager: NSLayoutManager
285
+ let container: NSTextContainer
286
+
287
+ init(text: NSAttributedString, size: CGSize) {
288
+ storage = NSTextStorage(attributedString: Self.invisibleCopy(of: text))
289
+ manager = NSLayoutManager()
290
+ manager.usesFontLeading = false
291
+ storage.addLayoutManager(manager)
292
+ container = NSTextContainer(size: size)
293
+ container.lineFragmentPadding = 0
294
+ container.lineBreakMode = .byClipping
295
+ container.widthTracksTextView = false
296
+ container.heightTracksTextView = false
297
+ manager.addTextContainer(container)
298
+ }
299
+
300
+ /// React Native draws the text; this copy only carries layout, so every
301
+ /// drawn attribute is cleared. The baseline offset React Native applies at
302
+ /// draw time is applied here too (RCTApplyBaselineOffset).
303
+ static func invisibleCopy(of text: NSAttributedString) -> NSAttributedString {
304
+ let copy = NSMutableAttributedString(attributedString: text)
305
+ let all = NSRange(location: 0, length: copy.length)
306
+ for key: NSAttributedString.Key in [
307
+ .backgroundColor, .underlineStyle, .strikethroughStyle, .link, .shadow, .strokeColor,
308
+ ] {
309
+ copy.removeAttribute(key, range: all)
310
+ }
311
+ copy.addAttribute(.foregroundColor, value: UIColor.clear, range: all)
312
+ var maximumLineHeight: CGFloat = 0
313
+ copy.enumerateAttribute(.paragraphStyle, in: all) { value, _, _ in
314
+ if let style = value as? NSParagraphStyle {
315
+ maximumLineHeight = max(maximumLineHeight, style.maximumLineHeight)
316
+ }
317
+ }
318
+ guard maximumLineHeight > 0 else { return copy }
319
+ var maximumFontLineHeight: CGFloat = 0
320
+ copy.enumerateAttribute(.font, in: all) { value, _, _ in
321
+ if let font = value as? UIFont {
322
+ maximumFontLineHeight = max(maximumFontLineHeight, font.lineHeight)
323
+ }
324
+ }
325
+ guard maximumLineHeight >= maximumFontLineHeight else { return copy }
326
+ copy.addAttribute(
327
+ .baselineOffset, value: (maximumLineHeight - maximumFontLineHeight) / 2, range: all)
328
+ return copy
329
+ }
330
+ }
331
+
332
+ /// A transparent TextKit 1 text view laid out exactly like React Native lays
333
+ /// out the paragraph underneath (same attributed string, line fragment padding,
334
+ /// font leading and baseline offset), so the selection highlight and handles
335
+ /// sit on the glyphs React Native drew. It exists only while a selection does.
336
+ final class OpenGeniSelectionTextView: UITextView, UITextViewDelegate, UIGestureRecognizerDelegate {
337
+ weak var paragraph: UIView?
338
+ private let layout: OpenGeniTextLayout
339
+ private var storage: NSTextStorage { layout.storage }
340
+ private let sourceKey: String
341
+ private let snapshot: String
342
+ private var outsideTap: UITapGestureRecognizer?
343
+ private var watcher: Timer?
344
+ private var dismissed = false
345
+ /// Presents the menu after the long press. The text view's own edit menu
346
+ /// is private and only appears for its own gestures (dragging a handle).
347
+ private var editMenu: AnyObject?
348
+
349
+ init(paragraph: UIView, text: NSAttributedString, frame: CGRect, sourceKey: String) {
350
+ self.paragraph = paragraph
351
+ self.sourceKey = sourceKey
352
+ snapshot = text.string
353
+ layout = OpenGeniTextLayout(text: text, size: frame.size)
354
+ super.init(frame: frame, textContainer: layout.container)
355
+ backgroundColor = .clear
356
+ isEditable = false
357
+ isSelectable = true
358
+ isScrollEnabled = false
359
+ textContainerInset = .zero
360
+ dataDetectorTypes = []
361
+ clipsToBounds = false
362
+ delegate = self
363
+ accessibilityElementsHidden = true
364
+ if #available(iOS 16.0, *) {
365
+ let interaction = UIEditMenuInteraction(delegate: self)
366
+ addInteraction(interaction)
367
+ editMenu = interaction
368
+ }
369
+ }
370
+
371
+ @available(*, unavailable)
372
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
373
+
374
+ /// Selects the word at `point`; the edit menu follows when the finger lifts.
375
+ func begin(at point: CGPoint) {
376
+ _ = becomeFirstResponder()
377
+ selectedRange = wordRange(at: point)
378
+ installOutsideTap()
379
+ // A streaming message or a recycled view changes the text underneath;
380
+ // the selection then no longer matches what is drawn.
381
+ watcher = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in
382
+ guard let self else { return }
383
+ let current = self.paragraph.flatMap(OpenGeniTextSelection.attributedText(of:))?.string
384
+ if self.window == nil || current != self.snapshot { self.dismiss() }
385
+ }
386
+ }
387
+
388
+ private func wordRange(at point: CGPoint) -> NSRange {
389
+ let string = storage.string as NSString
390
+ guard string.length > 0 else { return NSRange(location: 0, length: 0) }
391
+ var fraction: CGFloat = 0
392
+ let index = min(
393
+ layoutManager.characterIndex(
394
+ for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &fraction),
395
+ string.length - 1)
396
+ var found: NSRange?
397
+ var nearest: NSRange?
398
+ string.enumerateSubstrings(
399
+ in: NSRange(location: 0, length: string.length), options: .byWords
400
+ ) { _, range, _, stop in
401
+ if NSLocationInRange(index, range) {
402
+ found = range
403
+ stop.pointee = true
404
+ return
405
+ }
406
+ if range.location > index {
407
+ if nearest == nil { nearest = range }
408
+ stop.pointee = true
409
+ return
410
+ }
411
+ nearest = range
412
+ }
413
+ if let range = found ?? nearest { return range }
414
+ return NSRange(location: 0, length: string.length)
415
+ }
416
+
417
+ func showMenu() {
418
+ guard !dismissed, selectedRange.length > 0, let range = selectedTextRange else { return }
419
+ let rect = firstRect(for: range)
420
+ if #available(iOS 16.0, *), let interaction = editMenu as? UIEditMenuInteraction {
421
+ let configuration = UIEditMenuConfiguration(
422
+ identifier: nil, sourcePoint: CGPoint(x: rect.midX, y: rect.minY))
423
+ // Above the selection, like the system text views, unless it lacks room.
424
+ configuration.preferredArrowDirection = .down
425
+ interaction.presentEditMenu(with: configuration)
426
+ } else {
427
+ UIMenuController.shared.showMenu(from: self, rect: rect)
428
+ }
429
+ }
430
+
431
+ private func installOutsideTap() {
432
+ guard let window else { return }
433
+ let tap = UITapGestureRecognizer(target: self, action: #selector(outsideTapped))
434
+ tap.cancelsTouchesInView = false
435
+ tap.delegate = self
436
+ window.addGestureRecognizer(tap)
437
+ outsideTap = tap
438
+ }
439
+
440
+ @objc private func outsideTapped() { dismiss() }
441
+
442
+ func gestureRecognizer(
443
+ _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch
444
+ ) -> Bool {
445
+ guard gestureRecognizer === outsideTap else { return true }
446
+ guard let view = touch.view else { return true }
447
+ // Taps on the selection or its edit menu keep the selection; the menu's
448
+ // actions (Copy, Add note) run after this recognizer.
449
+ return !view.isDescendant(of: self) && !Self.isEditMenu(view)
450
+ }
451
+
452
+ private static func isEditMenu(_ view: UIView) -> Bool {
453
+ var current: UIView? = view
454
+ while let candidate = current {
455
+ let name = NSStringFromClass(type(of: candidate))
456
+ if name.contains("EditMenu") || name.contains("Callout") || name.contains("ContextMenu") {
457
+ return true
458
+ }
459
+ current = candidate.superview
460
+ }
461
+ return false
462
+ }
463
+
464
+ func gestureRecognizer(
465
+ _ gestureRecognizer: UIGestureRecognizer,
466
+ shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
467
+ ) -> Bool {
468
+ gestureRecognizer === outsideTap
469
+ }
470
+
471
+ func dismiss() {
472
+ guard !dismissed else { return }
473
+ dismissed = true
474
+ watcher?.invalidate()
475
+ watcher = nil
476
+ if let outsideTap { outsideTap.view?.removeGestureRecognizer(outsideTap) }
477
+ outsideTap = nil
478
+ selectedRange = NSRange(location: 0, length: 0)
479
+ if isFirstResponder { _ = super.resignFirstResponder() }
480
+ removeFromSuperview()
481
+ }
482
+
483
+ override func resignFirstResponder() -> Bool {
484
+ let resigned = super.resignFirstResponder()
485
+ if resigned { DispatchQueue.main.async { [weak self] in self?.dismiss() } }
486
+ return resigned
487
+ }
488
+
489
+ func textViewDidChangeSelection(_ textView: UITextView) {
490
+ guard !dismissed, selectedRange.length == 0 else { return }
491
+ DispatchQueue.main.async { [weak self] in
492
+ guard let self, self.selectedRange.length == 0 else { return }
493
+ self.dismiss()
494
+ }
495
+ }
496
+
497
+ private var selectedText: String {
498
+ (storage.string as NSString).substring(with: selectedRange)
499
+ }
500
+
501
+ /// The copy carries invisible colors; copy what React Native shows instead.
502
+ override func copy(_ sender: Any?) {
503
+ guard selectedRange.length > 0 else { return }
504
+ UIPasteboard.general.string = selectedText
505
+ }
506
+
507
+ @available(iOS 16.0, *)
508
+ func textView(
509
+ _ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]
510
+ ) -> UIMenu? {
511
+ menu(with: suggestedActions)
512
+ }
513
+
514
+ /// The system actions, led by Add note where this text can be annotated.
515
+ fileprivate func menu(with suggestedActions: [UIMenuElement]) -> UIMenu {
516
+ let selection = OpenGeniTextSelection.shared
517
+ guard !sourceKey.isEmpty, selection.canAnnotate, let title = selection.annotateTitle else {
518
+ return UIMenu(children: suggestedActions)
519
+ }
520
+ // The quote is read now: the action runs after the menu closes, when the
521
+ // selection may already be gone.
522
+ guard let quote = selectedQuote() else { return UIMenu(children: suggestedActions) }
523
+ let key = sourceKey
524
+ let annotate = UIAction(title: title, image: UIImage(systemName: "text.quote")) {
525
+ [weak self] _ in
526
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
527
+ OpenGeniTextSelection.shared.annotate(
528
+ key: key, quote: quote.quote, before: quote.before, after: quote.after)
529
+ self?.dismiss()
530
+ }
531
+ return UIMenu(children: [annotate] + suggestedActions)
532
+ }
533
+
534
+ private func selectedQuote() -> (quote: String, before: String, after: String)? {
535
+ let string = storage.string as NSString
536
+ let range = selectedRange
537
+ guard range.length > 0, NSMaxRange(range) <= string.length else { return nil }
538
+ return (
539
+ string.substring(with: range), string.substring(to: range.location),
540
+ string.substring(from: NSMaxRange(range))
541
+ )
542
+ }
543
+ }
544
+
545
+ @available(iOS 16.0, *)
546
+ extension OpenGeniSelectionTextView: UIEditMenuInteractionDelegate {
547
+ func editMenuInteraction(
548
+ _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
549
+ suggestedActions: [UIMenuElement]
550
+ ) -> UIMenu? {
551
+ menu(with: suggestedActions)
552
+ }
553
+
554
+ func editMenuInteraction(
555
+ _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration
556
+ ) -> CGRect {
557
+ guard let range = selectedTextRange else { return .null }
558
+ return firstRect(for: range)
559
+ }
560
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react-native",
3
- "version": "4.2.1-canary.39642758903001",
3
+ "version": "4.3.1",
4
4
  "description": "React Native and Expo kit for Opengeni agents: the native session timeline, composer adapters, voice dictation, and realtime voice calls (CallKit) on the same SDK controllers as the web.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -12,6 +12,8 @@
12
12
  "dist",
13
13
  "src",
14
14
  "ios",
15
+ "android/build.gradle",
16
+ "android/src",
15
17
  "expo-module.config.json",
16
18
  "README.md",
17
19
  "app.plugin.js",
@@ -66,9 +68,9 @@
66
68
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
67
69
  },
68
70
  "dependencies": {
69
- "@opengeni/contracts": "4.2.1-canary.39642758903001",
70
- "@opengeni/react": "4.2.1-canary.39642758903001",
71
- "@opengeni/sdk": "4.2.1-canary.39642758903001",
71
+ "@opengeni/contracts": "4.3.1",
72
+ "@opengeni/react": "4.3.1",
73
+ "@opengeni/sdk": "4.3.1",
72
74
  "thinking-orbs": "^0.3.1"
73
75
  },
74
76
  "peerDependencies": {
@@ -40,6 +40,7 @@ import {
40
40
  } from "./primitives";
41
41
  import { fontStyle, useNativeTimelineTheme } from "./theme";
42
42
  import { useNativeTimelineMessages } from "./messages";
43
+ import { annotationSourceTestId } from "./text-selection";
43
44
 
44
45
  /* ----------------------------------------------------------------------------
45
46
  Native activity rows. Tool rows draw from the shared presentation model
@@ -519,20 +520,23 @@ function ReasoningRow({ item, compact }: { item: ReasoningItem; compact: boolean
519
520
  accessibilityLabel={item.streaming ? m.thinking : m.thought}
520
521
  preview={plain || undefined}
521
522
  >
522
- {options.renderMarkdown ? (
523
- options.renderMarkdown(item.text, { tone: "muted", streaming: item.streaming })
524
- ) : (
525
- <Text
526
- style={{
527
- ...fontStyle(theme),
528
- fontSize: theme.size.base,
529
- lineHeight: 24,
530
- color: theme.colors["fg-muted"],
531
- }}
532
- >
533
- {item.text}
534
- </Text>
535
- )}
523
+ <View testID={annotationSourceTestId(undefined)} collapsable={false}>
524
+ {options.renderMarkdown ? (
525
+ options.renderMarkdown(item.text, { tone: "muted", streaming: item.streaming })
526
+ ) : (
527
+ <Text
528
+ selectable
529
+ style={{
530
+ ...fontStyle(theme),
531
+ fontSize: theme.size.base,
532
+ lineHeight: 24,
533
+ color: theme.colors["fg-muted"],
534
+ }}
535
+ >
536
+ {item.text}
537
+ </Text>
538
+ )}
539
+ </View>
536
540
  </ActivityDisclosure>
537
541
  );
538
542
  }
@@ -717,11 +721,16 @@ function ActivityNoteRow({ item }: { item: AgentMessageItem }) {
717
721
  <View style={{ marginTop: 4 }}>
718
722
  <Icon name="message-square-text" size={14} color={theme.colors["fg-subtle"]} />
719
723
  </View>
720
- <View style={{ flex: 1, minWidth: 0 }}>
724
+ <View
725
+ testID={annotationSourceTestId(item.annotationSource?.eventId)}
726
+ collapsable={false}
727
+ style={{ flex: 1, minWidth: 0 }}
728
+ >
721
729
  {options.renderMarkdown ? (
722
730
  options.renderMarkdown(item.text, { tone: "muted", streaming: item.streaming })
723
731
  ) : (
724
732
  <Text
733
+ selectable
725
734
  style={{
726
735
  ...fontStyle(theme),
727
736
  fontSize: theme.size.sm,