@alia.onl/sdk 13.1.0 → 15.2.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 (55) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +83 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +57 -107
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +28 -25
  8. package/src/components/AliaChatSheet.tsx +30 -8
  9. package/src/components/AliaComposer.tsx +382 -0
  10. package/src/components/ComposerDropZone.tsx +235 -0
  11. package/src/components/voice/VoiceControls.tsx +28 -4
  12. package/src/components/voice/VoiceSession.tsx +23 -7
  13. package/src/hooks/useAliaActivity.ts +31 -0
  14. package/src/hooks/useAliaChat.ts +14 -3
  15. package/src/hooks/useAmbientWave.ts +3 -3
  16. package/src/hooks/useAttachmentIntake.ts +401 -0
  17. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  18. package/src/hooks/useDictation.ts +295 -0
  19. package/src/hooks/useFocusWhenPresented.ts +41 -0
  20. package/src/hooks/usePendingQuestions.ts +19 -0
  21. package/src/hooks/useTTS.ts +24 -63
  22. package/src/hooks/useVoiceRoom.ts +148 -671
  23. package/src/index.ts +19 -3
  24. package/src/lib/activity-socket.ts +102 -0
  25. package/src/lib/activity-store.ts +53 -0
  26. package/src/lib/api-url.ts +2 -0
  27. package/src/lib/attachment-intake.ts +154 -0
  28. package/src/lib/attachment-tiles.ts +74 -0
  29. package/src/lib/audio-level.ts +2 -2
  30. package/src/lib/dictation-recorder-types.ts +37 -0
  31. package/src/lib/dictation-recorder.native.ts +90 -0
  32. package/src/lib/dictation-recorder.ts +131 -0
  33. package/src/lib/dictation.ts +208 -0
  34. package/src/lib/labels.tsx +50 -1
  35. package/src/lib/pcm.ts +57 -0
  36. package/src/lib/speech-language.ts +1 -1
  37. package/src/lib/speech-messages.ts +43 -65
  38. package/src/lib/speech-synthesis.ts +104 -9
  39. package/src/lib/turn-state.ts +15 -0
  40. package/src/lib/voice-audio-engine-types.ts +59 -0
  41. package/src/lib/voice-audio-engine.native.ts +232 -0
  42. package/src/lib/voice-audio-engine.ts +350 -0
  43. package/src/lib/voice-call.ts +539 -0
  44. package/src/lib/voice-levels.ts +7 -3
  45. package/src/lib/voice-protocol.ts +309 -0
  46. package/src/tools.ts +1 -0
  47. package/src/types.ts +63 -4
  48. package/src/voice.ts +31 -10
  49. package/tsconfig.json +2 -0
  50. package/src/hooks/useSpeechToText.ts +0 -268
  51. package/src/lib/speech-recognition-types.ts +0 -108
  52. package/src/lib/speech-recognition.native.ts +0 -237
  53. package/src/lib/speech-recognition.ts +0 -308
  54. package/src/lib/voice-text.ts +0 -198
  55. package/src/lib/voice-turn.ts +0 -89
