AttachmentUpload.tsx 8.4 KB

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