12.5 KB396 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {RefObject} from 'react';
9
10import {Button} from 'isl-components/Button';
11import {InlineErrorBadge} from 'isl-components/ErrorNotice';
12import {Icon} from 'isl-components/Icon';
13import {Tooltip} from 'isl-components/Tooltip';
14import {atom, useAtomValue} from 'jotai';
15import {useId, useState, type ReactNode} from 'react';
16import {randomId} from 'shared/utils';
17import clientToServerAPI from './ClientToServerAPI';
18import {T, t} from './i18n';
19import {atomFamilyWeak, readAtom, writeAtom} from './jotaiUtils';
20import platform from './platform';
21import {insertAtCursor, replaceInTextArea} from './textareaUtils';
22
23export type ImageUploadStatus = {id: number; field: string} & (
24 | {status: 'pending'}
25 | {status: 'complete'}
26 | {status: 'error'; error: Error; resolved: boolean}
27 | {status: 'canceled'}
28);
29export const imageUploadState = atom<{next: number; states: Record<number, ImageUploadStatus>}>({
30 next: 1,
31 states: {},
32});
33
34/**
35 * Number of currently ongoing image uploads for a given field name.
36 * If undefined is givens as the field name, all pending uploads across all fields are counted. */
37export const numPendingImageUploads = atomFamilyWeak((fieldName: string | undefined) =>
38 atom((get): number => {
39 const state = get(imageUploadState);
40 return Object.values(state.states).filter(
41 state => (fieldName == null || state.field === fieldName) && state.status === 'pending',
42 ).length;
43 }),
44);
45
46type UnresolvedErrorImageUploadStatus = ImageUploadStatus & {status: 'error'; resolved: false};
47export const unresolvedErroredImagedUploads = atomFamilyWeak((fieldName: string) =>
48 atom(get => {
49 const state = get(imageUploadState);
50 return Object.values(state.states).filter(
51 (state): state is UnresolvedErrorImageUploadStatus =>
52 state.field == fieldName && state.status === 'error' && !state.resolved,
53 );
54 }),
55);
56
57function placeholderForImageUpload(id: number): string {
58 // TODO: it might be better to use a `contenteditable: true` div rather than
59 // inserting text as a placeholder. It's possible to partly or completely delete this
60 // text and then the image link won't get inserted properly.
61 // TODO: We could add a text-based spinner that we periodically update, to make it feel like it's in progress:
62 // ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
63 return `【 Uploading #${id} 】`;
64}
65
66function getBase64(file: File): Promise<string> {
67 return new Promise((res, rej) => {
68 const reader = new FileReader();
69 reader.onload = function () {
70 const result = reader.result as string | null;
71 if (result == null) {
72 rej(new Error('got empty file content'));
73 return;
74 }
75 // The loaded image will be in the form:
76 // data:image/png;base64,iVB0R...
77 // Strip away the prefix (up to the ',') to get to the actual base64 portion
78 const commaIndex = result.indexOf(',');
79 if (commaIndex === -1) {
80 rej(new Error('file data is not in `data:*/*;base64,` format'));
81 return;
82 }
83 res(result.slice(commaIndex + 1));
84 };
85 reader.onerror = function (error) {
86 rej(error);
87 };
88 reader.readAsDataURL(file);
89 });
90}
91
92/**
93 * Send a File's contents to the server to get uploaded by an upload service,
94 * and return the link to embed in the textArea.
95 */
96export async function uploadFile(file: File): Promise<string> {
97 const base64 = await getBase64(file);
98 const id = randomId();
99 clientToServerAPI.postMessage({
100 type: 'uploadFile',
101 filename: file.name,
102 id,
103 b64Content: base64,
104 });
105 const result = await clientToServerAPI.nextMessageMatching(
106 'uploadFileResult',
107 message => message.id === id,
108 );
109
110 if (result.result.error) {
111 throw result.result.error;
112 }
113
114 const uploadedUrl = result.result.value;
115 return uploadedUrl;
116}
117
118/**
119 * Summary of ongoing image uploads. Click to cancel all ongoing uploads.
120 */
121export function PendingImageUploads({
122 fieldName,
123 textAreaRef,
124}: {
125 fieldName: string;
126 textAreaRef: RefObject<HTMLTextAreaElement>;
127}) {
128 const numPending = useAtomValue(numPendingImageUploads(fieldName));
129 const unresolvedErrors = useAtomValue(unresolvedErroredImagedUploads(fieldName));
130 const [isHovering, setIsHovering] = useState(false);
131 const onCancel = () => {
132 setIsHovering(false);
133 // Canceling ongoing uploads doesn't actually interrupt the async work for the uploads,
134 // it just deletes the tracking state, by replacing 'pending' uploads as 'cancelled'.
135 writeAtom(imageUploadState, current => {
136 const canceledIds: Array<number> = [];
137 const newState = {
138 ...current,
139 states: Object.fromEntries(
140 Object.entries(current.states).map(([idStr, state]) => {
141 const id = Number(idStr);
142 if (state.field === fieldName && state.status === 'pending') {
143 canceledIds.push(id);
144 return [id, {state: 'cancelled', id, fieldName}];
145 }
146 return [id, state];
147 }),
148 ) as Record<number, ImageUploadStatus>,
149 };
150
151 const textArea = textAreaRef.current;
152 if (textArea) {
153 for (const id of canceledIds) {
154 const placeholder = placeholderForImageUpload(id);
155 replaceInTextArea(textArea, placeholder, ''); // delete placeholder
156 }
157 }
158 return newState;
159 });
160 };
161
162 const onDismissErrors = () => {
163 writeAtom(imageUploadState, value => ({
164 ...value,
165 states: Object.fromEntries(
166 Object.entries(value.states).map(([id, state]) => [
167 id,
168 state.field === fieldName && state.status === 'error'
169 ? {...state, resolved: true}
170 : state,
171 ]),
172 ),
173 }));
174 };
175
176 if (unresolvedErrors.length === 0 && numPending === 0) {
177 return null;
178 }
179
180 let content;
181 if (unresolvedErrors.length > 0) {
182 content = (
183 <span className="upload-status-error">
184 <Tooltip title={t('Click to dismiss error')}>
185 <Button icon onClick={onDismissErrors} data-testid="dismiss-upload-errors">
186 <Icon icon="close" />
187 </Button>
188 </Tooltip>
189 <InlineErrorBadge error={unresolvedErrors[0].error} title={<T>Image upload failed</T>}>
190 <T count={unresolvedErrors.length}>imageUploadFailed</T>
191 </InlineErrorBadge>
192 </span>
193 );
194 } else if (numPending > 0) {
195 if (isHovering) {
196 content = (
197 <Button icon>
198 <Icon icon="stop-circle" slot="start" />
199 <T>Click to cancel</T>
200 </Button>
201 );
202 } else {
203 content = (
204 <Button icon>
205 <Icon icon="loading" slot="start" />
206 <T count={numPending}>numImagesUploading</T>
207 </Button>
208 );
209 }
210 }
211
212 return (
213 <span
214 className="upload-status"
215 onClick={onCancel}
216 onMouseEnter={() => setIsHovering(true)}
217 onMouseLeave={() => setIsHovering(false)}>
218 {content}
219 </span>
220 );
221}
222
223export function FilePicker({uploadFiles}: {uploadFiles: (files: Array<File>) => unknown}) {
224 const id = useId();
225 return (
226 <span key="choose-file">
227 <input
228 type="file"
229 accept="image/*,video/*"
230 className="choose-file"
231 data-testid="attach-file-input"
232 id={id}
233 multiple
234 onChange={event => {
235 if (event.target.files) {
236 uploadFiles([...event.target.files]);
237 }
238 event.target.files = null;
239 }}
240 />
241 <label htmlFor={id}>
242 <Tooltip
243 title={t(
244 'Choose image or video files to upload. Drag & Drop and Pasting images or videos is also supported.',
245 )}>
246 <Button
247 icon
248 data-testid="attach-file-button"
249 onClick={e => {
250 if (platform.chooseFile != null) {
251 e.preventDefault();
252 e.stopPropagation();
253 platform.chooseFile('Choose file to upload', /* multi */ true).then(chosen => {
254 if (chosen.length > 0) {
255 uploadFiles(chosen);
256 }
257 });
258 } else {
259 // By default, <button> clicks do not forward to the parent <label>'s htmlFor target.
260 // Manually trigger a click on the element instead.
261 const input = document.getElementById(id);
262 if (input) {
263 input.click();
264 }
265 }
266 }}>
267 <PaperclipIcon />
268 </Button>
269 </Tooltip>
270 </label>
271 </span>
272 );
273}
274
275export function useUploadFilesCallback(
276 fieldName: string,
277 ref: RefObject<HTMLTextAreaElement>,
278 onInput: (e: {currentTarget: HTMLTextAreaElement}) => unknown,
279) {
280 return async (files: Array<File>) => {
281 // capture snapshot of next before doing async work
282 // we need to account for all files in this batch
283 let {next} = readAtom(imageUploadState);
284
285 const textArea = ref.current;
286 if (textArea != null) {
287 // manipulating the text area directly does not emit change events,
288 // we need to simulate those ourselves so that controlled text areas
289 // update their underlying store
290 const emitChangeEvent = () => {
291 onInput({
292 currentTarget: textArea,
293 });
294 };
295
296 await Promise.all(
297 files.map(async file => {
298 const id = next;
299 next += 1;
300 const state: ImageUploadStatus = {status: 'pending' as const, id, field: fieldName};
301 writeAtom(imageUploadState, v => ({next, states: {...v.states, [id]: state}}));
302 // insert pending text
303 const placeholder = placeholderForImageUpload(state.id);
304 insertAtCursor(textArea, placeholder);
305 emitChangeEvent();
306
307 // start the file upload
308 try {
309 const uploadedFileText = await uploadFile(file);
310 writeAtom(imageUploadState, v => ({
311 next,
312 states: {...v.states, [id]: {status: 'complete' as const, id, field: fieldName}},
313 }));
314 replaceInTextArea(textArea, placeholder, uploadedFileText);
315 emitChangeEvent();
316 } catch (error) {
317 writeAtom(imageUploadState, v => ({
318 next,
319 states: {
320 ...v.states,
321 [id]: {
322 status: 'error' as const,
323 id,
324 field: fieldName,
325 error: error as Error,
326 resolved: false,
327 },
328 },
329 }));
330 replaceInTextArea(textArea, placeholder, ''); // delete placeholder
331 emitChangeEvent();
332 }
333 }),
334 );
335 }
336 };
337}
338
339/**
340 * Wrapper around children to allow dragging & dropping a file onto it.
341 * Renders a highlight when hovering with a file.
342 */
343export function ImageDropZone({
344 children,
345 onDrop,
346}: {
347 children: ReactNode;
348 onDrop: (files: Array<File>) => void;
349}) {
350 const [isHoveringToDropImage, setIsHoveringToDrop] = useState(false);
351 const highlight = (e: React.DragEvent) => {
352 if (e.dataTransfer.files.length > 0 || e.dataTransfer.items.length > 0) {
353 // only highlight if you're dragging files
354 setIsHoveringToDrop(true);
355 }
356 e.preventDefault();
357 e.stopPropagation();
358 };
359 const unhighlight = (e: React.DragEvent) => {
360 setIsHoveringToDrop(false);
361 e.preventDefault();
362 e.stopPropagation();
363 };
364 return (
365 <div
366 className={'image-drop-zone' + (isHoveringToDropImage ? ' hovering-to-drop' : '')}
367 onDragEnter={highlight}
368 onDragOver={highlight}
369 onDragLeave={unhighlight}
370 onDrop={event => {
371 unhighlight(event);
372 onDrop([...event.dataTransfer.files]);
373 }}>
374 {children}
375 </div>
376 );
377}
378
379/**
380 * Codicon-like 16x16 paperclip icon.
381 * This seems to be the standard iconographic way to attach files to a text area.
382 * Can you believe codicons don't have a paperclip icon?
383 */
384function PaperclipIcon() {
385 return (
386 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
387 <path
388 d="M5.795 9.25053L8.43233 6.58103C8.72536 6.35421 9.44589 6.03666 9.9837 6.58103C10.5215 7.1254 10.2078 7.78492 9.9837 8.04664L5.49998 12.5C4.99998 13 3.91267 13.2914 3.00253 12.2864C2.0924 11.2814 2.49999 10 3.00253 9.4599L8.89774 3.64982C9.51829 3.12638 11.111 2.42499 12.5176 3.80685C13.9242 5.1887 13.5 7 12.5 8L8.43233 12.2864"
389 stroke="currentColor"
390 strokeWidth="0.8"
391 strokeLinecap="round"
392 />
393 </svg>
394 );
395}
396