@@ -0,0 +1,382 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ type ReactNode,
8
+ type RefObject,
9
+ } from 'react';
10
+ import {
11
+ View,
12
+ type NativeSyntheticEvent,
13
+ type TextInput,
14
+ type TextInputKeyPressEventData,
15
+ } from 'react-native';
16
+ import {
17
+ ComposerPanel,
18
+ type ComposerPanelAddMenuGroup,
19
+ type ComposerPanelAttachment,
20
+ type ComposerPanelPermissionOption,
21
+ } from '@oxy.so/bloom/composer-panel';
22
+ import { toast } from '@oxy.so/bloom/toast';
23
+ import { Text } from '@oxy.so/bloom/typography';
24
+ import { useAttachmentIntake } from '../hooks/useAttachmentIntake';
25
+ import { useDictation } from '../hooks/useDictation';
26
+ import type { Completion } from '../lib/suggestions';
27
+ import { composerTiles, intakeError } from '../lib/attachment-tiles';
28
+ import type { IntakeKind } from '../lib/attachment-intake';
29
+ import { KeyboardAvoidingView } from '../lib/keyboard';
30
+ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
31
+ import type { AskedQuestion } from '../lib/questions';
32
+ import type { VoiceErrorCode } from '../lib/speech-messages';
33
+ import type { AliaAttachment, AliaSuggestion } from '../types';
34
+ import {
35
+ ComposerDropOverlay,
36
+ useComposerDropTarget,
37
+ useComposerPasteTarget,
38
+ } from './ComposerDropZone';
39
+ import { ComposerSuggestions } from './ComposerSuggestions';
40
+ import { QuestionCard } from './QuestionCard';
41
+
42
+ /**
43
+ * Alia's composer: the ONE composer of the sheet, the screen and the Alia app, Bloom's
44
+ * `ComposerPanel` plus everything Alia puts around it.
45
+ *
46
+ * Everything the panel draws is Bloom's: the prompt, the add menu, the mode selector (the
47
+ * panel's permission pill, which is the host's power-level selector), the mic, send / stop, the
48
+ * attachment tiles and the status tab. What this adds is what every Alia surface needs the same
49
+ * way: files arriving by paste and drop (read with real progress, retried, refused with a
50
+ * sentence), dictation, the suggestions over the field, and the questions Alia asked docked
51
+ * above it. What differs per surface is a prop: the add menu and the modes are the host's,
52
+ * and a host that offers no way to attach files gets no paste or drop either.
53
+ *
54
+ * A running turn is the stop control and the container's thinking line; the panel takes no
55
+ * loader (Bloom's loader traces the pill, and would light the status tab too).
56
+ */
57
+ const EMPTY_ADD_MENU: readonly ComposerPanelAddMenuGroup[] = [];
58
+ /** `[]` hides the selector; Bloom's own four modes are not Alia's. */
59
+ const NO_MODES: readonly ComposerPanelPermissionOption[] = [];
60
+ const NO_ATTACHMENTS: readonly AliaAttachment[] = [];
61
+
62
+ /** The rows over the field, as `useComposerSuggestions` hands them back. */
63
+ export interface AliaComposerSuggestions<T extends AliaSuggestion = AliaSuggestion> {
64
+ completions: readonly Completion<T>[];
65
+ /** The row the keyboard is on, or -1. */
66
+ selected: number;
67
+ onPick: (suggestion: T) => void;
68
+ }
69
+
70
+ /** The questions Alia asked and the person has not answered (`usePendingQuestions`). */
71
+ export interface AliaComposerQuestions {
72
+ items: AskedQuestion[];
73
+ /** Sends the answers as the next message. */
74
+ onSubmit: (message: string) => void;
75
+ onDismiss: () => void;
76
+ }
77
+
78
+ export interface AliaComposerProps<T extends AliaSuggestion = AliaSuggestion> {
79
+ value: string;
80
+ onValueChange: (value: string) => void;
81
+ /** Sends the draft. */
82
+ onSubmit: () => void;
83
+ /** A turn is streaming: send becomes stop. */
84
+ busy?: boolean;
85
+ /** The composer is closed (usage limit). Never reaches stop. */
86
+ disabled?: boolean;
87
+ onStop?: () => void;
88
+ placeholder?: string;
89
+ /** The mode selector (the power levels). Omit to hide it. */
90
+ modes?: readonly ComposerPanelPermissionOption[];
91
+ mode?: string;
92
+ onModeChange?: (mode: string) => void;
93
+ addMenu?: readonly ComposerPanelAddMenuGroup[];
94
+ onAddMenuSelect?: (rowId: string) => void;
95
+ /**
96
+ * The draft's files. A composer given no `onAddAttachment` takes none: its surface sends
97
+ * nothing but the text, so no paste, drop or tile may say otherwise.
98
+ */
99
+ attachments?: readonly AliaAttachment[];
100
+ onAddAttachment?: (attachment: AliaAttachment) => void;
101
+ onRemoveAttachment?: (id: string) => void;
102
+ /** The kinds of file the host's send carries; others are refused with a sentence. Default both. */
103
+ attachmentKinds?: readonly IntakeKind[];
104
+ /** The suggestions drawn over the field. Their keys are the host's `onKeyPress`. */
105
+ suggestions?: AliaComposerSuggestions<T>;
106
+ /** What Alia is waiting on, docked above the composer. */
107
+ questions?: AliaComposerQuestions;
108
+ /** The tab on the card's top edge (`ComposerPanelStatusTab`). */
109
+ status?: ReactNode;
110
+ /** Drawn where send would be while there is nothing to send (voice mode). */
111
+ emptyAction?: ReactNode;
112
+ /**
113
+ * Drawn over the conversation at the composer's top-right corner, and so pinned with it —
114
+ * the jump to the newest turn.
115
+ */
116
+ accessory?: ReactNode;
117
+ /** The field's keys, before the panel's own Enter rule (suggestions over it). */
118
+ onKeyPress?: (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => void;
119
+ /** The host already avoids the keyboard. */
120
+ disableKeyboardAvoidance?: boolean;
121
+ /** The field itself, for a host that moves focus into it. */
122
+ inputRef?: RefObject<TextInput | null>;
123
+ /** Dictation's language, a BCP-47 tag; omitted, the platform's own. */
124
+ speechLanguage?: string;
125
+ /** Alia's API, which transcribes dictation; omitted, `EXPO_PUBLIC_ALIA_API_URL` or production. */
126
+ apiUrl?: string;
127
+ /** Where a refused file or a failed dictation is said; omitted, Bloom's toast. */
128
+ onError?: (message: string) => void;
129
+ /** The composer's copy, over whatever labels are already in reach. */
130
+ labels?: Partial<AliaChatLabels>;
131
+ }
132
+
133
+ function AliaComposerBody<T extends AliaSuggestion>({
134
+ value,
135
+ onValueChange,
136
+ onSubmit,
137
+ busy = false,
138
+ disabled = false,
139
+ onStop,
140
+ placeholder,
141
+ modes,
142
+ mode,
143
+ onModeChange,
144
+ addMenu,
145
+ onAddMenuSelect,
146
+ attachments = NO_ATTACHMENTS,
147
+ onAddAttachment,
148
+ onRemoveAttachment,
149
+ attachmentKinds,
150
+ suggestions,
151
+ questions,
152
+ status,
153
+ emptyAction,
154
+ accessory,
155
+ onKeyPress,
156
+ disableKeyboardAvoidance = false,
157
+ inputRef,
158
+ speechLanguage,
159
+ apiUrl,
160
+ onError,
161
+ }: Omit<AliaComposerProps<T>, 'labels'>) {
162
+ const labels = useLabels();
163
+ const report = useCallback(
164
+ (message: string) => (onError ? onError(message) : toast.error(message)),
165
+ [onError],
166
+ );
167
+ // Paste is a DOM gesture on a DOM node; the panel publishes no ref to one.
168
+ const hostId = `composer-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
169
+
170
+ // The list's owner releases what a removed tile held (the draft store does).
171
+ const takesFiles = onAddAttachment !== undefined;
172
+ const addAttachment = useCallback(
173
+ (attachment: AliaAttachment) => onAddAttachment?.(attachment),
174
+ [onAddAttachment],
175
+ );
176
+ const removeAttachment = useCallback(
177
+ (id: string) => onRemoveAttachment?.(id),
178
+ [onRemoveAttachment],
179
+ );
180
+ const intake = useAttachmentIntake({ addAttachment, kinds: attachmentKinds });
181
+ // A surface that takes no files still catches a drop or a pasted file, so
182
+ // the browser does not open it in place of the page — and then does nothing.
183
+ const acceptsFiles = takesFiles && !disabled && !busy;
184
+ useComposerPasteTarget({ elementId: hostId, enabled: acceptsFiles, onFiles: intake.accept });
185
+ // Web only; on native there is no drag, and the hook attaches nothing.
186
+ const isDragOver = useComposerDropTarget({
187
+ elementId: hostId,
188
+ enabled: acceptsFiles,
189
+ onFiles: intake.accept,
190
+ });
191
+
192
+ /**
193
+ * A refused file — too large, or empty — is said once, in a toast, and
194
+ * leaves the queue.
195
+ *
196
+ * It is not a tile because Bloom's retry is the PANEL's: with
197
+ * `onAttachmentRetry` set, every error tile draws a retry button, and a
198
+ * refusal has nothing to retry — the same file reaches the same verdict.
199
+ * A tile whose button does nothing is worse than a sentence.
200
+ * A failed READ stays, as an error tile whose retry re-reads its file.
201
+ */
202
+ useEffect(() => {
203
+ for (const item of intake.items) {
204
+ if (item.status !== 'refused') continue;
205
+ const error = intakeError(item, labels);
206
+ if (error !== undefined) report(error);
207
+ intake.dismiss(item.id);
208
+ }
209
+ }, [intake, labels, report]);
210
+
211
+ // Landed files, then the ones being read with their real progress, then the
212
+ // ones whose read failed, with why.
213
+ const tiles = useMemo<ComposerPanelAttachment[]>(
214
+ () =>
215
+ composerTiles(attachments, intake.items, labels)
216
+ .filter((tile) => tile.error === undefined || tile.retryable === true)
217
+ .map(({ retryable: _retryable, ...tile }) => tile),
218
+ [attachments, intake.items, labels],
219
+ );
220
+ const removeTile = useCallback(
221
+ (id: string) => {
222
+ if (intake.items.some((item) => item.id === id)) intake.cancel(id);
223
+ else removeAttachment(id);
224
+ },
225
+ [intake, removeAttachment],
226
+ );
227
+
228
+ // The draft at the moment the transcription lands, not when the mic was pressed.
229
+ const draftRef = useRef(value);
230
+ draftRef.current = value;
231
+ const insertDictation = useCallback(
232
+ (text: string) => {
233
+ const draft = draftRef.current;
234
+ onValueChange(draft === '' ? text : `${draft} ${text}`);
235
+ },
236
+ [onValueChange],
237
+ );
238
+ const dictation = useDictation({
239
+ lang: speechLanguage,
240
+ ...(apiUrl === undefined ? {} : { apiUrl }),
241
+ onText: insertDictation,
242
+ });
243
+ // Said once per failure: a host re-rendering with new labels is not a new failure.
244
+ const sayVoiceError = useRef<(code: VoiceErrorCode, retry: (() => void) | null) => void>(
245
+ () => undefined,
246
+ );
247
+ sayVoiceError.current = (code, retry) => {
248
+ const message = labels.voiceError(code);
249
+ if (onError || retry === null) report(message);
250
+ else toast.error(message, { action: { label: labels.composerRetry, onClick: retry } });
251
+ };
252
+ const {
253
+ errorCode: dictationError,
254
+ canRetry: dictationCanRetry,
255
+ retry: retryDictation,
256
+ } = dictation;
257
+ useEffect(() => {
258
+ if (dictationError !== null)
259
+ sayVoiceError.current(dictationError, dictationCanRetry ? () => void retryDictation() : null);
260
+ }, [dictationError, dictationCanRetry, retryDictation]);
261
+
262
+ const handleListeningChange = useCallback(
263
+ async (next: boolean) => {
264
+ if (next) {
265
+ if (!disabled && !busy && !dictation.isTranscribing) void dictation.start();
266
+ return;
267
+ }
268
+ await dictation.stop();
269
+ },
270
+ [disabled, busy, dictation],
271
+ );
272
+ const remainingSeconds =
273
+ dictation.remainingMs === null ? null : Math.ceil(dictation.remainingMs / 1000);
274
+
275
+ const hasContent = value.trim() !== '' || attachments.length > 0;
276
+ const panelLabels = useMemo(
277
+ () => ({
278
+ message: labels.composerMessage,
279
+ addMenu: labels.composerAddMenu,
280
+ permissions: labels.composerModes,
281
+ permissionMode: labels.composerModes,
282
+ voice: labels.composerVoice,
283
+ send: labels.composerSend,
284
+ stop: labels.composerStop,
285
+ remove: labels.composerRemove,
286
+ // Bloom names the button "<retry> <file name>".
287
+ retry: labels.composerRetry,
288
+ }),
289
+ [labels],
290
+ );
291
+
292
+ const host = (
293
+ // The transcript's column (Bloom's `AgentChat`: 768 at most, centred), so
294
+ // the composer and the conversation share one edge.
295
+ <View id={hostId} className="w-full max-w-[768px] self-center">
296
+ {remainingSeconds !== null ? (
297
+ <Text
298
+ className="mb-1 self-center text-sm text-text-secondary"
299
+ accessibilityLiveRegion="polite"
300
+ aria-live="polite"
301
+ >
302
+ {labels.dictationRemaining(remainingSeconds)}
303
+ </Text>
304
+ ) : null}
305
+ <ComposerPanel
306
+ value={value}
307
+ onValueChange={onValueChange}
308
+ onSubmit={() => onSubmit()}
309
+ inputRef={inputRef}
310
+ busy={busy}
311
+ onStop={onStop}
312
+ disabled={disabled || intake.isBusy || !hasContent}
313
+ placeholder={placeholder ?? labels.placeholder}
314
+ addMenu={addMenu ?? EMPTY_ADD_MENU}
315
+ onAddMenuSelect={onAddMenuSelect}
316
+ permissions={modes ?? NO_MODES}
317
+ permission={mode}
318
+ onPermissionChange={onModeChange}
319
+ voiceState={
320
+ dictation.isTranscribing
321
+ ? 'transcribing'
322
+ : dictation.isRecording || dictation.state === 'requesting-permission'
323
+ ? 'recording'
324
+ : 'idle'
325
+ }
326
+ onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
327
+ attachments={tiles}
328
+ onRemoveAttachment={removeTile}
329
+ // Clears the tile's error and starts its progress again: `intake.retry`
330
+ // puts the item back to `reading` over the same `File`.
331
+ onAttachmentRetry={intake.retry}
332
+ question={
333
+ questions && questions.items.length > 0 ? (
334
+ <QuestionCard
335
+ questions={questions.items}
336
+ onSubmit={questions.onSubmit}
337
+ onDismiss={questions.onDismiss}
338
+ />
339
+ ) : undefined
340
+ }
341
+ status={status}
342
+ emptyAction={emptyAction}
343
+ onKeyPress={onKeyPress}
344
+ labels={panelLabels}
345
+ />
346
+ <ComposerDropOverlay visible={takesFiles && isDragOver} enabled={acceptsFiles} />
347
+ {accessory ? (
348
+ <View pointerEvents="box-none" className="absolute bottom-full right-0 mb-2">
349
+ {accessory}
350
+ </View>
351
+ ) : null}
352
+ </View>
353
+ );
354
+
355
+ return (
356
+ <>
357
+ {suggestions ? (
358
+ <ComposerSuggestions
359
+ completions={suggestions.completions}
360
+ selected={suggestions.selected}
361
+ onPick={suggestions.onPick}
362
+ />
363
+ ) : null}
364
+ {disableKeyboardAvoidance ? (
365
+ host
366
+ ) : (
367
+ <KeyboardAvoidingView behavior="padding">{host}</KeyboardAvoidingView>
368
+ )}
369
+ </>
370
+ );
371
+ }
372
+
373
+ export function AliaComposer<T extends AliaSuggestion = AliaSuggestion>({
374
+ labels,
375
+ ...props
376
+ }: AliaComposerProps<T>) {
377
+ return (
378
+ <AliaChatLabelsProvider labels={labels}>
379
+ <AliaComposerBody {...props} />
380
+ </AliaChatLabelsProvider>
381
+ );
382
+ }
@@ -0,0 +1,235 @@
1
+ import { dragCarriesFiles, nextDragDepth } from '../lib/attachment-intake';
2
+ import { useLabels } from '../lib/labels';
3
+ import { Text } from '@oxy.so/bloom/typography';
4
+ import React from 'react';
5
+ import { Platform, View } from 'react-native';
6
+ /**
7
+ * Dropping files onto the composer, on web, where such a gesture exists.
8
+ *
9
+ * Alia had no drag-and-drop at all — no `onDrop`, no `dataTransfer`, nothing —
10
+ * which on web means the browser's own default ran instead: dropping a file
11
+ * anywhere on the page NAVIGATED THE TAB TO IT, losing the draft, the
12
+ * conversation scroll position and any stream in flight. So the first thing
13
+ * this does is not "accept files"; it is `preventDefault`, and it happens even
14
+ * when the composer is closed, because the least a locked composer owes a
15
+ * dropped file is not to throw the page away over it.
16
+ *
17
+ * ## Why the listeners are attached by hand
18
+ *
19
+ * `View` has no `onDragEnter`. react-native-web forwards a fixed set of
20
+ * handlers and the drag family is not in it, and NativeWind's wrapper means a
21
+ * ref here does not resolve to the DOM node either — the fullscreen grow in
22
+ * old composer ran into the same wall and solved it the same way, by giving
23
+ * the bar an `id` and looking it up. This hook takes that id, and the id now
24
+ * belongs to the `View` the composer wraps Bloom's pill in — Bloom's own pill
25
+ * takes a `style` and a `testID` and no DOM handle at all, which is the right
26
+ * shape for a UI kit and leaves the drop target to its host.
27
+ */
28
+ export interface ComposerDropTargetOptions {
29
+ /** The DOM id of the element the drop lands on — the composer bar itself. */
30
+ elementId: string;
31
+ /** False while the composer is closed or a turn is streaming. */
32
+ enabled: boolean;
33
+ onFiles: (files: File[]) => void;
34
+ /**
35
+ * Changing this re-attaches the listeners. Fullscreen re-parents the bar
36
+ * through a portal, which destroys the node the listeners were on; without a
37
+ * rebind the composer silently stops taking drops the first time it is
38
+ * expanded.
39
+ */
40
+ rebindKey?: string | number;
41
+ }
42
+
43
+ /** Whether a file drag is currently over the composer. */
44
+ export function useComposerDropTarget({
45
+ elementId,
46
+ enabled,
47
+ onFiles,
48
+ rebindKey,
49
+ }: ComposerDropTargetOptions): boolean {
50
+ const [isOver, setIsOver] = React.useState(false);
51
+
52
+ /**
53
+ * How many of the bar's elements the pointer is inside.
54
+ *
55
+ * A ref, and read in the same tick it is written: `dragenter` on a child
56
+ * arrives immediately before `dragleave` on its parent, so the two have to
57
+ * be counted against each other synchronously. State would be one render
58
+ * stale for the pair that matters, which is every pair.
59
+ */
60
+ const depth = React.useRef(0);
61
+
62
+ // Held in a ref so the effect does not re-attach — and re-attaching matters:
63
+ // a listener swapped out mid-drag loses the depth count with it, and the
64
+ // overlay never clears.
65
+ const latest = React.useRef({ enabled, onFiles });
66
+ latest.current = { enabled, onFiles };
67
+
68
+ React.useEffect(() => {
69
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
70
+ const element = document.getElementById(elementId);
71
+ if (element === null) return;
72
+
73
+ const step = (kind: 'enter' | 'leave' | 'drop') => {
74
+ depth.current = nextDragDepth(depth.current, kind);
75
+ setIsOver(depth.current > 0);
76
+ };
77
+
78
+ const onDragEnter = (event: globalThis.DragEvent) => {
79
+ if (!dragCarriesFiles(event.dataTransfer?.types)) return;
80
+ event.preventDefault();
81
+ step('enter');
82
+ };
83
+
84
+ const onDragOver = (event: globalThis.DragEvent) => {
85
+ if (!dragCarriesFiles(event.dataTransfer?.types)) return;
86
+ // Both halves are required. Without `preventDefault` on dragOVER
87
+ // specifically — not just on enter — the element is not a drop target at
88
+ // all and `drop` never fires, which is the single most common way a
89
+ // hand-written drop zone appears to work and then does nothing.
90
+ event.preventDefault();
91
+ if (event.dataTransfer !== null)
92
+ event.dataTransfer.dropEffect = latest.current.enabled ? 'copy' : 'none';
93
+ };
94
+
95
+ const onDragLeave = (event: globalThis.DragEvent) => {
96
+ if (!dragCarriesFiles(event.dataTransfer?.types)) return;
97
+ step('leave');
98
+ };
99
+
100
+ const onDrop = (event: globalThis.DragEvent) => {
101
+ if (!dragCarriesFiles(event.dataTransfer?.types)) return;
102
+ event.preventDefault();
103
+ // Reset rather than decrement: no `dragleave` follows a drop, so a
104
+ // decrement leaves the count at one and the overlay stuck over the bar.
105
+ step('drop');
106
+ if (!latest.current.enabled) return;
107
+ const files = Array.from(event.dataTransfer?.files ?? []);
108
+ if (files.length > 0) latest.current.onFiles(files);
109
+ };
110
+
111
+ element.addEventListener('dragenter', onDragEnter);
112
+ element.addEventListener('dragover', onDragOver);
113
+ element.addEventListener('dragleave', onDragLeave);
114
+ element.addEventListener('drop', onDrop);
115
+ return () => {
116
+ element.removeEventListener('dragenter', onDragEnter);
117
+ element.removeEventListener('dragover', onDragOver);
118
+ element.removeEventListener('dragleave', onDragLeave);
119
+ element.removeEventListener('drop', onDrop);
120
+ // A drag that was over the bar when it was torn down would otherwise
121
+ // leave the count — and the overlay — set for the next mount.
122
+ depth.current = 0;
123
+ setIsOver(false);
124
+ };
125
+ }, [elementId, rebindKey]);
126
+
127
+ return isOver;
128
+ }
129
+
130
+ /**
131
+ * What a file drag looks like over the composer.
132
+ *
133
+ * `pointerEvents="none"` is load-bearing rather than tidy: an overlay that
134
+ * takes pointer events is an element the drag ENTERS, so it would fire its own
135
+ * `dragenter`/`dragleave` pair against the same counter it is drawn from, and
136
+ * the affordance would oscillate against itself.
137
+ *
138
+ * It is announced as well as drawn. A drop target that exists only as a
139
+ * dashed outline is a target only for the people who can see it, and while a
140
+ * drag is not a gesture a screen-reader user performs, the composer is shared
141
+ * with people who do both.
142
+ */
143
+ export function ComposerDropOverlay({ visible, enabled }: { visible: boolean; enabled: boolean }) {
144
+ const labels = useLabels();
145
+ if (!visible) return null;
146
+ return (
147
+ <View
148
+ pointerEvents="none"
149
+ accessibilityRole="alert"
150
+ accessibilityLiveRegion="polite"
151
+ // 26: the corner Bloom's composer pill wears at one line and past it.
152
+ className="absolute inset-0 z-20 items-center justify-center rounded-[26px] border-2 border-dashed border-primary bg-background/90"
153
+ >
154
+ <Text className="text-sm font-medium text-foreground">
155
+ {enabled ? labels.dropHint : labels.dropUnavailable}
156
+ </Text>
157
+ </View>
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Files PASTED into the composer, on web, where such a gesture exists.
163
+ *
164
+ * It lives beside the drop target rather than with the field because it is the
165
+ * same question asked with a different gesture: bytes have arrived, they were
166
+ * not typed, and the composer has to decide whether to take them. Both are
167
+ * hand-attached DOM listeners for the same reason — react-native-web forwards
168
+ * neither the drag family nor `onPaste` through `View`, and NativeWind's
169
+ * wrapper means a ref does not resolve to the node either — so both are keyed
170
+ * off the composer's own DOM id.
171
+ *
172
+ * ## Why it is on the composer and not on the field
173
+ *
174
+ * `ChatTextInput` used to own this, listening on `document` and checking that
175
+ * the focused element was inside ITS wrapper. Bloom's pill owns the field now
176
+ * and exposes no DOM handle for it, so the containment test moves out one ring,
177
+ * to the element the pill is mounted inside. That is the same test with a
178
+ * larger box: a paste belongs to this composer when the thing being pasted
179
+ * into is somewhere in this composer.
180
+ *
181
+ * ## Why it takes every file and not only images
182
+ *
183
+ * The old handler filtered `item.type.indexOf('image')`, so pasting a PDF
184
+ * pasted its NAME. The intake queue classifies and refuses on its own terms
185
+ * (`lib/attachment-intake.ts`) and says so in the strip, which is a better
186
+ * answer than a silent drop — and it is the same answer a dropped PDF already
187
+ * got, which is the point: one queue, one set of rules, whichever gesture the
188
+ * file arrived by.
189
+ */
190
+ export function useComposerPasteTarget({
191
+ elementId,
192
+ enabled,
193
+ onFiles,
194
+ }: Omit<ComposerDropTargetOptions, 'rebindKey'>): void {
195
+ // Held in a ref for the same reason the drop target holds its own: a
196
+ // listener swapped out mid-gesture is a gesture with no handler.
197
+ const latest = React.useRef({ enabled, onFiles });
198
+ latest.current = { enabled, onFiles };
199
+
200
+ React.useEffect(() => {
201
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
202
+
203
+ const onPaste = (event: globalThis.ClipboardEvent) => {
204
+ // Resolved per EVENT rather than once per mount, which is also why this
205
+ // hook needs no `rebindKey`: the pill re-renders its own subtree as the
206
+ // draft grows, and a node captured at mount is a node the composer may
207
+ // since have replaced.
208
+ const host = document.getElementById(elementId);
209
+ if (!(host instanceof HTMLElement)) return;
210
+ if (!host.contains(document.activeElement)) return;
211
+
212
+ const items = event.clipboardData?.items;
213
+ if (items === undefined) return;
214
+ const files: File[] = [];
215
+ for (const item of Array.from(items)) {
216
+ if (item.kind !== 'file') continue;
217
+ const file = item.getAsFile();
218
+ if (file !== null) files.push(file);
219
+ }
220
+ // Nothing was pasted that this is about. A paste carrying text as well
221
+ // as a picture — which is what copying from a web page produces — must
222
+ // still put its text in the field, so the default is left alone.
223
+ if (files.length === 0) return;
224
+
225
+ // Swallowed even when the composer is closed: the alternative is the
226
+ // browser's own default, which writes the file's bytes into the draft.
227
+ event.preventDefault();
228
+ if (!latest.current.enabled) return;
229
+ latest.current.onFiles(files);
230
+ };
231
+
232
+ document.addEventListener('paste', onPaste);
233
+ return () => document.removeEventListener('paste', onPaste);
234
+ }, [elementId]);
235
+ }