AttachmentUpload.tsx260 lines · main
1// End of third-party imports
2
3import { compact } from 'lodash'
4import { FileCode, Plus, X } from 'lucide-react'
5import {
6 useCallback,
7 useEffect,
8 useMemo,
9 useRef,
10 useState,
11 type ChangeEvent,
12 type RefObject,
13} from 'react'
14import { toast } from 'sonner'
15import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
16
17import { createSupportStorageClient } from './support-storage-client'
18import { InlineLink } from '@/components/ui/InlineLink'
19import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation'
20import { uuidv4 } from '@/lib/helpers'
21import { useProfile } from '@/lib/profile'
22
23const MAX_ATTACHMENTS = 5
24
25const 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
47export 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
140interface 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
149export 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}