addons/isl/src/ImageUpload.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {RefObject} from 'react';
b69ab319
b69ab3110import {Button} from 'isl-components/Button';
b69ab3111import {InlineErrorBadge} from 'isl-components/ErrorNotice';
b69ab3112import {Icon} from 'isl-components/Icon';
b69ab3113import {Tooltip} from 'isl-components/Tooltip';
b69ab3114import {atom, useAtomValue} from 'jotai';
b69ab3115import {useId, useState, type ReactNode} from 'react';
b69ab3116import {randomId} from 'shared/utils';
b69ab3117import clientToServerAPI from './ClientToServerAPI';
b69ab3118import {T, t} from './i18n';
b69ab3119import {atomFamilyWeak, readAtom, writeAtom} from './jotaiUtils';
b69ab3120import platform from './platform';
b69ab3121import {insertAtCursor, replaceInTextArea} from './textareaUtils';
b69ab3122
b69ab3123export type ImageUploadStatus = {id: number; field: string} & (
b69ab3124 | {status: 'pending'}
b69ab3125 | {status: 'complete'}
b69ab3126 | {status: 'error'; error: Error; resolved: boolean}
b69ab3127 | {status: 'canceled'}
b69ab3128);
b69ab3129export const imageUploadState = atom<{next: number; states: Record<number, ImageUploadStatus>}>({
b69ab3130 next: 1,
b69ab3131 states: {},
b69ab3132});
b69ab3133
b69ab3134/**
b69ab3135 * Number of currently ongoing image uploads for a given field name.
b69ab3136 * If undefined is givens as the field name, all pending uploads across all fields are counted. */
b69ab3137export const numPendingImageUploads = atomFamilyWeak((fieldName: string | undefined) =>
b69ab3138 atom((get): number => {
b69ab3139 const state = get(imageUploadState);
b69ab3140 return Object.values(state.states).filter(
b69ab3141 state => (fieldName == null || state.field === fieldName) && state.status === 'pending',
b69ab3142 ).length;
b69ab3143 }),
b69ab3144);
b69ab3145
b69ab3146type UnresolvedErrorImageUploadStatus = ImageUploadStatus & {status: 'error'; resolved: false};
b69ab3147export const unresolvedErroredImagedUploads = atomFamilyWeak((fieldName: string) =>
b69ab3148 atom(get => {
b69ab3149 const state = get(imageUploadState);
b69ab3150 return Object.values(state.states).filter(
b69ab3151 (state): state is UnresolvedErrorImageUploadStatus =>
b69ab3152 state.field == fieldName && state.status === 'error' && !state.resolved,
b69ab3153 );
b69ab3154 }),
b69ab3155);
b69ab3156
b69ab3157function placeholderForImageUpload(id: number): string {
b69ab3158 // TODO: it might be better to use a `contenteditable: true` div rather than
b69ab3159 // inserting text as a placeholder. It's possible to partly or completely delete this
b69ab3160 // text and then the image link won't get inserted properly.
b69ab3161 // TODO: We could add a text-based spinner that we periodically update, to make it feel like it's in progress:
b69ab3162 // ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
b69ab3163 return `【 Uploading #${id} 】`;
b69ab3164}
b69ab3165
b69ab3166function getBase64(file: File): Promise<string> {
b69ab3167 return new Promise((res, rej) => {
b69ab3168 const reader = new FileReader();
b69ab3169 reader.onload = function () {
b69ab3170 const result = reader.result as string | null;
b69ab3171 if (result == null) {
b69ab3172 rej(new Error('got empty file content'));
b69ab3173 return;
b69ab3174 }
b69ab3175 // The loaded image will be in the form:
b69ab3176 // data:image/png;base64,iVB0R...
b69ab3177 // Strip away the prefix (up to the ',') to get to the actual base64 portion
b69ab3178 const commaIndex = result.indexOf(',');
b69ab3179 if (commaIndex === -1) {
b69ab3180 rej(new Error('file data is not in `data:*/*;base64,` format'));
b69ab3181 return;
b69ab3182 }
b69ab3183 res(result.slice(commaIndex + 1));
b69ab3184 };
b69ab3185 reader.onerror = function (error) {
b69ab3186 rej(error);
b69ab3187 };
b69ab3188 reader.readAsDataURL(file);
b69ab3189 });
b69ab3190}
b69ab3191
b69ab3192/**
b69ab3193 * Send a File's contents to the server to get uploaded by an upload service,
b69ab3194 * and return the link to embed in the textArea.
b69ab3195 */
b69ab3196export async function uploadFile(file: File): Promise<string> {
b69ab3197 const base64 = await getBase64(file);
b69ab3198 const id = randomId();
b69ab3199 clientToServerAPI.postMessage({
b69ab31100 type: 'uploadFile',
b69ab31101 filename: file.name,
b69ab31102 id,
b69ab31103 b64Content: base64,
b69ab31104 });
b69ab31105 const result = await clientToServerAPI.nextMessageMatching(
b69ab31106 'uploadFileResult',
b69ab31107 message => message.id === id,
b69ab31108 );
b69ab31109
b69ab31110 if (result.result.error) {
b69ab31111 throw result.result.error;
b69ab31112 }
b69ab31113
b69ab31114 const uploadedUrl = result.result.value;
b69ab31115 return uploadedUrl;
b69ab31116}
b69ab31117
b69ab31118/**
b69ab31119 * Summary of ongoing image uploads. Click to cancel all ongoing uploads.
b69ab31120 */
b69ab31121export function PendingImageUploads({
b69ab31122 fieldName,
b69ab31123 textAreaRef,
b69ab31124}: {
b69ab31125 fieldName: string;
b69ab31126 textAreaRef: RefObject<HTMLTextAreaElement>;
b69ab31127}) {
b69ab31128 const numPending = useAtomValue(numPendingImageUploads(fieldName));
b69ab31129 const unresolvedErrors = useAtomValue(unresolvedErroredImagedUploads(fieldName));
b69ab31130 const [isHovering, setIsHovering] = useState(false);
b69ab31131 const onCancel = () => {
b69ab31132 setIsHovering(false);
b69ab31133 // Canceling ongoing uploads doesn't actually interrupt the async work for the uploads,
b69ab31134 // it just deletes the tracking state, by replacing 'pending' uploads as 'cancelled'.
b69ab31135 writeAtom(imageUploadState, current => {
b69ab31136 const canceledIds: Array<number> = [];
b69ab31137 const newState = {
b69ab31138 ...current,
b69ab31139 states: Object.fromEntries(
b69ab31140 Object.entries(current.states).map(([idStr, state]) => {
b69ab31141 const id = Number(idStr);
b69ab31142 if (state.field === fieldName && state.status === 'pending') {
b69ab31143 canceledIds.push(id);
b69ab31144 return [id, {state: 'cancelled', id, fieldName}];
b69ab31145 }
b69ab31146 return [id, state];
b69ab31147 }),
b69ab31148 ) as Record<number, ImageUploadStatus>,
b69ab31149 };
b69ab31150
b69ab31151 const textArea = textAreaRef.current;
b69ab31152 if (textArea) {
b69ab31153 for (const id of canceledIds) {
b69ab31154 const placeholder = placeholderForImageUpload(id);
b69ab31155 replaceInTextArea(textArea, placeholder, ''); // delete placeholder
b69ab31156 }
b69ab31157 }
b69ab31158 return newState;
b69ab31159 });
b69ab31160 };
b69ab31161
b69ab31162 const onDismissErrors = () => {
b69ab31163 writeAtom(imageUploadState, value => ({
b69ab31164 ...value,
b69ab31165 states: Object.fromEntries(
b69ab31166 Object.entries(value.states).map(([id, state]) => [
b69ab31167 id,
b69ab31168 state.field === fieldName && state.status === 'error'
b69ab31169 ? {...state, resolved: true}
b69ab31170 : state,
b69ab31171 ]),
b69ab31172 ),
b69ab31173 }));
b69ab31174 };
b69ab31175
b69ab31176 if (unresolvedErrors.length === 0 && numPending === 0) {
b69ab31177 return null;
b69ab31178 }
b69ab31179
b69ab31180 let content;
b69ab31181 if (unresolvedErrors.length > 0) {
b69ab31182 content = (
b69ab31183 <span className="upload-status-error">
b69ab31184 <Tooltip title={t('Click to dismiss error')}>
b69ab31185 <Button icon onClick={onDismissErrors} data-testid="dismiss-upload-errors">
b69ab31186 <Icon icon="close" />
b69ab31187 </Button>
b69ab31188 </Tooltip>
b69ab31189 <InlineErrorBadge error={unresolvedErrors[0].error} title={<T>Image upload failed</T>}>
b69ab31190 <T count={unresolvedErrors.length}>imageUploadFailed</T>
b69ab31191 </InlineErrorBadge>
b69ab31192 </span>
b69ab31193 );
b69ab31194 } else if (numPending > 0) {
b69ab31195 if (isHovering) {
b69ab31196 content = (
b69ab31197 <Button icon>
b69ab31198 <Icon icon="stop-circle" slot="start" />
b69ab31199 <T>Click to cancel</T>
b69ab31200 </Button>
b69ab31201 );
b69ab31202 } else {
b69ab31203 content = (
b69ab31204 <Button icon>
b69ab31205 <Icon icon="loading" slot="start" />
b69ab31206 <T count={numPending}>numImagesUploading</T>
b69ab31207 </Button>
b69ab31208 );
b69ab31209 }
b69ab31210 }
b69ab31211
b69ab31212 return (
b69ab31213 <span
b69ab31214 className="upload-status"
b69ab31215 onClick={onCancel}
b69ab31216 onMouseEnter={() => setIsHovering(true)}
b69ab31217 onMouseLeave={() => setIsHovering(false)}>
b69ab31218 {content}
b69ab31219 </span>
b69ab31220 );
b69ab31221}
b69ab31222
b69ab31223export function FilePicker({uploadFiles}: {uploadFiles: (files: Array<File>) => unknown}) {
b69ab31224 const id = useId();
b69ab31225 return (
b69ab31226 <span key="choose-file">
b69ab31227 <input
b69ab31228 type="file"
b69ab31229 accept="image/*,video/*"
b69ab31230 className="choose-file"
b69ab31231 data-testid="attach-file-input"
b69ab31232 id={id}
b69ab31233 multiple
b69ab31234 onChange={event => {
b69ab31235 if (event.target.files) {
b69ab31236 uploadFiles([...event.target.files]);
b69ab31237 }
b69ab31238 event.target.files = null;
b69ab31239 }}
b69ab31240 />
b69ab31241 <label htmlFor={id}>
b69ab31242 <Tooltip
b69ab31243 title={t(
b69ab31244 'Choose image or video files to upload. Drag & Drop and Pasting images or videos is also supported.',
b69ab31245 )}>
b69ab31246 <Button
b69ab31247 icon
b69ab31248 data-testid="attach-file-button"
b69ab31249 onClick={e => {
b69ab31250 if (platform.chooseFile != null) {
b69ab31251 e.preventDefault();
b69ab31252 e.stopPropagation();
b69ab31253 platform.chooseFile('Choose file to upload', /* multi */ true).then(chosen => {
b69ab31254 if (chosen.length > 0) {
b69ab31255 uploadFiles(chosen);
b69ab31256 }
b69ab31257 });
b69ab31258 } else {
b69ab31259 // By default, <button> clicks do not forward to the parent <label>'s htmlFor target.
b69ab31260 // Manually trigger a click on the element instead.
b69ab31261 const input = document.getElementById(id);
b69ab31262 if (input) {
b69ab31263 input.click();
b69ab31264 }
b69ab31265 }
b69ab31266 }}>
b69ab31267 <PaperclipIcon />
b69ab31268 </Button>
b69ab31269 </Tooltip>
b69ab31270 </label>
b69ab31271 </span>
b69ab31272 );
b69ab31273}
b69ab31274
b69ab31275export function useUploadFilesCallback(
b69ab31276 fieldName: string,
b69ab31277 ref: RefObject<HTMLTextAreaElement>,
b69ab31278 onInput: (e: {currentTarget: HTMLTextAreaElement}) => unknown,
b69ab31279) {
b69ab31280 return async (files: Array<File>) => {
b69ab31281 // capture snapshot of next before doing async work
b69ab31282 // we need to account for all files in this batch
b69ab31283 let {next} = readAtom(imageUploadState);
b69ab31284
b69ab31285 const textArea = ref.current;
b69ab31286 if (textArea != null) {
b69ab31287 // manipulating the text area directly does not emit change events,
b69ab31288 // we need to simulate those ourselves so that controlled text areas
b69ab31289 // update their underlying store
b69ab31290 const emitChangeEvent = () => {
b69ab31291 onInput({
b69ab31292 currentTarget: textArea,
b69ab31293 });
b69ab31294 };
b69ab31295
b69ab31296 await Promise.all(
b69ab31297 files.map(async file => {
b69ab31298 const id = next;
b69ab31299 next += 1;
b69ab31300 const state: ImageUploadStatus = {status: 'pending' as const, id, field: fieldName};
b69ab31301 writeAtom(imageUploadState, v => ({next, states: {...v.states, [id]: state}}));
b69ab31302 // insert pending text
b69ab31303 const placeholder = placeholderForImageUpload(state.id);
b69ab31304 insertAtCursor(textArea, placeholder);
b69ab31305 emitChangeEvent();
b69ab31306
b69ab31307 // start the file upload
b69ab31308 try {
b69ab31309 const uploadedFileText = await uploadFile(file);
b69ab31310 writeAtom(imageUploadState, v => ({
b69ab31311 next,
b69ab31312 states: {...v.states, [id]: {status: 'complete' as const, id, field: fieldName}},
b69ab31313 }));
b69ab31314 replaceInTextArea(textArea, placeholder, uploadedFileText);
b69ab31315 emitChangeEvent();
b69ab31316 } catch (error) {
b69ab31317 writeAtom(imageUploadState, v => ({
b69ab31318 next,
b69ab31319 states: {
b69ab31320 ...v.states,
b69ab31321 [id]: {
b69ab31322 status: 'error' as const,
b69ab31323 id,
b69ab31324 field: fieldName,
b69ab31325 error: error as Error,
b69ab31326 resolved: false,
b69ab31327 },
b69ab31328 },
b69ab31329 }));
b69ab31330 replaceInTextArea(textArea, placeholder, ''); // delete placeholder
b69ab31331 emitChangeEvent();
b69ab31332 }
b69ab31333 }),
b69ab31334 );
b69ab31335 }
b69ab31336 };
b69ab31337}
b69ab31338
b69ab31339/**
b69ab31340 * Wrapper around children to allow dragging & dropping a file onto it.
b69ab31341 * Renders a highlight when hovering with a file.
b69ab31342 */
b69ab31343export function ImageDropZone({
b69ab31344 children,
b69ab31345 onDrop,
b69ab31346}: {
b69ab31347 children: ReactNode;
b69ab31348 onDrop: (files: Array<File>) => void;
b69ab31349}) {
b69ab31350 const [isHoveringToDropImage, setIsHoveringToDrop] = useState(false);
b69ab31351 const highlight = (e: React.DragEvent) => {
b69ab31352 if (e.dataTransfer.files.length > 0 || e.dataTransfer.items.length > 0) {
b69ab31353 // only highlight if you're dragging files
b69ab31354 setIsHoveringToDrop(true);
b69ab31355 }
b69ab31356 e.preventDefault();
b69ab31357 e.stopPropagation();
b69ab31358 };
b69ab31359 const unhighlight = (e: React.DragEvent) => {
b69ab31360 setIsHoveringToDrop(false);
b69ab31361 e.preventDefault();
b69ab31362 e.stopPropagation();
b69ab31363 };
b69ab31364 return (
b69ab31365 <div
b69ab31366 className={'image-drop-zone' + (isHoveringToDropImage ? ' hovering-to-drop' : '')}
b69ab31367 onDragEnter={highlight}
b69ab31368 onDragOver={highlight}
b69ab31369 onDragLeave={unhighlight}
b69ab31370 onDrop={event => {
b69ab31371 unhighlight(event);
b69ab31372 onDrop([...event.dataTransfer.files]);
b69ab31373 }}>
b69ab31374 {children}
b69ab31375 </div>
b69ab31376 );
b69ab31377}
b69ab31378
b69ab31379/**
b69ab31380 * Codicon-like 16x16 paperclip icon.
b69ab31381 * This seems to be the standard iconographic way to attach files to a text area.
b69ab31382 * Can you believe codicons don't have a paperclip icon?
b69ab31383 */
b69ab31384function PaperclipIcon() {
b69ab31385 return (
b69ab31386 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
b69ab31387 <path
b69ab31388 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"
b69ab31389 stroke="currentColor"
b69ab31390 strokeWidth="0.8"
b69ab31391 strokeLinecap="round"
b69ab31392 />
b69ab31393 </svg>
b69ab31394 );
b69ab31395}