AttachmentUpload.tsx260 lines · main
| 1 | // End of third-party imports |
| 2 | |
| 3 | import { compact } from 'lodash' |
| 4 | import { FileCode, Plus, X } from 'lucide-react' |
| 5 | import { |
| 6 | useCallback, |
| 7 | useEffect, |
| 8 | useMemo, |
| 9 | useRef, |
| 10 | useState, |
| 11 | type ChangeEvent, |
| 12 | type RefObject, |
| 13 | } from 'react' |
| 14 | import { toast } from 'sonner' |
| 15 | import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 16 | |
| 17 | import { createSupportStorageClient } from './support-storage-client' |
| 18 | import { InlineLink } from '@/components/ui/InlineLink' |
| 19 | import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation' |
| 20 | import { uuidv4 } from '@/lib/helpers' |
| 21 | import { useProfile } from '@/lib/profile' |
| 22 | |
| 23 | const MAX_ATTACHMENTS = 5 |
| 24 | |
| 25 | const uploadAttachments = async ({ userId, files }: { userId: string; files: File[] }) => { |
| 26 | const supportBrivenClient = createSupportStorageClient() |
| 27 | |
| 28 | const filesToUpload = Array.from(files) |
| 29 | const uploadedFiles = await Promise.all( |
| 30 | filesToUpload.map(async (file) => { |
| 31 | const suffix = file.name.endsWith('.har') ? 'har' : file.type.split('/')[1] |
| 32 | const prefix = `${userId}/${uuidv4()}.${suffix}` |
| 33 | const options = { cacheControl: '3600' } |
| 34 | |
| 35 | const { data, error } = await supportBrivenClient.storage |
| 36 | .from('support-attachments') |
| 37 | .upload(prefix, file, options) |
| 38 | |
| 39 | if (error) console.error('Failed to upload:', file.name, error) |
| 40 | return data |
| 41 | }) |
| 42 | ) |
| 43 | const keys = compact(uploadedFiles).map((file) => file.path) |
| 44 | return keys |
| 45 | } |
| 46 | |
| 47 | export function useAttachmentUpload() { |
| 48 | const { profile } = useProfile() |
| 49 | const uploadButtonRef = useRef<HTMLInputElement>(null) |
| 50 | const [uploadedFiles, setUploadedFiles] = useState<File[]>([]) |
| 51 | const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([]) |
| 52 | |
| 53 | const { mutateAsync: generateAttachmentURLs } = useGenerateAttachmentURLsMutation() |
| 54 | |
| 55 | const isFull = uploadedFiles.length >= MAX_ATTACHMENTS |
| 56 | |
| 57 | const addFile = useCallback(() => { |
| 58 | uploadButtonRef.current?.click() |
| 59 | }, []) |
| 60 | |
| 61 | const handleFileUpload = useCallback( |
| 62 | async (event: ChangeEvent<HTMLInputElement>) => { |
| 63 | event.persist() |
| 64 | const items = event.target.files || (event as any).dataTransfer.items |
| 65 | const itemsCopied = Array.prototype.map.call(items, (item: any) => item) as File[] |
| 66 | const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length) |
| 67 | |
| 68 | setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded)) |
| 69 | if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) { |
| 70 | toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`) |
| 71 | } |
| 72 | event.target.value = '' |
| 73 | }, |
| 74 | [uploadedFiles] |
| 75 | ) |
| 76 | |
| 77 | const removeFileUpload = useCallback( |
| 78 | (idx: number) => { |
| 79 | const updatedFiles = uploadedFiles.slice() |
| 80 | updatedFiles.splice(idx, 1) |
| 81 | setUploadedFiles(updatedFiles) |
| 82 | |
| 83 | const updatedDataUrls = uploadedDataUrls.slice() |
| 84 | uploadedDataUrls.splice(idx, 1) |
| 85 | setUploadedDataUrls(updatedDataUrls) |
| 86 | }, |
| 87 | [uploadedFiles, uploadedDataUrls] |
| 88 | ) |
| 89 | |
| 90 | useEffect(() => { |
| 91 | if (!uploadedFiles) return |
| 92 | const objectUrls = uploadedFiles.map((file) => { |
| 93 | if (file.name.endsWith('.har')) { |
| 94 | return file.name |
| 95 | } else { |
| 96 | return URL.createObjectURL(file) |
| 97 | } |
| 98 | }) |
| 99 | setUploadedDataUrls(objectUrls) |
| 100 | |
| 101 | return () => { |
| 102 | objectUrls.forEach((url: any) => void URL.revokeObjectURL(url)) |
| 103 | } |
| 104 | }, [uploadedFiles]) |
| 105 | |
| 106 | const createAttachments = useCallback(async () => { |
| 107 | if (!profile?.id) { |
| 108 | console.error('[Support Form > uploadAttachments] Unable to upload files, missing user ID') |
| 109 | toast.error('Unable to upload attachments') |
| 110 | return [] |
| 111 | } |
| 112 | |
| 113 | if (uploadedFiles.length === 0) return |
| 114 | |
| 115 | try { |
| 116 | const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles }) |
| 117 | const urls = await generateAttachmentURLs({ bucket: 'support-attachments', filenames }) |
| 118 | return urls |
| 119 | } catch { |
| 120 | // Ignore attachments upload errors, images are additional context and support can ask for more if needed |
| 121 | return |
| 122 | } |
| 123 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 124 | }, [profile, uploadedFiles]) |
| 125 | |
| 126 | return useMemo( |
| 127 | () => ({ |
| 128 | uploadButtonRef, |
| 129 | isFull, |
| 130 | addFile, |
| 131 | handleFileUpload, |
| 132 | removeFileUpload, |
| 133 | createAttachments, |
| 134 | uploadedDataUrls, |
| 135 | }), |
| 136 | [isFull, addFile, handleFileUpload, removeFileUpload, createAttachments, uploadedDataUrls] |
| 137 | ) |
| 138 | } |
| 139 | |
| 140 | interface AttachmentUploadDisplayProps { |
| 141 | uploadButtonRef: RefObject<HTMLInputElement | null> |
| 142 | isFull: boolean |
| 143 | uploadedDataUrls: string[] |
| 144 | addFile: () => void |
| 145 | handleFileUpload: (event: ChangeEvent<HTMLInputElement>) => Promise<void> |
| 146 | removeFileUpload: (idx: number) => void |
| 147 | } |
| 148 | |
| 149 | export function AttachmentUploadDisplay({ |
| 150 | uploadButtonRef, |
| 151 | isFull, |
| 152 | uploadedDataUrls, |
| 153 | addFile, |
| 154 | handleFileUpload, |
| 155 | removeFileUpload, |
| 156 | }: AttachmentUploadDisplayProps) { |
| 157 | const { profile } = useProfile() |
| 158 | |
| 159 | if (!profile) { |
| 160 | return ( |
| 161 | <div> |
| 162 | <h3 className="text-sm text-foreground">Attachments</h3> |
| 163 | <p className="text-sm text-foreground-lighter mt-2"> |
| 164 | Uploads are only supported when logged in. Please reply to the acknowledgement email you |
| 165 | will receive with any screenshots you'd like to upload. |
| 166 | </p> |
| 167 | </div> |
| 168 | ) |
| 169 | } |
| 170 | |
| 171 | return ( |
| 172 | <div className="flex flex-col gap-y-4"> |
| 173 | <div className="flex flex-col gap-y-1"> |
| 174 | <p className="text-sm text-foreground">Attachments</p> |
| 175 | <p className="text-sm text-foreground-lighter"> |
| 176 | Optionally upload up to {MAX_ATTACHMENTS} relevant images or{' '} |
| 177 | <InlineLink href="https://github.com/orgs/briven/discussions/36540"> |
| 178 | HAR files |
| 179 | </InlineLink> |
| 180 | </p> |
| 181 | </div> |
| 182 | <input |
| 183 | multiple |
| 184 | type="file" |
| 185 | ref={uploadButtonRef} |
| 186 | className="hidden" |
| 187 | accept="image/png, image/jpeg, .har" |
| 188 | onChange={handleFileUpload} |
| 189 | /> |
| 190 | <div className="flex items-center gap-x-2"> |
| 191 | {uploadedDataUrls.map((url, idx) => { |
| 192 | if (url.endsWith('.har')) { |
| 193 | return ( |
| 194 | <div |
| 195 | key={url} |
| 196 | className="border relative h-14 w-14 rounded-sm flex items-center justify-center" |
| 197 | > |
| 198 | <Tooltip> |
| 199 | <TooltipTrigger className="cursor-default" onClick={(e) => e.preventDefault()}> |
| 200 | <div className="flex flex-col items-center justify-center gap-y-1"> |
| 201 | <FileCode className="text-foreground-light" size={16} /> |
| 202 | <p className="text-[10px] font-mono text-foreground-light tracking-wide leading-none"> |
| 203 | HAR |
| 204 | </p> |
| 205 | </div> |
| 206 | </TooltipTrigger> |
| 207 | <TooltipContent side="bottom">{url}</TooltipContent> |
| 208 | </Tooltip> |
| 209 | |
| 210 | <button |
| 211 | type="button" |
| 212 | aria-label="Remove attachment" |
| 213 | className={cn( |
| 214 | 'flex h-4 w-4 items-center justify-center rounded-full bg-red-900', |
| 215 | 'absolute -top-1 -right-1 cursor-pointer' |
| 216 | )} |
| 217 | onClick={() => removeFileUpload(idx)} |
| 218 | > |
| 219 | <X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" /> |
| 220 | </button> |
| 221 | </div> |
| 222 | ) |
| 223 | } else { |
| 224 | return ( |
| 225 | <div |
| 226 | key={url} |
| 227 | style={{ backgroundImage: `url("${url}")` }} |
| 228 | className="relative h-14 w-14 rounded-sm bg-cover bg-center bg-no-repeat" |
| 229 | > |
| 230 | <button |
| 231 | type="button" |
| 232 | aria-label="Remove attachment" |
| 233 | className={cn( |
| 234 | 'flex h-4 w-4 items-center justify-center rounded-full bg-red-900', |
| 235 | 'absolute -top-1 -right-1 cursor-pointer' |
| 236 | )} |
| 237 | onClick={() => removeFileUpload(idx)} |
| 238 | > |
| 239 | <X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" /> |
| 240 | </button> |
| 241 | </div> |
| 242 | ) |
| 243 | } |
| 244 | })} |
| 245 | {!isFull && ( |
| 246 | <button |
| 247 | type="button" |
| 248 | className={cn( |
| 249 | 'border border-stronger opacity-50 transition hover:opacity-100', |
| 250 | 'group flex h-14 w-14 cursor-pointer items-center justify-center rounded-sm' |
| 251 | )} |
| 252 | onClick={addFile} |
| 253 | > |
| 254 | <Plus strokeWidth={2} size={20} /> |
| 255 | </button> |
| 256 | )} |
| 257 | </div> |
| 258 | </div> |
| 259 | ) |
| 260 | } |