import { closeAllModals, openModal } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import DOMPurify from 'dompurify'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import packageJson from '../../package.json'; import { formatHrDateTime } from '/@/renderer/utils/format'; import { Button } from '/@/shared/components/button/button'; import { Center } from '/@/shared/components/center/center'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { Select } from '/@/shared/components/select/select'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases'; const RELEASES_TO_FETCH = 30; interface GitHubRelease { body: null | string; name: null | string; published_at: string; tag_name: string; } interface ReleaseNotesContentProps { onDismiss: () => void; version: string; } function parseVersionFromTag(tagName: string): string { return tagName.startsWith('v') ? tagName.slice(1) : tagName; } const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => { const { t } = useTranslation(); const [selectedVersion, setSelectedVersion] = useState(version); // Fetch list of recent releases for the selector const { data: releasesList = [] } = useQuery({ queryFn: async () => { const response = await axios.get(GITHUB_RELEASES_URL, { params: { per_page: RELEASES_TO_FETCH }, }); return response.data; }, queryKey: ['github-releases-list'], retry: 2, }); const releaseOptions = useMemo(() => { const options = releasesList.slice(0, RELEASES_TO_FETCH).map((r) => { const v = parseVersionFromTag(r.tag_name); const dateStr = formatHrDateTime(r.published_at); return { label: dateStr ? `${v} - ${dateStr}` : v, value: v, }; }); const versions = options.map((o) => o.value); if (!versions.includes(version)) { options.unshift({ label: version, value: version }); } return options; }, [releasesList, version]); const { data: releaseData, isError, isLoading, } = useQuery({ queryFn: async () => { const response = await axios.get( `${GITHUB_RELEASES_URL}/tags/v${selectedVersion}`, ); return response.data; }, queryKey: ['github-release', selectedVersion], retry: 2, }); // Convert markdown to HTML using GitHub's markdown API const { data: htmlContent, isLoading: isConverting } = useQuery({ enabled: !!releaseData?.body, queryFn: async () => { const response = await axios.post( 'https://api.github.com/markdown', { mode: 'gfm', text: releaseData?.body ?? '', }, { headers: { 'Content-Type': 'application/json', }, responseType: 'text', }, ); return response.data; }, queryKey: ['github-markdown', releaseData?.body], retry: 2, }); const sanitizedHtml = useMemo(() => { if (!htmlContent) return ''; return DOMPurify.sanitize(htmlContent, { ALLOWED_ATTR: ['alt', 'href', 'src', 'title'], ALLOWED_TAGS: [ 'a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'u', 'ul', ], }); }, [htmlContent]); if (isLoading || isConverting) { return (
); } if (isError || !releaseData) { return ( {releaseOptions.length > 1 && ( v && setSelectedVersion(v)} value={selectedVersion} /> )} ); }; const WAIT_FOR_LOCAL_STORAGE = 1000 * 2; interface ReleaseNotesModalContentWrapperProps { setDismissRef?: (fn: (() => void) | undefined) => void; } const ReleaseNotesModalContentWrapper = ({ setDismissRef, }: ReleaseNotesModalContentWrapperProps) => { const { version } = packageJson; const [, setValue] = useLocalStorage({ key: 'version' }); const handleDismiss = useCallback(() => { setValue(version); closeAllModals(); }, [setValue, version]); useEffect(() => { setDismissRef?.(handleDismiss); return () => setDismissRef?.(undefined); }, [handleDismiss, setDismissRef]); return ; }; export const openReleaseNotesModal = (title: string) => { const dismissRef = { current: null as (() => void) | null }; openModal({ children: ( { dismissRef.current = fn ?? null; }} /> ), onClose: () => dismissRef.current?.(), size: 'xl', title, }); }; export const ReleaseNotesModal = () => { const { version } = packageJson; const { t } = useTranslation(); const dismissRef = useRef<(() => void) | null>(null); useEffect(() => { const timeoutId = setTimeout(() => { const valueFromLocalStorage = localStorage.getItem('version'); const versionString = `"${version}"`; // Only show modal if the stored version is different from current version if (valueFromLocalStorage !== versionString) { openModal({ children: ( { dismissRef.current = fn ?? null; }} /> ), onClose: () => dismissRef.current?.(), size: 'xl', title: t('common.newVersion', { postProcess: 'sentenceCase', version, }) as string, }); } }, WAIT_FOR_LOCAL_STORAGE); return () => { clearTimeout(timeoutId); }; }, [t, version]); return null; };