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