/* * 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, 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 { 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 { LanguageSelect } from '@/modules/core/components/inputs/LanguageSelect.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'; import { createUpdateMetadataServerSettings, useMetadataServerSettings, } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { MetadataBrowseSettings } from '@/modules/browse/Browse.types.ts'; const LANGUAGE = 0; const EXTENSIONS = 1; const GroupHeader = ({ groupName, isFirstItem, groupExtensionIds, isUpdateGroup, updatingExtensionIds, setUpdatingExtensionIds, handleExtensionUpdate, }: { groupName: string; isFirstItem: boolean; groupExtensionIds: TExtension['pkgName'][]; isUpdateGroup: boolean; updatingExtensionIds: TExtension['pkgName'][]; setUpdatingExtensionIds: (ids: TExtension['pkgName'][]) => void; handleExtensionUpdate: () => void; }) => { const { t } = useTranslation(); return ( {translateExtensionLanguage(groupName)} {isUpdateGroup && ( )} ); }; 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 { data: serverSettingsData, loading: areServerSettingsLoading, error: serverSettingsError, refetch: refetchServerSettings, } = requestManager.useGetServerSettings({ notifyOnNetworkStatusChange: true }); const [fetchExtensions, { data, loading: areExtensionsLoading, error: extensionsError }] = requestManager.useExtensionListFetch(); const { settings: { extensionLanguages: shownLangs }, } = useMetadataServerSettings(); const updateMetadataServerSettings = createUpdateMetadataServerSettings< keyof Pick >((e) => makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e))); const [showNsfw] = useLocalStorage('showNsfw', true); const [query] = useQueryParam('query', StringParam); const [updatingExtensionIds, setUpdatingExtensionIds] = useState([]); const [refetchExtensions, setRefetchExtensions] = useState({}); const isLoading = areServerSettingsLoading || areExtensionsLoading; const error = serverSettingsError ?? extensionsError; const areReposDefined = !!serverSettingsData?.settings.extensionRepos.length; const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1; const allExtensions = data?.fetchExtensions?.extensions; 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 handleExtensionUpdate = useCallback(() => setRefetchExtensions({}), []); const setSelectedExtensionPkg = (newPkg: TExtension['pkgName'] | undefined) => { navigate(pathname + search, { replace: true, state: { selectedExtensionPkg: newPkg, }, }); }; const submitExternalExtension = (file: File) => { if (!file.name.toLowerCase().endsWith('apk')) { makeToast(t('global.error.label.invalid_file_type'), 'error'); return; } 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))); }; useEffect(() => { fetchExtensions(); }, [refetchExtensions]); useLayoutEffect(() => { setAction( <> { const input = document.createElement('input'); input.style.display = 'none'; input.type = 'file'; input.onchange = () => { const file = input.files?.[0]; if (file) { submitExternalExtension(file); } }; document.documentElement.appendChild(input); input.click(); document.documentElement.removeChild(input); }} color="inherit" > updateMetadataServerSettings('extensionLanguages', languages) } languages={allLangs} /> , ); 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); }; }, []); 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 ( {t('extension.label.add_repository_info')} ); } return ( <> { const [groupName, groupExtensions] = groupedExtensions[index]; const isUpdateGroup = groupName === ExtensionGroupState.UPDATE_PENDING; return ( extension.pkgName)} isUpdateGroup={isUpdateGroup} updatingExtensionIds={updatingExtensionIds} setUpdatingExtensionIds={setUpdatingExtensionIds} handleExtensionUpdate={handleExtensionUpdate} /> ); }} computeItemKey={computeItemKey} itemContent={(index) => { const item = visibleExtensions[index]; return ( setSelectedExtensionPkg(item.pkgName)} /> ); }} /> setSelectedExtensionPkg(undefined)} /> ); }