FeedbackWidget.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. // @ts-nocheck
  2. import { useDebounce } from '@uidotdev/usehooks'
  3. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  4. import { AnimatePresence, motion } from 'framer-motion'
  5. import { toPng } from 'html-to-image'
  6. import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react'
  7. import { useRouter } from 'next/router'
  8. import { ChangeEvent, useEffect, useRef, useState } from 'react'
  9. import { toast } from 'sonner'
  10. import {
  11. Button,
  12. cn,
  13. DropdownMenu,
  14. DropdownMenuContent,
  15. DropdownMenuItem,
  16. DropdownMenuSeparator,
  17. DropdownMenuTrigger,
  18. PopoverSeparator,
  19. TextArea,
  20. } from 'ui'
  21. import { Admonition } from 'ui-patterns'
  22. import {
  23. convertB64toBlob,
  24. isLikelySupportRequest,
  25. uploadAttachment,
  26. } from './FeedbackDropdown.utils'
  27. import { SupportLink } from '@/components/interfaces/Support/SupportLink'
  28. import { InlineLinkClassName } from '@/components/ui/InlineLink'
  29. import { useFeedbackCategoryQuery } from '@/data/feedback/feedback-category'
  30. import { useSendFeedbackMutation } from '@/data/feedback/feedback-send'
  31. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  32. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  33. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  34. import { timeout } from '@/lib/helpers'
  35. import { useProfile } from '@/lib/profile'
  36. interface FeedbackWidgetProps {
  37. onClose: () => void
  38. onSwitchToIssueOptions: () => void
  39. }
  40. export const FeedbackWidget = ({ onClose, onSwitchToIssueOptions }: FeedbackWidgetProps) => {
  41. const router = useRouter()
  42. const { profile } = useProfile()
  43. const { ref, slug } = useParams()
  44. const { data: org } = useSelectedOrganizationQuery()
  45. const uploadButtonRef = useRef(null)
  46. const [feedback, setFeedback] = useState('')
  47. const [isSending, setSending] = useState(false)
  48. const [isSavingScreenshot, setIsSavingScreenshot] = useState(false)
  49. const [isFeedbackSent, setIsFeedbackSent] = useState(false)
  50. const debouncedFeedback = useDebounce(feedback, 500)
  51. const [storedFeedback, setStoredFeedback] = useLocalStorageQuery<string | null>(
  52. LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_CONTENT,
  53. null
  54. )
  55. const [screenshot, setScreenshot, { isSuccess }] = useLocalStorageQuery<string | null>(
  56. LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_SCREENSHOT,
  57. null
  58. )
  59. const { data: category } = useFeedbackCategoryQuery({ prompt: debouncedFeedback })
  60. // Use client-side heuristic for immediate feedback, AI result takes precedence when available
  61. const isLikelySupport = isLikelySupportRequest(feedback)
  62. const effectiveCategory = category ?? (isLikelySupport ? 'support' : null)
  63. const { mutate: sendEvent } = useSendEventMutation()
  64. const { mutate: submitFeedback } = useSendFeedbackMutation({
  65. onSuccess: () => {
  66. setIsFeedbackSent(true)
  67. setFeedback('')
  68. setStoredFeedback(null)
  69. setScreenshot(null)
  70. setSending(false)
  71. },
  72. onError: (error) => {
  73. toast.error(`Failed to submit feedback: ${error.message}`)
  74. setSending(false)
  75. },
  76. })
  77. const captureScreenshot = async () => {
  78. setIsSavingScreenshot(true)
  79. function filter(node: HTMLElement) {
  80. if ((node?.children ?? []).length > 0) {
  81. return node.children[0].id !== 'feedback-widget'
  82. }
  83. return true
  84. }
  85. // Give time for dropdown to close
  86. await timeout(100)
  87. toPng(document.body, { filter })
  88. .then((dataUrl: any) => setScreenshot(dataUrl))
  89. .catch(() => toast.error('Failed to capture screenshot'))
  90. .finally(() => setIsSavingScreenshot(false))
  91. }
  92. const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
  93. event.persist()
  94. const [file] = event.target.files || (event as any).dataTransfer.items
  95. const reader = new FileReader()
  96. reader.onload = function (event) {
  97. const dataUrl = event.target?.result
  98. if (typeof dataUrl === 'string') setScreenshot(dataUrl)
  99. }
  100. reader.readAsDataURL(file)
  101. event.target.value = ''
  102. }
  103. const handlePasteEvent = async () => {
  104. // [Joshen] Support pasting images via Cmd / Ctrl + V
  105. const [data] = await navigator.clipboard.read()
  106. if (screenshot === undefined && data.types[0] === 'image/png') {
  107. const blob = await data.getType('image/png')
  108. const reader = new FileReader()
  109. reader.onload = function (event) {
  110. const dataUrl = event.target?.result
  111. if (typeof dataUrl === 'string') setScreenshot(dataUrl)
  112. }
  113. reader.readAsDataURL(blob)
  114. }
  115. }
  116. const sendFeedback = async () => {
  117. if (feedback.length === 0 && screenshot !== undefined) {
  118. return toast.error('Please include a message in your feedback.')
  119. } else if (feedback.length > 0) {
  120. setSending(true)
  121. const attachmentUrl =
  122. screenshot && profile?.gotrue_id
  123. ? await uploadAttachment({
  124. image: screenshot,
  125. userId: profile.gotrue_id,
  126. })
  127. : undefined
  128. const formattedFeedback =
  129. attachmentUrl !== undefined ? `${feedback}\n\nAttachments:\n${attachmentUrl}` : feedback
  130. submitFeedback({
  131. projectRef: ref,
  132. organizationSlug: slug,
  133. message: formattedFeedback,
  134. pathname: router.asPath,
  135. })
  136. }
  137. }
  138. // Hydrate form from localStorage once it's ready; deps intentionally omit storedFeedback/screenshot
  139. // so we don't overwrite user edits when those values change after initial load.
  140. useEffect(() => {
  141. if (storedFeedback) setFeedback(storedFeedback)
  142. if (screenshot) setScreenshot(screenshot)
  143. // eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once when localStorage is ready only
  144. }, [isSuccess])
  145. // Persist debounced input to localStorage; only re-run when debounced value changes.
  146. useEffect(() => {
  147. if (debouncedFeedback.length > 0) setStoredFeedback(debouncedFeedback)
  148. // eslint-disable-next-line react-hooks/exhaustive-deps -- setStoredFeedback is stable; only sync on debounced value
  149. }, [debouncedFeedback])
  150. const ThanksMessageView = () => (
  151. <>
  152. <div className="py-6 px-4 grid gap-4 text-center text-foreground-light">
  153. <CircleCheck className="mx-auto text-brand-500" size={24} />
  154. <div className="flex flex-col gap-1">
  155. <p className="text-foreground text-base">Your feedback has been sent. Thanks!</p>
  156. <p className="text-sm text-balance">
  157. We don’t always respond to feedback. If you need help with your project, use the button
  158. below.
  159. </p>
  160. </div>
  161. </div>
  162. <PopoverSeparator />
  163. <div className="px-4 pt-4 pb-4 flex flex-row items-center justify-between">
  164. <Button type="default" size="tiny" onClick={onSwitchToIssueOptions}>
  165. Get help
  166. </Button>
  167. <Button type="default" size="tiny" onClick={onClose}>
  168. Close
  169. </Button>
  170. </div>
  171. </>
  172. )
  173. return isFeedbackSent ? (
  174. <ThanksMessageView />
  175. ) : (
  176. <>
  177. <div className="p-4">
  178. <TextArea
  179. placeholder="My idea for improving Briven is..."
  180. rows={6}
  181. value={feedback}
  182. onChange={(e) => setFeedback(e.target.value)}
  183. onPaste={handlePasteEvent}
  184. className="text-sm mb-1 resize-none"
  185. />
  186. </div>
  187. <AnimatePresence>
  188. {effectiveCategory === 'support' && (
  189. <motion.div
  190. key="support-alert"
  191. initial={{ opacity: 0, y: 16 }}
  192. animate={{ opacity: 1, y: 0 }}
  193. exit={{ opacity: 0, y: 16 }}
  194. transition={{ duration: 0.2 }}
  195. >
  196. <Admonition
  197. type="caution"
  198. title="This looks like an issue that’s better handled by support"
  199. className="rounded-none border-x-0 border-b-0"
  200. >
  201. <p>
  202. Please{' '}
  203. <SupportLink
  204. className={cn(InlineLinkClassName)}
  205. queryParams={{ projectRef: slug, message: feedback }}
  206. >
  207. open a support ticket
  208. </SupportLink>{' '}
  209. to get help, as we do not reply to all product feedback.
  210. </p>
  211. </Admonition>
  212. </motion.div>
  213. )}
  214. </AnimatePresence>
  215. <PopoverSeparator />
  216. <div className="px-4 pt-4 pb-4 flex flex-row items-center justify-between">
  217. <Button type="default" size="tiny" onClick={onSwitchToIssueOptions}>
  218. Get help instead
  219. </Button>
  220. <div className="flex items-center gap-2 flex-row">
  221. {!!screenshot ? (
  222. <div
  223. style={{ backgroundImage: `url("${screenshot}")` }}
  224. onClick={() => {
  225. const blob = convertB64toBlob(screenshot)
  226. const blobUrl = URL.createObjectURL(blob)
  227. window.open(blobUrl, '_blank')
  228. }}
  229. className="cursor-pointer rounded-sm h-[26px] w-[26px] border border-control relative bg-cover bg-center bg-no-repeat"
  230. >
  231. <button
  232. className={[
  233. 'cursor-pointer rounded-full bg-red-900 h-3 w-3',
  234. 'flex items-center justify-center absolute -top-1 -right-1',
  235. ].join(' ')}
  236. onClick={(event) => {
  237. event.stopPropagation()
  238. setScreenshot(null)
  239. }}
  240. >
  241. <X size={8} strokeWidth={3} />
  242. </button>
  243. </div>
  244. ) : (
  245. <DropdownMenu>
  246. <DropdownMenuTrigger asChild>
  247. <Button
  248. type="default"
  249. disabled={isSavingScreenshot}
  250. loading={isSavingScreenshot}
  251. className="w-7"
  252. icon={<ImageIcon size={14} />}
  253. />
  254. </DropdownMenuTrigger>
  255. <DropdownMenuContent side="bottom" align="end" className="w-fit">
  256. <DropdownMenuItem
  257. className="flex gap-2"
  258. key="upload-screenshot"
  259. onSelect={() => {
  260. if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
  261. }}
  262. >
  263. <Upload size={14} />
  264. Upload screenshot
  265. </DropdownMenuItem>
  266. <DropdownMenuSeparator />
  267. <DropdownMenuItem
  268. className="flex gap-2"
  269. key="capture-screenshot"
  270. onSelect={() => captureScreenshot()}
  271. >
  272. <Camera size={14} />
  273. Capture screenshot
  274. </DropdownMenuItem>
  275. </DropdownMenuContent>
  276. </DropdownMenu>
  277. )}
  278. <input
  279. type="file"
  280. ref={uploadButtonRef}
  281. className="hidden"
  282. accept="image/png"
  283. onChange={onFilesUpload}
  284. />
  285. <Button
  286. disabled={feedback.length === 0 || isSending}
  287. loading={isSending}
  288. onClick={() => {
  289. sendFeedback()
  290. sendEvent({
  291. action: 'send_feedback_button_clicked',
  292. groups: { project: ref, organization: org?.slug },
  293. })
  294. }}
  295. >
  296. Send
  297. </Button>
  298. </div>
  299. </div>
  300. </>
  301. )
  302. }