/* * Copyright (C) Contributors to the Suwayomi project * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { fromEvent } from 'file-selector'; import IconButton from '@mui/material/IconButton'; import AddIcon from '@mui/icons-material/Add'; import { StringParam, useQueryParam } from 'use-query-params'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { extensionDefaultLangs } from '@/modules/core/utils/Languages.ts'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { LangSelect } from '@/modules/core/components/inputs/LangSelect.tsx'; import { ExtensionCard } from '@/modules/extension/components/ExtensionCard.tsx'; import { StyledGroupedVirtuoso } from '@/modules/core/components/virtuoso/StyledGroupedVirtuoso.tsx'; import { StyledGroupHeader } from '@/modules/core/components/virtuoso/StyledGroupHeader.tsx'; import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; import { groupExtensionsByLanguage, getLanguagesFromExtensions, translateExtensionLanguage, filterExtensions, } from '@/modules/extension/Extensions.utils.ts'; import { ExtensionAction, ExtensionGroupState, ExtensionState, TExtension, } from '@/modules/extension/Extensions.types.ts'; import { EXTENSION_ACTION_TO_FAILURE_TRANSLATION_KEY_MAP } from '@/modules/extension/Extensions.constants.ts'; import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { ExtensionOptions } from '@/modules/extension/components/ExtensionOptions'; const LANGUAGE = 0; const EXTENSIONS = 1; export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) { const { t } = useTranslation(); const { setAction } = useNavBarContext(); const navigate = useNavigate(); const { pathname, search, state } = useLocation<{ selectedExtensionPkg?: TExtension['pkgName'] }>(); const selectedExtensionPkg = state?.selectedExtensionPkg; const setSelectedExtensionPkg = (newPkg: TExtension['pkgName'] | undefined) => { navigate(pathname + search, { replace: true, state: { selectedExtensionPkg: newPkg, }, }); }; const { data: serverSettingsData, loading: areServerSettingsLoading, error: serverSettingsError, refetch: refetchServerSettings, } = requestManager.useGetServerSettings({ notifyOnNetworkStatusChange: true }); const areReposDefined = !!serverSettingsData?.settings.extensionRepos.length; const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1; const inputRef = useRef(null); const [shownLangs, setShownLangs] = useLocalStorage('shownExtensionLangs', extensionDefaultLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); const [query] = useQueryParam('query', StringParam); const [refetchExtensions, setRefetchExtensions] = useState({}); const [fetchExtensions, { data, loading: areExtensionsLoading, error: extensionsError }] = requestManager.useExtensionListFetch(); const allExtensions = data?.fetchExtensions?.extensions; const [updatingExtensionIds, setUpdatingExtensionIds] = useState([]); const handleExtensionUpdate = useCallback(() => setRefetchExtensions({}), []); useEffect(() => { fetchExtensions(); }, [refetchExtensions]); const allLangs = useMemo(() => getLanguagesFromExtensions(allExtensions ?? []), [allExtensions]); const filteredExtensions = useMemo( () => filterExtensions(allExtensions ?? [], shownLangs, showNsfw, query), [allExtensions, shownLangs, shownLangs, query], ); const groupedExtensions = useMemo(() => groupExtensionsByLanguage(filteredExtensions), [filteredExtensions]); const groupCounts = useMemo( () => groupedExtensions.map((extensionGroup) => extensionGroup[EXTENSIONS].length), [groupedExtensions], ); const visibleExtensions = useMemo( () => groupedExtensions.map(([, extensions]) => extensions).flat(1), [groupedExtensions], ); const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( groupCounts, useCallback((index) => groupedExtensions[index][LANGUAGE], [groupedExtensions]), useCallback((index) => visibleExtensions[index].pkgName, [visibleExtensions]), ); const submitExternalExtension = (file: File) => { if (file.name.toLowerCase().endsWith('apk')) { if (inputRef.current) { inputRef.current.value = ''; } makeToast(t('extension.label.installing_file'), 'info'); requestManager .installExternalExtension(file) .response.then(() => { handleExtensionUpdate(); makeToast(t('extension.label.installed_successfully'), 'success'); }) .catch((e) => makeToast(t('extension.label.installation_failed'), 'error', getErrorMessage(e))); } else { makeToast(t('global.error.label.invalid_file_type'), 'error'); } }; useLayoutEffect(() => { setAction( <> inputRef.current?.click()} color="inherit"> , ); return () => { setAction(null); }; }, [t, shownLangs, allLangs]); useEffect(() => { const dropHandler = async (e: Event) => { e.preventDefault(); const files = await fromEvent(e); submitExternalExtension(files[0] as File); }; const dragOverHandler = (e: Event) => { e.preventDefault(); }; document.addEventListener('drop', dropHandler); document.addEventListener('dragover', dragOverHandler); return () => { document.removeEventListener('drop', dropHandler); document.removeEventListener('dragover', dragOverHandler); }; }, []); const FileInputComponent = useMemo( () => ( { const file = e.target.files?.[0]; if (file) { submitExternalExtension(file); } }} /> ), [], ); const isLoading = areServerSettingsLoading || areExtensionsLoading; const error = serverSettingsError ?? extensionsError; if (isLoading) { return ; } if (error) { return ( { if (serverSettingsError) { refetchServerSettings().catch(defaultPromiseErrorHandler('Extensions::refetchServerSettings')); } if (extensionsError) { fetchExtensions().catch(defaultPromiseErrorHandler('Extensions::refetchExtensions')); } }} /> ); } const showAddRepoInfo = !allExtensions?.length && !areReposDefined; if (showAddRepoInfo) { return ( <> {FileInputComponent} {t('extension.label.add_repository_info')} ); } return ( <> {FileInputComponent} { const [groupName, groupExtensions] = groupedExtensions[index]; const isUpdateGroup = groupName === ExtensionGroupState.UPDATE_PENDING; return ( {translateExtensionLanguage(groupName)} {isUpdateGroup && ( )} ); }} computeItemKey={computeItemKey} itemContent={(index) => { const item = visibleExtensions[index]; return ( setSelectedExtensionPkg(item.pkgName)} /> ); }} /> setSelectedExtensionPkg(undefined)} /> ); }