@alia.onl/sdk 13.0.0 → 13.2.0
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/CHANGELOG.md +72 -0
- package/README.md +41 -12
- package/package.json +2 -2
- package/src/components/AliaChatContent.tsx +58 -90
- package/src/components/AliaChatScreen.tsx +29 -22
- package/src/components/AliaChatSheet.tsx +17 -2
- package/src/components/AliaComposer.tsx +336 -0
- package/src/components/ComposerDropZone.tsx +235 -0
- package/src/hooks/useAttachmentIntake.ts +401 -0
- package/src/hooks/useFocusWhenPresented.ts +41 -0
- package/src/hooks/usePendingQuestions.ts +19 -0
- package/src/hooks/useVoiceRoom.ts +81 -16
- package/src/index.ts +11 -0
- package/src/lib/attachment-intake.ts +154 -0
- package/src/lib/attachment-tiles.ts +74 -0
- package/src/lib/character-runtime.ts +10 -0
- package/src/lib/labels.tsx +54 -1
- package/src/lib/speech-messages.ts +34 -2
- package/src/lib/speech-synthesis.ts +99 -8
- package/src/tools.ts +1 -0
- package/src/types.ts +14 -0
- package/src/voice.ts +2 -0
|
@@ -0,0 +1,336 @@
|
|
|
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 { useAttachmentIntake } from '../hooks/useAttachmentIntake';
|
|
24
|
+
import { useSpeechToText } from '../hooks/useSpeechToText';
|
|
25
|
+
import type { Completion } from '../lib/suggestions';
|
|
26
|
+
import { composerTiles, intakeError } from '../lib/attachment-tiles';
|
|
27
|
+
import type { IntakeKind } from '../lib/attachment-intake';
|
|
28
|
+
import { KeyboardAvoidingView } from '../lib/keyboard';
|
|
29
|
+
import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
|
|
30
|
+
import type { AskedQuestion } from '../lib/questions';
|
|
31
|
+
import type { VoiceErrorCode } from '../lib/speech-messages';
|
|
32
|
+
import type { AliaAttachment, AliaSuggestion } from '../types';
|
|
33
|
+
import {
|
|
34
|
+
ComposerDropOverlay,
|
|
35
|
+
useComposerDropTarget,
|
|
36
|
+
useComposerPasteTarget,
|
|
37
|
+
} from './ComposerDropZone';
|
|
38
|
+
import { ComposerSuggestions } from './ComposerSuggestions';
|
|
39
|
+
import { QuestionCard } from './QuestionCard';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Alia's composer: the ONE composer of the sheet, the screen and the Alia app, Bloom's
|
|
43
|
+
* `ComposerPanel` plus everything Alia puts around it.
|
|
44
|
+
*
|
|
45
|
+
* Everything the panel draws is Bloom's: the prompt, the add menu, the mode selector (the
|
|
46
|
+
* panel's permission pill, which is the host's power-level selector), the mic, send / stop, the
|
|
47
|
+
* attachment tiles and the status tab. What this adds is what every Alia surface needs the same
|
|
48
|
+
* way: files arriving by paste and drop (read with real progress, retried, refused with a
|
|
49
|
+
* sentence), dictation, the suggestions over the field, and the questions Alia asked docked
|
|
50
|
+
* above it. What differs per surface is a prop: the add menu and the modes are the host's,
|
|
51
|
+
* and a host that offers no way to attach files gets no paste or drop either.
|
|
52
|
+
*
|
|
53
|
+
* A running turn is the stop control and the container's thinking line; the panel takes no
|
|
54
|
+
* loader (Bloom's loader traces the pill, and would light the status tab too).
|
|
55
|
+
*/
|
|
56
|
+
const EMPTY_ADD_MENU: readonly ComposerPanelAddMenuGroup[] = [];
|
|
57
|
+
/** `[]` hides the selector; Bloom's own four modes are not Alia's. */
|
|
58
|
+
const NO_MODES: readonly ComposerPanelPermissionOption[] = [];
|
|
59
|
+
const NO_ATTACHMENTS: readonly AliaAttachment[] = [];
|
|
60
|
+
|
|
61
|
+
/** The rows over the field, as `useComposerSuggestions` hands them back. */
|
|
62
|
+
export interface AliaComposerSuggestions<T extends AliaSuggestion = AliaSuggestion> {
|
|
63
|
+
completions: readonly Completion<T>[];
|
|
64
|
+
/** The row the keyboard is on, or -1. */
|
|
65
|
+
selected: number;
|
|
66
|
+
onPick: (suggestion: T) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The questions Alia asked and the person has not answered (`usePendingQuestions`). */
|
|
70
|
+
export interface AliaComposerQuestions {
|
|
71
|
+
items: AskedQuestion[];
|
|
72
|
+
/** Sends the answers as the next message. */
|
|
73
|
+
onSubmit: (message: string) => void;
|
|
74
|
+
onDismiss: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AliaComposerProps<T extends AliaSuggestion = AliaSuggestion> {
|
|
78
|
+
value: string;
|
|
79
|
+
onValueChange: (value: string) => void;
|
|
80
|
+
/** Sends the draft. */
|
|
81
|
+
onSubmit: () => void;
|
|
82
|
+
/** A turn is streaming: send becomes stop. */
|
|
83
|
+
busy?: boolean;
|
|
84
|
+
/** The composer is closed (usage limit). Never reaches stop. */
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
onStop?: () => void;
|
|
87
|
+
placeholder?: string;
|
|
88
|
+
/** The mode selector (the power levels). Omit to hide it. */
|
|
89
|
+
modes?: readonly ComposerPanelPermissionOption[];
|
|
90
|
+
mode?: string;
|
|
91
|
+
onModeChange?: (mode: string) => void;
|
|
92
|
+
addMenu?: readonly ComposerPanelAddMenuGroup[];
|
|
93
|
+
onAddMenuSelect?: (rowId: string) => void;
|
|
94
|
+
/**
|
|
95
|
+
* The draft's files. A composer given no `onAddAttachment` takes none: its surface sends
|
|
96
|
+
* nothing but the text, so no paste, drop or tile may say otherwise.
|
|
97
|
+
*/
|
|
98
|
+
attachments?: readonly AliaAttachment[];
|
|
99
|
+
onAddAttachment?: (attachment: AliaAttachment) => void;
|
|
100
|
+
onRemoveAttachment?: (id: string) => void;
|
|
101
|
+
/** The kinds of file the host's send carries; others are refused with a sentence. Default both. */
|
|
102
|
+
attachmentKinds?: readonly IntakeKind[];
|
|
103
|
+
/** The suggestions drawn over the field. Their keys are the host's `onKeyPress`. */
|
|
104
|
+
suggestions?: AliaComposerSuggestions<T>;
|
|
105
|
+
/** What Alia is waiting on, docked above the composer. */
|
|
106
|
+
questions?: AliaComposerQuestions;
|
|
107
|
+
/** The tab on the card's top edge (`ComposerPanelStatusTab`). */
|
|
108
|
+
status?: ReactNode;
|
|
109
|
+
/** Drawn where send would be while there is nothing to send (voice mode). */
|
|
110
|
+
emptyAction?: ReactNode;
|
|
111
|
+
/**
|
|
112
|
+
* Drawn over the conversation at the composer's top-right corner, and so pinned with it —
|
|
113
|
+
* the jump to the newest turn.
|
|
114
|
+
*/
|
|
115
|
+
accessory?: ReactNode;
|
|
116
|
+
/** The field's keys, before the panel's own Enter rule (suggestions over it). */
|
|
117
|
+
onKeyPress?: (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => void;
|
|
118
|
+
/** The host already avoids the keyboard. */
|
|
119
|
+
disableKeyboardAvoidance?: boolean;
|
|
120
|
+
/** The field itself, for a host that moves focus into it. */
|
|
121
|
+
inputRef?: RefObject<TextInput | null>;
|
|
122
|
+
/** Dictation's language, a BCP-47 tag; omitted, the platform's own. */
|
|
123
|
+
speechLanguage?: string;
|
|
124
|
+
/** Where a refused file or a failed dictation is said; omitted, Bloom's toast. */
|
|
125
|
+
onError?: (message: string) => void;
|
|
126
|
+
/** The composer's copy, over whatever labels are already in reach. */
|
|
127
|
+
labels?: Partial<AliaChatLabels>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function AliaComposerBody<T extends AliaSuggestion>({
|
|
131
|
+
value,
|
|
132
|
+
onValueChange,
|
|
133
|
+
onSubmit,
|
|
134
|
+
busy = false,
|
|
135
|
+
disabled = false,
|
|
136
|
+
onStop,
|
|
137
|
+
placeholder,
|
|
138
|
+
modes,
|
|
139
|
+
mode,
|
|
140
|
+
onModeChange,
|
|
141
|
+
addMenu,
|
|
142
|
+
onAddMenuSelect,
|
|
143
|
+
attachments = NO_ATTACHMENTS,
|
|
144
|
+
onAddAttachment,
|
|
145
|
+
onRemoveAttachment,
|
|
146
|
+
attachmentKinds,
|
|
147
|
+
suggestions,
|
|
148
|
+
questions,
|
|
149
|
+
status,
|
|
150
|
+
emptyAction,
|
|
151
|
+
accessory,
|
|
152
|
+
onKeyPress,
|
|
153
|
+
disableKeyboardAvoidance = false,
|
|
154
|
+
inputRef,
|
|
155
|
+
speechLanguage,
|
|
156
|
+
onError,
|
|
157
|
+
}: Omit<AliaComposerProps<T>, 'labels'>) {
|
|
158
|
+
const labels = useLabels();
|
|
159
|
+
const report = useCallback(
|
|
160
|
+
(message: string) => (onError ? onError(message) : toast.error(message)),
|
|
161
|
+
[onError],
|
|
162
|
+
);
|
|
163
|
+
// Paste is a DOM gesture on a DOM node; the panel publishes no ref to one.
|
|
164
|
+
const hostId = `composer-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
165
|
+
|
|
166
|
+
// The list's owner releases what a removed tile held (the draft store does).
|
|
167
|
+
const takesFiles = onAddAttachment !== undefined;
|
|
168
|
+
const addAttachment = useCallback(
|
|
169
|
+
(attachment: AliaAttachment) => onAddAttachment?.(attachment),
|
|
170
|
+
[onAddAttachment],
|
|
171
|
+
);
|
|
172
|
+
const removeAttachment = useCallback(
|
|
173
|
+
(id: string) => onRemoveAttachment?.(id),
|
|
174
|
+
[onRemoveAttachment],
|
|
175
|
+
);
|
|
176
|
+
const intake = useAttachmentIntake({ addAttachment, kinds: attachmentKinds });
|
|
177
|
+
// A surface that takes no files still catches a drop or a pasted file, so
|
|
178
|
+
// the browser does not open it in place of the page — and then does nothing.
|
|
179
|
+
const acceptsFiles = takesFiles && !disabled && !busy;
|
|
180
|
+
useComposerPasteTarget({ elementId: hostId, enabled: acceptsFiles, onFiles: intake.accept });
|
|
181
|
+
// Web only; on native there is no drag, and the hook attaches nothing.
|
|
182
|
+
const isDragOver = useComposerDropTarget({
|
|
183
|
+
elementId: hostId,
|
|
184
|
+
enabled: acceptsFiles,
|
|
185
|
+
onFiles: intake.accept,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A refused file — too large, or empty — is said once, in a toast, and
|
|
190
|
+
* leaves the queue.
|
|
191
|
+
*
|
|
192
|
+
* It is not a tile because Bloom's retry is the PANEL's: with
|
|
193
|
+
* `onAttachmentRetry` set, every error tile draws a retry button, and a
|
|
194
|
+
* refusal has nothing to retry — the same file reaches the same verdict.
|
|
195
|
+
* A tile whose button does nothing is worse than a sentence.
|
|
196
|
+
* A failed READ stays, as an error tile whose retry re-reads its file.
|
|
197
|
+
*/
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
for (const item of intake.items) {
|
|
200
|
+
if (item.status !== 'refused') continue;
|
|
201
|
+
const error = intakeError(item, labels);
|
|
202
|
+
if (error !== undefined) report(error);
|
|
203
|
+
intake.dismiss(item.id);
|
|
204
|
+
}
|
|
205
|
+
}, [intake, labels, report]);
|
|
206
|
+
|
|
207
|
+
// Landed files, then the ones being read with their real progress, then the
|
|
208
|
+
// ones whose read failed, with why.
|
|
209
|
+
const tiles = useMemo<ComposerPanelAttachment[]>(
|
|
210
|
+
() =>
|
|
211
|
+
composerTiles(attachments, intake.items, labels)
|
|
212
|
+
.filter((tile) => tile.error === undefined || tile.retryable === true)
|
|
213
|
+
.map(({ retryable: _retryable, ...tile }) => tile),
|
|
214
|
+
[attachments, intake.items, labels],
|
|
215
|
+
);
|
|
216
|
+
const removeTile = useCallback(
|
|
217
|
+
(id: string) => {
|
|
218
|
+
if (intake.items.some((item) => item.id === id)) intake.cancel(id);
|
|
219
|
+
else removeAttachment(id);
|
|
220
|
+
},
|
|
221
|
+
[intake, removeAttachment],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const stt = useSpeechToText({ lang: speechLanguage });
|
|
225
|
+
// Said once per failure: a host re-rendering with new labels is not a new failure.
|
|
226
|
+
const sayVoiceError = useRef((code: VoiceErrorCode) => report(labels.voiceError(code)));
|
|
227
|
+
sayVoiceError.current = (code) => report(labels.voiceError(code));
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (stt.errorCode !== null) sayVoiceError.current(stt.errorCode);
|
|
230
|
+
}, [stt.errorCode]);
|
|
231
|
+
|
|
232
|
+
const handleListeningChange = useCallback(
|
|
233
|
+
async (next: boolean) => {
|
|
234
|
+
if (next) {
|
|
235
|
+
if (!disabled && !busy && !stt.isTranscribing) stt.startRecording();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const text = await stt.stopAndTranscribe();
|
|
239
|
+
if (text !== null) onValueChange(value === '' ? text : `${value} ${text}`);
|
|
240
|
+
},
|
|
241
|
+
[disabled, busy, stt, value, onValueChange],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const hasContent = value.trim() !== '' || attachments.length > 0;
|
|
245
|
+
const panelLabels = useMemo(
|
|
246
|
+
() => ({
|
|
247
|
+
message: labels.composerMessage,
|
|
248
|
+
addMenu: labels.composerAddMenu,
|
|
249
|
+
permissions: labels.composerModes,
|
|
250
|
+
permissionMode: labels.composerModes,
|
|
251
|
+
voice: labels.composerVoice,
|
|
252
|
+
send: labels.composerSend,
|
|
253
|
+
stop: labels.composerStop,
|
|
254
|
+
remove: labels.composerRemove,
|
|
255
|
+
// Bloom names the button "<retry> <file name>".
|
|
256
|
+
retry: labels.composerRetry,
|
|
257
|
+
}),
|
|
258
|
+
[labels],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const host = (
|
|
262
|
+
// The transcript's column (Bloom's `AgentChat`: 768 at most, centred), so
|
|
263
|
+
// the composer and the conversation share one edge.
|
|
264
|
+
<View id={hostId} className="w-full max-w-[768px] self-center">
|
|
265
|
+
<ComposerPanel
|
|
266
|
+
value={value}
|
|
267
|
+
onValueChange={onValueChange}
|
|
268
|
+
onSubmit={() => onSubmit()}
|
|
269
|
+
inputRef={inputRef}
|
|
270
|
+
busy={busy}
|
|
271
|
+
onStop={onStop}
|
|
272
|
+
disabled={disabled || intake.isBusy || !hasContent}
|
|
273
|
+
placeholder={placeholder ?? labels.placeholder}
|
|
274
|
+
addMenu={addMenu ?? EMPTY_ADD_MENU}
|
|
275
|
+
onAddMenuSelect={onAddMenuSelect}
|
|
276
|
+
permissions={modes ?? NO_MODES}
|
|
277
|
+
permission={mode}
|
|
278
|
+
onPermissionChange={onModeChange}
|
|
279
|
+
voiceState={stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'}
|
|
280
|
+
onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
|
|
281
|
+
attachments={tiles}
|
|
282
|
+
onRemoveAttachment={removeTile}
|
|
283
|
+
// Clears the tile's error and starts its progress again: `intake.retry`
|
|
284
|
+
// puts the item back to `reading` over the same `File`.
|
|
285
|
+
onAttachmentRetry={intake.retry}
|
|
286
|
+
question={
|
|
287
|
+
questions && questions.items.length > 0 ? (
|
|
288
|
+
<QuestionCard
|
|
289
|
+
questions={questions.items}
|
|
290
|
+
onSubmit={questions.onSubmit}
|
|
291
|
+
onDismiss={questions.onDismiss}
|
|
292
|
+
/>
|
|
293
|
+
) : undefined
|
|
294
|
+
}
|
|
295
|
+
status={status}
|
|
296
|
+
emptyAction={emptyAction}
|
|
297
|
+
onKeyPress={onKeyPress}
|
|
298
|
+
labels={panelLabels}
|
|
299
|
+
/>
|
|
300
|
+
<ComposerDropOverlay visible={takesFiles && isDragOver} enabled={acceptsFiles} />
|
|
301
|
+
{accessory ? (
|
|
302
|
+
<View pointerEvents="box-none" className="absolute bottom-full right-0 mb-2">
|
|
303
|
+
{accessory}
|
|
304
|
+
</View>
|
|
305
|
+
) : null}
|
|
306
|
+
</View>
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<>
|
|
311
|
+
{suggestions ? (
|
|
312
|
+
<ComposerSuggestions
|
|
313
|
+
completions={suggestions.completions}
|
|
314
|
+
selected={suggestions.selected}
|
|
315
|
+
onPick={suggestions.onPick}
|
|
316
|
+
/>
|
|
317
|
+
) : null}
|
|
318
|
+
{disableKeyboardAvoidance ? (
|
|
319
|
+
host
|
|
320
|
+
) : (
|
|
321
|
+
<KeyboardAvoidingView behavior="padding">{host}</KeyboardAvoidingView>
|
|
322
|
+
)}
|
|
323
|
+
</>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function AliaComposer<T extends AliaSuggestion = AliaSuggestion>({
|
|
328
|
+
labels,
|
|
329
|
+
...props
|
|
330
|
+
}: AliaComposerProps<T>) {
|
|
331
|
+
return (
|
|
332
|
+
<AliaChatLabelsProvider labels={labels}>
|
|
333
|
+
<AliaComposerBody {...props} />
|
|
334
|
+
</AliaChatLabelsProvider>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -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
|
+
}
|