@opengeni/react-native 4.2.1-canary.39593846423001 → 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.
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/opengeni/textselection/OpenGeniTextSelectionModule.kt +274 -0
- package/dist/{chunk-D3MFBDVR.js → chunk-2RNMGRTB.js} +3 -3
- package/dist/{chunk-D3MFBDVR.js.map → chunk-2RNMGRTB.js.map} +1 -1
- package/dist/{chunk-2W74OY6I.js → chunk-FHPMRXIG.js} +16 -2
- package/dist/chunk-FHPMRXIG.js.map +1 -0
- package/dist/{chunk-63PZ52YJ.js → chunk-QFV5DISV.js} +38 -5
- package/dist/chunk-QFV5DISV.js.map +1 -0
- package/dist/{chunk-U5L2CUMJ.js → chunk-ZZNZBZWV.js} +3 -3
- package/dist/{chunk-U5L2CUMJ.js.map → chunk-ZZNZBZWV.js.map} +1 -1
- package/dist/index.js +2 -2
- package/dist/timeline/activity.d.ts +3 -1
- package/dist/timeline/annotations.d.ts +81 -0
- package/dist/timeline/icon-nodes.generated.d.ts +5 -0
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/index.js +1223 -203
- package/dist/timeline/index.js.map +1 -1
- package/dist/timeline/markdown.js +5 -2
- package/dist/timeline/markdown.js.map +1 -1
- package/dist/timeline/message-timeline.d.ts +20 -2
- package/dist/timeline/previews.js +2 -2
- package/dist/timeline/primitives.d.ts +7 -1
- package/dist/timeline/queue-dock.d.ts +4 -1
- package/dist/timeline/text-selection.d.ts +44 -0
- package/expo-module.config.json +5 -2
- package/ios/OpenGeniTextSelectionModule.swift +560 -0
- package/package.json +6 -4
- package/src/timeline/activity.tsx +76 -21
- package/src/timeline/annotations.tsx +877 -0
- package/src/timeline/icon-nodes.generated.ts +14 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/markdown.tsx +7 -0
- package/src/timeline/message-timeline.tsx +340 -174
- package/src/timeline/primitives.tsx +50 -1
- package/src/timeline/queue-dock.tsx +36 -0
- package/src/timeline/session-screen.tsx +122 -4
- package/src/timeline/sheet.tsx +3 -2
- package/src/timeline/text-selection.ts +115 -0
- package/dist/chunk-2W74OY6I.js.map +0 -1
- package/dist/chunk-63PZ52YJ.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
|
+
}
|