diff --git a/.prettierrc.js b/.prettierrc.js index d161a9d27..37c20c51f 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,4 +7,5 @@ module.exports = { trailingComma: 'all', arrowParens: 'avoid', quoteProps: 'preserve', + endOfLine: 'auto', // stop prettier from getting mad on windows }; diff --git a/App.tsx b/App.tsx index 888eb1551..e3f87ccc8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,5 @@ import 'react-native-gesture-handler'; +import 'react-native-url-polyfill/auto'; import { enableFreeze } from 'react-native-screens'; enableFreeze(true); diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 429e5909c..c645d6d37 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -30,6 +30,7 @@ body { body.page-reader { overflow: hidden; + padding-bottom: unset; } #LNReader-chapter { diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index aef408b84..fa34de5da 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -412,6 +412,7 @@ window.addEventListener('DOMContentLoaded', async () => { if (reader.generalSettings.val.pageReader) { const diffX = (e.changedTouches[0].screenX - this.initialX) / reader.layoutWidth; + reader.chapterElement.style.transition = 'unset'; reader.chapterElement.style.transform = 'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)'; } @@ -421,6 +422,7 @@ window.addEventListener('DOMContentLoaded', async () => { const diffX = e.changedTouches[0].screenX - this.initialX; const diffY = e.changedTouches[0].screenY - this.initialY; if (reader.generalSettings.val.pageReader) { + reader.chapterElement.style.transition = '200ms'; const diffXPercentage = diffX / reader.layoutWidth; if (diffXPercentage < -0.3) { pageReader.movePage(pageReader.page.val + 1); diff --git a/crowdin.yml b/crowdin.yml index a230decd6..df73f2f91 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,4 +1,4 @@ -pull_request_title: 'chore: Update translations' +pull_request_title: 'chore: Update Translations' commit_message: '[ci skip]' files: - source: /strings/languages/en/strings.json diff --git a/package-lock.json b/package-lock.json index ba690a529..64221bafb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "i18n-js": "^3.8.0", "lodash-es": "^4.17.21", "protobufjs": "^7.2.6", - "qs": "^6.12.0", "react": "18.2.0", "react-native": "0.72.10", "react-native-background-actions": "^3.0.1", @@ -55,6 +54,7 @@ "react-native-screens": "^3.22.0", "react-native-shimmer-placeholder": "^2.0.9", "react-native-tab-view": "^3.5.2", + "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^9.0.0", "react-native-webview": "^13.10.5", "react-native-zip-archive": "^7.0.0", @@ -10352,6 +10352,18 @@ "version": "3.9.2", "license": "MIT" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ @@ -14558,6 +14570,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "bin": { @@ -15027,6 +15048,18 @@ "react-native-pager-view": "*" } }, + "node_modules/react-native-url-polyfill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", + "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==", + "license": "MIT", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-vector-icons": { "version": "9.2.0", "license": "MIT", @@ -16819,14 +16852,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/url": { "version": "0.11.3", "license": "MIT", @@ -16853,21 +16878,13 @@ }, "node_modules/urlencode": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/urlencode/-/urlencode-2.0.0.tgz", + "integrity": "sha512-K4+koEq4II9FqKKdLyMwfVFiWvTLJsdsIihXCprumjlOwpviO44E4hAhLYBLb6CEVTZh9hXXMTQHIT+Hwv5BPw==", "license": "MIT", "dependencies": { "iconv-lite": "~0.6.3" } }, - "node_modules/urlencode/node_modules/iconv-lite": { - "version": "0.6.3", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/use-latest-callback": { "version": "0.1.11", "license": "MIT", @@ -16966,6 +16983,29 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/whatwg-url-without-unicode": { + "version": "8.0.0-3", + "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", + "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", + "license": "MIT", + "dependencies": { + "buffer": "^5.4.3", + "punycode": "^2.1.1", + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, "node_modules/which": { "version": "2.0.2", "license": "ISC", diff --git a/package.json b/package.json index 1308f0995..409f69844 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "i18n-js": "^3.8.0", "lodash-es": "^4.17.21", "protobufjs": "^7.2.6", - "qs": "^6.12.0", "react": "18.2.0", "react-native": "0.72.10", "react-native-background-actions": "^3.0.1", @@ -62,6 +61,7 @@ "react-native-screens": "^3.22.0", "react-native-shimmer-placeholder": "^2.0.9", "react-native-tab-view": "^3.5.2", + "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^9.0.0", "react-native-webview": "^13.10.5", "react-native-zip-archive": "^7.0.0", diff --git a/src/components/BottomSheet/BottomSheet.tsx b/src/components/BottomSheet/BottomSheet.tsx index cebda2f5e..75d2c0d5f 100644 --- a/src/components/BottomSheet/BottomSheet.tsx +++ b/src/components/BottomSheet/BottomSheet.tsx @@ -1,4 +1,4 @@ -import React, { Ref, useCallback } from 'react'; +import React, { RefObject, useCallback, useRef } from 'react'; import { BottomSheetBackdrop, BottomSheetBackdropProps, @@ -6,12 +6,20 @@ import { BottomSheetModalProps, } from '@gorhom/bottom-sheet'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useBackHandler } from '@hooks/index'; +import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; interface BottomSheetProps extends Omit { - bottomSheetRef: Ref | null; + bottomSheetRef: RefObject | null; } -const BottomSheet: React.FC = props => { +const BottomSheet: React.FC = ({ + bottomSheetRef, + children, + onChange, + ...otherProps +}) => { + const indexRef = useRef(); const { bottom } = useSafeAreaInsets(); const renderBackdrop = useCallback( (backdropProps: BottomSheetBackdropProps) => ( @@ -23,15 +31,26 @@ const BottomSheet: React.FC = props => { ), [], ); + useBackHandler(() => { + if (typeof indexRef.current === 'number' && indexRef.current !== -1) { + bottomSheetRef?.current?.close(); + return true; + } + return false; + }); return ( { + onChange?.(index); + indexRef.current = index; + }} + {...otherProps} > - {props.children} + {children} ); }; diff --git a/src/components/ListView.tsx b/src/components/ListView.tsx index 3f006cb7c..fb154395b 100644 --- a/src/components/ListView.tsx +++ b/src/components/ListView.tsx @@ -7,10 +7,10 @@ import { coverPlaceholderColor } from '../theme/colors'; import color from 'color'; import { ThemeColors } from '@theme/types'; import { NovelItem } from '@plugins/types'; -import { LibraryNovelInfo } from '@database/types'; +import { NovelInfo } from '@database/types'; interface ListViewProps { - item: NovelItem | LibraryNovelInfo; + item: NovelItem | NovelInfo; downloadBadge?: React.ReactNode; unreadBadge?: React.ReactNode; inLibraryBadge?: React.ReactNode; diff --git a/src/components/NovelCover.tsx b/src/components/NovelCover.tsx index 27debc47c..53acfe100 100644 --- a/src/components/NovelCover.tsx +++ b/src/components/NovelCover.tsx @@ -53,7 +53,7 @@ interface INovelCover { libraryStatus: boolean; theme: ThemeColors; isSelected: boolean; - addSkeletonLoading: boolean; + addSkeletonLoading?: boolean; onLongPress: (item: TNovel) => void; selectedNovelIds: number[]; } @@ -86,8 +86,7 @@ function NovelCover({ const coverHeight = useMemo( () => (window.width / numColumns) * (4 / 3), - // eslint-disable-next-line react-hooks/exhaustive-deps - [numColumns], + [window.width, numColumns], ); const selectNovel = () => onLongPress(item); diff --git a/src/database/db.ts b/src/database/db.ts index 949050a12..6b52ca2ad 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -16,7 +16,7 @@ import { createRepositoryTableQuery } from './tables/RepositoryTable'; const dbName = 'lnreader.db'; -const db = SQLite.openDatabase(dbName); +export const db = SQLite.openDatabase(dbName); export const createTables = () => { db.exec([{ sql: 'PRAGMA foreign_keys = ON', args: [] }], false, () => {}); diff --git a/src/database/queries/CategoryQueries.ts b/src/database/queries/CategoryQueries.ts index 85efbfe68..3a3ee58fe 100644 --- a/src/database/queries/CategoryQueries.ts +++ b/src/database/queries/CategoryQueries.ts @@ -1,10 +1,9 @@ -import * as SQLite from 'expo-sqlite'; import { noop } from 'lodash-es'; import { BackupCategory, Category, NovelCategory, CCategory } from '../types'; import { showToast } from '@utils/showToast'; import { txnErrorCallback } from '../utils/helpers'; import { getString } from '@strings/translations'; -const db = SQLite.openDatabase('lnreader.db'); +import { db } from '@database/db'; const getCategoriesQuery = ` SELECT * FROM Category ORDER BY sort diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index 149f92c2e..9ef20f305 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -1,4 +1,3 @@ -import * as SQLite from 'expo-sqlite'; import { showToast } from '@utils/showToast'; import { ChapterInfo, DownloadedChapter } from '../types'; import { ChapterItem } from '@plugins/types'; @@ -9,8 +8,8 @@ import { noop } from 'lodash-es'; import { getString } from '@strings/translations'; import FileManager from '@native/FileManager'; import { NOVEL_STORAGE } from '@utils/Storages'; +import { db } from '@database/db'; -const db = SQLite.openDatabase('lnreader.db'); const insertChapterQuery = ` INSERT OR IGNORE INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -42,7 +41,7 @@ export const insertChapters = async ( ` UPDATE Chapter SET page = ?, position = ? - WHERE path = ? AND novelId = ? (AND page != ? OR position != ?) + WHERE path = ? AND novelId = ? AND (page != ? OR position != ?) `, [ chapter.page || '1', diff --git a/src/database/queries/HistoryQueries.ts b/src/database/queries/HistoryQueries.ts index 8c2c818aa..434d0e97a 100644 --- a/src/database/queries/HistoryQueries.ts +++ b/src/database/queries/HistoryQueries.ts @@ -1,8 +1,7 @@ import { History } from '@database/types'; import { txnErrorCallback } from '@database/utils/helpers'; -import * as SQLite from 'expo-sqlite'; +import { db } from '@database/db'; import { noop } from 'lodash-es'; -const db = SQLite.openDatabase('lnreader.db'); import { showToast } from '../../utils/showToast'; import { getString } from '@strings/translations'; diff --git a/src/database/queries/LibraryQueries.ts b/src/database/queries/LibraryQueries.ts index e83ba61bd..73783f5a1 100644 --- a/src/database/queries/LibraryQueries.ts +++ b/src/database/queries/LibraryQueries.ts @@ -1,9 +1,7 @@ import { LibraryFilter } from '@screens/library/constants/constants'; -import * as SQLite from 'expo-sqlite'; import { LibraryNovelInfo, NovelInfo } from '../types'; import { txnErrorCallback } from '../utils/helpers'; - -const db = SQLite.openDatabase('lnreader.db'); +import { db } from '@database/db'; export const getNovelsWithCategory = ( categoryId: number, diff --git a/src/database/queries/NovelQueries.ts b/src/database/queries/NovelQueries.ts index ade3bbf13..54b2d0a78 100644 --- a/src/database/queries/NovelQueries.ts +++ b/src/database/queries/NovelQueries.ts @@ -1,6 +1,3 @@ -import * as SQLite from 'expo-sqlite'; -const db = SQLite.openDatabase('lnreader.db'); - import * as DocumentPicker from 'expo-document-picker'; import { fetchNovel } from '@services/plugin/fetch'; @@ -16,6 +13,7 @@ import { NOVEL_STORAGE } from '@utils/Storages'; import FileManager from '@native/FileManager'; import { downloadFile } from '@plugins/helpers/fetch'; import { getPlugin } from '@plugins/pluginManager'; +import { db } from '@database/db'; export const insertNovelAndChapters = async ( pluginId: string, diff --git a/src/database/queries/RepositoryQueries.ts b/src/database/queries/RepositoryQueries.ts index 6d200c830..c1e408f4e 100644 --- a/src/database/queries/RepositoryQueries.ts +++ b/src/database/queries/RepositoryQueries.ts @@ -1,11 +1,8 @@ -import * as SQLite from 'expo-sqlite'; - import { Repository } from '@database/types'; import { txnErrorCallback } from '../utils/helpers'; import { noop } from 'lodash-es'; - -const db = SQLite.openDatabase('lnreader.db'); +import { db } from '@database/db'; const getRepositoriesQuery = 'SELECT * FROM Repository'; diff --git a/src/database/queries/StatsQueries.ts b/src/database/queries/StatsQueries.ts index c3305989b..d8f4f4912 100644 --- a/src/database/queries/StatsQueries.ts +++ b/src/database/queries/StatsQueries.ts @@ -1,9 +1,7 @@ -import * as SQLite from 'expo-sqlite'; import { countBy } from 'lodash-es'; import { LibraryStats } from '../types'; import { txnErrorCallback } from '../utils/helpers'; - -const db = SQLite.openDatabase('lnreader.db'); +import { db } from '@database/db'; const getLibraryStatsQuery = ` SELECT COUNT(*) as novelsCount, COUNT(DISTINCT pluginId) as sourcesCount diff --git a/src/hooks/common/useFullscreenMode.ts b/src/hooks/common/useFullscreenMode.ts index acefdda91..e4b2d08ae 100644 --- a/src/hooks/common/useFullscreenMode.ts +++ b/src/hooks/common/useFullscreenMode.ts @@ -42,7 +42,7 @@ const useFullscreenMode = () => { * But in hexa, ##xxxxxx00 could be another color */ changeNavigationBarColor( - Color(theme.surface).alpha(0.05).hexa(), + Color(theme.surface).alpha(0.01).hexa(), theme.isDark, ); setStatusBarColor(theme); diff --git a/src/hooks/common/useSearch.ts b/src/hooks/common/useSearch.ts index 8bc46e6ea..1b8278b6a 100644 --- a/src/hooks/common/useSearch.ts +++ b/src/hooks/common/useSearch.ts @@ -1,7 +1,7 @@ import { useIsFocused } from '@react-navigation/native'; import { useCallback, useEffect, useState } from 'react'; -const useSearch = (defaultSearchText?: string) => { +const useSearch = (defaultSearchText?: string, clearSearchOnUnfocus = true) => { const isFocused = useIsFocused(); const [searchText, setSearchText] = useState(defaultSearchText || ''); @@ -9,8 +9,10 @@ const useSearch = (defaultSearchText?: string) => { const clearSearchbar = useCallback(() => setSearchText(''), []); useEffect(() => { - if (!isFocused) { - clearSearchbar(); + if (clearSearchOnUnfocus) { + if (!isFocused) { + clearSearchbar(); + } } }, [isFocused, clearSearchbar]); diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index 1761de45e..6c3ee8360 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -165,7 +165,7 @@ export const useNovel = (novelPath: string, pluginId: string) => { pages[pageIndex], ).then(chapters => setChapters(chapters)); } - }, [novel, pageIndex]); + }, [novel, pageIndex, sort, novelSettings]); const sortAndFilterChapters = async (sort?: string, filter?: string) => { if (novel) { @@ -437,6 +437,7 @@ export const useNovel = (novelPath: string, pluginId: string) => { setPages(['1']); } setNovel(novel); + setLoading(false); }, []); const getChapters = useCallback(async () => { @@ -466,11 +467,12 @@ export const useNovel = (novelPath: string, pluginId: string) => { } setChapters(chapters); } - setLoading(false); }, [novel, novelSettings, pageIndex]); + useEffect(() => { getNovel(); }, []); + useEffect(() => { getChapters().catch(e => showToast(e.message)); }, [getChapters]); diff --git a/src/hooks/persisted/usePlugins.ts b/src/hooks/persisted/usePlugins.ts index be5fbef05..c135ae044 100644 --- a/src/hooks/persisted/usePlugins.ts +++ b/src/hooks/persisted/usePlugins.ts @@ -108,6 +108,7 @@ export default function usePlugins() { const actualPlugin: PluginItem = { ...plugin, version: _plg.version, + hasSettings: !!_plg.pluginSettings, }; // safe if (!installedPlugins.some(plg => plg.id === plugin.id)) { diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 507f14d18..de8f1fbb3 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -28,6 +28,7 @@ export interface AppSettings { showUpdatesTab: boolean; showLabelsInNav: boolean; useFabForContinueReading: boolean; + disableLoadingAnimations: boolean; /** * Library settings @@ -56,6 +57,7 @@ export interface AppSettings { export interface BrowseSettings { showMyAnimeList: boolean; showAniList: boolean; + globalSearchConcurrency?: number; } export interface LibrarySettings { @@ -132,6 +134,7 @@ const initialAppSettings: AppSettings = { showUpdatesTab: true, showLabelsInNav: true, useFabForContinueReading: false, + disableLoadingAnimations: false, /** * Library settings @@ -160,6 +163,7 @@ const initialAppSettings: AppSettings = { const initialBrowseSettings: BrowseSettings = { showMyAnimeList: true, showAniList: true, + globalSearchConcurrency: 3, }; export const initialChapterGeneralSettings: ChapterGeneralSettings = { diff --git a/src/navigators/BottomNavigator.tsx b/src/navigators/BottomNavigator.tsx index 5e0e82516..6621d918d 100644 --- a/src/navigators/BottomNavigator.tsx +++ b/src/navigators/BottomNavigator.tsx @@ -1,14 +1,14 @@ -import React from 'react'; +import React, { lazy, useMemo } from 'react'; import { createMaterialBottomTabNavigator } from 'react-native-paper/react-navigation'; import Library from '../screens/library/LibraryScreen'; -import Updates from '../screens/updates/UpdatesScreen'; -import History from '../screens/history/HistoryScreen'; -import Browse from '../screens/browse/BrowseScreen'; -import More from '../screens/more/MoreScreen'; +const Updates = lazy(() => import('../screens/updates/UpdatesScreen')); +const History = lazy(() => import('../screens/history/HistoryScreen')); +const Browse = lazy(() => import('../screens/browse/BrowseScreen')); +const More = lazy(() => import('../screens/more/MoreScreen')); import { getString } from '@strings/translations'; -import { useAppSettings, useTheme } from '@hooks/persisted'; +import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted'; import { BottomNavigatorParamList } from './types'; const Tab = createMaterialBottomTabNavigator(); @@ -22,6 +22,12 @@ const BottomNavigator = () => { showLabelsInNav = false, } = useAppSettings(); + const { filteredInstalledPlugins } = usePlugins(); + const pluginsWithUpdate = useMemo( + () => filteredInstalledPlugins.filter(p => p.hasUpdate).length, + [filteredInstalledPlugins], + ); + return ( { options={{ title: getString('browse'), tabBarIcon: 'compass-outline', + tabBarBadge: pluginsWithUpdate + ? pluginsWithUpdate.toString() + : undefined, }} /> import('./MoreStack')); /** * Screens */ -import Novel from '../screens/novel/NovelScreen'; -import Reader from '../screens/reader/ReaderScreen'; -import BrowseSourceScreen from '../screens/BrowseSourceScreen/BrowseSourceScreen'; -import GlobalSearchScreen from '../screens/GlobalSearchScreen/GlobalSearchScreen'; -import Migration from '../screens/browse/migration/Migration'; -import SourceNovels from '../screens/browse/SourceNovels'; -import MigrateNovel from '../screens/browse/migration/MigrationNovels'; +const Novel = React.lazy(() => import('../screens/novel/NovelScreen')); +const Reader = React.lazy(() => import('../screens/reader/ReaderScreen')); +const BrowseSourceScreen = React.lazy( + () => import('../screens/BrowseSourceScreen/BrowseSourceScreen'), +); +const GlobalSearchScreen = React.lazy( + () => import('../screens/GlobalSearchScreen/GlobalSearchScreen'), +); +const Migration = React.lazy( + () => import('../screens/browse/migration/Migration'), +); +const SourceNovels = React.lazy(() => import('../screens/browse/SourceNovels')); +const MigrateNovel = React.lazy( + () => import('../screens/browse/migration/MigrationNovels'), +); -import MalTopNovels from '../screens/browse/discover/MalTopNovels'; -import AniListTopNovels from '../screens/browse/discover/AniListTopNovels'; -import NewUpdateDialog from '../components/NewUpdateDialog'; -import BrowseSettings from '../screens/browse/BrowseSettings'; -import WebviewScreen from '@screens/WebviewScreen/WebviewScreen'; +const MalTopNovels = React.lazy( + () => import('../screens/browse/discover/MalTopNovels'), +); +const AniListTopNovels = React.lazy( + () => import('../screens/browse/discover/AniListTopNovels'), +); +const NewUpdateDialog = React.lazy( + () => import('../components/NewUpdateDialog'), +); +const BrowseSettings = React.lazy( + () => import('../screens/browse/settings/BrowseSettings'), +); +const WebviewScreen = React.lazy( + () => import('@screens/WebviewScreen/WebviewScreen'), +); import { RootStackParamList } from './types'; import Color from 'color'; import { useMMKVBoolean } from 'react-native-mmkv'; @@ -98,6 +116,7 @@ const MainNavigator = () => { } return ( + // @ts-ignore {isNewVersion && } diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index 82f7f7c10..f041de5d4 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -53,6 +53,11 @@ export type HistoryScreenProps = CompositeScreenProps< StackScreenProps >; +export type UpdateScreenProps = CompositeScreenProps< + MaterialBottomTabScreenProps, + StackScreenProps +>; + export type BrowseScreenProps = CompositeScreenProps< MaterialBottomTabScreenProps, StackScreenProps diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 120690fff..5a73f6df4 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -5,7 +5,6 @@ import { store } from './helpers/storage'; // packages for plugins import { load } from 'cheerio'; import dayjs from 'dayjs'; -import qs from 'qs'; import { NovelStatus, Plugin, PluginItem } from './types'; import { FilterTypes } from './types/filterTypes'; import { isUrlAbsolute } from './helpers/isAbsoluteUrl'; @@ -23,7 +22,6 @@ const packages: Record = { 'htmlparser2': { Parser }, 'cheerio': { load }, 'dayjs': dayjs, - 'qs': qs, 'urlencode': { encode, decode }, '@libs/novelStatus': { NovelStatus }, '@libs/fetch': { fetchApi, fetchText, fetchProto }, diff --git a/src/plugins/types/index.ts b/src/plugins/types/index.ts index 691a1cc12..b8a7695fa 100644 --- a/src/plugins/types/index.ts +++ b/src/plugins/types/index.ts @@ -56,6 +56,7 @@ export interface PluginItem { customJS?: string; customCSS?: string; hasUpdate?: boolean; + hasSettings?: boolean; } export interface ImageRequestInit { @@ -68,6 +69,7 @@ export interface ImageRequestInit { export interface Plugin extends PluginItem { imageRequestInit?: ImageRequestInit; filters?: Filters; + pluginSettings: any; popularNovels: ( pageNo: number, options?: PopularNovelsOptions, diff --git a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx index 230ece16c..8aeb08699 100644 --- a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx +++ b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx @@ -29,6 +29,7 @@ import { getValueFor } from './filterUtils'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Switch from '@components/Switch/Switch'; const insertOrRemoveIntoArray = (array: string[], val: string): string[] => array.indexOf(val) > -1 ? array.filter(ele => ele !== val) : [...array, val]; @@ -56,6 +57,43 @@ const FilterItem: React.FC = ({ setFalse: closeCard, } = useBoolean(); const { width: screenWidth } = useWindowDimensions(); + if (filter.type === FilterTypes.TextInput) { + const value = getValueFor<(typeof filter)['type']>( + filter, + selectedFilters[filterKey], + ); + return ( + + + {` ${filter.label} `} + + } + defaultValue={value} + theme={{ colors: { background: 'transparent' } }} + outlineColor={theme.onSurface} + textColor={theme.onSurface} + onChangeText={text => + setSelectedFilters(prevState => ({ + ...prevState, + [filterKey]: { value: text, type: FilterTypes.TextInput }, + })) + } + /> + + ); + } if (filter.type === FilterTypes.Picker) { const value = getValueFor<(typeof filter)['type']>( filter, @@ -83,7 +121,7 @@ const FilterItem: React.FC = ({ styles.label, { color: isVisible ? theme.primary : theme.onSurface, - backgroundColor: theme.surface, + backgroundColor: overlay(2, theme.surface), }, ]} > @@ -165,6 +203,42 @@ const FilterItem: React.FC = ({ ); } + if (filter.type === FilterTypes.Switch) { + const value = getValueFor<(typeof filter)['type']>( + filter, + selectedFilters[filterKey], + ); + return ( + { + setSelectedFilters(prevState => ({ + ...prevState, + [filterKey]: { value: !value, type: FilterTypes.Switch }, + })); + }} + > + + + + {filter.label} + + + { + setSelectedFilters(prevState => ({ + ...prevState, + [filterKey]: { value: !value, type: FilterTypes.Switch }, + })); + }} + theme={theme} + /> + + + ); + } if (filter.type === FilterTypes.ExcludableCheckboxGroup) { const value = getValueFor<(typeof filter)['type']>( filter, @@ -344,6 +418,27 @@ const styles = StyleSheet.create({ paddingBottom: 8, paddingTop: 8, }, + switchLabelContainer: { + flex: 1, + justifyContent: 'center', + }, + switchLabel: { + fontSize: 16, + }, + switchContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginVertical: 8, + paddingHorizontal: 24, + }, + textContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginVertical: 8, + paddingHorizontal: 24, + }, pickerContainer: { flex: 1, flexDirection: 'row', diff --git a/src/screens/Categories/components/CategorySkeletonLoading.tsx b/src/screens/Categories/components/CategorySkeletonLoading.tsx index a190990a2..2c0bdbd87 100644 --- a/src/screens/Categories/components/CategorySkeletonLoading.tsx +++ b/src/screens/Categories/components/CategorySkeletonLoading.tsx @@ -3,7 +3,8 @@ import { StyleSheet, View } from 'react-native'; import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'; import { LinearGradient } from 'expo-linear-gradient'; import { ThemeColors } from '@theme/types'; -import getLoadingColors from '@utils/getLoadingColors'; +import useLoadingColors from '@utils/useLoadingColors'; +import { useAppSettings } from '@hooks/persisted/index'; interface Props { width: number; @@ -12,9 +13,10 @@ interface Props { } const CategorySkeletonLoading: React.FC = ({ height, width, theme }) => { + const { disableLoadingAnimations } = useAppSettings(); const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient); - const [highlightColor, backgroundColor] = getLoadingColors(theme); + const [highlightColor, backgroundColor] = useLoadingColors(theme); const renderLoadingCard = (item: number, index: number) => { return ( @@ -24,6 +26,7 @@ const CategorySkeletonLoading: React.FC = ({ height, width, theme }) => { shimmerColors={[backgroundColor, highlightColor, backgroundColor]} height={height} width={width} + stopAutoRun={disableLoadingAnimations} /> ); diff --git a/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx b/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx index 97da23772..020ca3022 100644 --- a/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx +++ b/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx @@ -23,6 +23,7 @@ const GlobalSearchScreen = (props: Props) => { const theme = useTheme(); const { searchText, setSearchText, clearSearchbar } = useSearch( props?.route?.params?.searchText, + false, ); const onChangeText = (text: string) => setSearchText(text); const onSubmitEditing = () => globalSearch(searchText); diff --git a/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx b/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx index 2c7667f27..ac47dbef6 100644 --- a/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx +++ b/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx @@ -13,8 +13,9 @@ import { GlobalSearchResult } from '../hooks/useGlobalSearch'; import GlobalSearchNovelItem from './GlobalSearchNovelItem'; import { useLibraryNovels } from '@screens/library/hooks/useLibrary'; import { LibraryNovelInfo } from '@database/types'; -import GlobalSearchSkeletonLoading from '@screens/browse/loadingAnimation/GlobalSearchSkeletonLoading'; import { switchNovelToLibrary } from '@database/queries/NovelQueries'; +import GlobalSearchSkeletonLoading from '@screens/browse/loadingAnimation/GlobalSearchSkeletonLoading'; +import { interpolateColor } from 'react-native-reanimated'; interface GlobalSearchResultsListProps { searchResults: GlobalSearchResult[]; @@ -25,12 +26,27 @@ const GlobalSearchResultsList: React.FC = ({ searchResults, ListEmptyComponent, }) => { - const theme = useTheme(); - const navigation = useNavigation>(); const keyExtractor = useCallback( (item: GlobalSearchResult) => item.plugin.id, [], ); + + return ( + + keyExtractor={keyExtractor} + data={searchResults} + contentContainerStyle={styles.resultList} + renderItem={({ item }) => } + ListEmptyComponent={ListEmptyComponent} + /> + ); +}; + +const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({ + item, +}) => { + const theme = useTheme(); + const navigation = useNavigation>(); const { library, setLibrary } = useLibraryNovels(); const novelInLibrary = (pluginId: string, novelPath: string) => @@ -38,7 +54,12 @@ const GlobalSearchResultsList: React.FC = ({ novel => novel.pluginId === pluginId && novel.path === novelPath, ); - const errorColor = useMemo(() => (theme.isDark ? '#B3261E' : '#F2B8B5'), []); + const errorColor = theme.isDark ? '#B3261E' : '#F2B8B5'; + const noResultsColor = interpolateColor( + 0.8, + [0, 1], + ['transparent', theme.onSurfaceVariant], + ); const navigateToNovel = useCallback( (item: { name: string; path: string; pluginId: string }) => @@ -46,109 +67,97 @@ const GlobalSearchResultsList: React.FC = ({ [], ); - return ( - - keyExtractor={keyExtractor} - data={searchResults} - contentContainerStyle={styles.resultList} - renderItem={({ item }) => ( - <> - - - navigation.navigate('SourceScreen', { - pluginId: item.plugin.id, - pluginName: item.plugin.name, - site: item.plugin.site, - }) - } - > - - - {item.plugin.name} - - - {item.plugin.lang} - - - - - {item.isLoading ? ( - - ) : item.error ? ( - - {item.error} + return useMemo( + () => ( + <> + + + navigation.navigate('SourceScreen', { + pluginId: item.plugin.id, + pluginName: item.plugin.name, + site: item.plugin.site, + }) + } + > + + + {item.plugin.name} - ) : ( - - item.plugin.id + '_' + novelItem.path - } - data={item.novels} - ListEmptyComponent={ - - {getString('sourceScreen.noResultsFound')} - - } - renderItem={({ item: novelItem }) => { - const inLibrary = novelInLibrary( - item.plugin.id, - novelItem.path, - ); + + {item.plugin.lang} + + + + + {item.isLoading ? ( + + ) : item.error ? ( + + {item.error} + + ) : ( + item.plugin.id + '_' + novelItem.path} + data={item.novels} + ListEmptyComponent={ + + {getString('sourceScreen.noResultsFound')} + + } + renderItem={({ item: novelItem }) => { + const inLibrary = novelInLibrary( + item.plugin.id, + novelItem.path, + ); - return ( - { - setLibrary(prevValues => { - if (inLibrary) { - return [ - ...prevValues.filter( - novel => novel.path !== novelItem.path, - ), - ]; - } else { - return [ - ...prevValues, - { - path: novelItem.path, - } as LibraryNovelInfo, - ]; - } - }); - switchNovelToLibrary(novelItem.path, item.plugin.id); - }} - /> - ); - }} - /> - )} - - - )} - ListEmptyComponent={ListEmptyComponent} - /> + return ( + { + setLibrary(prevValues => { + if (inLibrary) { + return [ + ...prevValues.filter( + novel => novel.path !== novelItem.path, + ), + ]; + } else { + return [ + ...prevValues, + { + path: novelItem.path, + } as LibraryNovelInfo, + ]; + } + }); + switchNovelToLibrary(novelItem.path, item.plugin.id); + }} + /> + ); + }} + /> + )} + + + ), + [item.isLoading], ); }; diff --git a/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts b/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts index c445d66d6..1ab3172ae 100644 --- a/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts +++ b/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { NovelItem, PluginItem } from '@plugins/types'; import { getPlugin } from '@plugins/pluginManager'; -import { usePlugins } from '@hooks/persisted'; +import { useBrowseSettings, usePlugins } from '@hooks/persisted'; +import { useFocusEffect } from '@react-navigation/native'; interface Props { defaultSearchText?: string; @@ -16,14 +17,35 @@ export interface GlobalSearchResult { } export const useGlobalSearch = ({ defaultSearchText }: Props) => { - const isMounted = useRef(true); + const isMounted = useRef(true); //if user closes the search screen, cancel the search + const isFocused = useRef(true); //if the user opens a sub-screen (e.g. novel screen), pause the search + const lastSearch = useRef(''); //if the user changes search, cancel running searches + useEffect( + () => () => { + isMounted.current = false; + }, + [], + ); + useFocusEffect( + useCallback(() => { + isFocused.current = true; + + return () => (isFocused.current = false); + }, []), + ); const { filteredInstalledPlugins } = usePlugins(); const [searchResults, setSearchResults] = useState([]); const [progress, setProgress] = useState(0); + const { globalSearchConcurrency = 1 } = useBrowseSettings(); + const globalSearch = (searchText: string) => { + if (lastSearch.current === searchText) { + return; + } + lastSearch.current = searchText; const defaultResult: GlobalSearchResult[] = filteredInstalledPlugins.map( plugin => ({ isLoading: true, @@ -33,46 +55,33 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => { }), ); - setSearchResults(defaultResult); + setSearchResults(defaultResult.sort(novelResultSorter)); + setProgress(0); - filteredInstalledPlugins.forEach(async _plugin => { - if (isMounted.current) { - try { - const plugin = getPlugin(_plugin.id); - if (!plugin) { - throw new Error(`Unknown plugin: ${_plugin.id}`); - } - const res = await plugin.searchNovels(searchText, 1); + let running = 0; + + async function searchInPlugin(_plugin: PluginItem) { + try { + const plugin = getPlugin(_plugin.id); + if (!plugin) { + throw new Error(`Unknown plugin: ${_plugin.id}`); + } + const res = await plugin.searchNovels(searchText, 1); - setSearchResults(prevState => - prevState.map(prevResult => + setSearchResults(prevState => + prevState + .map(prevResult => prevResult.plugin.id === plugin.id ? { ...prevResult, novels: res, isLoading: false } : { ...prevResult }, - ), - ); - - setSearchResults(prevState => - prevState.sort( - ( - { novels: a, plugin: { name: aName } }, - { novels: b, plugin: { name: bName } }, - ) => { - if (!a.length) { - return 1; - } - if (!b.length) { - return -1; - } - - return aName.localeCompare(bName); - }, - ), - ); - } catch (error: any) { - const errorMessage = error?.message || String(error); - setSearchResults(prevState => - prevState.map(prevResult => + ) + .sort(novelResultSorter), + ); + } catch (error: any) { + const errorMessage = error?.message || String(error); + setSearchResults(prevState => + prevState + .map(prevResult => prevResult.plugin.id === _plugin.id ? { ...prevResult, @@ -81,15 +90,57 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => { error: errorMessage, } : { ...prevResult }, - ), - ); - } finally { - setProgress( - prevState => prevState + 1 / filteredInstalledPlugins.length, - ); + ) + .sort(novelResultSorter), + ); + } + } + + //Sort so we load the plugins results in the same order as they show on the list + let filteredSortedInstalledPlugins = [...filteredInstalledPlugins].sort( + (a, b) => a.name.localeCompare(b.name), + ); + + (async () => { + if (globalSearchConcurrency > 1) { + for (let _plugin of filteredSortedInstalledPlugins) { + while (running >= globalSearchConcurrency || !isFocused.current) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!isMounted.current || lastSearch.current !== searchText) { + break; + } + running++; + searchInPlugin(_plugin) + .then(() => { + running--; + if (lastSearch.current === searchText) { + setProgress( + prevState => prevState + 1 / filteredInstalledPlugins.length, + ); + } + }) + .catch(() => { + running--; + }); + } + } else { + for (let _plugin of filteredSortedInstalledPlugins) { + if (!isMounted.current || lastSearch.current !== searchText) { + break; + } + while (!isFocused.current) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + await searchInPlugin(_plugin); + if (lastSearch.current === searchText) { + setProgress( + prevState => prevState + 1 / filteredInstalledPlugins.length, + ); + } } } - }); + })(); }; useEffect(() => { @@ -100,3 +151,20 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => { return { searchResults, globalSearch, progress }; }; + +function novelResultSorter( + { novels: a, plugin: { name: aName } }: GlobalSearchResult, + { novels: b, plugin: { name: bName } }: GlobalSearchResult, +) { + if (!a.length && !b.length) { + return aName.localeCompare(bName); + } + if (!a.length) { + return 1; + } + if (!b.length) { + return -1; + } + + return aName.localeCompare(bName); +} diff --git a/src/screens/WebviewScreen/WebviewScreen.tsx b/src/screens/WebviewScreen/WebviewScreen.tsx index b3b8c3035..a6fe71db5 100644 --- a/src/screens/WebviewScreen/WebviewScreen.tsx +++ b/src/screens/WebviewScreen/WebviewScreen.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import WebView, { WebViewNavigation } from 'react-native-webview'; import { ProgressBar } from 'react-native-paper'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { getPlugin } from '@plugins/pluginManager'; import { useBackHandler } from '@hooks'; @@ -25,6 +26,7 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => { const { name, url, pluginId, isNovel } = route.params; const isSave = getPlugin(pluginId)?.webStorageUtilized; const uri = resolveUrl(pluginId, url, isNovel); + const { bottom } = useSafeAreaInsets(); const theme = useTheme(); const webViewRef = useRef(null); @@ -107,6 +109,7 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => { onMessage={({ nativeEvent }) => setTempData(JSON.parse(nativeEvent.data)) } + containerStyle={{ paddingBottom: bottom }} /> {menuVisible ? ( { [], ); + useEffect( + () => + navigation.addListener('tabPress', e => { + if (navigation.isFocused()) { + e.preventDefault(); + + navigation.navigate('GlobalSearchScreen', {}); + } + }), + [navigation], + ); + const [index, setIndex] = React.useState(0); return ( <> diff --git a/src/screens/browse/components/BrowseTabs.tsx b/src/screens/browse/components/BrowseTabs.tsx index d1fa74e16..674c74544 100644 --- a/src/screens/browse/components/BrowseTabs.tsx +++ b/src/screens/browse/components/BrowseTabs.tsx @@ -28,6 +28,10 @@ import Animated, { useSharedValue, withTiming, } from 'react-native-reanimated'; +import { Portal } from 'react-native-paper'; +import SourceSettingsModal from './Modals/SourceSettings'; +import { useBoolean } from '@hooks'; +import { getPlugin } from '@plugins/pluginManager'; interface AvailableTabProps { searchText: string; @@ -50,6 +54,11 @@ export const InstalledTab = memo( updatePlugin, } = usePlugins(); const { showMyAnimeList, showAniList } = useBrowseSettings(); + const settingsModal = useBoolean(); + const [selectedPluginId, setSelectedPluginId] = useState(''); + + const pluginSettings = getPlugin(selectedPluginId)?.pluginSettings; + const navigateToSource = useCallback( (plugin: PluginItem, showLatestNovels?: boolean) => { navigation.navigate('SourceScreen', { @@ -159,6 +168,18 @@ export const InstalledTab = memo( + {item.hasSettings ? ( + { + setSelectedPluginId(item.id); + settingsModal.setTrue(); + }} + theme={theme} + /> + ) : null} {item.hasUpdate || __DEV__ ? ( {getString('browseScreen.installedPlugins')} + + + + } /> @@ -414,27 +446,37 @@ export const AvailableTab = memo(({ searchText, theme }: AvailableTabProps) => { /> } ListEmptyComponent={ - - - navigation.navigate('MoreStack', { - screen: 'SettingsStack', - params: { - screen: 'RespositorySettings', - }, - }), - }, - ]} - theme={theme} - /> - + actions={[ + { + iconName: 'cog-outline', + title: 'Add Repository', + onPress: () => + navigation.navigate('MoreStack', { + screen: 'SettingsStack', + params: { + screen: 'RespositorySettings', + }, + }), + }, + ]} + theme={theme} + /> + + ) : ( + + + + ) } /> ); diff --git a/src/screens/browse/components/Modals/SourceSettings.tsx b/src/screens/browse/components/Modals/SourceSettings.tsx new file mode 100644 index 000000000..45efcbfc2 --- /dev/null +++ b/src/screens/browse/components/Modals/SourceSettings.tsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Modal, overlay, TextInput } from 'react-native-paper'; +import { Button } from '@components/index'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import { Storage } from '@plugins/helpers/storage'; + +interface PluginSetting { + value: string; + label: string; +} + +interface PluginSettings { + [key: string]: PluginSetting; +} + +interface SourceSettingsModal { + visible: boolean; + onDismiss: () => void; + title: string; + description?: string; + pluginId: string; + pluginSettings?: PluginSettings; +} + +const SourceSettingsModal: React.FC = ({ + onDismiss, + visible, + title, + description, + pluginId, + pluginSettings, +}) => { + const theme = useTheme(); + + const [formValues, setFormValues] = useState<{ [key: string]: string }>({}); + + useEffect(() => { + if (pluginSettings) { + const storage = new Storage(pluginId); + + const loadFormValues = async () => { + const loadedValues = await Promise.all( + Object.keys(pluginSettings).map(async key => { + const storedValue = await storage.get(key); + return { + key, + value: + storedValue !== null ? storedValue : pluginSettings[key].value, + }; + }), + ); + + const initialFormValues = Object.fromEntries( + loadedValues.map(({ key, value }) => [key, value]), + ); + + setFormValues(initialFormValues); + }; + + loadFormValues(); + } + }, [pluginSettings, pluginId]); + + const handleChange = (key: string, value: string) => { + setFormValues(prevValues => ({ + ...prevValues, + [key]: value, + })); + }; + + const handleSave = () => { + const storage = new Storage(pluginId); + Object.entries(formValues).forEach(([key, value]) => { + storage.set(key, value); + }); + onDismiss(); + }; + + if (!pluginSettings || Object.keys(pluginSettings).length === 0) { + return ( + + + {title} + + + {description || 'No settings available.'} + + + ); + } + + return ( + + + {title} + + {description} + + {Object.entries(pluginSettings).map(([key, setting]) => ( + handleChange(key, value)} + placeholder={`Enter ${setting.label}`} + placeholderTextColor={theme.onSurfaceDisabled} + underlineColor={theme.outline} + style={[{ color: theme.onSurface }, styles.textInput]} + theme={{ colors: { ...theme } }} + /> + ))} + + +