import { ArrowUpRight, Bug, Chats as MessagesSquare, Check, Copy, GitPullRequest, Lightbulb, Megaphone, Newspaper } from '@phosphor-icons/react' import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { REFACTORING_HOME_URL, TOLARIA_GITHUB_CONTRIBUTING_URL, TOLARIA_GITHUB_DISCUSSIONS_URL, TOLARIA_GITHUB_ISSUES_URL, TOLARIA_GITHUB_PULL_REQUESTS_URL, TOLARIA_PRODUCT_BOARD_URL, } from '../constants/feedback' import { buildSanitizedDiagnosticBundle, startFeedbackDiagnosticsCapture, } from '../lib/feedbackDiagnostics' import { cn } from '../lib/utils' import { takeFeedbackDialogOpener } from '../lib/feedbackDialogOpener' import { useBuildNumber } from '../hooks/useBuildNumber' import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher' import { createTranslator, type AppLocale, type TranslationKey } from '../lib/i18n' import { openExternalUrl } from '../utils/url' interface FeedbackDialogProps { open: boolean onClose: () => void buildNumber?: string locale?: AppLocale releaseChannel?: string | null } interface ContributionCardProps { title: string description: string ctaLabel: string icon: typeof Lightbulb tone: ContributionTone onAction: () => void autoFocus?: boolean secondaryAction?: ReactNode } interface LinkFallback { label: string url: string } interface ContributionPath { titleKey: TranslationKey descriptionKey: TranslationKey ctaLabelKey: TranslationKey labelKey: TranslationKey url: string icon: typeof Lightbulb tone: ContributionTone secondaryLink?: ContributionLink } interface ContributionLink { ctaLabelKey: TranslationKey labelKey: TranslationKey url: string } const EMPTY_DIALOG_OPENER: ReturnType = { element: null, reopenCommandPalette: false, } type ContributionTone = 'blue' | 'green' | 'yellow' | 'purple' | 'red' const CONTRIBUTION_TONE_CLASSES: Record = { blue: 'bg-[var(--accent-blue-light)] text-[var(--accent-blue)]', green: 'bg-[var(--accent-green-light)] text-[var(--accent-green)]', yellow: 'bg-[var(--accent-yellow-light)] text-[var(--accent-yellow)]', purple: 'bg-[var(--accent-purple-light)] text-[var(--accent-purple)]', red: 'bg-[var(--accent-red-light)] text-[var(--accent-red)]', } const CONTRIBUTION_BUTTON_CLASSES: Record = { blue: 'border-[var(--accent-blue)] hover:bg-[var(--accent-blue-light)] [&_svg]:text-[var(--accent-blue)]', green: 'border-[var(--accent-green)] hover:bg-[var(--accent-green-light)] [&_svg]:text-[var(--accent-green)]', yellow: 'border-[var(--accent-yellow)] hover:bg-[var(--accent-yellow-light)] [&_svg]:text-[var(--accent-yellow)]', purple: 'border-[var(--accent-purple)] hover:bg-[var(--accent-purple-light)] [&_svg]:text-[var(--accent-purple)]', red: 'border-[var(--accent-red)] hover:bg-[var(--accent-red-light)] [&_svg]:text-[var(--accent-red)]', } const SPONSOR_SUPPORT_PATH = { titleKey: 'feedback.sponsor.title', descriptionKey: 'feedback.sponsor.description', ctaLabelKey: 'feedback.sponsor.cta', labelKey: 'feedback.sponsor.linkLabel', url: REFACTORING_HOME_URL, icon: Newspaper, tone: 'blue', } satisfies ContributionPath const CONTRIBUTION_PATHS: ContributionPath[] = [ { titleKey: 'feedback.featureRequests.title', descriptionKey: 'feedback.featureRequests.description', ctaLabelKey: 'feedback.featureRequests.cta', labelKey: 'feedback.featureRequests.linkLabel', url: TOLARIA_PRODUCT_BOARD_URL, icon: Lightbulb, tone: 'green', }, { titleKey: 'feedback.discussions.title', descriptionKey: 'feedback.discussions.description', ctaLabelKey: 'feedback.discussions.cta', labelKey: 'feedback.discussions.linkLabel', url: TOLARIA_GITHUB_DISCUSSIONS_URL, icon: MessagesSquare, tone: 'purple', }, { titleKey: 'feedback.contributeCode.title', descriptionKey: 'feedback.contributeCode.description', ctaLabelKey: 'feedback.contributeCode.cta', labelKey: 'feedback.contributeCode.linkLabel', url: TOLARIA_GITHUB_PULL_REQUESTS_URL, icon: GitPullRequest, tone: 'yellow', secondaryLink: { ctaLabelKey: 'feedback.contributingGuide.cta', labelKey: 'feedback.contributingGuide.linkLabel', url: TOLARIA_GITHUB_CONTRIBUTING_URL, }, }, ] function ContributionLinkButton({ label, tone, onAction, autoFocus = false, accented = true, }: { label: string tone: ContributionTone onAction: () => void autoFocus?: boolean accented?: boolean }) { return ( ) } function ContributionCard({ title, description, ctaLabel, icon: Icon, tone, onAction, autoFocus = false, secondaryAction, }: ContributionCardProps) { return (
{title}
{description}
{secondaryAction ? {secondaryAction} : null}
) } type Translate = ReturnType function LinkFallbackBanner({ linkFallback, t }: { linkFallback: LinkFallback | null; t: Translate }) { if (!linkFallback) return null return (

{t('feedback.linkFallback.title', { label: linkFallback.label })}

{t('feedback.linkFallback.description')}

{linkFallback.url}

) } function getCopyDiagnosticsLabel(copyState: 'idle' | 'copied' | 'failed', t: Translate) { return copyState === 'copied' ? t('feedback.diagnosticsCopied') : t('feedback.copyDiagnostics') } function BugReportActions({ copyState, canCopyDiagnostics, onCopyDiagnostics, t, }: { copyState: 'idle' | 'copied' | 'failed' canCopyDiagnostics: boolean onCopyDiagnostics: () => void t: Translate }) { return (
{copyState === 'copied' ? (

{t('feedback.diagnosticsCopiedSentence')}

) : null} {copyState === 'failed' ? (

{t('feedback.clipboardUnavailable')}

) : null}
) } function useDialogReturnFocus(open: boolean, onClose: () => void) { const openerRef = useRef(EMPTY_DIALOG_OPENER) useLayoutEffect(() => { if (open) { openerRef.current = takeFeedbackDialogOpener() } }, [open]) return () => { const { element: opener, reopenCommandPalette } = openerRef.current openerRef.current = takeFeedbackDialogOpener() onClose() window.setTimeout(() => { if (reopenCommandPalette) { window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, { detail: APP_COMMAND_IDS.viewCommandPalette, })) return } if (opener?.isConnected) { opener.focus() } }, 80) } } function useFeedbackDialogActions(diagnosticsBundle: string) { const [linkFallback, setLinkFallback] = useState(null) const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') const canCopyDiagnostics = typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function' const handleOpenLink = (label: string, url: string) => { void openExternalUrl(url) .then(() => { setLinkFallback(null) }) .catch(() => { setLinkFallback({ label, url }) }) } const handleCopyDiagnostics = () => { if (!canCopyDiagnostics) { setCopyState('failed') return } void navigator.clipboard.writeText(diagnosticsBundle) .then(() => { setCopyState('copied') }) .catch(() => { setCopyState('failed') }) } const reset = () => { setLinkFallback(null) setCopyState('idle') } return { linkFallback, copyState, canCopyDiagnostics, handleOpenLink, handleCopyDiagnostics, reset, } } function ContributionGrid({ onOpenLink, copyState, canCopyDiagnostics, onCopyDiagnostics, t, }: { onOpenLink: (label: string, url: string) => void copyState: 'idle' | 'copied' | 'failed' canCopyDiagnostics: boolean onCopyDiagnostics: () => void t: Translate }) { return (
onOpenLink(t(SPONSOR_SUPPORT_PATH.labelKey), SPONSOR_SUPPORT_PATH.url)} />
{CONTRIBUTION_PATHS.map((path) => { const secondaryLink = path.secondaryLink return ( onOpenLink(t(path.labelKey), path.url)} secondaryAction={secondaryLink ? ( onOpenLink(t(secondaryLink.labelKey), secondaryLink.url)} /> ) : undefined} /> ) })} onOpenLink(t('feedback.reportBug.linkLabel'), TOLARIA_GITHUB_ISSUES_URL)} secondaryAction={( )} />
) } export function FeedbackDialog({ open, onClose, buildNumber, locale = 'en', releaseChannel, }: FeedbackDialogProps) { const t = createTranslator(locale) const detectedBuildNumber = useBuildNumber() const resolvedBuildNumber = buildNumber ?? detectedBuildNumber const diagnosticsBundle = useMemo( () => buildSanitizedDiagnosticBundle({ buildNumber: resolvedBuildNumber, releaseChannel }), [releaseChannel, resolvedBuildNumber], ) const handleRequestClose = useDialogReturnFocus(open, onClose) const { linkFallback, copyState, canCopyDiagnostics, handleOpenLink, handleCopyDiagnostics, reset, } = useFeedbackDialogActions(diagnosticsBundle) useEffect(() => startFeedbackDiagnosticsCapture(), []) const handleClose = () => { reset() handleRequestClose() } return ( { if (!next) handleClose() }}> {t('feedback.title')} {t('feedback.description')} ) }