@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,877 @@
1
+ import type {
2
+ DraftTimelineAnnotation,
3
+ TimelineAnnotation,
4
+ TimelineAnnotationSource,
5
+ } from "@opengeni/sdk";
6
+ import {
7
+ ANNOTATION_MAX_NOTE_LENGTH,
8
+ annotationDisplayOrdinal,
9
+ annotationHasNote,
10
+ annotationNoteNeedsDisclosure,
11
+ truncateAnnotationQuote,
12
+ } from "@opengeni/react/timeline-model";
13
+ import { useOpenGeniText } from "@opengeni/react/messages";
14
+ import {
15
+ createContext,
16
+ useCallback,
17
+ useContext,
18
+ useEffect,
19
+ useRef,
20
+ useState,
21
+ type ComponentRef,
22
+ type ReactNode,
23
+ } from "react";
24
+ import {
25
+ Animated,
26
+ Easing,
27
+ Pressable,
28
+ ScrollView,
29
+ Text,
30
+ TextInput,
31
+ View,
32
+ type StyleProp,
33
+ type ViewStyle,
34
+ } from "react-native";
35
+ import { Icon } from "./icon";
36
+ import { withAlpha } from "./primitives";
37
+ import { BottomSheet } from "./sheet";
38
+ import {
39
+ annotationSourceTestId,
40
+ measureQuoteAnchors,
41
+ type QuoteAnchor,
42
+ type QuoteAnchorQuery,
43
+ type QuoteBox,
44
+ } from "./text-selection";
45
+ import { fontStyle, useNativeTimelineTheme } from "./theme";
46
+
47
+ /* ----------------------------------------------------------------------------
48
+ Timeline annotations at phone width: the quote cards in sent messages, the
49
+ composer and queue chips, the review sheet where notes are written (the
50
+ web's annotations dialog) and the numbered badge where a draft quote ends.
51
+ -------------------------------------------------------------------------- */
52
+
53
+ export type NativeAnnotationLike = DraftTimelineAnnotation | TimelineAnnotation;
54
+
55
+ /**
56
+ * Reveals a quote in the timeline: scrolls its words into view and flashes
57
+ * them (the whole message when they can't be located). False when the
58
+ * message is not loaded.
59
+ */
60
+ export type AnnotationReveal = (
61
+ source: TimelineAnnotationSource,
62
+ quote?: QuoteAnchorQuery,
63
+ ) => boolean;
64
+
65
+ /** The text a revealed quote is found by. */
66
+ export function annotationQuoteQuery(annotation: NativeAnnotationLike): QuoteAnchorQuery {
67
+ return {
68
+ quote: annotation.quote,
69
+ before: annotation.source.contextBefore,
70
+ after: annotation.source.contextAfter,
71
+ };
72
+ }
73
+
74
+ type DraftMarker = {
75
+ id: string;
76
+ ordinal: number;
77
+ incomplete: boolean;
78
+ quote: string;
79
+ before: string;
80
+ after: string;
81
+ };
82
+
83
+ export type AnnotationSourceNode = ComponentRef<typeof View>;
84
+
85
+ type AnnotationSourcesValue = {
86
+ register: (sourceKey: string, node: AnnotationSourceNode | null) => void;
87
+ flash: AnnotationFlash | null;
88
+ drafts: ReadonlyMap<string, readonly DraftMarker[]>;
89
+ onSelectDraft?: ((id: string) => void) | undefined;
90
+ };
91
+
92
+ /** A reveal's highlight: one message, and the quote in it when known. */
93
+ export type AnnotationFlash = { sourceKey: string; token: number; quote?: QuoteAnchorQuery };
94
+
95
+ export const AnnotationSourcesContext = createContext<AnnotationSourcesValue | null>(null);
96
+
97
+ /** Draft annotations grouped by the message they quote. */
98
+ export function draftMarkersBySource(
99
+ annotations: readonly DraftTimelineAnnotation[] | undefined,
100
+ ): Map<string, DraftMarker[]> {
101
+ const markers = new Map<string, DraftMarker[]>();
102
+ (annotations ?? []).forEach((annotation, index) => {
103
+ const list = markers.get(annotation.source.eventId) ?? [];
104
+ list.push({
105
+ id: annotation.id,
106
+ ordinal: index + 1,
107
+ incomplete: !annotationHasNote(annotation.note),
108
+ quote: annotation.quote,
109
+ before: annotation.source.contextBefore,
110
+ after: annotation.source.contextAfter,
111
+ });
112
+ markers.set(annotation.source.eventId, list);
113
+ });
114
+ return markers;
115
+ }
116
+
117
+ function quotePreview(quote: string): string {
118
+ const trimmed = quote.trim();
119
+ return trimmed.includes("\n") ? trimmed : trimmed.replace(/\s+/g, " ");
120
+ }
121
+
122
+ /**
123
+ * A message body that can be quoted: marks its text for native selection
124
+ * (Add note), shows a numbered badge where each draft quote ends (the web's
125
+ * annotation markers) and briefly highlights when a quote of it is revealed.
126
+ */
127
+ export function AnnotationSourceView({
128
+ sourceKey,
129
+ children,
130
+ style,
131
+ }: {
132
+ sourceKey: string | undefined;
133
+ children: ReactNode;
134
+ style?: StyleProp<ViewStyle>;
135
+ }) {
136
+ const theme = useNativeTimelineTheme();
137
+ const sources = useContext(AnnotationSourcesContext);
138
+ const glow = useRef(new Animated.Value(0)).current;
139
+ const flash = sourceKey && sources?.flash?.sourceKey === sourceKey ? sources.flash : null;
140
+ // The revealed quote's lines, or null to flash the whole message.
141
+ const [flashBoxes, setFlashBoxes] = useState<QuoteBox[] | null>(null);
142
+ useEffect(() => {
143
+ if (!flash || !sourceKey) return;
144
+ let cancelled = false;
145
+ const start = (boxes: QuoteBox[] | null) => {
146
+ if (cancelled) return;
147
+ setFlashBoxes(boxes);
148
+ glow.setValue(1);
149
+ Animated.timing(glow, {
150
+ toValue: 0,
151
+ duration: boxes ? 1800 : 1400,
152
+ delay: 300,
153
+ easing: Easing.out(Easing.quad),
154
+ useNativeDriver: false,
155
+ }).start();
156
+ };
157
+ if (!flash.quote) start(null);
158
+ else
159
+ void measureQuoteAnchors(sourceKey, [flash.quote]).then(([anchor]) =>
160
+ start(anchor?.boxes.length ? anchor.boxes : null),
161
+ );
162
+ return () => {
163
+ cancelled = true;
164
+ };
165
+ }, [flash, glow, sourceKey]);
166
+ const register = sources?.register;
167
+ const markers = sourceKey ? sources?.drafts.get(sourceKey) : undefined;
168
+ const [anchors, setAnchors] = useState<{
169
+ markers: readonly DraftMarker[];
170
+ at: (QuoteAnchor | null)[];
171
+ } | null>(null);
172
+ const [size, setSize] = useState({ width: 0, height: 0 });
173
+ const measure = useCallback(() => {
174
+ if (!sourceKey || !markers?.length) return;
175
+ const queries = markers.map(({ quote, before, after }) => ({ quote, before, after }));
176
+ void measureQuoteAnchors(sourceKey, queries).then((at) => setAnchors({ markers, at }));
177
+ }, [markers, sourceKey]);
178
+ // Text lays out after the view; measure once it has drawn, and again when
179
+ // the drafts or the width change.
180
+ useEffect(() => {
181
+ if (!markers?.length) {
182
+ setAnchors(null);
183
+ return;
184
+ }
185
+ const frame = requestAnimationFrame(measure);
186
+ return () => cancelAnimationFrame(frame);
187
+ }, [markers, measure, size.width, size.height]);
188
+ return (
189
+ <Animated.View
190
+ ref={(node: AnnotationSourceNode | null) => {
191
+ if (sourceKey && register) register(sourceKey, node);
192
+ }}
193
+ testID={annotationSourceTestId(sourceKey)}
194
+ collapsable={false}
195
+ onLayout={(event) => {
196
+ const { width, height } = event.nativeEvent.layout;
197
+ setSize((current) =>
198
+ current.width === width && current.height === height ? current : { width, height },
199
+ );
200
+ }}
201
+ style={[
202
+ style,
203
+ {
204
+ borderRadius: theme.radius.md,
205
+ backgroundColor: flashBoxes
206
+ ? "transparent"
207
+ : glow.interpolate({
208
+ inputRange: [0, 1],
209
+ outputRange: [withAlpha(theme.colors.fg, 0), withAlpha(theme.colors.fg, 0.08)],
210
+ }),
211
+ },
212
+ ]}
213
+ >
214
+ {flashBoxes ? <QuoteFlash boxes={flashBoxes} glow={glow} /> : null}
215
+ {markers?.length && anchors?.markers === markers ? (
216
+ <QuoteHighlights anchors={anchors.at} />
217
+ ) : null}
218
+ {children}
219
+ {markers?.length && anchors?.markers === markers ? (
220
+ <QuoteBadges
221
+ markers={markers}
222
+ anchors={anchors.at}
223
+ size={size}
224
+ onSelect={sources?.onSelectDraft}
225
+ />
226
+ ) : null}
227
+ </Animated.View>
228
+ );
229
+ }
230
+
231
+ const BADGE_SIZE = 15;
232
+
233
+ /** A line box's identity: where it sits (two quotes never share a line box). */
234
+ function boxKey(box: QuoteBox): string {
235
+ return `${Math.round(box.x)}:${Math.round(box.y)}:${Math.round(box.width)}`;
236
+ }
237
+
238
+ /** A revealed quote's lines light up, then fade, under its text. */
239
+ function QuoteFlash({ boxes, glow }: { boxes: readonly QuoteBox[]; glow: Animated.Value }) {
240
+ const theme = useNativeTimelineTheme();
241
+ const tint = withAlpha(theme.colors.accent, theme.scheme === "dark" ? 0.45 : 0.28);
242
+ return (
243
+ <>
244
+ {boxes.map((box) => (
245
+ <Animated.View
246
+ key={boxKey(box)}
247
+ pointerEvents="none"
248
+ style={{
249
+ position: "absolute",
250
+ left: box.x - 3,
251
+ top: box.y - 2,
252
+ width: box.width + 6,
253
+ height: box.height + 4,
254
+ borderRadius: 5,
255
+ backgroundColor: tint,
256
+ opacity: glow,
257
+ }}
258
+ />
259
+ ))}
260
+ </>
261
+ );
262
+ }
263
+
264
+ /**
265
+ * A soft tint with an accent underline behind each draft quote, so what is
266
+ * quoted reads at a glance and never looks like an inline code span. Drawn
267
+ * before the message, under its text.
268
+ */
269
+ function QuoteHighlights({ anchors }: { anchors: readonly (QuoteAnchor | null)[] }) {
270
+ const theme = useNativeTimelineTheme();
271
+ const dark = theme.scheme === "dark";
272
+ const tint = withAlpha(theme.colors.accent, dark ? 0.16 : 0.08);
273
+ const rule = withAlpha(theme.colors.accent, dark ? 0.7 : 0.55);
274
+ return (
275
+ <>
276
+ {anchors.flatMap((anchor) =>
277
+ (anchor?.boxes ?? []).map((box) => (
278
+ <View
279
+ key={boxKey(box)}
280
+ pointerEvents="none"
281
+ style={{
282
+ position: "absolute",
283
+ left: box.x - 2,
284
+ top: box.y - 1,
285
+ width: box.width + 4,
286
+ height: box.height + 2,
287
+ borderTopLeftRadius: 3,
288
+ borderTopRightRadius: 3,
289
+ borderBottomWidth: 1.5,
290
+ borderBottomColor: rule,
291
+ backgroundColor: tint,
292
+ }}
293
+ />
294
+ )),
295
+ )}
296
+ </>
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Numbered badges at the top-right corner of each quote, mostly in the gap
302
+ * above its line so they cover no text (the web's markers sit on the quote's
303
+ * end the same way). A quote that cannot be located (text that changed, or
304
+ * no native measuring) gets its badge at the end of the message instead.
305
+ */
306
+ function QuoteBadges({
307
+ markers,
308
+ anchors,
309
+ size,
310
+ onSelect,
311
+ }: {
312
+ markers: readonly DraftMarker[];
313
+ anchors: readonly (QuoteAnchor | null)[];
314
+ size: { width: number; height: number };
315
+ onSelect?: ((id: string) => void) | undefined;
316
+ }) {
317
+ const t = useOpenGeniText();
318
+ const theme = useNativeTimelineTheme();
319
+ const c = theme.colors;
320
+ let unplaced = 0;
321
+ return (
322
+ <>
323
+ {markers.map((marker, index) => {
324
+ const anchor = anchors[index];
325
+ // Unplaced badges line up along the message's bottom edge.
326
+ const center = anchor
327
+ ? { x: anchor.x + 2, y: anchor.top - 1 }
328
+ : {
329
+ x: size.width - BADGE_SIZE / 2 - unplaced++ * (BADGE_SIZE + 4),
330
+ y: size.height - BADGE_SIZE / 2,
331
+ };
332
+ const left = Math.min(Math.max(center.x - BADGE_SIZE / 2, 0), size.width - BADGE_SIZE);
333
+ return (
334
+ <Pressable
335
+ key={marker.id}
336
+ accessibilityRole="button"
337
+ accessibilityLabel={t("timelineAnnotationMarkers.annotationOrdinal", {
338
+ ordinal: marker.ordinal,
339
+ })}
340
+ hitSlop={14}
341
+ disabled={!onSelect}
342
+ onPress={() => onSelect?.(marker.id)}
343
+ style={({ pressed }) => ({
344
+ position: "absolute",
345
+ left,
346
+ top: center.y - BADGE_SIZE / 2,
347
+ zIndex: marker.ordinal,
348
+ minWidth: BADGE_SIZE,
349
+ height: BADGE_SIZE,
350
+ paddingHorizontal: 2,
351
+ borderRadius: BADGE_SIZE / 2,
352
+ alignItems: "center",
353
+ justifyContent: "center",
354
+ backgroundColor: c.accent,
355
+ borderWidth: marker.incomplete ? 1 : 0,
356
+ borderColor: withAlpha(c["accent-fg"], 0.35),
357
+ opacity: pressed ? 0.7 : 1,
358
+ shadowColor: "#000",
359
+ shadowOpacity: 0.12,
360
+ shadowRadius: 2,
361
+ shadowOffset: { width: 0, height: 1 },
362
+ })}
363
+ >
364
+ <Text
365
+ style={{
366
+ ...fontStyle(theme, 600),
367
+ fontSize: 10,
368
+ lineHeight: 12,
369
+ color: c["accent-fg"],
370
+ fontVariant: ["tabular-nums"],
371
+ }}
372
+ >
373
+ {marker.ordinal}
374
+ </Text>
375
+ </Pressable>
376
+ );
377
+ })}
378
+ </>
379
+ );
380
+ }
381
+
382
+ /**
383
+ * A quote's accent rule beside its content. `shrink` sizes the content to
384
+ * itself (inside a bubble that wraps its content: a flex-basis-0 column there
385
+ * is measured unwrapped and its wrapped lines spill out); otherwise it fills
386
+ * the row.
387
+ */
388
+ function AccentRow({ children, shrink = false }: { children: ReactNode; shrink?: boolean }) {
389
+ const theme = useNativeTimelineTheme();
390
+ return (
391
+ <View style={{ flexDirection: "row", gap: 10 }}>
392
+ <View
393
+ style={{
394
+ width: 2,
395
+ borderRadius: 1,
396
+ backgroundColor: withAlpha(theme.colors.accent, 0.55),
397
+ }}
398
+ />
399
+ <View style={shrink ? { flexShrink: 1, minWidth: 0 } : { flex: 1, minWidth: 0 }}>
400
+ {children}
401
+ </View>
402
+ </View>
403
+ );
404
+ }
405
+
406
+ function OrdinalLabel({ ordinal }: { ordinal: number }) {
407
+ const t = useOpenGeniText();
408
+ const theme = useNativeTimelineTheme();
409
+ return (
410
+ <Text
411
+ style={{
412
+ ...fontStyle(theme, 500),
413
+ fontSize: theme.size.xs,
414
+ lineHeight: 16,
415
+ color: theme.colors["fg-subtle"],
416
+ fontVariant: ["tabular-nums"],
417
+ }}
418
+ >
419
+ {t("timelineAnnotations.annotationOrdinal", { ordinal })}
420
+ </Text>
421
+ );
422
+ }
423
+
424
+ function QuoteButton({
425
+ annotation,
426
+ onReveal,
427
+ }: {
428
+ annotation: NativeAnnotationLike;
429
+ onReveal?: AnnotationReveal | undefined;
430
+ }) {
431
+ const t = useOpenGeniText();
432
+ const theme = useNativeTimelineTheme();
433
+ const [unavailable, setUnavailable] = useState(false);
434
+ const text = (
435
+ <Text
436
+ numberOfLines={2}
437
+ style={{
438
+ ...fontStyle(theme),
439
+ fontSize: theme.size.sm,
440
+ lineHeight: 20,
441
+ color: theme.colors["fg-muted"],
442
+ }}
443
+ >
444
+ {quotePreview(annotation.quote)}
445
+ </Text>
446
+ );
447
+ if (!onReveal) return text;
448
+ return (
449
+ <View>
450
+ <Pressable
451
+ accessibilityRole="button"
452
+ accessibilityLabel={t(
453
+ "timelineAnnotationChrome.viewSourcelabelSourceTruncateannotationquote",
454
+ {
455
+ sourceLabel:
456
+ annotation.source.label?.trim() ||
457
+ (annotation.source.kind === "user_message"
458
+ ? t("timelineAnnotationShared.yourMessage")
459
+ : annotation.source.kind === "assistant_message"
460
+ ? t("timelineAnnotationShared.assistant")
461
+ : t("timelineAnnotationShared.toolOutput")),
462
+ truncateAnnotationQuote: truncateAnnotationQuote(annotation.quote, 88),
463
+ },
464
+ )}
465
+ onPress={() =>
466
+ setUnavailable(!onReveal(annotation.source, annotationQuoteQuery(annotation)))
467
+ }
468
+ style={({ pressed }) => ({ opacity: pressed ? 0.55 : 1, paddingVertical: 2 })}
469
+ >
470
+ {text}
471
+ </Pressable>
472
+ {unavailable ? (
473
+ <Text
474
+ accessibilityLiveRegion="polite"
475
+ style={{
476
+ ...fontStyle(theme),
477
+ fontSize: theme.size.xs,
478
+ lineHeight: 16,
479
+ color: theme.colors["status-waiting"],
480
+ }}
481
+ >
482
+ {t("timelineAnnotations.sourceIsOutsideTheLoaded")}
483
+ </Text>
484
+ ) : null}
485
+ </View>
486
+ );
487
+ }
488
+
489
+ function NotePreview({ note, ordinal }: { note: string; ordinal: number }) {
490
+ const t = useOpenGeniText();
491
+ const theme = useNativeTimelineTheme();
492
+ const [expanded, setExpanded] = useState(false);
493
+ if (!note) return null;
494
+ const collapsible = annotationNoteNeedsDisclosure(note);
495
+ return (
496
+ <View style={{ marginTop: 2 }}>
497
+ <Text
498
+ selectable
499
+ numberOfLines={expanded ? undefined : 4}
500
+ style={{
501
+ ...fontStyle(theme),
502
+ fontSize: theme.size.sm,
503
+ lineHeight: 20,
504
+ color: theme.colors.fg,
505
+ }}
506
+ >
507
+ {note}
508
+ </Text>
509
+ {collapsible ? (
510
+ <Pressable
511
+ accessibilityRole="button"
512
+ accessibilityLabel={
513
+ expanded
514
+ ? t("timelineAnnotationChrome.showLessAnnotationOrdinal", { ordinal })
515
+ : t("timelineAnnotationChrome.showMoreAnnotationOrdinal", { ordinal })
516
+ }
517
+ hitSlop={8}
518
+ onPress={() => setExpanded((current) => !current)}
519
+ style={{ alignSelf: "flex-start", paddingVertical: 4 }}
520
+ >
521
+ <Text
522
+ style={{
523
+ ...fontStyle(theme, 500),
524
+ fontSize: theme.size.xs,
525
+ color: theme.colors["fg-muted"],
526
+ }}
527
+ >
528
+ {expanded
529
+ ? t("timelineAnnotationChrome.showLess")
530
+ : t("timelineAnnotationChrome.showMore")}
531
+ </Text>
532
+ </Pressable>
533
+ ) : null}
534
+ </View>
535
+ );
536
+ }
537
+
538
+ /** The quotes and notes a sent message carries, inside its bubble. */
539
+ export function SentAnnotationCards({
540
+ annotations,
541
+ onReveal,
542
+ }: {
543
+ annotations: readonly NativeAnnotationLike[];
544
+ onReveal?: AnnotationReveal | undefined;
545
+ }) {
546
+ if (annotations.length === 0) return null;
547
+ return (
548
+ <View style={{ gap: 10 }}>
549
+ {annotations.map((annotation, index) => {
550
+ const ordinal = annotationDisplayOrdinal(annotation, index);
551
+ return (
552
+ <AccentRow key={annotation.id} shrink>
553
+ <OrdinalLabel ordinal={ordinal} />
554
+ <QuoteButton annotation={annotation} onReveal={onReveal} />
555
+ <NotePreview note={annotation.note} ordinal={ordinal} />
556
+ </AccentRow>
557
+ );
558
+ })}
559
+ </View>
560
+ );
561
+ }
562
+
563
+ /**
564
+ * The "N annotations" chip: in the composer (editable, with remove all) or on
565
+ * a queued message (compact). Tinted while a quote still needs its note.
566
+ */
567
+ export function AnnotationsChip({
568
+ annotations,
569
+ onPress,
570
+ onClear,
571
+ compact = false,
572
+ }: {
573
+ annotations: readonly NativeAnnotationLike[];
574
+ onPress: () => void;
575
+ onClear?: (() => void) | undefined;
576
+ compact?: boolean | undefined;
577
+ }) {
578
+ const t = useOpenGeniText();
579
+ const theme = useNativeTimelineTheme();
580
+ const c = theme.colors;
581
+ if (annotations.length === 0) return null;
582
+ const countLabel = t("counts.annotations", { count: annotations.length });
583
+ const incomplete =
584
+ onClear !== undefined && annotations.some((item) => !annotationHasNote(item.note));
585
+ const height = compact ? 26 : 32;
586
+ return (
587
+ <View
588
+ style={{
589
+ flexDirection: "row",
590
+ alignItems: "center",
591
+ alignSelf: "flex-start",
592
+ height,
593
+ borderRadius: height / 2,
594
+ borderWidth: 1,
595
+ borderColor: incomplete ? withAlpha(c.accent, 0.45) : c.border,
596
+ backgroundColor: incomplete ? c["accent-soft"] : c["surface-1"],
597
+ }}
598
+ >
599
+ <Pressable
600
+ accessibilityRole="button"
601
+ accessibilityLabel={t("timelineAnnotations.reviewCountlabel", { countLabel })}
602
+ hitSlop={{ top: (44 - height) / 2, bottom: (44 - height) / 2 }}
603
+ onPress={onPress}
604
+ style={({ pressed }) => ({
605
+ flexDirection: "row",
606
+ alignItems: "center",
607
+ gap: 6,
608
+ height,
609
+ paddingLeft: compact ? 8 : 10,
610
+ paddingRight: onClear ? 2 : compact ? 8 : 12,
611
+ opacity: pressed ? 0.6 : 1,
612
+ })}
613
+ >
614
+ <Icon name="quote" size={compact ? 11 : 13} color={c.accent} />
615
+ <Text
616
+ numberOfLines={1}
617
+ style={{
618
+ ...fontStyle(theme, 500),
619
+ fontSize: compact ? theme.size.xs : theme.size.sm,
620
+ color: c["fg-muted"],
621
+ }}
622
+ >
623
+ {countLabel}
624
+ </Text>
625
+ </Pressable>
626
+ {onClear ? (
627
+ <Pressable
628
+ accessibilityRole="button"
629
+ accessibilityLabel={t("timelineAnnotations.removeAllAnnotations")}
630
+ hitSlop={{ top: 6, bottom: 6, right: 6 }}
631
+ onPress={onClear}
632
+ style={({ pressed }) => ({
633
+ width: height,
634
+ height,
635
+ alignItems: "center",
636
+ justifyContent: "center",
637
+ opacity: pressed ? 0.5 : 1,
638
+ })}
639
+ >
640
+ <Icon name="x" size={13} color={c["fg-subtle"]} />
641
+ </Pressable>
642
+ ) : null}
643
+ </View>
644
+ );
645
+ }
646
+
647
+ type NoteInput = ComponentRef<typeof TextInput>;
648
+
649
+ /**
650
+ * Every quote with its note, as the web's annotations dialog: editable in the
651
+ * composer (write, remove; Return moves to the next empty note, then back to
652
+ * the message), read-only for a queued message. A quote reveals its message.
653
+ */
654
+ export function AnnotationsSheet({
655
+ open,
656
+ onClose,
657
+ annotations,
658
+ editable,
659
+ focusId,
660
+ onFocusConsumed,
661
+ onUpdate,
662
+ onRemove,
663
+ onReveal,
664
+ onCommit,
665
+ }: {
666
+ open: boolean;
667
+ onClose: () => void;
668
+ annotations: readonly NativeAnnotationLike[];
669
+ editable: boolean;
670
+ focusId?: string | null | undefined;
671
+ onFocusConsumed?: (() => void) | undefined;
672
+ onUpdate?: ((id: string, note: string) => void) | undefined;
673
+ onRemove?: ((id: string) => void) | undefined;
674
+ onReveal?: AnnotationReveal | undefined;
675
+ /** Every note is written: back to the message. */
676
+ onCommit?: (() => void) | undefined;
677
+ }) {
678
+ const t = useOpenGeniText();
679
+ const theme = useNativeTimelineTheme();
680
+ const c = theme.colors;
681
+ const inputs = useRef(new Map<string, NoteInput>());
682
+ const scroll = useRef<ComponentRef<typeof ScrollView>>(null);
683
+ const rowTops = useRef(new Map<string, number>());
684
+ const empty = annotations.length === 0;
685
+
686
+ // Removing the last quote leaves nothing to review.
687
+ useEffect(() => {
688
+ if (open && empty) onClose();
689
+ }, [empty, onClose, open]);
690
+
691
+ const focusNote = (id: string) => {
692
+ const top = rowTops.current.get(id);
693
+ if (top !== undefined) scroll.current?.scrollTo({ y: Math.max(0, top - 8), animated: true });
694
+ inputs.current.get(id)?.focus();
695
+ };
696
+
697
+ // A new quote (or a send with an empty note) opens on its note. The sheet
698
+ // slides in first; focusing during the slide drops the keyboard request.
699
+ useEffect(() => {
700
+ if (!open || !focusId || !editable) return;
701
+ const timer = setTimeout(() => {
702
+ focusNote(focusId);
703
+ onFocusConsumed?.();
704
+ }, 320);
705
+ return () => clearTimeout(timer);
706
+ // eslint-disable-next-line react-hooks/exhaustive-deps
707
+ }, [editable, focusId, open]);
708
+
709
+ const commit = (fromId: string) => {
710
+ const incomplete = annotations.filter((item) => !annotationHasNote(item.note));
711
+ if (incomplete.length === 0) {
712
+ onCommit?.();
713
+ return;
714
+ }
715
+ const fromIndex = annotations.findIndex((item) => item.id === fromId);
716
+ const next = incomplete.find((item) => annotations.indexOf(item) > fromIndex) ?? incomplete[0]!;
717
+ focusNote(next.id);
718
+ };
719
+
720
+ const incompleteCount = annotations.filter((item) => !annotationHasNote(item.note)).length;
721
+ const countLabel = t("counts.annotations", { count: annotations.length });
722
+ return (
723
+ <BottomSheet
724
+ open={open && !empty}
725
+ onClose={onClose}
726
+ maxHeightRatio={0.8}
727
+ accessibilityLabel={
728
+ editable
729
+ ? t("timelineAnnotationsDialog.editQuotedNotes")
730
+ : t("timelineAnnotationsDialog.quotedNotes")
731
+ }
732
+ >
733
+ <View
734
+ style={{
735
+ flexDirection: "row",
736
+ alignItems: "center",
737
+ justifyContent: "space-between",
738
+ paddingLeft: 20,
739
+ paddingRight: 8,
740
+ paddingBottom: 4,
741
+ }}
742
+ >
743
+ <Text
744
+ accessibilityRole="header"
745
+ style={{ ...fontStyle(theme, 600), fontSize: theme.size.md, color: c.fg }}
746
+ >
747
+ {countLabel}
748
+ </Text>
749
+ <Pressable
750
+ accessibilityRole="button"
751
+ onPress={() => {
752
+ if (editable && incompleteCount === 0 && onCommit) onCommit();
753
+ else onClose();
754
+ }}
755
+ style={({ pressed }) => ({
756
+ height: 44,
757
+ paddingHorizontal: 12,
758
+ justifyContent: "center",
759
+ opacity: pressed ? 0.5 : 1,
760
+ })}
761
+ >
762
+ <Text style={{ ...fontStyle(theme, 600), fontSize: theme.size.md, color: c.fg }}>
763
+ {t("native.timeline.annotations.done")}
764
+ </Text>
765
+ </Pressable>
766
+ </View>
767
+ <ScrollView
768
+ ref={scroll}
769
+ keyboardShouldPersistTaps="handled"
770
+ contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 12, gap: 18 }}
771
+ style={{ flexGrow: 0 }}
772
+ >
773
+ {annotations.map((annotation, index) => {
774
+ const ordinal = annotationDisplayOrdinal(annotation, index);
775
+ const last = index === annotations.length - 1;
776
+ const othersIncomplete = annotations.some(
777
+ (item) => item.id !== annotation.id && !annotationHasNote(item.note),
778
+ );
779
+ return (
780
+ <View
781
+ key={annotation.id}
782
+ onLayout={(event) => rowTops.current.set(annotation.id, event.nativeEvent.layout.y)}
783
+ >
784
+ <AccentRow>
785
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 4 }}>
786
+ <View style={{ flex: 1, minWidth: 0 }}>
787
+ <OrdinalLabel ordinal={ordinal} />
788
+ <QuoteButton
789
+ annotation={annotation}
790
+ onReveal={
791
+ onReveal
792
+ ? (source, quote) => {
793
+ const revealed = onReveal(source, quote);
794
+ if (revealed) onClose();
795
+ return revealed;
796
+ }
797
+ : undefined
798
+ }
799
+ />
800
+ </View>
801
+ {editable && onRemove ? (
802
+ <Pressable
803
+ accessibilityRole="button"
804
+ accessibilityLabel={t("timelineAnnotationsDialog.removeAnnotationOrdinal", {
805
+ ordinal,
806
+ })}
807
+ onPress={() => onRemove(annotation.id)}
808
+ style={({ pressed }) => ({
809
+ width: 36,
810
+ height: 36,
811
+ marginTop: -6,
812
+ marginRight: -10,
813
+ alignItems: "center",
814
+ justifyContent: "center",
815
+ opacity: pressed ? 0.5 : 1,
816
+ })}
817
+ >
818
+ <Icon name="x" size={15} color={c["fg-subtle"]} />
819
+ </Pressable>
820
+ ) : null}
821
+ </View>
822
+ {editable && onUpdate ? (
823
+ <TextInput
824
+ ref={(node) => {
825
+ if (node) inputs.current.set(annotation.id, node);
826
+ else inputs.current.delete(annotation.id);
827
+ }}
828
+ value={annotation.note}
829
+ onChangeText={(note) => onUpdate(annotation.id, note)}
830
+ placeholder={t("timelineAnnotationChrome.addANote")}
831
+ placeholderTextColor={c["fg-subtle"]}
832
+ accessibilityLabel={t("timelineAnnotationChrome.note")}
833
+ maxLength={ANNOTATION_MAX_NOTE_LENGTH}
834
+ multiline
835
+ submitBehavior="submit"
836
+ returnKeyType={othersIncomplete || (last && !onCommit) ? "next" : "done"}
837
+ onSubmitEditing={() => commit(annotation.id)}
838
+ style={{
839
+ ...fontStyle(theme),
840
+ fontSize: theme.size.md,
841
+ lineHeight: 22,
842
+ color: c.fg,
843
+ marginTop: 6,
844
+ minHeight: 40,
845
+ maxHeight: 132,
846
+ paddingHorizontal: 12,
847
+ paddingTop: 9,
848
+ paddingBottom: 9,
849
+ borderRadius: theme.radius.md,
850
+ backgroundColor: c["surface-2"],
851
+ borderWidth: 1,
852
+ borderColor: c.border,
853
+ }}
854
+ />
855
+ ) : (
856
+ <NotePreview note={annotation.note} ordinal={ordinal} />
857
+ )}
858
+ </AccentRow>
859
+ </View>
860
+ );
861
+ })}
862
+ {editable && incompleteCount > 0 ? (
863
+ <Text
864
+ style={{
865
+ ...fontStyle(theme),
866
+ fontSize: theme.size.xs,
867
+ lineHeight: 16,
868
+ color: c["fg-subtle"],
869
+ }}
870
+ >
871
+ {t("composerMessages.addANoteToEach")}
872
+ </Text>
873
+ ) : null}
874
+ </ScrollView>
875
+ </BottomSheet>
876
+ );
877
+ }