From eafa545b2a34320b5e1eb2d99ba61b6888838da8 Mon Sep 17 00:00:00 2001 From: Patrick Loser Date: Wed, 6 May 2026 16:55:55 +0200 Subject: [PATCH 01/12] Use novel refactor (#1838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * feat: add zustand dependency and persistence key contract * refactor: extract bootstrap data loading into reusable service * refactor: move chapter mutations into store-ready action helpers * feat: add zustand novel store with cache and core actions * refactor: bridge novel persistence contracts for migration safety * refactor: migrate NovelScreen domain flows to zustand selectors * refactor: migrate NovelScreenList to selector-based store access * refactor: move reader chapter flows onto store boundaries * refactor: decouple useNovelSettings from broad context domain state * refactor: align migrateNovel with stable persistence contracts * refactor: cut novel-reader consumers to store-only context boundary * refactor: retire legacy useNovel and route cache cleanup export * test: update suites for store-only context boundary cutover * test: modernize store-era mocks and add contract coverage * test: finalize Task-15 sweep—remove dead useNovelData and lint clear mocksContract Final validation confirms mock-contract test suite clean and target file deletion verified with zero stale references in src/ scope. * remove imports from NovelScreen * reworked ai output * improvements * implemented synchronus novel and chapter fetch * refactor tests * fix db tests * Update remaining tests. * Harden chapter actions and bootstrap flows * Only count filtered chapters * improved chapter insert speed by optimizing triggers * Improved the batching function * Added drizzle support to dbManager.batch * removed better-sqlite3 for testing * fix tests * Update updateNovelChapters fucntion * reverse read filter * fix snackbar * fixed page bottomsheet * resolved paged novels showing wrong chapter number on opening * use openPage instead of setPageIndex in chapterDrawer * fix lint & tests * fix type issues * fix various smaller issues * updated novel restore * Delete tsconfig.tsbuildinfo --- __mocks__/database.js | 3 + package.json | 5 +- pnpm-lock.yaml | 43 +- src/database/__tests__/db.test.ts | 139 +--- src/database/db.ts | 4 + src/database/manager/manager.d.ts | 19 +- src/database/manager/manager.ts | 83 +- src/database/queries/ChapterQueries.ts | 198 +++-- src/database/queries/NovelQueries.ts | 27 +- .../queries/__tests__/ChapterQueries.test.ts | 83 ++ .../queries/__tests__/LibraryQueries.test.ts | 190 ++--- .../queries/__tests__/NovelQueries.test.ts | 41 +- src/database/queries/__tests__/setup.ts | 43 +- src/database/queries/__tests__/testData.ts | 22 +- src/database/queries/__tests__/testDb.ts | 52 +- .../queries/__tests__/testDbManager.ts | 134 ---- src/database/queryStrings/triggers.ts | 17 +- src/database/utils/filter.ts | 26 + src/database/utils/parser.ts | 3 +- src/hooks/__tests__/mocks.ts | 120 +++ src/hooks/__tests__/mocksContract.test.ts | 166 ++++ src/hooks/__tests__/useNovel.test.ts | 540 ++----------- src/hooks/__tests__/useNovelStore.test.ts | 137 ++++ .../persisted/__mocks__/useCategories.ts | 2 +- src/hooks/persisted/__mocks__/useNovel.ts | 31 + .../persisted/__mocks__/useNovelSettings.ts | 3 +- src/hooks/persisted/index.ts | 2 +- src/hooks/persisted/useNovel.ts | 725 ++---------------- .../__tests__/bootstrapService.test.ts | 413 ++++++++++ .../useNovel/__tests__/chapterActions.test.ts | 280 +++++++ .../useNovel/__tests__/keyContract.test.ts | 160 ++++ .../novelStore.chapterActions.test.ts | 409 ++++++++++ .../__tests__/novelStore.chapterState.test.ts | 24 + .../useNovel/__tests__/persistence.test.ts | 174 +++++ .../__tests__/useNovelSettings.test.ts | 127 +++ .../useNovel/store-helper/bootstrapService.ts | 408 ++++++++++ .../useNovel/store-helper/contracts.ts | 11 + .../useNovel/store-helper/keyContract.ts | 24 + .../useNovel/store-helper/persistence.ts | 188 +++++ .../useNovel/store/chapterActions.ts | 326 ++++++++ .../persisted/useNovel/store/createStore.ts | 101 +++ .../useNovel/store/novelStore.actions.ts | 205 +++++ .../store/novelStore.chapterActions.ts | 293 +++++++ .../useNovel/store/novelStore.chapterState.ts | 11 + .../persisted/useNovel/store/novelStore.ts | 34 + .../useNovel/store/novelStore.types.ts | 106 +++ src/hooks/persisted/useNovel/types.ts | 27 + .../useNovel/useChapterOperations.ts | 172 +++++ src/hooks/persisted/useNovelSettings.ts | 72 +- src/hooks/persisted/useTheme.ts | 2 +- src/screens/novel/NovelContext.tsx | 156 ++-- src/screens/novel/NovelScreen.tsx | 37 +- .../novel/__tests__/NovelScreen.test.tsx | 335 ++++++++ src/screens/novel/components/ChapterItem.tsx | 11 +- .../novel/components/Info/NovelInfoHeader.tsx | 36 +- .../novel/components/JumpToChapterModal.tsx | 14 +- .../novel/components/NovelBottomSheet.tsx | 30 +- .../novel/components/NovelScreenList.tsx | 69 +- .../components/PageNavigationBottomSheet.tsx | 27 +- .../__tests__/NovelScreenList.test.tsx | 349 +++++++++ .../__tests__/ChapterDrawer.test.tsx | 160 ++++ .../reader/components/ChapterDrawer/index.tsx | 22 +- .../reader/components/ReaderAppbar.tsx | 4 +- .../reader/components/ReaderFooter.tsx | 4 +- .../reader/hooks/__tests__/useChapter.test.ts | 294 +++++++ src/screens/reader/hooks/useChapter.ts | 20 +- .../settings/SettingsAdvancedScreen.tsx | 3 +- .../tabs/NavigationTab.tsx | 14 +- src/screens/updates/UpdatesScreen.tsx | 7 +- src/services/Trackers/myAnimeList.ts | 4 +- src/services/migrate/migrateNovel.ts | 32 +- src/services/updates/LibraryUpdateQueries.ts | 130 ++-- src/utils/mmkv/zustand-adapter.ts | 59 ++ strings/languages/en/strings.json | 3 +- strings/types/index.ts | 1 + 75 files changed, 6232 insertions(+), 2014 deletions(-) delete mode 100644 src/database/queries/__tests__/testDbManager.ts create mode 100644 src/hooks/__tests__/mocksContract.test.ts create mode 100644 src/hooks/__tests__/useNovelStore.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/keyContract.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/persistence.test.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts create mode 100644 src/hooks/persisted/useNovel/store-helper/bootstrapService.ts create mode 100644 src/hooks/persisted/useNovel/store-helper/contracts.ts create mode 100644 src/hooks/persisted/useNovel/store-helper/keyContract.ts create mode 100644 src/hooks/persisted/useNovel/store-helper/persistence.ts create mode 100644 src/hooks/persisted/useNovel/store/chapterActions.ts create mode 100644 src/hooks/persisted/useNovel/store/createStore.ts create mode 100644 src/hooks/persisted/useNovel/store/novelStore.actions.ts create mode 100644 src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts create mode 100644 src/hooks/persisted/useNovel/store/novelStore.chapterState.ts create mode 100644 src/hooks/persisted/useNovel/store/novelStore.ts create mode 100644 src/hooks/persisted/useNovel/store/novelStore.types.ts create mode 100644 src/hooks/persisted/useNovel/types.ts create mode 100644 src/hooks/persisted/useNovel/useChapterOperations.ts create mode 100644 src/screens/novel/__tests__/NovelScreen.test.tsx create mode 100644 src/screens/novel/components/__tests__/NovelScreenList.test.tsx create mode 100644 src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx create mode 100644 src/screens/reader/hooks/__tests__/useChapter.test.ts create mode 100644 src/utils/mmkv/zustand-adapter.ts diff --git a/__mocks__/database.js b/__mocks__/database.js index f023116ec2..103783474c 100644 --- a/__mocks__/database.js +++ b/__mocks__/database.js @@ -1,4 +1,5 @@ jest.mock('@database/queries/NovelQueries', () => ({ + getNovelById: jest.fn(), getNovelByPath: jest.fn(), deleteCachedNovels: jest.fn(), getCachedNovels: jest.fn(), @@ -30,7 +31,9 @@ jest.mock('@database/queries/ChapterQueries', () => ({ insertChapters: jest.fn(), getCustomPages: jest.fn(), getChapterCount: jest.fn(), + getChapterCountSync: jest.fn(), getPageChaptersBatched: jest.fn(), + getNovelChaptersSync: jest.fn(), getFirstUnreadChapter: jest.fn(), updateChapterProgress: jest.fn(), })); diff --git a/package.json b/package.json index 7b6e98cc11..e48ed0e57b 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,8 @@ "react-native-worklets": "^0.8.1", "react-native-zip-archive": "^7.0.2", "sanitize-html": "^2.17.2", - "urlencode": "^2.0.0" + "urlencode": "^2.0.0", + "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -145,7 +146,7 @@ "@typescript-eslint/parser": "^8.58.0", "babel-plugin-module-resolver": "^5.0.3", "babel-plugin-react-compiler": "^1.0.0", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.9.0", "drizzle-kit": "1.0.0-beta.20", "eslint": "^8.57.1", "eslint-plugin-eslint-comments": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01635ce04c..202c68ed63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ importers: version: 1.11.20 drizzle-orm: specifier: 1.0.0-beta.20 - version: 1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.8.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@4.3.6) + version: 1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@4.3.6) expo: specifier: ^55.0.9 version: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) @@ -218,6 +218,9 @@ importers: urlencode: specifier: ^2.0.0 version: 2.0.0 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@babel/core': specifier: ^7.29.0 @@ -292,8 +295,8 @@ importers: specifier: ^1.0.0 version: 1.0.0 better-sqlite3: - specifier: ^12.8.0 - version: 12.8.0 + specifier: ^12.9.0 + version: 12.9.0 drizzle-kit: specifier: 1.0.0-beta.20 version: 1.0.0-beta.20 @@ -2410,8 +2413,8 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} - better-sqlite3@12.8.0: - resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} big-integer@1.6.52: @@ -6320,6 +6323,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@azure-rest/core-client@2.5.1': @@ -9189,7 +9210,7 @@ snapshots: dependencies: open: 8.4.2 - better-sqlite3@12.8.0: + better-sqlite3@12.9.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 @@ -9736,7 +9757,7 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 - drizzle-orm@1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.8.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@4.3.6): + drizzle-orm@1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@4.3.6): dependencies: '@types/mssql': 9.1.9(@azure/core-client@1.10.1) mssql: 11.0.1(@azure/core-client@1.10.1) @@ -9744,7 +9765,7 @@ snapshots: '@op-engineering/op-sqlite': 15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@sinclair/typebox': 0.34.49 '@types/better-sqlite3': 7.6.13 - better-sqlite3: 12.8.0 + better-sqlite3: 12.9.0 expo-sqlite: 16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) zod: 4.3.6 @@ -13786,3 +13807,9 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/database/__tests__/db.test.ts b/src/database/__tests__/db.test.ts index f54c9eac26..51426058a5 100644 --- a/src/database/__tests__/db.test.ts +++ b/src/database/__tests__/db.test.ts @@ -1,22 +1,9 @@ -import Database from 'better-sqlite3'; +import { open, type DB } from '@op-engineering/op-sqlite'; import { drizzle } from 'drizzle-orm/op-sqlite'; import { migrate } from 'drizzle-orm/op-sqlite/migrator'; import migrations from '../../../drizzle/migrations'; import { schema } from '@database/schema'; -jest.mock('@op-engineering/op-sqlite', () => ({ - __esModule: true, - open: jest.fn(() => ({ - execute: jest.fn().mockResolvedValue({ rows: [] }), - executeAsync: jest.fn().mockResolvedValue({ rows: [] }), - executeSync: jest.fn().mockReturnValue({ rows: [] }), - executeRawAsync: jest.fn().mockResolvedValue([]), - executeBatch: jest.fn().mockResolvedValue(undefined), - flushPendingReactiveQueries: jest.fn(), - reactiveExecute: jest.fn(() => () => undefined), - })), -})); - import { runDatabaseBootstrap } from '@database/db'; const MIGRATION_STATEMENTS = [ @@ -80,84 +67,26 @@ const MIGRATION_STATEMENTS = [ `CREATE UNIQUE INDEX IF NOT EXISTS repository_url_unique ON Repository (url)`, ]; -const createExecutor = (sqlite: Database.Database) => ({ +const createExecutor = (sqlite: DB) => ({ executeSync: (sql: string, params?: unknown[]) => { - if (params && params.length) { - const stmt = sqlite.prepare(sql); - stmt.run(params as any[]); - return; - } - sqlite.exec(sql); + sqlite.executeSync(sql, params as any[]); }, }); -const createOpSqliteAdapter = (sqlite: Database.Database) => { - return { - execute: async (sql: string, params?: unknown[]) => { - const stmt = sqlite.prepare(sql); - const rows = - params && params.length ? stmt.all(params as any[]) : stmt.all(); - return { - rows: { - _array: rows.map(row => - Object.values(row as Record), - ), - }, - }; - }, - executeSync: (sql: string, params?: unknown[]) => { - const stmt = sqlite.prepare(sql); - const result = - params && params.length ? stmt.run(params as any[]) : stmt.run(); - return { rows: [], rowsAffected: result.changes ?? 0 }; - }, - executeAsync: async (sql: string, params?: unknown[]) => { - const stmt = sqlite.prepare(sql); - const result = - params && params.length ? stmt.run(params as any[]) : stmt.run(); - return { rows: [], rowsAffected: result.changes ?? 0 }; - }, - executeRawAsync: async (sql: string, params?: unknown[]) => { - const stmt = sqlite.prepare(sql).raw(); - const rows = - params && params.length ? stmt.all(params as any[]) : stmt.all(); - return rows as unknown[][]; - }, - executeBatch: async ( - commands: Array<[string, unknown[] | unknown[][]]>, - ) => { - const transaction = sqlite.transaction((cmds: typeof commands) => { - for (const cmd of cmds) { - const stmt = sqlite.prepare(cmd[0]); - if (Array.isArray(cmd[1])) { - for (const arg of cmd[1]) { - stmt.run(arg as any[]); - } - } else { - stmt.run(cmd[1] as any[]); - } - } - }); - transaction(commands); - }, - flushPendingReactiveQueries: () => undefined, - reactiveExecute: () => () => undefined, - }; -}; - describe('new database initialization', () => { it('creates schema, triggers, and default data', async () => { - const sqlite = new Database(':memory:'); + const sqlite = open({ name: ':memory:' }); + (sqlite as any).executeAsync ??= sqlite.execute; + (sqlite as any).executeRawAsync ??= sqlite.executeRaw; try { - const adapter = createOpSqliteAdapter(sqlite); - const drizzleDb = drizzle(adapter, { schema }); + const drizzleDb = drizzle(sqlite, { schema }); await migrate(drizzleDb, migrations); runDatabaseBootstrap(createExecutor(sqlite)); - const tables = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all() as Array<{ name: string }>; + const tables = sqlite.executeSync( + "SELECT name FROM sqlite_master WHERE type='table'", + ).rows as Array<{ name: string }>; const tableNames = tables.map(table => table.name); expect(tableNames).toEqual( expect.arrayContaining([ @@ -169,9 +98,9 @@ describe('new database initialization', () => { ]), ); - const triggers = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='trigger'") - .all() as Array<{ name: string }>; + const triggers = sqlite.executeSync( + "SELECT name FROM sqlite_master WHERE type='trigger'", + ).rows as Array<{ name: string }>; const triggerNames = triggers.map(trigger => trigger.name); expect(triggerNames).toEqual( expect.arrayContaining([ @@ -182,9 +111,9 @@ describe('new database initialization', () => { ]), ); - const categories = sqlite - .prepare('SELECT id, name FROM Category ORDER BY id') - .all() as Array<{ id: number; name: string }>; + const categories = sqlite.executeSync( + 'SELECT id, name FROM Category ORDER BY id', + ).rows as Array<{ id: number; name: string }>; expect(categories.map(category => category.id)).toEqual([1, 2]); } finally { sqlite.close(); @@ -194,20 +123,23 @@ describe('new database initialization', () => { describe('runDatabaseBootstrap', () => { it('applies pragmas, triggers, and default categories', () => { - const sqlite = new Database(':memory:'); + const sqlite = open({ name: ':memory:' }); + (sqlite as any).executeAsync ??= sqlite.execute; + (sqlite as any).executeRawAsync ??= sqlite.executeRaw; try { for (const statement of MIGRATION_STATEMENTS) { - sqlite.exec(statement.trim()); + sqlite.executeSync(statement.trim()); } + sqlite.executeSync('PRAGMA journal_mode = WAL'); runDatabaseBootstrap(createExecutor(sqlite)); - const journalMode = sqlite.pragma('journal_mode', { simple: true }); + const journalMode = sqlite.executeRawSync('PRAGMA journal_mode')[0]?.[0]; expect(['wal', 'memory']).toContain(String(journalMode).toLowerCase()); - const triggers = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='trigger'") - .all() as Array<{ name: string }>; + const triggers = sqlite.executeSync( + "SELECT name FROM sqlite_master WHERE type='trigger'", + ).rows as Array<{ name: string }>; const triggerNames = triggers.map(trigger => trigger.name); expect(triggerNames).toEqual( expect.arrayContaining([ @@ -218,9 +150,9 @@ describe('runDatabaseBootstrap', () => { ]), ); - const categories = sqlite - .prepare('SELECT id, name FROM Category ORDER BY id') - .all() as Array<{ id: number; name: string }>; + const categories = sqlite.executeSync( + 'SELECT id, name FROM Category ORDER BY id', + ).rows as Array<{ id: number; name: string }>; expect(categories.map(category => category.id)).toEqual([1, 2]); expect(categories.map(category => category.name)).toEqual([ 'categories.default', @@ -234,19 +166,20 @@ describe('runDatabaseBootstrap', () => { describe('production migrations', () => { it('can run after test schema exists', async () => { - const sqlite = new Database(':memory:'); + const sqlite = open({ name: ':memory:' }); + (sqlite as any).executeAsync ??= sqlite.execute; + (sqlite as any).executeRawAsync ??= sqlite.executeRaw; try { for (const statement of MIGRATION_STATEMENTS) { - sqlite.exec(statement.trim()); + sqlite.executeSync(statement.trim()); } - const adapter = createOpSqliteAdapter(sqlite); - const drizzleDb = drizzle(adapter, { schema }); + const drizzleDb = drizzle(sqlite, { schema }); await migrate(drizzleDb, migrations); - const tables = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all() as Array<{ name: string }>; + const tables = sqlite.executeSync( + "SELECT name FROM sqlite_master WHERE type='table'", + ).rows as Array<{ name: string }>; const tableNames = tables.map(table => table.name); expect(tableNames).toEqual( expect.arrayContaining([ diff --git a/src/database/db.ts b/src/database/db.ts index cd55a1527f..b2cc4faaff 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -69,6 +69,10 @@ const populateDatabase = (executor: SqlExecutor) => { const createDbTriggers = (executor: SqlExecutor) => { console.log('Creating database triggers'); + executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats'); + executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_update'); + executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_delete'); + executor.executeSync('DROP TRIGGER IF EXISTS add_category'); executor.executeSync(createCategoryTriggerQuery); executor.executeSync(createNovelTriggerQueryDelete); executor.executeSync(createNovelTriggerQueryInsert); diff --git a/src/database/manager/manager.d.ts b/src/database/manager/manager.d.ts index 4e5eda3319..195bab40bb 100644 --- a/src/database/manager/manager.d.ts +++ b/src/database/manager/manager.d.ts @@ -1,5 +1,10 @@ // db-manager.types.ts -import type { SQLiteTransaction, TablesRelationalConfig } from 'drizzle-orm'; +import type { + SQLiteTransaction, + TablesRelationalConfig, + Placeholder, +} from 'drizzle-orm'; +import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core'; // Define the TransactionParameter type based on your DrizzleDb export type TransactionParameter = SQLiteTransaction< @@ -14,6 +19,18 @@ export type TransactionParameter = SQLiteTransaction< * This contract ensures consistent documentation and type safety across the application. */ export interface IDbManager { + /** + * Efficiently executes a Drizzle query for multiple data rows using + * op-sqlite executeBatch under the hood. + */ + batch>( + data: T[], + fn: ( + tx: TransactionParameter, + ph: (arg: Extract) => Placeholder, + ) => SQLitePreparedQuery, + ): Promise; + /** * Creates a subquery that defines a temporary named result set as a CTE. * diff --git a/src/database/manager/manager.ts b/src/database/manager/manager.ts index 1d8667ad07..e6d9a7d5a9 100644 --- a/src/database/manager/manager.ts +++ b/src/database/manager/manager.ts @@ -1,10 +1,17 @@ import { db, drizzleDb } from '@database/db'; +import type { SQLBatchTuple, Scalar } from '@op-engineering/op-sqlite'; import { IDbManager } from './manager.d'; import { DbTaskQueue } from './queue'; import { Schema } from '../schema'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { GetSelectTableName } from 'drizzle-orm/query-builders/select.types'; -import { AnyColumn, Placeholder, Query, sql } from 'drizzle-orm'; +import { + AnyColumn, + fillPlaceholders, + Placeholder, + Query, + sql, +} from 'drizzle-orm'; import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core'; type DrizzleDb = typeof drizzleDb; @@ -18,7 +25,11 @@ interface ExecutableSelect { get(): Promise; } -let _dbManager: DbManager; +let _dbManager: DbManager | undefined; + +export const __resetDbManagerForTests = () => { + _dbManager = undefined; +}; export function castInt(value: number | string | AnyColumn) { return sql`CAST(${value} AS INTEGER)`; @@ -66,34 +77,18 @@ class DbManager implements IDbManager { query: T, ): Awaited> { const { sql: sqlString, params } = query.toSQL(); - return db.executeSync(sqlString, params as any[]).rows[0] as Awaited< - ReturnType - >; + return this.db.$client.executeSync(sqlString, params as any[]) + .rows[0] as Awaited>; } - public async allSync( + public allSync( query: T, - ): Promise>> { + ): Awaited> { const { sql: sqlString, params } = query.toSQL(); - return db.executeSync(sqlString, params as any[]).rows as Awaited< - ReturnType - >; + return this.db.$client.executeSync(sqlString, params as any[]) + .rows as Awaited>; } - /** - * Efficiently executes a Drizzle query for multiple data rows using a single - * prepared statement within a write transaction. - * - * @param data - Array of objects containing the parameters for each execution. - * @param fn - Callback to build the query. Use `ph("key")` to bind to properties in your data. - * Must return a prepared query via `.prepare()`. - * - * @example - * await dbManager.batch( - * [{ id: 1, val: 'a' }, { id: 2, val: 'b' }], - * (tx, ph) => tx.insert(table).values({ id: ph('id'), val: ph('val') }).prepare() - * ); - */ public async batch>( data: T[], fn: ( @@ -101,12 +96,27 @@ class DbManager implements IDbManager { ph: (arg: Extract) => Placeholder, ) => SQLitePreparedQuery, ) { + if (!data.length) { + return; + } + const ph = (arg: Extract) => sql.placeholder(arg); - await this.write(async tx => { - const prep = fn(tx, ph); - for (let index = 0; index < data.length; index++) { - prep.run(data[index]); - } + const prepared = fn(this.db as unknown as TransactionParameter, ph); + const query = prepared.getQuery(); + const params = data.map(item => { + const values = fillPlaceholders(query.params, item); + return values.map(value => + value === undefined ? null : (value as Scalar), + ) as Scalar[]; + }); + const commands: SQLBatchTuple[] = [[query.sql, params]]; + + await this.queue.enqueue({ + id: 'write', + run: async () => { + await this.db.$client.executeBatch(commands); + this.db.$client?.flushPendingReactiveQueries(); + }, }); } @@ -118,7 +128,7 @@ class DbManager implements IDbManager { run: async () => await this.db.transaction(async tx => { const result = await fn(tx); - db?.flushPendingReactiveQueries(); + this.db.$client?.flushPendingReactiveQueries(); return result; }), }); @@ -135,16 +145,20 @@ type FireOn = Array<{ table: TableNames; ids?: number[] }>; export function useLiveQuery( query: T, fireOn: FireOn, + callback?: (data: Awaited>) => void, ) { type ReturnValue = Awaited>; const { sql: sqlString, params } = query.toSQL(); const paramsKey = JSON.stringify(params); const fireOnKey = JSON.stringify(fireOn); + const cb = useRef(callback ?? (() => {})); - const [data, setData] = useState( - () => db.executeSync(sqlString, params as any[]).rows as ReturnValue, - ); + const [data, setData] = useState(() => { + const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue; + cb.current(r); + return r; + }); useEffect(() => { const unsub = db.reactiveExecute({ @@ -153,6 +167,7 @@ export function useLiveQuery( fireOn, callback: (result: { rows: ReturnValue }) => { setData(result.rows); + cb.current(result.rows); }, }); return unsub; diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index 981ad26bf4..d3224b7a2e 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -35,42 +35,59 @@ import { castInt } from '@database/manager/manager'; export const insertChapters = async ( novelId: number, chapters?: ChapterItem[], + options?: { + page?: string; + touchUpdatedTime?: boolean; + preferNullReleaseTime?: boolean; + }, ): Promise => { if (!chapters?.length) { return; } - await dbManager.batch( - chapters.map((c, i) => ({ - path: c.path, - name: c.name || 'Chapter ' + (i + 1), - releaseTime: c.releaseTime || '', - chapterNumber: c.chapterNumber ?? null, - page: c.page || '1', - position: i, - })), - (tx, ph) => - tx - .insert(chapterSchema) - .values({ - path: ph('path'), - name: ph('name'), - releaseTime: ph('releaseTime'), - novelId, - chapterNumber: ph('chapterNumber'), - page: ph('page'), - position: ph('position'), - }) - .onConflictDoUpdate({ - target: [chapterSchema.novelId, chapterSchema.path], - set: { - page: ph('page'), - position: ph('position'), - name: ph('name'), - releaseTime: ph('releaseTime'), - chapterNumber: ph('chapterNumber'), - }, - }) - .prepare(), + + const nowSql = sql`datetime('now','localtime')`; + + const rows = chapters.map((c, index) => ({ + path: c.path, + name: c.name || `Chapter ${index + 1}`, + releaseTime: c.releaseTime ?? (options?.preferNullReleaseTime ? null : ''), + novelId, + chapterNumber: c.chapterNumber ?? index + 1, + page: options?.page ?? c.page ?? '1', + position: index, + })); + await dbManager.batch(rows, (tx, ph) => + tx + .insert(chapterSchema) + .values({ + path: ph('path'), + name: ph('name'), + releaseTime: ph('releaseTime'), + novelId: ph('novelId'), + chapterNumber: ph('chapterNumber'), + page: ph('page'), + position: ph('position'), + ...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}), + }) + .onConflictDoUpdate({ + target: [chapterSchema.novelId, chapterSchema.path], + set: { + page: sql`excluded.page`, + position: sql`excluded.position`, + name: sql`excluded.name`, + releaseTime: sql`excluded.releaseTime`, + chapterNumber: sql`excluded.chapterNumber`, + ...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}), + }, + where: sql`NOT ( + ${chapterSchema.page} IS excluded.page + AND ${chapterSchema.position} IS excluded.position + AND ${chapterSchema.name} IS excluded.name + AND ${chapterSchema.releaseTime} IS excluded.releaseTime + AND ${chapterSchema.chapterNumber} IS excluded.chapterNumber + )`, + }) + .prepare(), ); }; @@ -300,23 +317,61 @@ export const clearUpdates = async (): Promise => { // #endregion // #region Selectors -export const getCustomPages = async (novelId: number) => { - return await dbManager - .selectDistinct({ page: chapterSchema.page }) - .from(chapterSchema) - .where(eq(chapterSchema.novelId, novelId)) - .orderBy(asc(castInt(chapterSchema.page))) - .all(); +export const getCustomPages = (novelId: number) => { + return dbManager.allSync( + dbManager + .selectDistinct({ page: chapterSchema.page }) + .from(chapterSchema) + .where(eq(chapterSchema.novelId, novelId)) + .orderBy(asc(castInt(chapterSchema.page))), + ); }; export const getNovelChapters = async ( novelId: number, + sort?: ChapterOrderKey, + filter?: ChapterFilterKey[], + page?: string, + limit: number = 1000, ): Promise => dbManager .select() .from(chapterSchema) - .where(eq(chapterSchema.novelId, novelId)); + .where( + and( + eq(chapterSchema.novelId, novelId), + !page ? sql.raw('true') : eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + ), + ) + .orderBy(chapterOrderToSQL(sort)) + .limit(limit) + .all(); +export const getNovelChaptersSync = ( + novelId: number, + sort?: ChapterOrderKey, + filter?: ChapterFilterKey[], + page?: string, + limit: number = 1000, +): ChapterInfo[] => + dbManager.allSync( + dbManager + .select() + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + !page ? sql.raw('true') : eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + ), + ) + .orderBy(chapterOrderToSQL(sort)) + .limit(limit), // Adding a limit to prevent potential performance issues with large datasets + ); +/** + * @deprecated, use getNovelChapters with whereConditions instead + */ export const getUnreadNovelChapters = async ( novelId: number, ): Promise => @@ -326,7 +381,9 @@ export const getUnreadNovelChapters = async ( .where( and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.unread, true)), ); - +/** + * @deprecated, use getNovelChapters with whereConditions instead + */ export const getAllUndownloadedChapters = async ( novelId: number, ): Promise => @@ -339,7 +396,9 @@ export const getAllUndownloadedChapters = async ( eq(chapterSchema.isDownloaded, false), ), ); - +/** + * @deprecated, use getNovelChapters with whereConditions instead + */ export const getAllUndownloadedAndUnreadChapters = async ( novelId: number, ): Promise => @@ -409,6 +468,28 @@ export const getChapterCount = async ( ), ); +export const getChapterCountSync = ( + novelId: number, + page: string = '1', + filter?: ChapterFilterKey[], +): number => { + // Using count(*) as name because the current drizzle version generates wrong type + const result = dbManager.getSync( + dbManager + .select({ 'count(*)': count() }) + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + ), + ), + ); + + return result?.['count(*)'] ?? 0; +}; + export const getPageChaptersBatched = async ( novelId: number, sort?: ChapterOrderKey, @@ -416,8 +497,8 @@ export const getPageChaptersBatched = async ( page?: string, batch: number = 0, ) => { - const limit = 300; - const offset = 300 * batch; + const limit = 1000; + const offset = 1000 * batch; const query = dbManager .select() .from(chapterSchema) @@ -459,20 +540,21 @@ export const getFirstUnreadChapter = ( filter?: ChapterFilterKey[], page?: string, ) => - dbManager - .select() - .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page || '1'), - eq(chapterSchema.unread, true), - chapterFilterToSQL(filter), - ), - ) - .orderBy(asc(chapterSchema.position)) - .limit(1) - .get(); + dbManager.getSync( + dbManager + .select() + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page || '1'), + eq(chapterSchema.unread, true), + chapterFilterToSQL(filter), + ), + ) + .orderBy(asc(chapterSchema.position)) + .limit(1), + ); export const getNovelChaptersByName = async ( novelId: number, diff --git a/src/database/queries/NovelQueries.ts b/src/database/queries/NovelQueries.ts index 52dbad1687..45a0fdd85a 100644 --- a/src/database/queries/NovelQueries.ts +++ b/src/database/queries/NovelQueries.ts @@ -6,7 +6,7 @@ import { insertChapters } from './ChapterQueries'; import { showToast } from '@utils/showToast'; import { getString } from '@strings/translations'; -import { BackupNovel, NovelInfo } from '../types'; +import { BackupNovel, DBNovelInfo, NovelInfo } from '../types'; import { SourceNovel } from '@plugins/types'; import { NOVEL_STORAGE } from '@utils/Storages'; import { downloadFile } from '@plugins/helpers/fetch'; @@ -82,21 +82,16 @@ export const getAllNovels = async (): Promise => { return dbManager.select().from(novelSchema).all(); }; -export const getNovelById = async ( - novelId: number, -): Promise => { - const res = dbManager - .select() - .from(novelSchema) - .where(eq(novelSchema.id, novelId)) - .get(); - return res; +export const getNovelById = (novelId: number): DBNovelInfo | undefined => { + return dbManager.getSync( + dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)), + ); }; export const getNovelByPath = ( novelPath: string, pluginId: string, -): NovelInfo | undefined => { +): DBNovelInfo | undefined => { const res = dbManager.getSync( dbManager .select() @@ -430,7 +425,15 @@ export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => { tx.delete(chapterSchema).where(eq(chapterSchema.novelId, novel.id)).run(); // Restore novel - tx.insert(novelSchema).values(novel).run(); + tx + .insert(novelSchema) + .values({ + ...novel, + totalChapters: 0, + chaptersDownloaded: 0, + chaptersUnread: 0, + }) + .run(); // Restore chapters in batches if (chapters.length > 0) { diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts index 005d4c001e..216bcfe4cd 100644 --- a/src/database/queries/__tests__/ChapterQueries.test.ts +++ b/src/database/queries/__tests__/ChapterQueries.test.ts @@ -324,6 +324,89 @@ describe('ChapterQueries', () => { const chapters = await getNovelChapters(novelId); expect(chapters[0].name).toBe('Chapter 1'); }); + + it('should force page override option when inserting chapters', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + + await insertChapters( + novelId, + [ + { + path: '/chapter/1', + name: 'Chapter 1', + page: '99', + }, + ], + { page: '2' }, + ); + + const chapters = await getNovelChapters(novelId); + expect(chapters).toHaveLength(1); + expect(chapters[0].page).toBe('2'); + }); + + it('should set updatedTime when touchUpdatedTime is enabled on insert', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + + await insertChapters( + novelId, + [ + { + path: '/chapter/1', + name: 'Chapter 1', + }, + ], + { touchUpdatedTime: true }, + ); + + const chapters = await getNovelChapters(novelId); + expect(chapters).toHaveLength(1); + expect(chapters[0].updatedTime).not.toBeNull(); + }); + + it('should set releaseTime to null when preferNullReleaseTime is enabled', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + + await insertChapters( + novelId, + [ + { + path: '/chapter/1', + name: 'Chapter 1', + }, + ], + { preferNullReleaseTime: true }, + ); + + const chapters = await getNovelChapters(novelId); + expect(chapters).toHaveLength(1); + expect(chapters[0].releaseTime).toBeNull(); + }); + + it('should backfill chapterNumber with index fallback over existing null', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + + await insertTestChapter(testDb, novelId, { + path: '/chapter/1', + chapterNumber: null, + }); + + await insertChapters(novelId, [ + { + path: '/chapter/1', + name: 'Chapter 1', + }, + ]); + + const chapters = await getNovelChapters(novelId); + expect(chapters).toHaveLength(1); + expect(chapters[0].chapterNumber).toBe(1); + expect(chapters[0].chapterNumber).not.toBeNull(); + }); }); describe('deleteChapter', () => { diff --git a/src/database/queries/__tests__/LibraryQueries.test.ts b/src/database/queries/__tests__/LibraryQueries.test.ts index 3f72e3f8f2..2fa1474048 100644 --- a/src/database/queries/__tests__/LibraryQueries.test.ts +++ b/src/database/queries/__tests__/LibraryQueries.test.ts @@ -4,13 +4,13 @@ import { insertTestNovel, insertTestChapter, insertTestNovelCategory, + insertTestCategory, } from './testData'; import { getLibraryNovelsFromDb, getLibraryWithCategory, } from '../LibraryQueries'; import { TestDb } from './testDb'; -import { categorySchema } from '@database/schema'; import { setupTestDatabase, teardownTestDatabase } from './setup'; describe('LibraryQueries', () => { @@ -140,22 +140,65 @@ describe('LibraryQueries', () => { it('should combine all filters (sort, search, downloaded only, exclude local)', async () => { // Setup: Insert multiple novels with varying properties - const novel1Id = await insertTestNovel(testDb, { + const novelId1 = await insertTestNovel(testDb, { inLibrary: true, name: 'The Great Local Novel', author: 'Author One', isLocal: true, }); - await insertTestChapter(testDb, novel1Id, { isDownloaded: true }); + const novelId2 = await insertTestNovel(testDb, { + inLibrary: true, + name: 'Novel 2', + isLocal: false, + }); + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); + await insertTestNovelCategory(testDb, novelId1, categoryId1); + await insertTestNovelCategory(testDb, novelId2, categoryId1); - const novel2Id = await insertTestNovel(testDb, { + const novels = await getLibraryWithCategory(categoryId1, undefined, true); + expect(novels).toHaveLength(1); + expect(novels[0].name).toBe('Novel 2'); + }); + + it('should not filter by excludeLocalNovels = false', async () => { + const novelId1 = await insertTestNovel(testDb, { + inLibrary: true, + name: 'Novel 1', + isLocal: true, + }); + const novelId2 = await insertTestNovel(testDb, { + inLibrary: true, + name: 'Novel 2', + isLocal: false, + }); + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); + await insertTestNovelCategory(testDb, novelId1, categoryId1); + await insertTestNovelCategory(testDb, novelId2, categoryId1); + + const novels = await getLibraryWithCategory( + categoryId1, + undefined, + false, + ); + expect(novels).toHaveLength(2); + expect(novels.map(n => n.name).sort()).toEqual(['Novel 1', 'Novel 2']); + }); + + it('should handle novels belonging to multiple categories', async () => { + // Test Case 1: Filter by remote novels with downloaded chapters + const novel1Id = await insertTestNovel(testDb, { inLibrary: true, name: 'A Good Remote Story', author: 'Author Two', isLocal: false, }); - await insertTestChapter(testDb, novel2Id, { isDownloaded: true }); + await insertTestChapter(testDb, novel1Id, { isDownloaded: true }); + // Novel 2: Remote but not downloaded await insertTestNovel(testDb, { inLibrary: true, name: 'Another Remote Novel', @@ -163,14 +206,14 @@ describe('LibraryQueries', () => { isLocal: false, }); + // Novel 3: Downloaded but not matching author filter await insertTestNovel(testDb, { inLibrary: true, - name: 'Downloaded Local Book', + name: 'Downloaded Book', author: 'Author One', isLocal: false, }); // No chapters, so chaptersDownloaded is 0 - // Test Case 1: All filters combined, expecting specific result const novels1 = await getLibraryNovelsFromDb( 'name ASC', // sortOrder "author = 'Author Two'", // filter @@ -181,14 +224,21 @@ describe('LibraryQueries', () => { expect(novels1).toHaveLength(1); expect(novels1[0].name).toBe('A Good Remote Story'); - // Test Case 2: Different combination, expecting a different result - // Looking for local, downloaded novels by Author One, sorted by name DESC - const novel3Id = await insertTestNovel(testDb, { + // Test Case 2: Filter by local novels with downloaded chapters by Author One + const novel2Id = await insertTestNovel(testDb, { inLibrary: true, name: 'An Old Local Story', author: 'Author One', isLocal: true, }); + await insertTestChapter(testDb, novel2Id, { isDownloaded: true }); + + const novel3Id = await insertTestNovel(testDb, { + inLibrary: true, + name: 'The Great Local Novel', + author: 'Author One', + isLocal: true, + }); await insertTestChapter(testDb, novel3Id, { isDownloaded: true }); const novels2 = await getLibraryNovelsFromDb( @@ -199,16 +249,8 @@ describe('LibraryQueries', () => { false, // Include local novels ); - // We expect 'The Great Local Novel' and 'An Old Local Story' // Both are local, by Author One, and have downloaded chapters. // Sorted DESC, so 'The Great Local Novel' comes first. - expect( - novels2.map(n => ({ - name: n.name, - isLocal: n.isLocal, - chaptersDownloaded: n.chaptersDownloaded, - })), - ).toHaveLength(2); expect(novels2.map(n => n.name)).toEqual([ 'The Great Local Novel', 'An Old Local Story', @@ -258,16 +300,12 @@ describe('LibraryQueries', () => { name: 'Novel 3', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; - const categoryId2 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category B' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); + const categoryId2 = await insertTestCategory(testDb, { + name: 'Category B', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -288,11 +326,9 @@ describe('LibraryQueries', () => { name: 'Novel 2', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -307,16 +343,12 @@ describe('LibraryQueries', () => { inLibrary: true, name: 'Novel 1', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; - const categoryId2 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category B' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); + const categoryId2 = await insertTestCategory(testDb, { + name: 'Category B', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); @@ -329,11 +361,9 @@ describe('LibraryQueries', () => { await insertTestNovel(testDb, { inLibrary: true, name: 'Novel 1' }); await insertTestNovel(testDb, { inLibrary: true, name: 'Novel 2' }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); // No novel-category associations made for categoryId1 @@ -358,11 +388,9 @@ describe('LibraryQueries', () => { name: 'Novel 2', status: 'Completed', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -382,11 +410,9 @@ describe('LibraryQueries', () => { name: 'Novel 2', status: 'Completed', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -406,11 +432,9 @@ describe('LibraryQueries', () => { name: 'Novel 2', isLocal: false, }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -430,11 +454,9 @@ describe('LibraryQueries', () => { name: 'Novel 2', isLocal: false, }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -457,16 +479,12 @@ describe('LibraryQueries', () => { name: 'Novel 2', }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; - const categoryId2 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category B' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); + const categoryId2 = await insertTestCategory(testDb, { + name: 'Category B', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId1, categoryId2); // Novel 1 in two categories @@ -495,11 +513,9 @@ describe('LibraryQueries', () => { }); await insertTestNovel(testDb, { inLibrary: false, name: 'Novel 3' }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); @@ -520,11 +536,9 @@ describe('LibraryQueries', () => { }); await insertTestNovel(testDb, { inLibrary: false, name: 'Novel 3' }); - const categoryId1 = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Category A' }) - .returning() - .get().id; + const categoryId1 = await insertTestCategory(testDb, { + name: 'Category A', + }); await insertTestNovelCategory(testDb, novelId1, categoryId1); await insertTestNovelCategory(testDb, novelId2, categoryId1); diff --git a/src/database/queries/__tests__/NovelQueries.test.ts b/src/database/queries/__tests__/NovelQueries.test.ts index c1c3955be2..7c7aa8efe5 100644 --- a/src/database/queries/__tests__/NovelQueries.test.ts +++ b/src/database/queries/__tests__/NovelQueries.test.ts @@ -9,6 +9,7 @@ import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup'; import { insertTestNovel, insertTestNovelCategory, + insertTestCategory, clearAllTables, } from './testData'; import { categorySchema, novelCategorySchema } from '@database/schema'; @@ -147,9 +148,9 @@ describe('NovelQueries', () => { 'test-plugin', ); - expect(result?.inLibrary).toBe(true); - const novel = await getNovelById(novelId); - expect(novel?.inLibrary).toBe(true); + expect(Boolean(result?.inLibrary)).toBe(true); + const novel = getNovelById(novelId); + expect(Boolean(novel?.inLibrary)).toBe(true); }); it('should remove novel from library', async () => { @@ -165,9 +166,9 @@ describe('NovelQueries', () => { 'test-plugin', ); - expect(result?.inLibrary).toBe(false); - const novel = await getNovelById(novelId); - expect(novel?.inLibrary).toBe(false); + expect(Boolean(result?.inLibrary)).toBe(false); + const novel = getNovelById(novelId); + expect(Boolean(novel?.inLibrary)).toBe(false); }); it('should assign default category when adding to library', async () => { @@ -211,8 +212,8 @@ describe('NovelQueries', () => { const novel1 = await getNovelById(novelId1); const novel2 = await getNovelById(novelId2); - expect(novel1?.inLibrary).toBe(false); - expect(novel2?.inLibrary).toBe(false); + expect(Boolean(novel1?.inLibrary)).toBe(false); + expect(Boolean(novel2?.inLibrary)).toBe(false); }); it('should handle empty array', async () => { @@ -222,11 +223,9 @@ describe('NovelQueries', () => { it('should clean up categories when removing from library', async () => { const testDb = getTestDb(); const novelId = await insertTestNovel(testDb, { inLibrary: true }); - const categoryId = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Test Category' }) - .returning() - .get().id; + const categoryId = await insertTestCategory(testDb, { + name: 'Test Category', + }); await insertTestNovelCategory(testDb, novelId, categoryId); await removeNovelsFromLibrary([novelId]); @@ -379,11 +378,9 @@ describe('NovelQueries', () => { it('should add categories to a novel', async () => { const testDb = getTestDb(); const novelId = await insertTestNovel(testDb, { inLibrary: true }); - const categoryId = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Test Category' }) - .returning() - .get().id; + const categoryId = await insertTestCategory(testDb, { + name: 'Test Category', + }); await updateNovelCategoryById(novelId, [categoryId]); @@ -402,11 +399,9 @@ describe('NovelQueries', () => { const testDb = getTestDb(); const novelId1 = await insertTestNovel(testDb, { inLibrary: true }); const novelId2 = await insertTestNovel(testDb, { inLibrary: true }); - const categoryId = await testDb.drizzleDb - .insert(categorySchema) - .values({ name: 'Test Category' }) - .returning() - .get().id; + const categoryId = await insertTestCategory(testDb, { + name: 'Test Category', + }); await updateNovelCategories([novelId1, novelId2], [categoryId]); diff --git a/src/database/queries/__tests__/setup.ts b/src/database/queries/__tests__/setup.ts index 4e114c11a3..2a6e7d9c9c 100644 --- a/src/database/queries/__tests__/setup.ts +++ b/src/database/queries/__tests__/setup.ts @@ -8,7 +8,9 @@ // @ts-ignore global.__DEV__ ??= false; -import { createTestDb, cleanupTestDb, type TestDb } from './testDb'; +import type { TestDb } from './testDb'; + +const getTestDbModule = () => require('./testDb') as typeof import('./testDb'); // Module-level variable to hold the test database // Using 'mock' prefix so Jest allows it in jest.mock() factory @@ -19,6 +21,7 @@ let mockTestDbInstance: TestDb | null = null; * This should be called in beforeEach of test files */ export function setupTestDatabase(): TestDb { + const { createTestDb, cleanupTestDb } = getTestDbModule(); if (mockTestDbInstance) { cleanupTestDb(mockTestDbInstance); } @@ -42,6 +45,7 @@ export function getTestDb(): TestDb { * Cleans up the test database */ export function teardownTestDatabase() { + const { cleanupTestDb } = getTestDbModule(); if (mockTestDbInstance) { cleanupTestDb(mockTestDbInstance); mockTestDbInstance = null; @@ -114,43 +118,6 @@ jest.mock('expo-document-picker', () => ({ }), })); -// Mock database utilities -jest.mock('@database/utils/parser', () => { - const { sql } = require('drizzle-orm'); - return { - chapterFilterToSQL: jest.fn().mockImplementation(filter => { - if (!filter || !filter.length) return undefined; - const map: Record = { - 'read': '`unread`=0', - 'not-read': '`unread`=1', - 'downloaded': 'isDownloaded=1', - 'not-downloaded': 'isDownloaded=0', - 'bookmarked': 'bookmark=1', - 'not-bookmarked': 'bookmark=0', - }; - const parts = filter.map((f: string) => map[f]).filter(Boolean); - if (!parts.length) return undefined; - return sql.raw(parts.join(' AND ')); - }), - chapterOrderToSQL: jest.fn().mockReturnValue(undefined), - }; -}); - -// Mock database constants -jest.mock('@database/constants', () => ({ - ChapterFilterKey: { - UNREAD: 'unread', - DOWNLOADED: 'downloaded', - BOOKMARKED: 'bookmarked', - }, - ChapterOrderKey: { - BY_SOURCE: 'bySource', - BY_SOURCE_DESC: 'bySourceDesc', - BY_CHAPTER_NUMBER: 'byChapterNumber', - BY_CHAPTER_NUMBER_DESC: 'byChapterNumberDesc', - }, -})); - // Mock lodash-es to avoid ES module issues jest.mock('lodash-es', () => { const lodash = jest.requireActual('lodash'); diff --git a/src/database/queries/__tests__/testData.ts b/src/database/queries/__tests__/testData.ts index 4df447e076..77367dd9db 100644 --- a/src/database/queries/__tests__/testData.ts +++ b/src/database/queries/__tests__/testData.ts @@ -21,13 +21,11 @@ import { */ export function clearAllTables(testDb: TestDb) { const { sqlite } = testDb; - sqlite.exec(` - DELETE FROM NovelCategory; - DELETE FROM Chapter; - DELETE FROM Novel; - DELETE FROM Repository; - DELETE FROM Category WHERE id > 2; - `); + sqlite.executeSync('DELETE FROM NovelCategory'); + sqlite.executeSync('DELETE FROM Chapter'); + sqlite.executeSync('DELETE FROM Novel'); + sqlite.executeSync('DELETE FROM Repository'); + sqlite.executeSync('DELETE FROM Category WHERE id > 2'); } /** @@ -60,7 +58,7 @@ export async function insertTestNovel( ...data, }; - const result = drizzleDb + const result = await drizzleDb .insert(novelSchema) .values(novelData) .returning() @@ -96,7 +94,7 @@ export async function insertTestChapter( novelId, }; - const result = drizzleDb + const result = await drizzleDb .insert(chapterSchema) .values(chapterData) .returning() @@ -118,7 +116,7 @@ export async function insertTestCategory( sort: data.sort ?? null, }; - const result = drizzleDb + const result = await drizzleDb .insert(categorySchema) .values(categoryData) .returning() @@ -139,7 +137,7 @@ export async function insertTestRepository( url: data.url ?? `https://test-repo-${Date.now()}.example.com`, }; - const result = drizzleDb + const result = await drizzleDb .insert(repositorySchema) .values(repoData) .returning() @@ -162,7 +160,7 @@ export async function insertTestNovelCategory( categoryId, }; - const result = drizzleDb + const result = await drizzleDb .insert(novelCategorySchema) .values(data) .returning() diff --git a/src/database/queries/__tests__/testDb.ts b/src/database/queries/__tests__/testDb.ts index cf70e2efe3..de12977848 100644 --- a/src/database/queries/__tests__/testDb.ts +++ b/src/database/queries/__tests__/testDb.ts @@ -1,13 +1,16 @@ /* eslint-disable no-console */ /** * Test database factory for creating in-memory SQLite databases - * Uses better-sqlite3 for Node.js testing environment + * Uses op-sqlite Node runtime for Jest testing environment */ -import Database from 'better-sqlite3'; -import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { open } from '@op-engineering/op-sqlite'; +import { drizzle } from 'drizzle-orm/op-sqlite'; import { schema } from '@database/schema'; -import { createTestDbManager } from './testDbManager'; +import { + __resetDbManagerForTests, + createDbManager, +} from '@database/manager/manager'; import { createCategoryTriggerQuery, createNovelTriggerQueryDelete, @@ -84,20 +87,23 @@ const MIGRATION_STATEMENTS = [ */ export function createTestDb() { // Create in-memory database - const sqlite = new Database(':memory:'); + const sqlite = open({ name: ':memory:' }); + // drizzle-orm/op-sqlite expects executeAsync on the client + (sqlite as any).executeAsync ??= sqlite.execute; + (sqlite as any).executeRawAsync ??= sqlite.executeRaw; // Set pragmas for better performance and behavior - sqlite.pragma('journal_mode = WAL'); - sqlite.pragma('synchronous = NORMAL'); - sqlite.pragma('temp_store = MEMORY'); - sqlite.pragma('busy_timeout = 5000'); - sqlite.pragma('foreign_keys = ON'); + sqlite.executeSync('PRAGMA journal_mode = WAL'); + sqlite.executeSync('PRAGMA synchronous = NORMAL'); + sqlite.executeSync('PRAGMA temp_store = MEMORY'); + sqlite.executeSync('PRAGMA busy_timeout = 5000'); + sqlite.executeSync('PRAGMA foreign_keys = ON'); // Run migration SQL to create tables // Execute each statement separately for (const statement of MIGRATION_STATEMENTS) { try { - sqlite.exec(statement.trim()); + sqlite.executeSync(statement.trim()); } catch (error) { console.error('Migration error:', error); console.error('Failed statement:', statement); @@ -106,9 +112,9 @@ export function createTestDb() { } // Verify tables were created (for debugging) - const tables = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all(); + const tables = sqlite.executeSync( + "SELECT name FROM sqlite_master WHERE type='table'", + ).rows; const tableNames = tables .map((t: any) => t.name) .filter((n: string) => n !== 'sqlite_sequence'); @@ -123,23 +129,26 @@ export function createTestDb() { } // Create Drizzle instance - const drizzleDb = drizzle({ client: sqlite, schema }); + const drizzleDb = drizzle(sqlite, { schema }); + + // Ensure singleton manager is bound to this test DB instance + __resetDbManagerForTests(); // Create triggers (same as production) - sqlite.exec(createNovelTriggerQueryInsert); - sqlite.exec(createNovelTriggerQueryUpdate); - sqlite.exec(createNovelTriggerQueryDelete); - sqlite.exec(createCategoryTriggerQuery); + sqlite.executeSync(createNovelTriggerQueryInsert); + sqlite.executeSync(createNovelTriggerQueryUpdate); + sqlite.executeSync(createNovelTriggerQueryDelete); + sqlite.executeSync(createCategoryTriggerQuery); // Populate default categories - sqlite.exec(` + sqlite.executeSync(` INSERT OR IGNORE INTO Category (id, name, sort) VALUES (1, 'Default', 1), (2, 'Local', 2) `); // Create test-compatible dbManager - const dbManager = createTestDbManager(drizzleDb, sqlite); + const dbManager = createDbManager(drizzleDb); return { sqlite, @@ -153,6 +162,7 @@ export function createTestDb() { */ export function cleanupTestDb(testDb: ReturnType) { testDb.sqlite.close(); + __resetDbManagerForTests(); } /** diff --git a/src/database/queries/__tests__/testDbManager.ts b/src/database/queries/__tests__/testDbManager.ts deleted file mode 100644 index 15495201a3..0000000000 --- a/src/database/queries/__tests__/testDbManager.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Test-specific dbManager implementation for better-sqlite3 - * Provides compatibility with op-sqlite specific methods - */ - -import type { IDbManager } from '@database/manager/manager.d'; -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import Database from 'better-sqlite3'; -import { Placeholder, sql as drizzleSql } from 'drizzle-orm'; -import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core'; - -interface ExecutableSelect { - toSQL(): { sql: string; params: unknown[] }; - get(): Promise; - all(): Promise; -} - -type DrizzleDb = BetterSQLite3Database; -type TransactionParameter = Parameters< - Parameters[0] ->[0]; - -const isBuilderLike = (value: unknown): value is Record => { - return ( - !!value && - typeof value === 'object' && - typeof (value as { get?: unknown }).get === 'function' && - typeof (value as { all?: unknown }).all === 'function' - ); -}; - -const wrapBuilder = (builder: T): T => { - return new Proxy(builder, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (prop === 'get' && typeof value === 'function') { - return (...args: unknown[]) => - Promise.resolve(value.apply(target, args as never[])); - } - if (prop === 'all' && typeof value === 'function') { - return (...args: unknown[]) => - Promise.resolve(value.apply(target, args as never[])); - } - if (typeof value === 'function') { - return (...args: unknown[]) => { - const result = value.apply(target, args as never[]); - return isBuilderLike(result) ? wrapBuilder(result as object) : result; - }; - } - return value; - }, - }); -}; - -/** - * Creates a test-compatible dbManager that works with better-sqlite3 - */ -export function createTestDbManager( - drizzleDb: DrizzleDb, - sqlite: Database.Database, -): IDbManager { - // Create a wrapper that implements the IDbManager interface - const dbManager = { - // Drizzle methods - delegate to drizzleDb - select: (...args: Parameters) => - wrapBuilder(drizzleDb.select(...args)), - selectDistinct: (...args: Parameters) => - wrapBuilder(drizzleDb.selectDistinct(...args)), - $count: drizzleDb.$count.bind(drizzleDb), - query: drizzleDb.query, - run: drizzleDb.run.bind(drizzleDb), - with: (...args: Parameters) => - wrapBuilder(drizzleDb.with(...args)), - $with: (...args: Parameters) => - wrapBuilder(drizzleDb.$with(...args)), - all: (...args: Parameters) => - Promise.resolve(drizzleDb.all(...args)), - get: (...args: Parameters) => - Promise.resolve(drizzleDb.get(...args)), - values: (...args: Parameters) => - Promise.resolve(drizzleDb.values(...args)), - - // Test-compatible implementations of op-sqlite specific methods - getSync( - query: T, - ): Awaited> { - const { sql, params } = query.toSQL(); - const stmt = sqlite.prepare(sql); - const result = stmt.get(params as any[]) as Awaited>; - return result; - }, - - async allSync( - query: T, - ): Promise>> { - const { sql, params } = query.toSQL(); - const stmt = sqlite.prepare(sql); - const results = stmt.all(params as any[]) as Awaited< - ReturnType - >; - return results; - }, - - async batch>( - data: T[], - fn: ( - tx: TransactionParameter, - ph: (arg: Extract) => Placeholder, - ) => SQLitePreparedQuery, - ) { - const ph = (arg: Extract) => drizzleSql.placeholder(arg); - await this.write(async tx => { - const prep = fn(tx, ph); - for (let index = 0; index < data.length; index++) { - prep.run(data[index]); - } - }); - }, - - // better-sqlite3 can't handle an async transaction function - async write(fn: (tx: TransactionParameter) => Promise): Promise { - const result = await fn(drizzleDb as any); - - return result; - }, - async transaction( - fn: (tx: TransactionParameter) => Promise, - ): Promise { - return await this.write(fn); - }, - }; - - return dbManager; -} diff --git a/src/database/queryStrings/triggers.ts b/src/database/queryStrings/triggers.ts index f93e4caa12..3f7c3096f1 100644 --- a/src/database/queryStrings/triggers.ts +++ b/src/database/queryStrings/triggers.ts @@ -3,16 +3,21 @@ AFTER INSERT ON Chapter BEGIN UPDATE Novel SET - totalChapters = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id), - chaptersDownloaded = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.isDownloaded = 1), - chaptersUnread = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.unread = 1), - lastUpdatedAt = (SELECT MAX(updatedTime) FROM Chapter WHERE Chapter.novelId = Novel.id) + totalChapters = totalChapters + 1, + chaptersDownloaded = chaptersDownloaded + CASE WHEN NEW.isDownloaded = 1 THEN 1 ELSE 0 END, + chaptersUnread = chaptersUnread + CASE WHEN NEW.unread = 1 THEN 1 ELSE 0 END, + lastUpdatedAt = CASE + WHEN NEW.updatedTime IS NOT NULL + AND (lastUpdatedAt IS NULL OR NEW.updatedTime > lastUpdatedAt) + THEN NEW.updatedTime + ELSE lastUpdatedAt + END WHERE id = NEW.novelId; END; `; export const createNovelTriggerQueryUpdate = `CREATE TRIGGER IF NOT EXISTS update_novel_stats_on_update -AFTER UPDATE ON Chapter +AFTER UPDATE OF isDownloaded, unread, readTime, updatedTime ON Chapter BEGIN UPDATE Novel SET @@ -42,4 +47,4 @@ export const createCategoryTriggerQuery = ` BEGIN UPDATE Category SET sort = (SELECT IFNULL(sort, new.id)) WHERE id = new.id; END; -`; \ No newline at end of file +`; diff --git a/src/database/utils/filter.ts b/src/database/utils/filter.ts index 759dbb762c..87c5aa4a2b 100644 --- a/src/database/utils/filter.ts +++ b/src/database/utils/filter.ts @@ -10,6 +10,12 @@ const FILTER_STATES = { } as const; export type FilterStates = typeof FILTER_STATES; +export type FilterObject = { + unread?: boolean; + isDownloaded?: boolean; + bookmark?: boolean; +}; + export class ChapterFilterObject { private filter: Map< ChapterFilterPositiveKey, @@ -51,6 +57,26 @@ export class ChapterFilterObject { return res as ChapterFilterKey[]; } + toFilterObject(): FilterObject { + const result: FilterObject = {}; + for (const [key, value] of this.filter.entries()) { + if (value === FILTER_STATES.OFF) continue; + + switch (key) { + case 'read': + result.unread = value !== FILTER_STATES.ON; + break; + case 'downloaded': + result.isDownloaded = value === FILTER_STATES.ON; + break; + case 'bookmarked': + result.bookmark = value === FILTER_STATES.ON; + break; + } + } + return result; + } + set(key: ChapterFilterPositiveKey, value: keyof typeof FILTER_STATES) { this.filter.set(key, FILTER_STATES[value]); this.setState([...this.toArray()]); diff --git a/src/database/utils/parser.ts b/src/database/utils/parser.ts index 4f27df2cf7..3a5c7f4bbd 100644 --- a/src/database/utils/parser.ts +++ b/src/database/utils/parser.ts @@ -6,7 +6,8 @@ import { } from '@database/constants'; import { SQL, sql } from 'drizzle-orm'; -export function chapterOrderToSQL(order: ChapterOrderKey) { +export function chapterOrderToSQL(order?: ChapterOrderKey) { + if (!order) return sql.raw(CHAPTER_ORDER.positionAsc); const o = CHAPTER_ORDER[order] ?? CHAPTER_ORDER.positionAsc; return sql.raw(o); } diff --git a/src/hooks/__tests__/mocks.ts b/src/hooks/__tests__/mocks.ts index cf1223e813..457f0a56d3 100644 --- a/src/hooks/__tests__/mocks.ts +++ b/src/hooks/__tests__/mocks.ts @@ -29,3 +29,123 @@ jest.mock('@hooks/persisted/useTrackedNovel'); jest.mock('@hooks/persisted/useUpdates'); jest.mock('@services/plugin/fetch'); jest.mock('@components/Context/LibraryContext'); + +const createMockChapterTextCache = () => { + const cache = new Map>(); + + return { + read: jest.fn((chapterId: number) => cache.get(chapterId)), + write: jest.fn((chapterId: number, value: string | Promise) => { + cache.set(chapterId, value); + }), + remove: jest.fn((chapterId: number) => cache.delete(chapterId)), + clear: jest.fn(() => cache.clear()), + }; +}; + +export const createMockNovelStoreState = ( + overrides: Record = {}, +) => ({ + loading: false, + fetching: false, + pageIndex: 0, + pages: ['1'], + novel: undefined, + chapters: [], + firstUnreadChapter: undefined, + batchInformation: { + batch: 0, + total: 0, + }, + novelSettings: { + filter: [], + showChapterTitles: true, + }, + chapterTextCache: createMockChapterTextCache(), + lastRead: undefined, + + bootstrapNovel: jest.fn().mockResolvedValue(true), + getChapters: jest.fn().mockResolvedValue(undefined), + getNextChapterBatch: jest.fn().mockResolvedValue(undefined), + loadUpToBatch: jest.fn().mockResolvedValue(undefined), + refreshNovel: jest.fn().mockResolvedValue(undefined), + + setNovel: jest.fn(), + setPages: jest.fn(), + setPageIndex: jest.fn(), + openPage: jest.fn().mockResolvedValue(undefined), + setNovelSettings: jest.fn(), + setLastRead: jest.fn(), + followNovel: jest.fn(), + + updateChapter: jest.fn(), + setChapters: jest.fn(), + extendChapters: jest.fn(), + + bookmarkChapters: jest.fn(), + markPreviouschaptersRead: jest.fn(), + markChapterRead: jest.fn(), + markChaptersRead: jest.fn(), + markPreviousChaptersUnread: jest.fn(), + markChaptersUnread: jest.fn(), + updateChapterProgress: jest.fn(), + deleteChapter: jest.fn(), + deleteChapters: jest.fn(), + refreshChapters: jest.fn(), + ...overrides, +}); + +export const createMockNovelStore = ( + stateOverrides: Record = {}, +) => { + let state = createMockNovelStoreState(stateOverrides); + + return { + getState: jest.fn(() => state), + setState: jest.fn(nextState => { + const partial = + typeof nextState === 'function' ? nextState(state) : nextState; + state = { + ...state, + ...partial, + }; + }), + subscribe: jest.fn(() => () => {}), + }; +}; + +const defaultMockNovelContext = { + novelStore: createMockNovelStore(), + navigationBarHeight: 0, + statusBarHeight: 0, +}; + +export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext); +export const mockUseNovelValue = jest.fn((key: string) => { + const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {}; + return state[key as keyof typeof state]; +}); +export const mockUseNovelState = jest.fn((selector: (state: any) => unknown) => { + const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {}; + return selector(state); +}); +export const mockUseNovelActions = jest.fn(() => { + const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {}; + const stateWithOptionalActions = state as Record & { + actions?: Record; + }; + return stateWithOptionalActions.actions ?? stateWithOptionalActions; +}); +export const mockUseNovelAction = jest.fn((key: string) => { + const actions = mockUseNovelActions(); + return actions?.[key]; +}); + +jest.mock('@screens/novel/NovelContext', () => ({ + useNovelContext: () => mockUseNovelContext(), + useNovelValue: (key: string) => mockUseNovelValue(key), + useNovelState: (selector: (state: any) => unknown) => + mockUseNovelState(selector), + useNovelActions: () => mockUseNovelActions(), + useNovelAction: (key: string) => mockUseNovelAction(key), +})); diff --git a/src/hooks/__tests__/mocksContract.test.ts b/src/hooks/__tests__/mocksContract.test.ts new file mode 100644 index 0000000000..76bebe18a8 --- /dev/null +++ b/src/hooks/__tests__/mocksContract.test.ts @@ -0,0 +1,166 @@ +import { + createMockNovelStore, + createMockNovelStoreState, + mockUseNovelContext, +} from './mocks'; +import { + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + defaultNovelSettings as novelDefaultNovelSettings, + defaultPageIndex, + deleteCachedNovels, + useNovel, +} from '@hooks/persisted/useNovel'; +import { useNovelSettings } from '@hooks/persisted/useNovelSettings'; + +jest.mock('@hooks/persisted/useNovel'); + +describe('mock contracts (zustand novel architecture)', () => { + it('useNovel mock exports persistence constants and compatibility helpers', () => { + expect(NOVEL_PAGE_INDEX_PREFIX).toBe('NOVEL_PAGE_INDEX_PREFIX'); + expect(NOVEL_SETTINGS_PREFIX).toBe('NOVEL_SETTINGS'); + expect(LAST_READ_PREFIX).toBe('LAST_READ_PREFIX'); + expect(defaultPageIndex).toBe(0); + expect(novelDefaultNovelSettings).toEqual({ + filter: [], + showChapterTitles: true, + }); + + expect(typeof useNovel).toBe('function'); + expect(jest.isMockFunction(useNovel)).toBe(true); + expect(jest.isMockFunction(deleteCachedNovels)).toBe(true); + }); + + it('useNovel mock state includes store-era action and cache surface', () => { + const state = useNovel() as unknown as { + chapterTextCache: { + read: unknown; + write: unknown; + remove: unknown; + clear: unknown; + }; + } & Record; + + const requiredMembers = [ + 'loading', + 'fetching', + 'pageIndex', + 'pages', + 'novel', + 'chapters', + 'firstUnreadChapter', + 'batchInformation', + 'novelSettings', + 'lastRead', + 'bootstrapNovel', + 'getChapters', + 'getNextChapterBatch', + 'loadUpToBatch', + 'refreshNovel', + 'setNovel', + 'setPages', + 'setPageIndex', + 'openPage', + 'setNovelSettings', + 'setLastRead', + 'followNovel', + 'updateChapter', + 'setChapters', + 'extendChapters', + 'bookmarkChapters', + 'markPreviouschaptersRead', + 'markChapterRead', + 'markChaptersRead', + 'markPreviousChaptersUnread', + 'markChaptersUnread', + 'updateChapterProgress', + 'deleteChapter', + 'deleteChapters', + 'refreshChapters', + 'chapterTextCache', + ] as const; + + requiredMembers.forEach(member => { + expect(state).toHaveProperty(member); + }); + + expect(state.chapterTextCache).toEqual( + expect.objectContaining({ + read: expect.any(Function), + write: expect.any(Function), + remove: expect.any(Function), + clear: expect.any(Function), + }), + ); + }); + + it('useNovelSettings mock keeps settings API contract available', () => { + const result = useNovelSettings() as unknown as Record; + + expect({ + sort: result.sort, + filter: result.filter, + showChapterTitles: result.showChapterTitles, + }).toEqual({ + sort: undefined, + filter: [], + showChapterTitles: true, + }); + + [ + 'sort', + 'filter', + 'showChapterTitles', + 'cycleChapterFilter', + 'setChapterFilter', + 'setChapterFilterValue', + 'getChapterFilterState', + 'getChapterFilter', + 'setChapterSort', + 'setShowChapterTitles', + ].forEach(member => { + expect(result).toHaveProperty(member); + }); + }); + + it('test harness mock context exposes subscribable novelStore boundary', () => { + const state = createMockNovelStoreState(); + const store = createMockNovelStore(); + const context = mockUseNovelContext(); + + [ + 'bootstrapNovel', + 'getChapters', + 'getNextChapterBatch', + 'setPageIndex', + 'openPage', + 'setNovelSettings', + 'setLastRead', + 'updateChapter', + 'refreshChapters', + 'chapterTextCache', + ].forEach(member => { + expect(state).toHaveProperty(member); + }); + + expect(store).toEqual( + expect.objectContaining({ + getState: expect.any(Function), + setState: expect.any(Function), + subscribe: expect.any(Function), + }), + ); + + expect(context).toEqual( + expect.objectContaining({ + novelStore: expect.objectContaining({ + getState: expect.any(Function), + subscribe: expect.any(Function), + }), + navigationBarHeight: expect.any(Number), + statusBarHeight: expect.any(Number), + }), + ); + }); +}); diff --git a/src/hooks/__tests__/useNovel.test.ts b/src/hooks/__tests__/useNovel.test.ts index 071b366a27..71e0af6af2 100644 --- a/src/hooks/__tests__/useNovel.test.ts +++ b/src/hooks/__tests__/useNovel.test.ts @@ -1,492 +1,96 @@ import './mocks'; -import { renderHook, act, waitFor } from '@test-utils'; -import { useNovel, deleteCachedNovels } from '@hooks/persisted/useNovel'; +import { deleteCachedNovels, useNovel } from '@hooks/persisted/useNovel'; import { - getNovelByPath, - insertNovelAndChapters, getCachedNovels as _getCachedNovels, deleteCachedNovels as _deleteCachedNovels, } from '@database/queries/NovelQueries'; -import { - getChapterCount, - getCustomPages, - getPageChaptersBatched, - insertChapters, - getFirstUnreadChapter as _getFirstUnreadChapter, - getPageChapters as _getPageChapters, - markChapterRead as _markChapterRead, - markChaptersRead as _markChaptersRead, - markChaptersUnread as _markChaptersUnread, - markPreviousChaptersUnread as _markPreviousChaptersUnread, - markPreviuschaptersRead as _markPreviuschaptersRead, - deleteChapter as _deleteChapter, - deleteChapters as _deleteChapters, - bookmarkChapter as _bookmarkChapter, - updateChapterProgress as _updateChapterProgress, -} from '@database/queries/ChapterQueries'; -import { fetchNovel, fetchPage } from '@services/plugin/fetch'; import NativeFile from '@specs/NativeFile'; import { NOVEL_STORAGE } from '@utils/Storages'; -import { ChapterInfo, NovelInfo } from '@database/types'; import { MMKVStorage } from '@utils/mmkv/mmkv'; - -// --- fixtures --- - -const PLUGIN_ID = 'test-plugin'; - -const mockNovel: NovelInfo = { - id: 1, - path: '/novel/test', - pluginId: PLUGIN_ID, - name: 'Test Novel', - inLibrary: false, - totalPages: 0, -}; - -const makeChapter = (id: number, overrides = {}): ChapterInfo => ({ - id, - novelId: mockNovel.id, - name: `Chapter ${id}`, - path: `/chapter/${id}`, - releaseTime: '2024-01-01', - updatedTime: '2024-01-02', - readTime: '2024-01-03', - chapterNumber: id, - unread: true, - isDownloaded: false, - bookmark: false, - progress: 0, - page: '1', - ...overrides, -}); - -const mockChapters = [makeChapter(1), makeChapter(2), makeChapter(3)]; - -// --- helpers --- - -async function renderUseNovel(novelOrPath: string | NovelInfo = mockNovel) { - const utils = renderHook(() => useNovel(novelOrPath, PLUGIN_ID)); - await waitFor(() => { - expect(utils.result.current.fetching).toBe(false); +import { TRACKED_NOVEL_PREFIX } from '@hooks/persisted/useTrackedNovel'; +import { + keyContract, + novelPersistence, +} from '@hooks/persisted/useNovel/store-helper/contracts'; + +describe('useNovel (legacy retirement)', () => { + it('throws with guidance to use store selectors', () => { + expect(() => useNovel()).toThrow( + 'useNovel has been retired. Access novel domain state/actions via useNovelContext().novelStore selectors.', + ); }); - return utils; -} +}); -// --- tests --- +describe('deleteCachedNovels', () => { + const cachedNovels = [ + { id: 10, pluginId: 'p1', path: '/n/1', name: 'N1', inLibrary: false }, + { id: 11, pluginId: 'p2', path: '/n/2', name: 'N2', inLibrary: false }, + ]; -describe('useNovel', () => { beforeEach(() => { jest.clearAllMocks(); MMKVStorage.clearAll(); - // Default happy-path mocks - (getNovelByPath as jest.Mock).mockReturnValue(mockNovel); - (getChapterCount as jest.Mock).mockResolvedValue(mockChapters.length); - (getPageChaptersBatched as jest.Mock).mockResolvedValue(mockChapters); - (_getFirstUnreadChapter as jest.Mock).mockResolvedValue(mockChapters[0]); - (getCustomPages as jest.Mock).mockResolvedValue([]); - }); - - // #region initialization - - describe('initialization', () => { - it('uses the passed NovelInfo object directly without fetching from DB', async () => { - await renderUseNovel(mockNovel); - - await waitFor(() => { - expect(getNovelByPath).not.toHaveBeenCalled(); - }); - }); - - it('fetches novel from DB when a path string is passed', async () => { - await renderUseNovel(mockNovel.path); - - await waitFor(() => { - expect(getNovelByPath).toHaveBeenCalledWith(mockNovel.path, PLUGIN_ID); - }); - }); - - it('fetches from source and inserts when novel is not in DB', async () => { - const sourceNovel = { ...mockNovel, chapters: mockChapters }; - (getNovelByPath as jest.Mock) - .mockReturnValueOnce(null) - .mockReturnValueOnce(mockNovel); - (fetchNovel as jest.Mock).mockResolvedValue(sourceNovel); - - await renderUseNovel(mockNovel.path); - - await waitFor(() => { - expect(fetchNovel).toHaveBeenCalledWith(PLUGIN_ID, mockNovel.path); - expect(insertNovelAndChapters).toHaveBeenCalledWith( - PLUGIN_ID, - sourceNovel, - ); - }); - }); - - it('throws when source fetch fails and novel is not in DB', async () => { - (getNovelByPath as jest.Mock).mockReturnValue(null); - (fetchNovel as jest.Mock).mockRejectedValue(new Error('network error')); - - const { result } = renderHook(() => useNovel(mockNovel.path, PLUGIN_ID)); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - expect(result.current.novel).toBeUndefined(); - }); - - it('sets loading to false after novel resolves', async () => { - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - }); - - it('sets fetching to false after chapters load', async () => { - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.fetching).toBe(false); - }); - }); - - it('populates chapters after load', async () => { - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.chapters).toHaveLength(mockChapters.length); - }); - }); - - it('sets firstUnreadChapter', async () => { - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.firstUnreadChapter?.id).toBe(mockChapters[0].id); - }); - }); - }); - - // #endregion - // #region pages - - describe('pages', () => { - it('builds numeric pages from totalPages when > 0', async () => { - const pagedNovel = { ...mockNovel, totalPages: 3 }; - (getNovelByPath as jest.Mock).mockReturnValue(pagedNovel); - - const { result } = await renderUseNovel(pagedNovel.path); - - await waitFor(() => { - expect(result.current.pages).toEqual(['1', '2', '3']); - }); - }); - - it('falls back to ["1"] when novel has no pages', async () => { - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.pages).toEqual(['1']); - }); - }); - - it('reads custom pages from DB when totalPages is 0', async () => { - (getCustomPages as jest.Mock).mockResolvedValue([ - { page: 'vol1' }, - { page: 'vol2' }, - ]); - - const { result } = await renderUseNovel(); - - await waitFor(() => { - expect(result.current.pages).toEqual(['vol1', 'vol2']); - }); - }); - - it('openPage updates pageIndex', async () => { - const pagedNovel = { ...mockNovel, totalPages: 3 }; - (getNovelByPath as jest.Mock).mockReturnValue(pagedNovel); - - const { result } = await renderUseNovel(pagedNovel.path); - - act(() => result.current.openPage(2)); - await waitFor(() => expect(result.current.fetching).toBe(false)); - - expect(result.current.pageIndex).toBe(2); - - act(() => result.current.openPage(0)); - await waitFor(() => expect(result.current.fetching).toBe(false)); - - expect(result.current.pageIndex).toBe(0); - }); - }); - - // #endregion - // #region chapter batching - - describe('chapter batching', () => { - it('sets batchInformation.total based on chapter count', async () => { - (getChapterCount as jest.Mock).mockResolvedValue(900); - (getPageChaptersBatched as jest.Mock).mockResolvedValue([]); - - const { result } = await renderUseNovel(); - - await waitFor(() => { - // Math.floor(900 / 300) = 3 - expect(result.current.batchInformation.total).toBe(3); - }); - }); - - it('getNextChapterBatch appends chapters and increments batch', async () => { - const batch1 = Array.from({ length: 2 }, (_, i) => makeChapter(i + 4)); - (getChapterCount as jest.Mock).mockResolvedValue(600); - (getPageChaptersBatched as jest.Mock) - .mockResolvedValueOnce(mockChapters) // initial load - .mockResolvedValueOnce(batch1); // next batch - - const { result } = await renderUseNovel(); - - expect(result.current.chapters).toHaveLength(mockChapters.length); - - await act(() => result.current.getNextChapterBatch()); - - expect(result.current.chapters).toHaveLength( - mockChapters.length + batch1.length, - ); - expect(result.current.batchInformation.batch).toBe(1); - }); - - it('getNextChapterBatch does nothing when already at last batch', async () => { - (getChapterCount as jest.Mock).mockResolvedValue(mockChapters.length); - // total = Math.floor(3 / 300) = 0, so nextBatch(1) > total(0) - - const { result } = await renderUseNovel(); - - const chaptersBefore = result.current.chapters.length; - await act(() => result.current.getNextChapterBatch()); - - expect(result.current.chapters).toHaveLength(chaptersBefore); - }); - - it('loadUpToBatch loads all intermediate batches sequentially', async () => { - (getChapterCount as jest.Mock).mockResolvedValue(900); - const batchChapter = makeChapter(99); - (getPageChaptersBatched as jest.Mock).mockResolvedValue([batchChapter]); - - const { result } = await renderUseNovel(); - - const initialChapterCount = result.current.chapters.length; - - await act(() => result.current.loadUpToBatch(3)); - - expect(result.current.batchInformation.batch).toBe(3); - // 3 batches loaded, each returning 1 chapter - expect(result.current.chapters.length).toBe(initialChapterCount + 3); - }); - }); - - // #endregion - // #region fetching missing page from source - - describe('fetching missing page chapters from source', () => { - it('fetches page from source when chapter count is 0 for that page', async () => { - const pagedNovel = { ...mockNovel, totalPages: 2 }; - (getNovelByPath as jest.Mock).mockReturnValue(pagedNovel); - (getChapterCount as jest.Mock) - .mockResolvedValueOnce(0) // page 1 missing - .mockResolvedValueOnce(2); // after insert - (fetchPage as jest.Mock).mockResolvedValue({ chapters: mockChapters }); - (_getPageChapters as jest.Mock).mockResolvedValue(mockChapters); - - const { result } = await renderUseNovel(pagedNovel.path); - - await waitFor(() => expect(result.current.fetching).toBe(false)); - - expect(fetchPage).toHaveBeenCalledWith(PLUGIN_ID, pagedNovel.path, '1'); - expect(insertChapters).toHaveBeenCalled(); - }); - }); - - // #endregion - // #region mark chapters - - describe('markChapterRead', () => { - it('marks single chapter as read in state', async () => { - const { result } = await renderUseNovel(); - - act(() => result.current.markChapterRead(1)); - - expect(_markChapterRead).toHaveBeenCalledWith(1); - const ch = result.current.chapters.find(c => c.id === 1); - expect(ch?.unread).toBe(false); - }); - - it('markChaptersRead marks multiple chapters', async () => { - const { result } = await renderUseNovel(); - - act(() => - result.current.markChaptersRead([mockChapters[0], mockChapters[1]]), - ); - - expect(_markChaptersRead).toHaveBeenCalledWith([1, 2]); - result.current.chapters - .filter(c => [1, 2].includes(c.id)) - .forEach(c => expect(c.unread).toBe(false)); - }); - - it('markChaptersUnread marks multiple chapters', async () => { - const readChapters = mockChapters.map(c => ({ ...c, unread: false })); - (getPageChaptersBatched as jest.Mock).mockResolvedValue(readChapters); - - const { result } = await renderUseNovel(); - - act(() => result.current.markChaptersUnread([readChapters[0]])); - - expect(_markChaptersUnread).toHaveBeenCalledWith([1]); - expect(result.current.chapters.find(c => c.id === 1)?.unread).toBe(true); - }); - - it('markPreviouschaptersRead marks chapters with id <= given id', async () => { - const { result } = await renderUseNovel(); - - act(() => result.current.markPreviouschaptersRead(2)); - - expect(_markPreviuschaptersRead).toHaveBeenCalledWith(2, mockNovel.id); - result.current.chapters - .filter(c => c.id <= 2) - .forEach(c => expect(c.unread).toBe(false)); - expect(result.current.chapters.find(c => c.id === 3)?.unread).toBe(true); - }); - - it('markPreviousChaptersUnread marks chapters with id <= given id as unread', async () => { - const { result } = await renderUseNovel(); - - act(() => result.current.markPreviousChaptersUnread(2)); - - expect(_markPreviousChaptersUnread).toHaveBeenCalledWith(2, mockNovel.id); - result.current.chapters - .filter(c => c.id <= 2) - .forEach(c => expect(c.unread).toBe(true)); - }); + (_getCachedNovels as jest.Mock).mockResolvedValue(cachedNovels); + (NativeFile.exists as jest.Mock).mockReturnValue(false); }); - // #endregion - // #region bookmark - - describe('bookmarkChapters', () => { - it('toggles bookmark state for given chapters', async () => { - const { result } = await renderUseNovel(); - - const before = result.current.chapters.find(c => c.id === 1)?.bookmark; - - act(() => result.current.bookmarkChapters([mockChapters[0]])); - - expect(_bookmarkChapter).toHaveBeenCalledWith(1); - expect(result.current.chapters.find(c => c.id === 1)?.bookmark).toBe( - !before, + it('clears tracked novel and legacy persistence keys for each cached novel', async () => { + for (const novel of cachedNovels) { + MMKVStorage.set(`${TRACKED_NOVEL_PREFIX}_${novel.id}`, 'tracked'); + MMKVStorage.set( + keyContract.pageIndex({ + pluginId: novel.pluginId, + novelPath: novel.path, + }), + 4, ); - }); - }); - - // #endregion - // #region progress - - describe('updateChapterProgress', () => { - it('updates progress in state and caps at 100', async () => { - const { result } = await renderUseNovel(); - - act(() => result.current.updateChapterProgress(1, 150)); - - expect(_updateChapterProgress).toHaveBeenCalledWith(1, 100); - expect(result.current.chapters.find(c => c.id === 1)?.progress).toBe(150); - }); - - it('stores the raw progress value in state', async () => { - const { result } = await renderUseNovel(); - - act(() => result.current.updateChapterProgress(1, 42)); - - expect(result.current.chapters.find(c => c.id === 1)?.progress).toBe(42); - }); - }); - - // #endregion - // #region delete - - describe('deleteChapter / deleteChapters', () => { - it('sets isDownloaded to false after deleteChapter', async () => { - (_deleteChapter as jest.Mock).mockResolvedValue(undefined); - const downloaded = mockChapters.map(c => ({ ...c, isDownloaded: true })); - (getPageChaptersBatched as jest.Mock).mockResolvedValue(downloaded); - - const { result } = await renderUseNovel(); - - act(() => result.current.deleteChapter(downloaded[0])); - - expect(_deleteChapter).toHaveBeenCalledWith(PLUGIN_ID, mockNovel.id, 1); - await waitFor(() => - expect( - result.current.chapters.find(c => c.id === 1)?.isDownloaded, - ).toBe(false), + MMKVStorage.set( + keyContract.settings({ + pluginId: novel.pluginId, + novelPath: novel.path, + }), + JSON.stringify({ filter: [], showChapterTitles: true }), ); - }); - - it('sets isDownloaded to false for all deleted chapters', async () => { - (_deleteChapters as jest.Mock).mockResolvedValue(undefined); - const downloaded = mockChapters.map(c => ({ ...c, isDownloaded: true })); - (getPageChaptersBatched as jest.Mock).mockResolvedValue(downloaded); - - const { result } = await renderUseNovel(); - - act(() => result.current.deleteChapters([downloaded[0], downloaded[1]])); - - await waitFor(() => - [1, 2].forEach(id => { - expect( - result.current.chapters.find(c => c.id === id)?.isDownloaded, - ).toBe(false); + MMKVStorage.set( + keyContract.lastRead({ + pluginId: novel.pluginId, + novelPath: novel.path, }), + JSON.stringify({ id: 1 }), ); - }); - }); - - // #endregion - // #region followNovel - - describe('followNovel', () => { - it('toggles inLibrary on the novel', async () => { - const { switchNovelToLibrary } = - require('@components/Context/LibraryContext').useLibraryContext(); - (switchNovelToLibrary as jest.Mock).mockResolvedValue(undefined); - - const { result } = await renderUseNovel(); - - const before = result.current.novel?.inLibrary; + } - act(() => result.current.followNovel()); + await deleteCachedNovels(); - await waitFor(() => - expect(result.current.novel?.inLibrary).toBe(!before), + for (const novel of cachedNovels) { + expect(MMKVStorage.contains(`${TRACKED_NOVEL_PREFIX}_${novel.id}`)).toBe( + false, ); - }); - }); - - // #endregion -}); - -// #region deleteCachedNovels - -describe('deleteCachedNovels', () => { - const cachedNovels: NovelInfo[] = [ - { id: 10, pluginId: 'p1', path: '/n/1', name: 'N1', inLibrary: false }, - { id: 11, pluginId: 'p2', path: '/n/2', name: 'N2', inLibrary: false }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - (_getCachedNovels as jest.Mock).mockResolvedValue(cachedNovels); - (NativeFile.exists as jest.Mock).mockReturnValue(false); + expect( + MMKVStorage.contains( + keyContract.pageIndex({ + pluginId: novel.pluginId, + novelPath: novel.path, + }), + ), + ).toBe(false); + expect( + MMKVStorage.contains( + novelPersistence.keys.settings({ + pluginId: novel.pluginId, + novelPath: novel.path, + }), + ), + ).toBe(false); + expect( + MMKVStorage.contains( + novelPersistence.keys.lastRead({ + pluginId: novel.pluginId, + novelPath: novel.path, + }), + ), + ).toBe(false); + } }); it('unlinks novel directory when it exists on disk', async () => { @@ -508,11 +112,9 @@ describe('deleteCachedNovels', () => { expect(NativeFile.unlink).not.toHaveBeenCalled(); }); - it('calls _deleteCachedNovels after cleanup', async () => { + it('calls database cached-novel delete after cleanup', async () => { await deleteCachedNovels(); expect(_deleteCachedNovels).toHaveBeenCalledTimes(1); }); }); - -// #endregion diff --git a/src/hooks/__tests__/useNovelStore.test.ts b/src/hooks/__tests__/useNovelStore.test.ts new file mode 100644 index 0000000000..a98c4b9dfa --- /dev/null +++ b/src/hooks/__tests__/useNovelStore.test.ts @@ -0,0 +1,137 @@ +import './mocks'; + +import { NovelInfo } from '@database/types'; +import { createStore } from '@hooks/persisted/useNovel/store/createStore'; +import { novelPersistence } from '@hooks/persisted/useNovel/store-helper/persistence'; + +const defaultSort = 'positionAsc'; + +const mockNovel: NovelInfo = { + id: 1, + pluginId: 'plugin-id', + path: '/novel/path', + name: 'Novel', + cover: '', + summary: '', + author: '', + artist: '', + genres: 'Genre1, Genre2', + status: 'Unknown', + totalPages: 0, + inLibrary: false, +}; + +const createNovelStore = ( + overrides: Partial = {}, + switchNovelToLibrary = jest.fn().mockResolvedValue(undefined), +) => + createStore({ + pluginId: 'plugin-id', + path: '/novel/path', + novel: { ...mockNovel, ...overrides }, + defaultChapterSort: defaultSort, + switchNovelToLibrary, + }); + +describe('useNovel store', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.spyOn(novelPersistence, 'readLastRead').mockReturnValue(undefined); + jest.spyOn(novelPersistence, 'readPageIndex').mockReturnValue(0); + jest.spyOn(novelPersistence, 'readSettings').mockReturnValue({ + sort: defaultSort, + filter: [], + showChapterTitles: true, + }); + jest + .spyOn(novelPersistence, 'writePageIndex') + .mockImplementation(() => undefined); + jest + .spyOn(novelPersistence, 'writeSettings') + .mockImplementation(() => undefined); + }); + + it('hydrates persisted page index and settings defaults', () => { + jest.spyOn(novelPersistence, 'readPageIndex').mockReturnValue(3); + jest.spyOn(novelPersistence, 'readSettings').mockReturnValue({ + filter: ['read'], + showChapterTitles: false, + }); + + const store = createNovelStore(); + + expect(store.getState().pageIndex).toBe(3); + expect(store.getState().novelSettings).toEqual({ + sort: defaultSort, + filter: ['read'], + showChapterTitles: false, + }); + }); + + it('persists page index updates through actions', () => { + const writePageIndex = jest.spyOn(novelPersistence, 'writePageIndex'); + const store = createNovelStore(); + + store.getState().actions.setPageIndex(2); + + expect(store.getState().pageIndex).toBe(2); + expect(writePageIndex).toHaveBeenCalledWith( + { pluginId: 'plugin-id', novelPath: '/novel/path' }, + 2, + ); + }); + + it('persists novel settings updates through actions', () => { + const writeSettings = jest.spyOn(novelPersistence, 'writeSettings'); + const store = createNovelStore(); + + store.getState().actions.setNovelSettings({ + sort: 'positionDesc', + filter: ['downloaded'], + showChapterTitles: false, + }); + + expect(store.getState().novelSettings).toEqual({ + sort: 'positionDesc', + filter: ['downloaded'], + showChapterTitles: false, + }); + expect(writeSettings).toHaveBeenCalledWith( + { pluginId: 'plugin-id', novelPath: '/novel/path' }, + { + sort: 'positionDesc', + filter: ['downloaded'], + showChapterTitles: false, + }, + ); + }); + + it('updates chapterTextCache via cache action helpers', () => { + const store = createNovelStore(); + const cache = store.getState().actions.chapterTextCache; + + cache.write(10, 'chapter text'); + expect(store.getState().chapterTextCache).toEqual({ 10: 'chapter text' }); + expect(cache.read(10)).toBe('chapter text'); + + cache.remove(10); + expect(cache.read(10)).toBeUndefined(); + + cache.write(11, 'next chapter'); + cache.clear(); + expect(store.getState().chapterTextCache).toEqual({}); + }); + + it('toggles follow state after followNovel action', async () => { + const switchNovelToLibrary = jest.fn().mockResolvedValue(undefined); + const store = createNovelStore({ inLibrary: false }, switchNovelToLibrary); + + await store.getState().actions.followNovel(); + + expect(switchNovelToLibrary).toHaveBeenCalledWith( + '/novel/path', + 'plugin-id', + ); + expect(store.getState().novel?.inLibrary).toBe(true); + }); +}); diff --git a/src/hooks/persisted/__mocks__/useCategories.ts b/src/hooks/persisted/__mocks__/useCategories.ts index 831ea5e922..212b068cc3 100644 --- a/src/hooks/persisted/__mocks__/useCategories.ts +++ b/src/hooks/persisted/__mocks__/useCategories.ts @@ -1,4 +1,4 @@ -const mockCategories = []; +const mockCategories: string[] = []; const useCategories = jest.fn(() => ({ isLoading: false, diff --git a/src/hooks/persisted/__mocks__/useNovel.ts b/src/hooks/persisted/__mocks__/useNovel.ts index 5e73e7fb0d..5fd9ab03b2 100644 --- a/src/hooks/persisted/__mocks__/useNovel.ts +++ b/src/hooks/persisted/__mocks__/useNovel.ts @@ -1,6 +1,27 @@ export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX'; export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS'; export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; +export const defaultPageIndex = 0; +export const defaultNovelSettings = { + filter: [], + showChapterTitles: true, +}; + +type ChapterTextValue = string | Promise; + +const createChapterTextCache = () => { + const cache = new Map(); + + return { + read: jest.fn((chapterId: number) => cache.get(chapterId)), + write: jest.fn((chapterId: number, value: ChapterTextValue) => { + cache.set(chapterId, value); + }), + remove: jest.fn((chapterId: number) => cache.delete(chapterId)), + clear: jest.fn(() => cache.clear()), + }; +}; + const mockNovel = { id: 123, pluginId: 'mock-plugin', @@ -10,6 +31,8 @@ const mockNovel = { }; const mockChapters: unknown[] = []; const useNovel = jest.fn(() => ({ + pluginId: 'mock-plugin', + novelPath: '/mock/path', loading: false, fetching: false, pageIndex: 0, @@ -26,14 +49,22 @@ const useNovel = jest.fn(() => ({ batch: 1, total: 1, }, + chapterTextCache: createChapterTextCache(), + bootstrapNovel: jest.fn().mockResolvedValue(true), + getChapters: jest.fn().mockResolvedValue(undefined), getNextChapterBatch: jest.fn(), loadUpToBatch: jest.fn(), + refreshNovel: jest.fn().mockResolvedValue(undefined), getNovel: jest.fn().mockResolvedValue(mockNovel), + setPages: jest.fn(), setPageIndex: jest.fn(), openPage: jest.fn(), setNovel: jest.fn(), + setNovelSettings: jest.fn(), setLastRead: jest.fn(), followNovel: jest.fn(), + setChapters: jest.fn(), + extendChapters: jest.fn(), bookmarkChapters: jest.fn(), markPreviouschaptersRead: jest.fn(), markChapterRead: jest.fn(), diff --git a/src/hooks/persisted/__mocks__/useNovelSettings.ts b/src/hooks/persisted/__mocks__/useNovelSettings.ts index b4d6d08310..78fe6f234a 100644 --- a/src/hooks/persisted/__mocks__/useNovelSettings.ts +++ b/src/hooks/persisted/__mocks__/useNovelSettings.ts @@ -1,8 +1,9 @@ export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX'; export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS'; export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; +export const defaultPageIndex = 0; -const defaultNovelSettings = { +export const defaultNovelSettings = { sort: undefined, filter: [], showChapterTitles: true, diff --git a/src/hooks/persisted/index.ts b/src/hooks/persisted/index.ts index 588f5799ec..d4437d481d 100644 --- a/src/hooks/persisted/index.ts +++ b/src/hooks/persisted/index.ts @@ -12,6 +12,6 @@ export { export { default as usePlugins } from './usePlugins'; export { getTracker, useTracker } from './useTracker'; export { useTrackedNovel } from './useTrackedNovel'; -export { useNovel } from './useNovel'; +export { deleteCachedNovels } from './useNovel'; export { default as useDownload } from './useDownload'; export { default as useUserAgent } from './useUserAgent'; diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index eee13ca6b3..dd4c3cd0b7 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -1,694 +1,61 @@ -/* eslint-disable no-console */ -import { useLibraryContext } from '@components/Context/LibraryContext'; -import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; -import { - bookmarkChapter as _bookmarkChapter, - deleteChapter as _deleteChapter, - deleteChapters as _deleteChapters, - getFirstUnreadChapter as _getFirstUnreadChapter, - getPageChapters as _getPageChapters, - markChapterRead as _markChapterRead, - markChaptersRead as _markChaptersRead, - markChaptersUnread as _markChaptersUnread, - markPreviousChaptersUnread as _markPreviousChaptersUnread, - markPreviuschaptersRead as _markPreviuschaptersRead, - updateChapterProgress as _updateChapterProgress, - getChapterCount, - getCustomPages, - getPageChaptersBatched, - insertChapters, -} from '@database/queries/ChapterQueries'; -import { - deleteCachedNovels as _deleteCachedNovels, - getCachedNovels as _getCachedNovels, - getNovelByPath, - insertNovelAndChapters, -} from '@database/queries/NovelQueries'; -import { ChapterInfo, NovelInfo } from '@database/types'; -import { fetchNovel, fetchPage } from '@services/plugin/fetch'; import NativeFile from '@specs/NativeFile'; -import { getString } from '@strings/translations'; -import { MMKVStorage } from '@utils/mmkv/mmkv'; -import { parseChapterNumber } from '@utils/parseChapterNumber'; -import { showToast } from '@utils/showToast'; import { NOVEL_STORAGE } from '@utils/Storages'; -import dayjs from 'dayjs'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useMMKVNumber, useMMKVObject } from 'react-native-mmkv'; -import { useAppSettings } from './useSettings'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { + deleteCachedNovels as deleteCachedNovelsFromDb, + getCachedNovels, +} from '@database/queries/NovelQueries'; import { TRACKED_NOVEL_PREFIX } from './useTrackedNovel'; - -// #region constants - -export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX'; -export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS'; -export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; - -const defaultNovelSettings: NovelSettings = { - showChapterTitles: true, - filter: [], -}; -const defaultPageIndex = 0; - -// #endregion -// #region types - -export interface NovelSettings { - sort?: ChapterOrderKey; - filter: ChapterFilterKey[]; - showChapterTitles?: boolean; -} - -// #endregion -// #region definition useNovel - -export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { - const { switchNovelToLibrary } = useLibraryContext(); - const [loading, setLoading] = useState(true); - const [fetching, setFetching] = useState(true); - const [novel, setNovel] = useState( - typeof novelOrPath === 'object' ? novelOrPath : undefined, - ); - const [pages, setPages] = useState(() => { - if (novel && (novel.totalPages ?? 0) > 0) { - const tmpPages = Array(novel.totalPages) - .fill(0) - .map((_, idx) => String(idx + 1)); - return tmpPages.length > 1 ? tmpPages : ['1']; - } - return []; - }); - - const { defaultChapterSort } = useAppSettings(); - - const novelPath = novel?.path ?? (novelOrPath as string); - - const [pageIndex = defaultPageIndex, setPageIndex] = useMMKVNumber( - `${NOVEL_PAGE_INDEX_PREFIX}_${pluginId}_${novelPath}`, - ); - const currentPage = pages[pageIndex]; - - const [lastRead, setLastRead] = useMMKVObject( - `${LAST_READ_PREFIX}_${pluginId}_${novelPath}`, - ); - const [novelSettings = defaultNovelSettings, _setNovelSettings] = - useMMKVObject( - `${NOVEL_SETTINGS_PREFIX}_${pluginId}_${novelPath}`, - ); - - const [chapters, _setChapters] = useState([]); - const [firstUnreadChapter, setFirstUnreadChapter] = useState< - ChapterInfo | undefined - >(); - const [batchInformation, setBatchInformation] = useState<{ - batch: number; - total: number; - totalChapters?: number; - }>({ batch: 0, total: 0 }); - - const settingsSort: ChapterOrderKey = - novelSettings.sort || defaultChapterSort; - const settingsFilter: ChapterFilterKey[] = useMemo( - () => novelSettings.filter ?? [], - [novelSettings.filter], - ); - - // #endregion - // #region setters - - async function calculatePages(tmpNovel: NovelInfo) { - let tmpPages: string[]; - if ((tmpNovel.totalPages ?? 0) > 0) { - tmpPages = Array(tmpNovel.totalPages) - .fill(0) - .map((_, idx) => String(idx + 1)); - } else { - tmpPages = (await getCustomPages(tmpNovel.id)) - .map(c => c.page) - .filter((page): page is string => page !== null); - } - - return tmpPages.length > 1 ? tmpPages : ['1']; - } - - const mutateChapters = useCallback( - (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => { - if (novel) { - _setChapters(mutation); - } - }, - [novel], - ); - - const updateChapter = useCallback( - (index: number, update: Partial) => { - if (novel) { - _setChapters(chs => { - const next = [...chs]; - next[index] = { ...next[index], ...update }; - return next; - }); - } - }, - [novel], - ); - - const openPage = useCallback( - (index: number) => { - setPageIndex(index); - }, - [setPageIndex], - ); - - const transformChapters = useCallback( - (chs: ChapterInfo[]) => { - if (!novel) return chs; - const newChapters = chs.map(chapter => { - const parsedTime = dayjs(chapter.releaseTime); - const releaseTime = parsedTime.isValid() - ? parsedTime.format('LL') - : chapter.releaseTime; - const chapterNumber = chapter.chapterNumber - ? chapter.chapterNumber - : parseChapterNumber(novel.name, chapter.name); - return { - ...chapter, - releaseTime, - chapterNumber, - }; - }); - return newChapters; - }, - [novel], - ); - - const setChapters = useCallback( - async (chs: ChapterInfo[]) => { - _setChapters(transformChapters(chs)); - }, - [transformChapters], - ); - - const extendChapters = useCallback( - async (chs: ChapterInfo[]) => { - _setChapters(prev => prev.concat(transformChapters(chs))); - }, - [transformChapters], - ); - - const followNovel = useCallback(() => { - switchNovelToLibrary(novelPath, pluginId).then(() => { - if (novel) { - setNovel({ - ...novel, - inLibrary: !novel?.inLibrary, - }); - } - }); - }, [novel, novelPath, pluginId, switchNovelToLibrary]); - - // #endregion - // #region getters - - const getNovel = useCallback(async () => { - let tmpNovel = getNovelByPath(novelPath, pluginId); - if (!tmpNovel) { - const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => { - throw new Error(getString('updatesScreen.unableToGetNovel')); - }); - - await insertNovelAndChapters(pluginId, sourceNovel); - tmpNovel = getNovelByPath(novelPath, pluginId); - - if (!tmpNovel) { - return; - } - } - - setPages(await calculatePages(tmpNovel)); - - setNovel(tmpNovel); - }, [novelPath, pluginId]); - - const getChapters = useCallback(async () => { - const page = pages[pageIndex] ?? '1'; - - if (novel && page) { - let newChapters: ChapterInfo[] = []; - - const config = [novel.id, settingsSort, settingsFilter, page] as const; - - let chapterCount = await getChapterCount(novel.id, page, settingsFilter); - - if (chapterCount) { - try { - newChapters = (await getPageChaptersBatched(...config)) || []; - } catch (error) { - console.error('Error fetching chapters:', error); - } - } - // Fetch next page if no chapters - else if (Number(page)) { - _setChapters([]); - const sourcePage = await fetchPage(pluginId, novelPath, page); - const sourceChapters = sourcePage.chapters.map(ch => { - return { - ...ch, - page, - }; - }); - await insertChapters(novel.id, sourceChapters); - newChapters = await _getPageChapters(...config); - chapterCount = await getChapterCount(novel.id, page, settingsFilter); - } - - setBatchInformation({ - batch: 0, - total: Math.floor(chapterCount / 300), - totalChapters: chapterCount, - }); - setChapters(newChapters); - - const unread = await _getFirstUnreadChapter( - novel.id, - settingsFilter, - page, - ); - setFirstUnreadChapter(unread ?? undefined); - } - }, [ - pages, - pageIndex, - novel, - settingsSort, - settingsFilter, - setChapters, - novelSettings.filter, - pluginId, - novelPath, - ]); - - const getNextChapterBatch = useCallback(async () => { - const page = pages[pageIndex]; - const nextBatch = batchInformation.batch + 1; - if (novel && page && nextBatch <= batchInformation.total) { - let newChapters: ChapterInfo[] = []; - - try { - newChapters = - (await getPageChaptersBatched( - novel.id, - settingsSort, - settingsFilter, - page, - nextBatch, - )) || []; - } catch (error) { - console.error('teaser', error); - } - setBatchInformation({ ...batchInformation, batch: nextBatch }); - extendChapters(newChapters); - } - }, [ - batchInformation, - extendChapters, - novel, - pageIndex, - pages, - settingsFilter, - settingsSort, - ]); - - const loadUpToBatch = useCallback( - async (targetBatch: number) => { - const page = pages[pageIndex] ?? '1'; - if (!novel || !page || targetBatch <= batchInformation.batch) { - return; - } - - // Load all batches from current + 1 up to targetBatch - for ( - let batch = batchInformation.batch + 1; - batch <= targetBatch; - batch++ - ) { - if (batch > batchInformation.total) break; - - let newChapters: ChapterInfo[] = []; - try { - newChapters = - (await getPageChaptersBatched( - novel.id, - settingsSort, - novelSettings.filter, - page, - batch, - )) || []; - } catch (error) { - console.error('Error loading batch', batch, error); - } - - setBatchInformation(prev => ({ ...prev, batch })); - extendChapters(newChapters); - } - }, - [ - batchInformation, - extendChapters, - novel, - novelSettings.filter, - pageIndex, - pages, - settingsSort, - ], - ); - - // #endregion - // #region Mark chapters - - const bookmarkChapters = useCallback( - (_chapters: ChapterInfo[]) => { - _chapters.forEach(_chapter => { - _bookmarkChapter(_chapter.id); - }); - mutateChapters(chs => - chs.map(chapter => { - if (_chapters.some(_c => _c.id === chapter.id)) { - return { - ...chapter, - bookmark: !chapter.bookmark, - }; - } - return chapter; - }), - ); - }, - [mutateChapters], - ); - - const markPreviouschaptersRead = useCallback( - (chapterId: number) => { - if (novel) { - _markPreviuschaptersRead(chapterId, novel.id); - mutateChapters(chs => - chs.map(chapter => - chapter.id <= chapterId ? { ...chapter, unread: false } : chapter, - ), - ); - } - }, - [mutateChapters, novel], - ); - - const markChapterRead = useCallback( - (chapterId: number) => { - _markChapterRead(chapterId); - - mutateChapters(chs => - chs.map(c => { - if (c.id !== chapterId) { - return c; - } - return { - ...c, - unread: false, - }; - }), - ); - }, - [mutateChapters], - ); - - const updateChapterProgress = useCallback( - (chapterId: number, progress: number) => { - _updateChapterProgress(chapterId, Math.min(progress, 100)); - - mutateChapters(chs => - chs.map(c => { - if (c.id !== chapterId) { - return c; - } - return { - ...c, - progress, - }; - }), - ); - }, - [mutateChapters], - ); - - const markChaptersRead = useCallback( - (_chapters: ChapterInfo[]) => { - const chapterIds = _chapters.map(chapter => chapter.id); - _markChaptersRead(chapterIds); - - mutateChapters(chs => - chs.map(chapter => { - if (chapterIds.includes(chapter.id)) { - return { - ...chapter, - unread: false, - }; - } - return chapter; - }), - ); - }, - [mutateChapters], - ); - - const markPreviousChaptersUnread = useCallback( - (chapterId: number) => { - if (novel) { - _markPreviousChaptersUnread(chapterId, novel.id); - mutateChapters(chs => - chs.map(chapter => - chapter.id <= chapterId ? { ...chapter, unread: true } : chapter, - ), - ); - } - }, - [mutateChapters, novel], - ); - - const markChaptersUnread = useCallback( - (_chapters: ChapterInfo[]) => { - const chapterIds = _chapters.map(chapter => chapter.id); - _markChaptersUnread(chapterIds); - - mutateChapters(chs => - chs.map(chapter => { - if (chapterIds.includes(chapter.id)) { - return { - ...chapter, - unread: true, - }; - } - return chapter; - }), - ); - }, - [mutateChapters], - ); - - // #endregion - // #region refresh and delete - - const deleteChapter = useCallback( - (_chapter: ChapterInfo) => { - if (novel) { - _deleteChapter(novel.pluginId, novel.id, _chapter.id).then(() => { - mutateChapters(chs => - chs.map(chapter => { - if (chapter.id !== _chapter.id) { - return chapter; - } - return { - ...chapter, - isDownloaded: false, - }; - }), - ); - showToast(getString('common.deleted', { name: _chapter.name })); - }); - } - }, - [mutateChapters, novel], - ); - - const deleteChapters = useCallback( - (_chaters: ChapterInfo[]) => { - if (novel) { - _deleteChapters(novel.pluginId, novel.id, _chaters).then(() => { - showToast( - getString('updatesScreen.deletedChapters', { - num: _chaters.length, - }), - ); - mutateChapters(chs => - chs.map(chapter => { - if (_chaters.some(_c => _c.id === chapter.id)) { - return { - ...chapter, - isDownloaded: false, - }; - } - return chapter; - }), - ); - }); - } - }, - [novel, mutateChapters], - ); - - const refreshChapters = useCallback(() => { - if (novel?.id && !fetching) { - _getPageChapters( - novel.id, - settingsSort, - settingsFilter, - currentPage, - ).then(chs => { - setChapters(chs); - }); - } - }, [ - novel?.id, - fetching, - settingsSort, - settingsFilter, - currentPage, - setChapters, - ]); - - // #endregion - // #region useEffects - - useEffect(() => { - if (novel) { - if (pages.length === 0) { - calculatePages(novel).then(setPages); - } - setLoading(false); - } else { - getNovel() - .catch(() => { - // Error is handled - novel stays undefined and loading becomes false - }) - .finally(() => { - //? Sometimes loading state changes doesn't trigger rerender causing NovelScreen to be in endless loading state - setLoading(false); - // getNovel(); - }); - } - }, [getNovel, novel, pages.length]); - - useEffect(() => { - if (novel === undefined || pages.length === 0) { - return; - } - - setFetching(true); - getChapters() - .catch(e => { - if (__DEV__) console.error(e); - - showToast(e.message); - }) - .finally(() => { - setFetching(false); - }); - }, [getChapters, novel, novelOrPath, pages.length]); - - // #endregion - - return useMemo( - () => ({ - loading, - fetching, - pageIndex, - pages, - novel, - lastRead, - firstUnreadChapter, - chapters, - novelSettings, - batchInformation, - getNextChapterBatch, - loadUpToBatch, - getNovel, - setPageIndex, - openPage, - setNovel, - setLastRead, - - followNovel, - bookmarkChapters, - markPreviouschaptersRead, - markChapterRead, - markChaptersRead, - markPreviousChaptersUnread, - markChaptersUnread, - - refreshChapters, - updateChapter, - updateChapterProgress, - deleteChapter, - deleteChapters, - }), - [ - loading, - fetching, - pageIndex, - pages, - novel, - lastRead, - firstUnreadChapter, - chapters, - novelSettings, - batchInformation, - getNextChapterBatch, - loadUpToBatch, - getNovel, - setPageIndex, - openPage, - setLastRead, - followNovel, - bookmarkChapters, - markPreviouschaptersRead, - markChapterRead, - markChaptersRead, - markPreviousChaptersUnread, - markChaptersUnread, - refreshChapters, - updateChapter, - updateChapterProgress, - deleteChapter, - deleteChapters, - ], +import { + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + defaultNovelSettings, + defaultPageIndex, + keyContract, + type NovelPersistenceInput, + novelPersistence, +} from './useNovel/store-helper/contracts'; +import type { BatchInfo, NovelSettings } from './useNovel/types'; + +export { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX, LAST_READ_PREFIX }; +export { defaultNovelSettings, defaultPageIndex }; +export type { NovelSettings, BatchInfo }; + +export const useNovel = () => { + throw new Error( + 'useNovel has been retired. Access novel domain state/actions via useNovelContext().novelStore selectors.', ); }; -// #region DeleteCachedNovels +const clearNovelPersistence = ({ + pluginId, + novelPath, +}: NovelPersistenceInput) => { + MMKVStorage.remove(keyContract.pageIndex({ pluginId, novelPath })); + MMKVStorage.remove(keyContract.settings({ pluginId, novelPath })); + MMKVStorage.remove(keyContract.lastRead({ pluginId, novelPath })); + + MMKVStorage.remove(novelPersistence.keys.pageIndex({ pluginId, novelPath })); + MMKVStorage.remove(novelPersistence.keys.settings({ pluginId, novelPath })); + MMKVStorage.remove(novelPersistence.keys.lastRead({ pluginId, novelPath })); +}; export const deleteCachedNovels = async () => { - const cachedNovels = await _getCachedNovels(); + const cachedNovels = await getCachedNovels(); + for (const novel of cachedNovels) { MMKVStorage.remove(`${TRACKED_NOVEL_PREFIX}_${novel.id}`); - MMKVStorage.remove( - `${NOVEL_PAGE_INDEX_PREFIX}_${novel.pluginId}_${novel.path}`, - ); - MMKVStorage.remove( - `${NOVEL_SETTINGS_PREFIX}_${novel.pluginId}_${novel.path}`, - ); - MMKVStorage.remove(`${LAST_READ_PREFIX}_${novel.pluginId}_${novel.path}`); - const novelDir = NOVEL_STORAGE + '/' + novel.pluginId + '/' + novel.id; + clearNovelPersistence({ + pluginId: novel.pluginId, + novelPath: novel.path, + }); + + const novelDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}`; if (NativeFile.exists(novelDir)) { NativeFile.unlink(novelDir); } } - _deleteCachedNovels(); + + await deleteCachedNovelsFromDb(); }; -// #endregion diff --git a/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts new file mode 100644 index 0000000000..8a9dec67a5 --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts @@ -0,0 +1,413 @@ +import '../../../__tests__/mocks'; +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { ChapterInfo, DBNovelInfo } from '@database/types'; +import { + getChapterCount, + getChapterCountSync, + getCustomPages, + getFirstUnreadChapter, + getNovelChaptersSync, + getPageChapters, + getPageChaptersBatched, + insertChapters, +} from '@database/queries/ChapterQueries'; +import { + getNovelById, + getNovelByPath, + insertNovelAndChapters, +} from '@database/queries/NovelQueries'; +import { fetchNovel, fetchPage } from '@services/plugin/fetch'; +import { createBootstrapService } from '../store-helper/bootstrapService'; + +const PLUGIN_ID = 'test-plugin'; +const NOVEL_PATH = '/novels/test'; + +const settingsSort: ChapterOrderKey = 'positionAsc'; +const settingsFilter: ChapterFilterKey[] = []; + +const mockNovel: DBNovelInfo = { + id: 1, + path: NOVEL_PATH, + pluginId: PLUGIN_ID, + name: 'Test Novel', + inLibrary: false, + totalPages: 0, + chaptersDownloaded: 0, + chaptersUnread: 0, + totalChapters: 0, + lastReadAt: null, + lastUpdatedAt: null, +}; + +const makeChapter = (id: number, overrides: Partial = {}) => ({ + id, + novelId: mockNovel.id, + name: `Chapter ${id}`, + path: `/chapter/${id}`, + updatedTime: '2024-01-02', + readTime: '2024-01-03', + chapterNumber: id, + unread: true, + isDownloaded: false, + bookmark: false, + progress: 0, + page: '1', + position: id, + ...overrides, + releaseTime: overrides.releaseTime || '2024-01-01', +}); + +const mockChapters: ChapterInfo[] = [ + makeChapter(1), + makeChapter(2), + makeChapter(3), +]; + +const mockGetCustomPages = getCustomPages as jest.MockedFunction< + typeof getCustomPages +>; +const mockGetNovelByPath = getNovelByPath as jest.MockedFunction< + typeof getNovelByPath +>; +const mockGetNovelById = getNovelById as jest.MockedFunction< + typeof getNovelById +>; +const mockFetchNovel = fetchNovel as jest.MockedFunction; +const mockInsertNovelAndChapters = + insertNovelAndChapters as jest.MockedFunction; +const mockGetChapterCount = getChapterCount as jest.MockedFunction< + typeof getChapterCount +>; +const mockGetChapterCountSync = getChapterCountSync as jest.MockedFunction< + typeof getChapterCountSync +>; +const mockGetPageChaptersBatched = + getPageChaptersBatched as jest.MockedFunction; +const mockGetNovelChaptersSync = getNovelChaptersSync as jest.MockedFunction< + typeof getNovelChaptersSync +>; +const mockFetchPage = fetchPage as jest.MockedFunction; +const mockInsertChapters = insertChapters as jest.MockedFunction< + typeof insertChapters +>; +const mockGetPageChapters = getPageChapters as jest.MockedFunction< + typeof getPageChapters +>; +const mockGetFirstUnreadChapter = getFirstUnreadChapter as jest.MockedFunction< + typeof getFirstUnreadChapter +>; + +const setupDbFirstSuccess = () => { + mockGetCustomPages.mockReturnValue([]); + mockGetNovelById.mockReturnValue(mockNovel); + mockGetNovelByPath.mockReturnValue(mockNovel); + mockGetChapterCount.mockResolvedValue(mockChapters.length); + mockGetChapterCountSync.mockReturnValue(mockChapters.length); //@ts-ignore + mockGetPageChaptersBatched.mockResolvedValue(mockChapters); + mockGetNovelChaptersSync.mockReturnValue(mockChapters); //@ts-ignore + mockGetFirstUnreadChapter.mockReturnValue(mockChapters[0]); +}; + +describe('bootstrapService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns success payload from db-first branch', async () => { + setupDbFirstSuccess(); + const service = createBootstrapService(); + + const result = await service.bootstrapNovelAsync({ + novel: undefined, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.novel).toEqual(mockNovel); + expect(result.pages).toEqual(['1']); + expect(result.chapters).toEqual(mockChapters); + expect(result.firstUnreadChapter).toEqual(mockChapters[0]); + expect(result.batchInformation).toEqual({ + batch: 0, + total: 0, + totalChapters: mockChapters.length, + }); + expect(mockGetNovelByPath).toHaveBeenCalledWith(NOVEL_PATH, PLUGIN_ID); + expect(mockGetChapterCount).toHaveBeenCalledWith( + mockNovel.id, + '1', + settingsFilter, + ); + }); + + it('falls back to source page and inserts chapters when db count is 0', async () => { + setupDbFirstSuccess(); + mockGetChapterCount + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(mockChapters.length); + mockFetchPage.mockResolvedValue({ + chapters: mockChapters.map(ch => ({ ...ch, page: null })), + } as never); + mockGetPageChapters.mockResolvedValue(mockChapters); + const service = createBootstrapService(); + + const result = await service.bootstrapNovelAsync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(mockFetchPage).toHaveBeenCalledWith(PLUGIN_ID, NOVEL_PATH, '1'); + expect(mockInsertChapters).toHaveBeenCalled(); + expect(mockGetPageChapters).toHaveBeenCalledWith( + mockNovel.id, + settingsSort, + settingsFilter, + '1', + ); + expect(result.batchInformation.totalChapters).toBe(mockChapters.length); + }); + + it('returns missing-novel when source insert path still resolves no novel', async () => { + mockGetNovelByPath + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + mockFetchNovel.mockResolvedValue({ ...mockNovel, chapters: [] } as never); + mockInsertNovelAndChapters.mockResolvedValue(undefined); + const service = createBootstrapService(); + + const result = await service.bootstrapNovelAsync({ + novel: undefined, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }); + + expect(result).toEqual({ ok: false, reason: 'missing-novel' }); + }); + + it('returns error result when underlying data operation throws', async () => { + setupDbFirstSuccess(); + mockGetChapterCount.mockRejectedValue(new Error('db failed')); + const service = createBootstrapService(); + + const result = await service.bootstrapNovelAsync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('error'); + }); + + it('dedupes in-flight bootstrap per ${pluginId}_${novelPath}', async () => { + setupDbFirstSuccess(); + mockGetChapterCount.mockImplementation( + () => + new Promise(resolve => + setTimeout(() => resolve(mockChapters.length), 10), + ), + ); + const service = createBootstrapService(); + + const [result1, result2] = await Promise.all([ + service.bootstrapNovelAsync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }), + service.bootstrapNovelAsync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter, + }), + ]); + + expect(result1.ok).toBe(true); + expect(result2.ok).toBe(true); + expect(mockGetChapterCount).toHaveBeenCalledTimes(1); + }); + + it('uses custom pages and selected page index when custom pages are available', async () => { + setupDbFirstSuccess(); + mockGetCustomPages.mockReturnValue([ + { page: '1' }, + { page: '3' }, + ] as ReturnType); + const service = createBootstrapService(); + + const result = await service.bootstrapNovelAsync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 1, + settingsSort, + settingsFilter, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.pages).toEqual(['1', '3']); + expect(mockGetChapterCount).toHaveBeenCalledWith( + mockNovel.id, + '3', + settingsFilter, + ); + expect(mockGetPageChaptersBatched).toHaveBeenCalledWith( + mockNovel.id, + settingsSort, + settingsFilter, + '3', + ); + }); + + it('getNextChapterBatch loads the next batch when available', async () => { + mockGetPageChaptersBatched.mockResolvedValue([makeChapter(10)]); + const service = createBootstrapService(); + + const result = await service.getNextChapterBatch({ + novel: mockNovel, + pages: ['1'], + pageIndex: 0, + settingsSort, + settingsFilter, + batchInformation: { batch: 0, total: 2 }, + }); + + expect(mockGetPageChaptersBatched).toHaveBeenCalledWith( + mockNovel.id, + settingsSort, + settingsFilter, + '1', + 1, + ); + expect(result).toEqual({ + batch: 1, + chapters: [expect.objectContaining({ id: 10 })], + }); + }); + + it('getNextChapterBatch returns undefined when at last batch', async () => { + const service = createBootstrapService(); + + const result = await service.getNextChapterBatch({ + novel: mockNovel, + pages: ['1'], + pageIndex: 0, + settingsSort, + settingsFilter, + batchInformation: { batch: 1, total: 1 }, + }); + + expect(result).toBeUndefined(); + expect(mockGetPageChaptersBatched).not.toHaveBeenCalled(); + }); + + it('loadUpToBatch only loads until total batch count', async () => { + mockGetPageChaptersBatched.mockResolvedValue([makeChapter(11)]); + const onBatchLoaded = jest.fn(); + const service = createBootstrapService(); + + await service.loadUpToBatch({ + targetBatch: 4, + novel: mockNovel, + pages: ['1'], + pageIndex: 0, + settingsSort, + settingsFilter, + batchInformation: { batch: 0, total: 1 }, + onBatchLoaded, + }); + + expect(mockGetPageChaptersBatched).toHaveBeenCalledTimes(1); + expect(onBatchLoaded).toHaveBeenCalledWith(1, [ + expect.objectContaining({ id: 11 }), + ]); + }); + + it('bootstrapNovelSync uses filtered sync count for totalChapters', () => { + setupDbFirstSuccess(); + mockGetChapterCountSync.mockReturnValue(2); + mockGetNovelByPath.mockReturnValue({ + ...mockNovel, + totalChapters: 999, + chaptersDownloaded: 3, + chaptersUnread: 3, + lastReadAt: null, + lastUpdatedAt: null, + }); + mockGetNovelChaptersSync.mockReturnValue([ + mockChapters[0], + mockChapters[2], + ]); + const service = createBootstrapService(); + + const result = service.bootstrapNovelSync({ + novel: undefined, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter: ['not-read'], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.batchInformation.totalChapters).toBe(2); + expect(mockGetChapterCountSync).toHaveBeenCalledWith(mockNovel.id, '1', [ + 'not-read', + ]); + }); + + it('bootstrapNovelSync returns missing-chapters only when unfiltered count is zero', () => { + setupDbFirstSuccess(); + mockGetChapterCountSync.mockReturnValue(0); + const service = createBootstrapService(); + + const unfiltered = service.bootstrapNovelSync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter: [], + }); + expect(unfiltered).toEqual({ ok: false, reason: 'missing-chapters' }); + + const filtered = service.bootstrapNovelSync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter: ['not-read'], + }); + expect(filtered.ok).toBe(true); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts b/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts new file mode 100644 index 0000000000..f6ee01d900 --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts @@ -0,0 +1,280 @@ +import '../../../__tests__/mocks'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { + bookmarkChaptersAction, + ChapterActionsDependencies, + deleteChapterAction, + deleteChaptersAction, + markChapterReadAction, + markChaptersReadAction, + markChaptersUnreadAction, + markPreviouschaptersReadAction, + markPreviousChaptersUnreadAction, + refreshChaptersAction, + updateChapterProgressAction, +} from '../store/chapterActions'; + +const makeChapter = (id: number, overrides: Partial = {}) => ({ + id, + novelId: 1, + path: `/chapter/${id}`, + name: `Chapter ${id}`, + releaseTime: '2024-01-01', + readTime: null, + bookmark: false, + unread: true, + isDownloaded: true, + updatedTime: '2024-01-01', + chapterNumber: id, + page: '1', + progress: 0, + position: id - 1, + ...overrides, +}); + +const mockNovel: NovelInfo = { + id: 1, + path: '/novels/test', + pluginId: 'plugin.test', + name: 'Test Novel', +}; + +const createDeps = (): jest.Mocked => ({ + bookmarkChapter: jest.fn().mockResolvedValue(undefined), + markChapterRead: jest.fn().mockResolvedValue(undefined), + markChaptersRead: jest.fn().mockResolvedValue(undefined), + markPreviuschaptersRead: jest.fn().mockResolvedValue(undefined), + markPreviousChaptersUnread: jest.fn().mockResolvedValue(undefined), + markChaptersUnread: jest.fn().mockResolvedValue(undefined), + updateChapterProgress: jest.fn().mockResolvedValue(undefined), + deleteChapter: jest.fn().mockResolvedValue(undefined), + deleteChapters: jest.fn().mockResolvedValue(undefined), + getPageChapters: jest.fn().mockResolvedValue([]), + showToast: jest.fn(), + getString: jest + .fn< + ReturnType, + Parameters + >() + .mockImplementation(stringKey => String(stringKey)), +}); + +const createStateMutator = (initial: ChapterInfo[]) => { + let state = [...initial]; + const mutate = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => { + state = mutation(state); + }; + + return { + mutate, + getState: () => state, + }; +}; + +describe('chapterActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('bookmarkChaptersAction toggles bookmark state and calls db mutation for each id', () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + bookmarkChaptersAction([makeChapter(2)], state.mutate, deps); + + expect(deps.bookmarkChapter).toHaveBeenCalledWith(2); + expect(state.getState().map(ch => ch.bookmark)).toEqual([false, true]); + }); + + it('markChapterReadAction marks target chapter read in db and state', () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + markChapterReadAction(1, state.mutate, deps); + + expect(deps.markChapterRead).toHaveBeenCalledWith(1); + expect(state.getState().map(ch => ch.unread)).toEqual([false, true]); + }); + + it('markChaptersReadAction supports empty selection and still keeps state stable', () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + markChaptersReadAction([], state.mutate, deps); + + expect(deps.markChaptersRead).toHaveBeenCalledWith([]); + expect(state.getState().map(ch => ch.unread)).toEqual([true, true]); + }); + + it('markPreviouschaptersReadAction is safe no-op when novel is absent', () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + markPreviouschaptersReadAction(2, undefined, state.mutate, deps); + + expect(deps.markPreviuschaptersRead).not.toHaveBeenCalled(); + expect(state.getState().map(ch => ch.unread)).toEqual([true, true]); + }); + + it('markPreviousChaptersUnreadAction updates previous chapters and persists mutation', () => { + const deps = createDeps(); + const state = createStateMutator([ + makeChapter(1, { unread: false }), + makeChapter(2, { unread: false }), + makeChapter(3, { unread: false }), + ]); + + markPreviousChaptersUnreadAction(2, mockNovel, state.mutate, deps); + + expect(deps.markPreviousChaptersUnread).toHaveBeenCalledWith( + 2, + mockNovel.id, + ); + expect(state.getState().map(ch => ch.unread)).toEqual([true, true, false]); + }); + + it('markChaptersUnreadAction marks selected chapters unread in db and state', () => { + const deps = createDeps(); + const state = createStateMutator([ + makeChapter(1, { unread: false }), + makeChapter(2, { unread: false }), + ]); + + markChaptersUnreadAction([makeChapter(2)], state.mutate, deps); + + expect(deps.markChaptersUnread).toHaveBeenCalledWith([2]); + expect(state.getState().map(ch => ch.unread)).toEqual([false, true]); + }); + + it('updateChapterProgressAction clamps persisted and in-memory progress values', () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1, { progress: 10 })]); + + updateChapterProgressAction(1, 145, state.mutate, deps); + + expect(deps.updateChapterProgress).toHaveBeenCalledWith(1, 100); + expect(state.getState()[0].progress).toBe(100); + }); + + it('deleteChapterAction is safe no-op when novel is absent', async () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + deleteChapterAction(makeChapter(1), undefined, state.mutate, deps); + await Promise.resolve(); + + expect(deps.deleteChapter).not.toHaveBeenCalled(); + expect(deps.showToast).not.toHaveBeenCalled(); + expect(state.getState().map(ch => ch.isDownloaded)).toEqual([true, true]); + }); + + it('deleteChapterAction updates downloaded flag and emits toast after delete resolves', async () => { + const deps = createDeps(); + const state = createStateMutator([makeChapter(1), makeChapter(2)]); + + deleteChapterAction(makeChapter(2), mockNovel, state.mutate, deps); + await Promise.resolve(); + + expect(deps.deleteChapter).toHaveBeenCalledWith( + mockNovel.pluginId, + mockNovel.id, + 2, + ); + expect(deps.getString).toHaveBeenCalledWith('common.deleted', { + name: 'Chapter 2', + }); + expect(deps.showToast).toHaveBeenCalledWith('common.deleted'); + expect(state.getState().map(ch => ch.isDownloaded)).toEqual([true, false]); + }); + + it('deleteChaptersAction updates selected chapters and toast payload after delete resolves', async () => { + const deps = createDeps(); + const state = createStateMutator([ + makeChapter(1), + makeChapter(2), + makeChapter(3), + ]); + + deleteChaptersAction( + [makeChapter(1), makeChapter(3)], + mockNovel, + state.mutate, + deps, + ); + await Promise.resolve(); + + expect(deps.deleteChapters).toHaveBeenCalledWith( + mockNovel.pluginId, + mockNovel.id, + [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 3 })], + ); + expect(deps.getString).toHaveBeenCalledWith( + 'updatesScreen.deletedChapters', + { + num: 2, + }, + ); + expect(deps.showToast).toHaveBeenCalledWith( + 'updatesScreen.deletedChapters', + ); + expect(state.getState().map(ch => ch.isDownloaded)).toEqual([ + false, + true, + false, + ]); + }); + + it('refreshChaptersAction guards on missing novel/fetching and transforms fetched chapters', async () => { + const deps = createDeps(); + const sourceChapters = [makeChapter(1), makeChapter(2)]; + deps.getPageChapters.mockResolvedValue(sourceChapters); + const setChapters = jest.fn(); + + refreshChaptersAction({ + novel: undefined, + fetching: false, + settingsSort: 'positionAsc', + settingsFilter: [], + currentPage: '1', + transformChapters: chs => chs, + setChapters, + deps, + }); + + refreshChaptersAction({ + novel: mockNovel, + fetching: true, + settingsSort: 'positionAsc', + settingsFilter: [], + currentPage: '1', + transformChapters: chs => chs, + setChapters, + deps, + }); + + expect(deps.getPageChapters).not.toHaveBeenCalled(); + + refreshChaptersAction({ + novel: mockNovel, + fetching: false, + settingsSort: 'positionAsc', + settingsFilter: [], + currentPage: '2', + transformChapters: chs => chs.map(ch => ({ ...ch, unread: false })), + setChapters, + deps, + }); + await Promise.resolve(); + + expect(deps.getPageChapters).toHaveBeenCalledWith( + mockNovel.id, + 'positionAsc', + [], + '2', + ); + expect(setChapters).toHaveBeenCalledWith([ + expect.objectContaining({ id: 1, unread: false }), + expect.objectContaining({ id: 2, unread: false }), + ]); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts b/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts new file mode 100644 index 0000000000..c97ea8a339 --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts @@ -0,0 +1,160 @@ +import { keyContract, KeyContractInput } from '../store-helper/keyContract'; +import { + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + LAST_READ_PREFIX, +} from '../types'; + +describe('keyContract', () => { + describe('pageIndex', () => { + it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => { + const input: KeyContractInput = { + pluginId: 'webnovel', + novelPath: 'api/novels/xyz-123', + }; + + const result = keyContract.pageIndex(input); + + expect(result).toBe( + 'NOVEL_PAGE_INDEX_PREFIX_webnovel_api/novels/xyz-123', + ); + }); + + it('preserves pluginId and novelPath in exact order', () => { + const input: KeyContractInput = { + pluginId: 'archive', + novelPath: 'light-novel/ch1', + }; + + const result = keyContract.pageIndex(input); + + expect(result).toContain( + `${NOVEL_PAGE_INDEX_PREFIX}_archive_light-novel/ch1`, + ); + }); + + it('handles complex novelPath with special characters', () => { + const input: KeyContractInput = { + pluginId: 'source', + novelPath: 'path/to/novel-with-dashes_and_underscores/123', + }; + + const result = keyContract.pageIndex(input); + + expect(result).toBe( + `${NOVEL_PAGE_INDEX_PREFIX}_source_path/to/novel-with-dashes_and_underscores/123`, + ); + }); + }); + + describe('settings', () => { + it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => { + const input: KeyContractInput = { + pluginId: 'webnovel', + novelPath: 'api/novels/xyz-123', + }; + + const result = keyContract.settings(input); + + expect(result).toBe('NOVEL_SETTINGS_webnovel_api/novels/xyz-123'); + }); + + it('preserves pluginId and novelPath in exact order', () => { + const input: KeyContractInput = { + pluginId: 'archive', + novelPath: 'light-novel/ch1', + }; + + const result = keyContract.settings(input); + + expect(result).toContain( + `${NOVEL_SETTINGS_PREFIX}_archive_light-novel/ch1`, + ); + }); + }); + + describe('lastRead', () => { + it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => { + const input: KeyContractInput = { + pluginId: 'webnovel', + novelPath: 'api/novels/xyz-123', + }; + + const result = keyContract.lastRead(input); + + expect(result).toBe('LAST_READ_PREFIX_webnovel_api/novels/xyz-123'); + }); + + it('preserves pluginId and novelPath in exact order', () => { + const input: KeyContractInput = { + pluginId: 'archive', + novelPath: 'light-novel/ch1', + }; + + const result = keyContract.lastRead(input); + + expect(result).toContain(`${LAST_READ_PREFIX}_archive_light-novel/ch1`); + }); + }); + + describe('key continuity across calls', () => { + it('produces deterministic keys for same input', () => { + const input: KeyContractInput = { + pluginId: 'plugin-a', + novelPath: 'novel/path', + }; + + const key1 = keyContract.pageIndex(input); + const key2 = keyContract.pageIndex(input); + + expect(key1).toBe(key2); + }); + + it('differentiates keys by pluginId', () => { + const base: KeyContractInput = { + pluginId: 'plugin-a', + novelPath: 'same/path', + }; + + const otherPluginId: KeyContractInput = { + pluginId: 'plugin-b', + novelPath: 'same/path', + }; + + const key1 = keyContract.pageIndex(base); + const key2 = keyContract.pageIndex(otherPluginId); + + expect(key1).not.toBe(key2); + expect(key2).toContain('plugin-b'); + }); + + it('differentiates keys by novelPath', () => { + const base: KeyContractInput = { + pluginId: 'same-plugin', + novelPath: 'novel/path-a', + }; + + const otherPath: KeyContractInput = { + pluginId: 'same-plugin', + novelPath: 'novel/path-b', + }; + + const key1 = keyContract.pageIndex(base); + const key2 = keyContract.pageIndex(otherPath); + + expect(key1).not.toBe(key2); + expect(key2).toContain('novel/path-b'); + }); + + it('uses correct prefix constants from types', () => { + const input: KeyContractInput = { + pluginId: 'p1', + novelPath: 'n1', + }; + + expect(keyContract.pageIndex(input)).toContain(NOVEL_PAGE_INDEX_PREFIX); + expect(keyContract.settings(input)).toContain(NOVEL_SETTINGS_PREFIX); + expect(keyContract.lastRead(input)).toContain(LAST_READ_PREFIX); + }); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts new file mode 100644 index 0000000000..2fbf68e6ad --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts @@ -0,0 +1,409 @@ +import '../../../__tests__/mocks'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { createBootstrapService } from '../store-helper/bootstrapService'; +import { + bookmarkChaptersAction, + ChapterActionsDependencies, + deleteChapterAction, + deleteChaptersAction, + markChapterReadAction, + markChaptersReadAction, + markChaptersUnreadAction, + markPreviouschaptersReadAction, + markPreviousChaptersUnreadAction, + refreshChaptersAction, + updateChapterProgressAction, +} from '../store/chapterActions'; +import { createNovelStoreChapterActions } from '../store/novelStore.chapterActions'; +import { BatchInfo, NovelSettingsWithoutSort } from '../types'; + +jest.mock('../store/chapterActions', () => { + const actual = jest.requireActual('../store/chapterActions'); + return { + ...actual, + bookmarkChaptersAction: jest.fn(), + deleteChapterAction: jest.fn(), + deleteChaptersAction: jest.fn(), + markChapterReadAction: jest.fn(), + markChaptersReadAction: jest.fn(), + markChaptersUnreadAction: jest.fn(), + markPreviouschaptersReadAction: jest.fn(), + markPreviousChaptersUnreadAction: jest.fn(), + refreshChaptersAction: jest.fn(), + updateChapterProgressAction: jest.fn(), + }; +}); + +type BootstrapServiceSlice = Pick< + ReturnType, + 'getNextChapterBatch' | 'loadUpToBatch' +>; + +interface TestState { + novel: NovelInfo | undefined; + pages: string[]; + pageIndex: number; + chapters: ChapterInfo[]; + chapterTextCache: Record>; + fetching: boolean; + novelSettings: NovelSettingsWithoutSort; + batchInformation: BatchInfo; +} + +const makeChapter = (id: number, overrides: Partial = {}) => ({ + id, + novelId: 1, + path: `/chapter/${id}`, + name: `Chapter ${id}`, + releaseTime: '2024-01-01', + readTime: null, + bookmark: false, + unread: true, + isDownloaded: true, + updatedTime: '2024-01-01', + chapterNumber: id, + page: '1', + progress: 0, + position: id - 1, + ...overrides, +}); + +const mockNovel: NovelInfo = { + id: 1, + path: '/novels/test', + pluginId: 'plugin.test', + name: 'Test Novel', +}; + +const createDeps = (): jest.Mocked => ({ + bookmarkChapter: jest.fn().mockResolvedValue(undefined), + markChapterRead: jest.fn().mockResolvedValue(undefined), + markChaptersRead: jest.fn().mockResolvedValue(undefined), + markPreviuschaptersRead: jest.fn().mockResolvedValue(undefined), + markPreviousChaptersUnread: jest.fn().mockResolvedValue(undefined), + markChaptersUnread: jest.fn().mockResolvedValue(undefined), + updateChapterProgress: jest.fn().mockResolvedValue(undefined), + deleteChapter: jest.fn().mockResolvedValue(undefined), + deleteChapters: jest.fn().mockResolvedValue(undefined), + getPageChapters: jest.fn().mockResolvedValue([]), + showToast: jest.fn(), + getString: jest + .fn< + ReturnType, + Parameters + >() + .mockImplementation(stringKey => String(stringKey)), +}); + +const createHarness = (overrides: Partial = {}) => { + let state: TestState = { + novel: mockNovel, + pages: ['1', '2'], + pageIndex: 0, + chapters: [makeChapter(1)], + chapterTextCache: {}, + fetching: false, + novelSettings: { filter: [], showChapterTitles: true }, + batchInformation: { batch: 0, total: 4 }, + ...overrides, + }; + + const set = jest.fn( + (partial: Partial | ((s: TestState) => Partial)) => { + const patch = typeof partial === 'function' ? partial(state) : partial; + state = { ...state, ...patch }; + }, + ); + const get = () => state; + const bootstrapService: jest.Mocked = { + getNextChapterBatch: jest.fn(), + loadUpToBatch: jest.fn(), + }; + const chapterDeps = createDeps(); + const transformChapters = jest.fn((chs: ChapterInfo[]) => + chs.map(ch => ({ ...ch, name: `[tx] ${ch.name}` })), + ); + + const actions = createNovelStoreChapterActions({ + //@ts-expect-error partial state/actions for testing + set, //@ts-expect-error + get, + bootstrapService, + chapterActionsDependencies: chapterDeps, + transformChapters, + defaultChapterSort: 'positionAsc', + }); + + return { + actions, + getState: () => state, + set, + bootstrapService, + chapterDeps, + transformChapters, + }; +}; + +describe('novelStore.chapterActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getNextChapterBatch appends transformed chapters and advances batch', async () => { + const harness = createHarness(); + harness.bootstrapService.getNextChapterBatch.mockResolvedValue({ + batch: 1, + chapters: [makeChapter(2), makeChapter(3)], + }); + + await harness.actions.getNextChapterBatch(); + + expect(harness.bootstrapService.getNextChapterBatch).toHaveBeenCalledWith({ + novel: mockNovel, + pages: ['1', '2'], + pageIndex: 0, + settingsSort: 'positionAsc', + settingsFilter: [], + batchInformation: { batch: 0, total: 4 }, + }); + expect(harness.getState().batchInformation.batch).toBe(1); + expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2, 3]); + expect(harness.getState().chapters[1].name).toBe('[tx] Chapter 2'); + }); + + it('getNextChapterBatch dedupes concurrent calls', async () => { + const harness = createHarness(); + harness.bootstrapService.getNextChapterBatch.mockImplementation( + () => + new Promise(resolve => { + setTimeout( + () => + resolve({ + batch: 1, + chapters: [makeChapter(2)], + }), + 1, + ); + }), + ); + + await Promise.all([ + harness.actions.getNextChapterBatch(), + harness.actions.getNextChapterBatch(), + ]); + + expect(harness.bootstrapService.getNextChapterBatch).toHaveBeenCalledTimes( + 1, + ); + expect(harness.getState().batchInformation.batch).toBe(1); + expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2]); + }); + + it('getNextChapterBatch guard keeps state stable when bootstrap returns no result', async () => { + const harness = createHarness(); + const before = harness.getState(); + harness.bootstrapService.getNextChapterBatch.mockResolvedValue(undefined); + + await harness.actions.getNextChapterBatch(); + + expect(harness.getState()).toEqual(before); + expect(harness.set).not.toHaveBeenCalled(); + }); + + it('loadUpToBatch merges each loaded batch through onBatchLoaded callback', async () => { + const harness = createHarness(); + harness.bootstrapService.loadUpToBatch.mockImplementation(async params => { + params.onBatchLoaded(1, [makeChapter(2)]); + params.onBatchLoaded(2, [makeChapter(3)]); + }); + + await harness.actions.loadUpToBatch(2); + + expect(harness.bootstrapService.loadUpToBatch).toHaveBeenCalledWith( + expect.objectContaining({ + targetBatch: 2, + novel: mockNovel, + settingsSort: 'positionAsc', + settingsFilter: [], + }), + ); + expect(harness.getState().batchInformation.batch).toBe(2); + expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2, 3]); + }); + + it('loadUpToBatch coalesces overlapping in-flight targets', async () => { + const harness = createHarness(); + harness.bootstrapService.loadUpToBatch.mockImplementation(async params => { + if (params.targetBatch === 2) { + params.onBatchLoaded(1, [makeChapter(2)]); + params.onBatchLoaded(2, [makeChapter(3)]); + await Promise.resolve(); + return; + } + + if (params.targetBatch === 4) { + params.onBatchLoaded(3, [makeChapter(4)]); + params.onBatchLoaded(4, [makeChapter(5)]); + } + }); + + const first = harness.actions.loadUpToBatch(2); + const second = harness.actions.loadUpToBatch(4); + await Promise.all([first, second]); + + expect(harness.bootstrapService.loadUpToBatch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + targetBatch: 2, + }), + ); + expect(harness.bootstrapService.loadUpToBatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + targetBatch: 4, + }), + ); + expect(harness.getState().batchInformation.batch).toBe(4); + expect(harness.getState().chapters.map(ch => ch.id)).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + + it('chapterTextCache supports read/write/remove/clear through state-backed cache', () => { + const harness = createHarness(); + const pendingText = Promise.resolve('chapter text'); + + expect(harness.actions.chapterTextCache.read(1)).toBeUndefined(); + + harness.actions.chapterTextCache.write(1, pendingText); + expect(harness.actions.chapterTextCache.read(1)).toBe(pendingText); + expect(harness.getState().chapterTextCache[1]).toBe(pendingText); + + harness.actions.chapterTextCache.remove(1); + expect(harness.actions.chapterTextCache.read(1)).toBeUndefined(); + + harness.actions.chapterTextCache.write(2, 'second'); + harness.actions.chapterTextCache.clear(); + expect(harness.getState().chapterTextCache).toEqual({}); + }); + + it('updateChapter guard does nothing when novel is missing', () => { + const harness = createHarness({ novel: undefined }); + + harness.actions.updateChapter(0, { progress: 87 }); + + expect(harness.getState().chapters[0].progress).toBe(0); + expect(harness.set).not.toHaveBeenCalled(); + }); + + it('bookmarkChapters delegates to chapterActions and mutate guard blocks writes without novel', () => { + const harness = createHarness({ novel: undefined }); + ( + bookmarkChaptersAction as jest.MockedFunction< + typeof bookmarkChaptersAction + > + ).mockImplementation((_chapters, mutate) => { + mutate(chs => chs.map(ch => ({ ...ch, bookmark: true }))); + }); + + harness.actions.bookmarkChapters([makeChapter(1)]); + + expect(bookmarkChaptersAction).toHaveBeenCalledWith( + [expect.objectContaining({ id: 1 })], + expect.any(Function), + harness.chapterDeps, + ); + expect(harness.getState().chapters[0].bookmark).toBe(false); + expect(harness.set).not.toHaveBeenCalled(); + }); + + it('markChapterRead delegates mutation to low-level action with dependencies', () => { + const harness = createHarness(); + ( + markChapterReadAction as jest.MockedFunction + ).mockImplementation((chapterId, mutate) => { + mutate(chs => + chs.map(ch => (ch.id === chapterId ? { ...ch, unread: false } : ch)), + ); + }); + + harness.actions.markChapterRead(1); + + expect(markChapterReadAction).toHaveBeenCalledWith( + 1, + expect.any(Function), + harness.chapterDeps, + ); + expect(harness.getState().chapters[0].unread).toBe(false); + }); + + it('refreshChapters delegates computed args and fallback currentPage for guard-friendly params', () => { + const harness = createHarness({ pages: [], pageIndex: 3, fetching: true }); + + harness.actions.refreshChapters(); + + expect(refreshChaptersAction).toHaveBeenCalledWith( + expect.objectContaining({ + novel: mockNovel, + fetching: true, + settingsSort: 'positionAsc', + settingsFilter: [], + currentPage: '1', + deps: harness.chapterDeps, + }), + ); + }); + + it('delegates remaining chapter action entry points to low-level helpers', () => { + const harness = createHarness(); + + harness.actions.markPreviouschaptersRead(3); + harness.actions.markChaptersRead([makeChapter(1)]); + harness.actions.markPreviousChaptersUnread(3); + harness.actions.markChaptersUnread([makeChapter(1)]); + harness.actions.updateChapterProgress(1, 50); + harness.actions.deleteChapter(makeChapter(1)); + harness.actions.deleteChapters([makeChapter(1)]); + + expect(markPreviouschaptersReadAction).toHaveBeenCalledWith( + 3, + mockNovel, + expect.any(Function), + harness.chapterDeps, + ); + expect(markChaptersReadAction).toHaveBeenCalledWith( + [expect.objectContaining({ id: 1 })], + expect.any(Function), + harness.chapterDeps, + ); + expect(markPreviousChaptersUnreadAction).toHaveBeenCalledWith( + 3, + mockNovel, + expect.any(Function), + harness.chapterDeps, + ); + expect(markChaptersUnreadAction).toHaveBeenCalledWith( + [expect.objectContaining({ id: 1 })], + expect.any(Function), + harness.chapterDeps, + ); + expect(updateChapterProgressAction).toHaveBeenCalledWith( + 1, + 50, + expect.any(Function), + harness.chapterDeps, + ); + expect(deleteChapterAction).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + mockNovel, + expect.any(Function), + harness.chapterDeps, + ); + expect(deleteChaptersAction).toHaveBeenCalledWith( + [expect.objectContaining({ id: 1 })], + mockNovel, + expect.any(Function), + harness.chapterDeps, + ); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts new file mode 100644 index 0000000000..b202c00525 --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts @@ -0,0 +1,24 @@ +import { createInitialChapterSlice } from '../store/novelStore.chapterState'; + +describe('novelStore.chapterState', () => { + it('creates the expected initial chapter slice state', () => { + expect(createInitialChapterSlice()).toEqual({ + chapters: [], + firstUnreadChapter: undefined, + chapterTextCache: {}, + batchInformation: { + batch: 0, + total: 0, + }, + }); + }); + + it('returns independent objects on each call', () => { + const first = createInitialChapterSlice(); + const second = createInitialChapterSlice(); + + expect(first).not.toBe(second); + expect(first.batchInformation).not.toBe(second.batchInformation); + expect(first.chapterTextCache).not.toBe(second.chapterTextCache); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/persistence.test.ts b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts new file mode 100644 index 0000000000..1d27cb608e --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts @@ -0,0 +1,174 @@ +import '../../../__tests__/mocks'; +import { ChapterInfo } from '@database/types'; +import { + createNovelPersistenceBridge, + defaultNovelSettings, + defaultPageIndex, + keyContract, + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + novelPersistence, +} from '../store-helper/contracts'; + +jest.mock('@services/ServiceManager', () => ({ + __esModule: true, + default: { + manager: { + addTask: jest.fn(), + }, + }, +})); + +jest.mock('@database/db', () => ({ + dbManager: { + write: jest.fn(), + }, +})); + +const createStorage = () => { + const numbers = new Map(); + const strings = new Map(); + + return { + numbers, + strings, + getNumber: (key: string) => numbers.get(key), + getString: (key: string) => strings.get(key), + set: (key: string, value: number | string | boolean) => { + if (typeof value === 'number') { + numbers.set(key, value); + strings.delete(key); + return; + } + + strings.set(key, String(value)); + numbers.delete(key); + }, + delete: (key: string) => { + numbers.delete(key); + strings.delete(key); + }, + }; +}; + +const sampleChapter: ChapterInfo = { + id: 42, + novelId: 7, + name: 'Chapter 42', + path: '/chapter/42', + releaseTime: '2026-01-01', + updatedTime: '2026-01-02', + readTime: '2026-01-03', + chapterNumber: 42, + unread: false, + isDownloaded: false, + bookmark: true, + progress: 70, + page: '1', +}; + +describe('novelPersistence bridge', () => { + const input = { + pluginId: 'webnovel', + novelPath: 'api/novels/xyz-123', + }; + + it('reads legacy continuity keys for page/settings/lastRead', () => { + const storage = createStorage(); + const bridge = createNovelPersistenceBridge(storage); + + const pageKey = `${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`; + const settingsKey = `${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`; + const lastReadKey = `${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`; + + storage.numbers.set(pageKey, 5); + storage.strings.set(settingsKey, JSON.stringify(defaultNovelSettings)); + storage.strings.set(lastReadKey, JSON.stringify(sampleChapter)); + + expect(bridge.readPageIndex(input)).toBe(5); + expect(bridge.readSettings(input)).toEqual(defaultNovelSettings); + expect(bridge.readLastRead(input)).toEqual(sampleChapter); + }); + + it('keeps bridge key builders aligned with shared key contract exports', () => { + const bridge = createNovelPersistenceBridge(createStorage()); + + expect(bridge.keys.pageIndex(input)).toBe( + `${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`, + ); + expect(bridge.keys.settings(input)).toBe( + `${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`, + ); + expect(bridge.keys.lastRead(input)).toBe( + `${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`, + ); + + expect(bridge.keys.pageIndex(input)).toBe(keyContract.pageIndex(input)); + expect(bridge.keys.settings(input)).toBe(keyContract.settings(input)); + expect(bridge.keys.lastRead(input)).toBe(keyContract.lastRead(input)); + }); + + it('recovers from corrupt persisted values with safe defaults', () => { + const storage = createStorage(); + const bridge = createNovelPersistenceBridge(storage); + + const pageKey = bridge.keys.pageIndex(input); + const settingsKey = bridge.keys.settings(input); + const lastReadKey = bridge.keys.lastRead(input); + + storage.strings.set(pageKey, 'not-a-number'); + storage.strings.set(settingsKey, '{invalid-json'); + storage.strings.set(lastReadKey, JSON.stringify({ bad: 'shape' })); + + expect(bridge.readPageIndex(input)).toBe(defaultPageIndex); + expect(bridge.readSettings(input)).toEqual(defaultNovelSettings); + expect(bridge.readLastRead(input)).toBeUndefined(); + expect(storage.numbers.get(pageKey)).toBe(defaultPageIndex); + expect(storage.strings.get(settingsKey)).toBe( + JSON.stringify(defaultNovelSettings), + ); + expect(storage.strings.has(lastReadKey)).toBe(false); + }); + + it('copies settings and lastRead via stable bridge API', () => { + const storage = createStorage(); + const bridge = createNovelPersistenceBridge(storage); + + const from = { + pluginId: 'source-plugin', + novelPath: 'source/path', + }; + const to = { + pluginId: 'target-plugin', + novelPath: 'target/path', + }; + + storage.strings.set( + bridge.keys.settings(from), + JSON.stringify(defaultNovelSettings), + ); + storage.strings.set( + bridge.keys.lastRead(from), + JSON.stringify(sampleChapter), + ); + + bridge.copySettings(from, to); + bridge.copyLastRead(from, to); + + expect(storage.strings.get(bridge.keys.settings(to))).toBe( + JSON.stringify(defaultNovelSettings), + ); + expect(storage.strings.get(bridge.keys.lastRead(to))).toBe( + JSON.stringify(sampleChapter), + ); + }); + + it('keeps migrate contract usage compile-safe through stable exports', () => { + const { migrateNovel } = require('@services/migrate/migrateNovel'); + + expect(typeof novelPersistence.copySettings).toBe('function'); + expect(typeof novelPersistence.readLastRead).toBe('function'); + expect(typeof migrateNovel).toBe('function'); + }); +}); diff --git a/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts new file mode 100644 index 0000000000..1f2ef83d7d --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts @@ -0,0 +1,127 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useNovelSettings } from '../../useNovelSettings'; + +const mockUseNovelValue = jest.fn(); +const mockUseNovelAction = jest.fn(); + +jest.mock('@screens/novel/NovelContext', () => ({ + useNovelValue: (key: string) => mockUseNovelValue(key), + useNovelAction: (key: string) => mockUseNovelAction(key), +})); + +jest.mock('../../useSettings', () => ({ + useAppSettings: () => ({ + defaultChapterSort: 'positionAsc', + }), +})); + +describe('useNovelSettings', () => { + const baseNovel = { + id: 1, + path: '/novels/test', + pluginId: 'plugin.test', + name: 'Novel', + inLibrary: false, + totalPages: 1, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reads selector-backed values and writes through setNovelSettings', async () => { + const storeSetNovelSettings = jest.fn(); + const storeNovelSettings = { + sort: 'positionDesc', + filter: ['read'], + showChapterTitles: true, + }; + + mockUseNovelValue.mockImplementation(key => { + if (key === 'novel') return baseNovel; + if (key === 'novelSettings') return storeNovelSettings; + return undefined; + }); + mockUseNovelAction.mockImplementation(key => { + if (key === 'setNovelSettings') return storeSetNovelSettings; + return undefined; + }); + + const { result } = renderHook(() => useNovelSettings()); + + expect(result.current.sort).toBe('positionDesc'); + expect(result.current.filter).toEqual(['read']); + expect(result.current.showChapterTitles).toBe(true); + + await act(async () => { + await result.current.setChapterSort('nameDesc'); + }); + + expect(storeSetNovelSettings).toHaveBeenCalledWith({ + showChapterTitles: true, + sort: 'nameDesc', + filter: ['read'], + }); + expect(storeSetNovelSettings).toHaveBeenCalledTimes(1); + }); + + it('falls back to app default sort and persists it when changing filter', async () => { + const storeSetNovelSettings = jest.fn(); + const storeNovelSettings = { + filter: ['read'], + showChapterTitles: true, + }; + + mockUseNovelValue.mockImplementation(key => { + if (key === 'novel') return baseNovel; + if (key === 'novelSettings') return storeNovelSettings; + return undefined; + }); + mockUseNovelAction.mockImplementation(key => { + if (key === 'setNovelSettings') return storeSetNovelSettings; + return undefined; + }); + + const { result } = renderHook(() => useNovelSettings()); + + expect(result.current.sort).toBeUndefined(); + + await act(async () => { + await result.current.setChapterFilter(['downloaded']); + }); + + expect(storeSetNovelSettings).toHaveBeenCalledWith({ + showChapterTitles: true, + sort: 'positionAsc', + filter: ['downloaded'], + }); + }); + + it('does not write sort/filter settings when novel is absent', async () => { + const storeSetNovelSettings = jest.fn(); + + mockUseNovelValue.mockImplementation(key => { + if (key === 'novel') return undefined; + if (key === 'novelSettings') { + return { + filter: [], + showChapterTitles: true, + }; + } + return undefined; + }); + mockUseNovelAction.mockImplementation(key => { + if (key === 'setNovelSettings') return storeSetNovelSettings; + return undefined; + }); + + const { result } = renderHook(() => useNovelSettings()); + + await act(async () => { + await result.current.setChapterSort('nameDesc'); + await result.current.setChapterFilter(['read']); + }); + + expect(storeSetNovelSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts new file mode 100644 index 0000000000..f8a705eed3 --- /dev/null +++ b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts @@ -0,0 +1,408 @@ +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { + getChapterCount as defaultGetChapterCount, + getChapterCountSync as defaultGetChapterCountSync, + getCustomPages as defaultGetCustomPages, + getFirstUnreadChapter as defaultGetFirstUnreadChapter, + getNovelChaptersSync as defaultGetNovelChaptersSync, + getPageChapters as defaultGetPageChapters, + getPageChaptersBatched as defaultGetPageChaptersBatched, + insertChapters as defaultInsertChapters, +} from '@database/queries/ChapterQueries'; +import { + getNovelById as defaultGetNovelById, + getNovelByPath as defaultGetNovelByPath, + insertNovelAndChapters as defaultInsertNovelAndChapters, +} from '@database/queries/NovelQueries'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { + fetchNovel as defaultFetchNovel, + fetchPage as defaultFetchPage, +} from '@services/plugin/fetch'; +import { getString as defaultGetString } from '@strings/translations'; +import { BatchInfo } from '../types'; + +export interface ChapterLoadResult { + chapters: ChapterInfo[]; + batchInformation: BatchInfo; + firstUnreadChapter: ChapterInfo | undefined; +} + +export interface BootstrapSuccessResult extends ChapterLoadResult { + ok: true; + novel: NovelInfo; + pages: string[]; +} + +export interface BootstrapFailureResult { + ok: false; + reason: 'missing-novel' | 'missing-chapters' | 'error'; + error?: unknown; +} + +export type BootstrapResult = BootstrapSuccessResult | BootstrapFailureResult; + +const inflightBootstraps = new Map>(); + +const getBootstrapKey = (pluginId: string, novelPath: string) => + `${pluginId}_${novelPath}`; + +const defaultBootstrapServiceDependencies = { + getCustomPages: defaultGetCustomPages, + getNovelByPath: defaultGetNovelByPath, + getNovelById: defaultGetNovelById, + fetchNovel: defaultFetchNovel, + insertNovelAndChapters: defaultInsertNovelAndChapters, + getChapterCount: defaultGetChapterCount, + getChapterCountSync: defaultGetChapterCountSync, + getPageChaptersBatched: defaultGetPageChaptersBatched, + getNovelChaptersSync: defaultGetNovelChaptersSync, + fetchPage: defaultFetchPage, + insertChapters: defaultInsertChapters, + getPageChapters: defaultGetPageChapters, + getFirstUnreadChapter: defaultGetFirstUnreadChapter, + getString: defaultGetString, +} as const; +export type BootstrapServiceDependencies = + typeof defaultBootstrapServiceDependencies; + +export const createBootstrapService = ( + dependencies: Partial = {}, +) => { + const deps: BootstrapServiceDependencies = { + ...defaultBootstrapServiceDependencies, + ...dependencies, + }; + + const calculatePages = (tmpNovel: NovelInfo): string[] => { + let tmpPages: string[]; + if ((tmpNovel.totalPages ?? 0) > 0) { + tmpPages = Array(tmpNovel.totalPages) + .fill(0) + .map((_, idx) => String(idx + 1)); + } else { + tmpPages = deps + .getCustomPages(tmpNovel.id) + .map(c => c.page) + .filter((page): page is string => page !== null); + } + + return tmpPages.length > 1 ? tmpPages : ['1']; + }; + + const resolveNovel = async ( + novelPath: string, + pluginId: string, + ): Promise => { + let tmpNovel = deps.getNovelByPath(novelPath, pluginId); + if (!tmpNovel) { + const sourceNovel = await deps + .fetchNovel(pluginId, novelPath) + .catch(() => { + throw new Error(deps.getString('updatesScreen.unableToGetNovel')); + }); + await deps.insertNovelAndChapters(pluginId, sourceNovel); + tmpNovel = deps.getNovelByPath(novelPath, pluginId); + + if (!tmpNovel) { + return; + } + } + + return tmpNovel; + }; + + const getChaptersForPage = async ({ + novel, + novelPath, + pluginId, + pages, + pageIndex, + settingsSort, + settingsFilter, + }: { + novel: NovelInfo; + novelPath: string; + pluginId: string; + pages: string[]; + pageIndex: number; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + }): Promise => { + const page = pages[pageIndex]; + let newChapters: ChapterInfo[] = []; + const config = [novel.id, settingsSort, settingsFilter, page] as const; + + let chapterCount = await deps.getChapterCount( + novel.id, + page, + settingsFilter, + ); + if (chapterCount) { + try { + newChapters = (await deps.getPageChaptersBatched(...config)) || []; + } catch { + newChapters = []; + } + } else if (settingsFilter.length === 0) { + const sourcePage = await deps.fetchPage(pluginId, novelPath, page); + const sourceChapters = sourcePage.chapters.map(ch => { + return { + ...ch, + page, + }; + }); + await deps.insertChapters(novel.id, sourceChapters); + newChapters = await deps.getPageChapters(...config); + chapterCount = await deps.getChapterCount(novel.id, page, settingsFilter); + } + + const batchInformation: BatchInfo = { + batch: 0, + total: Math.floor(chapterCount / 1000), + totalChapters: chapterCount, + }; + const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page); + return { + chapters: newChapters, + batchInformation, + firstUnreadChapter: unread ?? undefined, + }; + }; + + const getNextChapterBatch = async ({ + novel, + pages, + pageIndex, + settingsSort, + settingsFilter, + batchInformation, + }: { + novel: NovelInfo | undefined; + pages: string[]; + pageIndex: number; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + batchInformation: BatchInfo; + }) => { + const page = pages[pageIndex]; + const nextBatch = batchInformation.batch + 1; + if (!novel || !page || nextBatch > batchInformation.total) { + return; + } + + let newChapters: ChapterInfo[] = []; + try { + newChapters = + (await deps.getPageChaptersBatched( + novel.id, + settingsSort, + settingsFilter, + page, + nextBatch, + )) || []; + } catch { + newChapters = []; + } + + return { + batch: nextBatch, + chapters: newChapters, + }; + }; + + const loadUpToBatch = async ({ + targetBatch, + novel, + pages, + pageIndex, + settingsSort, + settingsFilter, + batchInformation, + onBatchLoaded, + }: { + targetBatch: number; + novel: NovelInfo | undefined; + pages: string[]; + pageIndex: number; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + batchInformation: BatchInfo; + onBatchLoaded: (batch: number, chapters: ChapterInfo[]) => void; + }) => { + const page = pages[pageIndex] ?? '1'; + if (!novel || !page || targetBatch <= batchInformation.batch) { + return; + } + + for ( + let batch = batchInformation.batch + 1; + batch <= targetBatch; + batch++ + ) { + if (batch > batchInformation.total) break; + + let newChapters: ChapterInfo[] = []; + try { + newChapters = + (await deps.getPageChaptersBatched( + novel.id, + settingsSort, + settingsFilter, + page, + batch, + )) || []; + } catch { + newChapters = []; + } + + onBatchLoaded(batch, newChapters); + } + }; + + const bootstrapNovelAsync = async ({ + novel, + novelPath, + pluginId, + pageIndex, + settingsSort, + settingsFilter, + }: { + novel: NovelInfo | undefined; + novelPath: string; + pluginId: string; + pageIndex: number; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + }): Promise => { + const key = getBootstrapKey(pluginId, novelPath); + const existing = inflightBootstraps.get(key); + if (existing) { + return existing; + } + + const bootstrapPromise = (async () => { + try { + const resolvedNovel = + novel ?? (await resolveNovel(novelPath, pluginId)); + if (!resolvedNovel) { + return { + ok: false, + reason: 'missing-novel', + } satisfies BootstrapFailureResult; + } + + const pages = calculatePages(resolvedNovel); + const chapterState = await getChaptersForPage({ + novel: resolvedNovel, + novelPath, + pluginId, + pages, + pageIndex, + settingsSort, + settingsFilter, + }); + + return { + ok: true, + novel: resolvedNovel, + pages, + ...chapterState, + } satisfies BootstrapSuccessResult; + } catch (error) { + return { + ok: false, + reason: 'error', + error, + } satisfies BootstrapFailureResult; + } finally { + inflightBootstraps.delete(key); + } + })(); + + inflightBootstraps.set(key, bootstrapPromise); + return bootstrapPromise; + }; + const bootstrapNovelSync = ({ + novel: _novel, + novelPath, + pluginId, + pageIndex, + settingsSort, + settingsFilter, + }: { + novel: NovelInfo | undefined; + novelPath: string; + pluginId: string; + pageIndex: number; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + }): BootstrapResult => { + try { + const novel = !_novel?.id + ? deps.getNovelByPath(novelPath, pluginId) + : deps.getNovelById(_novel.id); + if (!novel) { + return { + ok: false, + reason: 'missing-novel', + } satisfies BootstrapFailureResult; + } + + const pages = calculatePages(novel); + const page = pages[pageIndex] ?? '1'; + const chapterCount = + settingsFilter.length === 0 && pages.length === 1 + ? novel.totalChapters ?? 0 + : deps.getChapterCountSync(novel.id, page, settingsFilter); + if (chapterCount === 0 && settingsFilter.length === 0) { + return { + ok: false, + reason: 'missing-chapters', + } satisfies BootstrapFailureResult; + } + + const config = [ + novel.id, + settingsSort, + settingsFilter, + page, + 1000, + ] as const; + + const newChapters = deps.getNovelChaptersSync(...config); + + const batchInformation: BatchInfo = { + batch: 0, + total: Math.floor(chapterCount / 1000), + totalChapters: chapterCount, + }; + const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page); + + return { + ok: true, + novel, + pages, + chapters: newChapters, + batchInformation, + firstUnreadChapter: unread ?? undefined, + } satisfies BootstrapSuccessResult; + } catch (error) { + return { + ok: false, + reason: 'error', + error, + } satisfies BootstrapFailureResult; + } + }; + + return { + getChaptersForPage, + getNextChapterBatch, + loadUpToBatch, + bootstrapNovelAsync, + bootstrapNovelSync, + }; +}; + +export const bootstrapService = createBootstrapService(); diff --git a/src/hooks/persisted/useNovel/store-helper/contracts.ts b/src/hooks/persisted/useNovel/store-helper/contracts.ts new file mode 100644 index 0000000000..240037264b --- /dev/null +++ b/src/hooks/persisted/useNovel/store-helper/contracts.ts @@ -0,0 +1,11 @@ +export type { KeyContractInput as NovelPersistenceInput } from './keyContract'; +export { keyContract } from './keyContract'; +export { + createNovelPersistenceBridge, + novelPersistence, + defaultNovelSettings, + defaultPageIndex, + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, +} from './persistence'; diff --git a/src/hooks/persisted/useNovel/store-helper/keyContract.ts b/src/hooks/persisted/useNovel/store-helper/keyContract.ts new file mode 100644 index 0000000000..90222f250b --- /dev/null +++ b/src/hooks/persisted/useNovel/store-helper/keyContract.ts @@ -0,0 +1,24 @@ +import { + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + LAST_READ_PREFIX, +} from '../types'; + +export interface KeyContractInput { + pluginId: string; + novelPath: string; +} + +export const keyContract = { + pageIndex: (input: KeyContractInput): string => { + return `${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`; + }, + + settings: (input: KeyContractInput): string => { + return `${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`; + }, + + lastRead: (input: KeyContractInput): string => { + return `${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`; + }, +}; diff --git a/src/hooks/persisted/useNovel/store-helper/persistence.ts b/src/hooks/persisted/useNovel/store-helper/persistence.ts new file mode 100644 index 0000000000..0c1a409152 --- /dev/null +++ b/src/hooks/persisted/useNovel/store-helper/persistence.ts @@ -0,0 +1,188 @@ +import { ChapterInfo } from '@database/types'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { KeyContractInput, keyContract } from './keyContract'; +import { + defaultNovelSettings, + defaultPageIndex, + NovelSettings, + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + NovelSettingsWithoutSort, +} from '../types'; + +export type NovelPersistenceInput = KeyContractInput; + +interface NovelPersistenceStorage { + getNumber: (key: string) => number | undefined; + getString: (key: string) => string | undefined; + set: (key: string, value: number | string | boolean) => void; + delete: (key: string) => void; +} + +const defaultStorage: NovelPersistenceStorage = { + getNumber: key => MMKVStorage.getNumber(key), + getString: key => MMKVStorage.getString(key), + set: (key, value) => MMKVStorage.set(key, value), + delete: key => MMKVStorage.remove(key), +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +const parseJsonSafely = (value: string): unknown | undefined => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +const isValidNovelSettings = (value: unknown): value is NovelSettings => { + if (!isRecord(value)) return false; + if (!Array.isArray(value.filter)) return false; + if (!value.filter.every(filter => typeof filter === 'string')) return false; + if (value.sort !== undefined && typeof value.sort !== 'string') return false; + if ( + value.showChapterTitles !== undefined && + typeof value.showChapterTitles !== 'boolean' + ) { + return false; + } + return true; +}; + +const isValidChapterLike = (value: unknown): value is ChapterInfo => { + return isRecord(value) && typeof value.id === 'number'; +}; + +export const createNovelPersistenceBridge = ( + storage: NovelPersistenceStorage = defaultStorage, +) => { + const keys = { + pageIndex: (input: NovelPersistenceInput) => keyContract.pageIndex(input), + settings: (input: NovelPersistenceInput) => keyContract.settings(input), + lastRead: (input: NovelPersistenceInput) => keyContract.lastRead(input), + }; + + const readPageIndex = (input: NovelPersistenceInput): number => { + const key = keys.pageIndex(input); + const numberValue = storage.getNumber(key); + + if (typeof numberValue === 'number' && Number.isFinite(numberValue)) { + return numberValue; + } + + const stringValue = storage.getString(key); + if (stringValue !== undefined) { + const parsed = Number(stringValue); + if (Number.isFinite(parsed)) { + storage.set(key, parsed); + return parsed; + } + } + + storage.set(key, defaultPageIndex); + return defaultPageIndex; + }; + + const writePageIndex = (input: NovelPersistenceInput, value: number) => { + const key = keys.pageIndex(input); + const safeValue = Number.isFinite(value) ? value : defaultPageIndex; + storage.set(key, safeValue); + }; + + const readSettings = ( + input: NovelPersistenceInput, + ): NovelSettingsWithoutSort => { + const key = keys.settings(input); + const raw = storage.getString(key); + if (raw === undefined) { + return defaultNovelSettings; + } + + const parsed = parseJsonSafely(raw); + if (isValidNovelSettings(parsed)) { + return parsed; + } + + storage.delete(key); + storage.set(key, JSON.stringify(defaultNovelSettings)); + return defaultNovelSettings; + }; + + const writeSettings = ( + input: NovelPersistenceInput, + value: NovelSettingsWithoutSort, + ) => { + const key = keys.settings(input); + storage.set(key, JSON.stringify(value)); + }; + + const readLastRead = ( + input: NovelPersistenceInput, + ): ChapterInfo | undefined => { + const key = keys.lastRead(input); + const raw = storage.getString(key); + if (raw === undefined) { + return undefined; + } + + const parsed = parseJsonSafely(raw); + if (isValidChapterLike(parsed)) { + return parsed; + } + + storage.delete(key); + return undefined; + }; + + const writeLastRead = (input: NovelPersistenceInput, value: ChapterInfo) => { + const key = keys.lastRead(input); + storage.set(key, JSON.stringify(value)); + }; + + const copySettings = ( + from: NovelPersistenceInput, + to: NovelPersistenceInput, + ) => { + const settings = readSettings(from); + if (settings) { + writeSettings(to, settings); + } + }; + + const copyLastRead = ( + from: NovelPersistenceInput, + to: NovelPersistenceInput, + ) => { + const lastRead = readLastRead(from); + if (lastRead) { + writeLastRead(to, lastRead); + } + }; + + return { + keys, + readPageIndex, + writePageIndex, + readSettings, + writeSettings, + readLastRead, + writeLastRead, + copySettings, + copyLastRead, + }; +}; + +export const novelPersistence = createNovelPersistenceBridge(); + +export { + defaultNovelSettings, + defaultPageIndex, + LAST_READ_PREFIX, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, + keyContract, +}; diff --git a/src/hooks/persisted/useNovel/store/chapterActions.ts b/src/hooks/persisted/useNovel/store/chapterActions.ts new file mode 100644 index 0000000000..d2acaa137f --- /dev/null +++ b/src/hooks/persisted/useNovel/store/chapterActions.ts @@ -0,0 +1,326 @@ +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { + bookmarkChapter as _bookmarkChapter, + deleteChapter as _deleteChapter, + deleteChapters as _deleteChapters, + getPageChapters as _getPageChapters, + markChapterRead as _markChapterRead, + markChaptersRead as _markChaptersRead, + markChaptersUnread as _markChaptersUnread, + markPreviousChaptersUnread as _markPreviousChaptersUnread, + markPreviuschaptersRead as _markPreviuschaptersRead, + updateChapterProgress as _updateChapterProgress, +} from '@database/queries/ChapterQueries'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { getString as translateGetString } from '@strings/translations'; +import { showToast } from '@utils/showToast'; + +type MutateChapters = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => void; +type SetChapters = (chs: ChapterInfo[]) => void; +type TransformChapters = (chs: ChapterInfo[]) => ChapterInfo[]; + +export interface ChapterActionsDependencies { + bookmarkChapter: (chapterId: number) => Promise; + markChapterRead: (chapterId: number) => Promise; + markChaptersRead: (chapterIds: number[]) => Promise; + markPreviuschaptersRead: ( + chapterId: number, + novelId: number, + ) => Promise; + markPreviousChaptersUnread: ( + chapterId: number, + novelId: number, + ) => Promise; + markChaptersUnread: (chapterIds: number[]) => Promise; + updateChapterProgress: (chapterId: number, progress: number) => Promise; + deleteChapter: ( + pluginId: string, + novelId: number, + chapterId: number, + ) => Promise; + deleteChapters: ( + pluginId: string, + novelId: number, + chapters?: ChapterInfo[], + ) => Promise; + getPageChapters: ( + novelId: number, + sort?: ChapterOrderKey, + filter?: ChapterFilterKey[], + page?: string, + ) => Promise; + showToast: (message: string) => void; + getString: typeof translateGetString; +} + +export const defaultChapterActionsDependencies: ChapterActionsDependencies = { + bookmarkChapter: _bookmarkChapter, + markChapterRead: _markChapterRead, + markChaptersRead: _markChaptersRead, + markPreviuschaptersRead: _markPreviuschaptersRead, + markPreviousChaptersUnread: _markPreviousChaptersUnread, + markChaptersUnread: _markChaptersUnread, + updateChapterProgress: _updateChapterProgress, + deleteChapter: _deleteChapter, + deleteChapters: _deleteChapters, + getPageChapters: _getPageChapters, + showToast, + getString: translateGetString, +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + + return String(error); +}; + +const runAsyncAction = ( + promise: Promise, + deps: ChapterActionsDependencies, +) => { + promise.catch(error => { + deps.showToast(getErrorMessage(error)); + }); +}; + +export const bookmarkChaptersAction = ( + _chapters: ChapterInfo[], + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + runAsyncAction( + Promise.all(_chapters.map(_chapter => deps.bookmarkChapter(_chapter.id))), + deps, + ); + + mutateChapters(chs => + chs.map(chapter => { + if (_chapters.some(_c => _c.id === chapter.id)) { + return { + ...chapter, + bookmark: !chapter.bookmark, + }; + } + return chapter; + }), + ); +}; + +export const markPreviouschaptersReadAction = ( + chapterId: number, + novel: NovelInfo | undefined, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + if (novel) { + runAsyncAction(deps.markPreviuschaptersRead(chapterId, novel.id), deps); + mutateChapters(chs => + chs.map(chapter => + chapter.id <= chapterId ? { ...chapter, unread: false } : chapter, + ), + ); + } +}; + +export const markChapterReadAction = ( + chapterId: number, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + runAsyncAction(deps.markChapterRead(chapterId), deps); + + mutateChapters(chs => + chs.map(c => { + if (c.id !== chapterId) { + return c; + } + + return { + ...c, + unread: false, + }; + }), + ); +}; + +export const markChaptersReadAction = ( + _chapters: ChapterInfo[], + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + const chapterIds = _chapters.map(chapter => chapter.id); + runAsyncAction(deps.markChaptersRead(chapterIds), deps); + + mutateChapters(chs => + chs.map(chapter => { + if (chapterIds.includes(chapter.id)) { + return { + ...chapter, + unread: false, + }; + } + return chapter; + }), + ); +}; + +export const markPreviousChaptersUnreadAction = ( + chapterId: number, + novel: NovelInfo | undefined, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + if (novel) { + runAsyncAction(deps.markPreviousChaptersUnread(chapterId, novel.id), deps); + mutateChapters(chs => + chs.map(chapter => + chapter.id <= chapterId ? { ...chapter, unread: true } : chapter, + ), + ); + } +}; + +export const markChaptersUnreadAction = ( + _chapters: ChapterInfo[], + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + const chapterIds = _chapters.map(chapter => chapter.id); + runAsyncAction(deps.markChaptersUnread(chapterIds), deps); + + mutateChapters(chs => + chs.map(chapter => { + if (chapterIds.includes(chapter.id)) { + return { + ...chapter, + unread: true, + }; + } + return chapter; + }), + ); +}; + +export const updateChapterProgressAction = ( + chapterId: number, + progress: number, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + const normalizedProgress = Math.min(progress, 100); + runAsyncAction( + deps.updateChapterProgress(chapterId, normalizedProgress), + deps, + ); + + mutateChapters(chs => + chs.map(c => { + if (c.id !== chapterId) { + return c; + } + + return { + ...c, + progress: normalizedProgress, + }; + }), + ); +}; + +export const deleteChapterAction = ( + _chapter: ChapterInfo, + novel: NovelInfo | undefined, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + if (novel) { + runAsyncAction( + (async () => { + await deps.deleteChapter(novel.pluginId, novel.id, _chapter.id); + mutateChapters(chs => + chs.map(chapter => { + if (chapter.id !== _chapter.id) { + return chapter; + } + + return { + ...chapter, + isDownloaded: false, + }; + }), + ); + + deps.showToast(deps.getString('common.deleted', { name: _chapter.name })); + })(), + deps, + ); + } +}; + +export const deleteChaptersAction = ( + _chapters: ChapterInfo[], + novel: NovelInfo | undefined, + mutateChapters: MutateChapters, + deps: ChapterActionsDependencies = defaultChapterActionsDependencies, +) => { + if (novel) { + runAsyncAction( + (async () => { + await deps.deleteChapters(novel.pluginId, novel.id, _chapters); + deps.showToast( + deps.getString('updatesScreen.deletedChapters', { + num: _chapters.length, + }), + ); + + mutateChapters(chs => + chs.map(chapter => { + if (_chapters.some(_c => _c.id === chapter.id)) { + return { + ...chapter, + isDownloaded: false, + }; + } + return chapter; + }), + ); + })(), + deps, + ); + } +}; + +export interface RefreshChaptersParams { + novel: NovelInfo | undefined; + fetching: boolean; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + currentPage: string; + transformChapters: TransformChapters; + setChapters: SetChapters; + deps?: ChapterActionsDependencies; +} + +export const refreshChaptersAction = ({ + novel, + fetching, + settingsSort, + settingsFilter, + currentPage, + transformChapters, + setChapters, + deps = defaultChapterActionsDependencies, +}: RefreshChaptersParams) => { + if (novel?.id && !fetching) { + runAsyncAction( + deps + .getPageChapters(novel.id, settingsSort, settingsFilter, currentPage) + .then(chs => { + setChapters(transformChapters(chs)); + }), + deps, + ); + } +}; diff --git a/src/hooks/persisted/useNovel/store/createStore.ts b/src/hooks/persisted/useNovel/store/createStore.ts new file mode 100644 index 0000000000..d7942bae0d --- /dev/null +++ b/src/hooks/persisted/useNovel/store/createStore.ts @@ -0,0 +1,101 @@ +import { NovelInfo } from '@database/types'; +import { NovelSettings } from '@hooks/persisted/useNovel'; +import { novelPersistence } from '@hooks/persisted/useNovel/store-helper/persistence'; +import { createNovelSlice } from '@hooks/persisted/useNovel/store/novelStore'; +import { ChapterOrderKey } from '@database/constants'; +import { + NovelStoreApi, + NovelStoreDependencies, + NovelStoreState, +} from './novelStore.types'; +import { createStore as createZustandStore } from 'zustand'; +import { createNovelStoreActions } from './novelStore.actions'; +import { createInitialChapterSlice } from './novelStore.chapterState'; +import { createNovelStoreChapterActions } from './novelStore.chapterActions'; +import { createBootstrapService } from '../store-helper/bootstrapService'; +import { defaultChapterActionsDependencies } from './chapterActions'; + +interface Props { + pluginId: string; + path: string; + novel?: NovelInfo; + defaultChapterSort: ChapterOrderKey; + switchNovelToLibrary: (novelPath: string, pluginId: string) => Promise; +} + +export function createStore({ + novel, + defaultChapterSort, + path, + pluginId, + switchNovelToLibrary, +}: Props): NovelStoreApi { + const persistenceInput = { + pluginId, + novelPath: path, + }; + + const novelSettings: NovelSettings = { + sort: defaultChapterSort, + ...novelPersistence.readSettings(persistenceInput), + }; + + const bootstrapService = createBootstrapService(); + const deps: NovelStoreDependencies = { + bootstrapService, + chapterActionsDependencies: defaultChapterActionsDependencies, + transformChapters: c => c, + persistPageIndex: value => + novelPersistence.writePageIndex(persistenceInput, value), + persistNovelSettings: value => { + novelPersistence.writeSettings(persistenceInput, value); + }, + persistLastRead: chapter => + novelPersistence.writeLastRead(persistenceInput, chapter), + switchNovelToLibrary, + }; + + const store = createZustandStore()((set, get) => { + const chapterSlice = createInitialChapterSlice(); + const actions = { + ...createNovelStoreActions({ + set, + get, + deps, + defaultChapterSort: novelSettings.sort, + }), + ...createNovelStoreChapterActions({ + set, + get, + bootstrapService: deps.bootstrapService, + chapterActionsDependencies: deps.chapterActionsDependencies, + transformChapters: deps.transformChapters, + defaultChapterSort: novelSettings.sort, + }), + }; + return { + ...createNovelSlice({ + pluginId, + novelPath: path, + novel, + defaultChapterSort, + initialPageIndex: novelPersistence.readPageIndex({ + pluginId, + novelPath: path, + }), + initialNovelSettings: novelSettings, + initialLastRead: novelPersistence.readLastRead(persistenceInput), + }), + ...chapterSlice, + actions, + }; + }); + + const success = store.getState().actions.bootstrapNovelSync(); + if (!success) { + // If bootstrapNovelSync fails, it means the novel or chapters are not in the db + store.getState().actions.bootstrapNovel(); + } + + return store; +} diff --git a/src/hooks/persisted/useNovel/store/novelStore.actions.ts b/src/hooks/persisted/useNovel/store/novelStore.actions.ts new file mode 100644 index 0000000000..22bb3f0f71 --- /dev/null +++ b/src/hooks/persisted/useNovel/store/novelStore.actions.ts @@ -0,0 +1,205 @@ +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { NovelSettings } from '../types'; +import { + GetState, + NovelStoreDependencies, + NovelStoreNovelActions, + SetState, +} from './novelStore.types'; + +interface CreateNovelStoreActionsParams { + set: SetState; + get: GetState; + deps: NovelStoreDependencies; + defaultChapterSort: ChapterOrderKey; +} + +export const createNovelStoreActions = ({ + set, + get, + deps, + defaultChapterSort, +}: CreateNovelStoreActionsParams): NovelStoreNovelActions => { + let inflightBootstrap: Promise | null = null; + + const getSettingsSort = (settings: NovelSettings): ChapterOrderKey => + settings.sort || defaultChapterSort; + + const getSettingsFilter = (settings: NovelSettings): ChapterFilterKey[] => + settings.filter ?? []; + + return { + bootstrapNovel: async () => { + if (inflightBootstrap) { + return inflightBootstrap; + } + + inflightBootstrap = (async () => { + set({ loading: true, fetching: true }); + + const state = get(); + const result = await deps.bootstrapService.bootstrapNovelAsync({ + novel: state.novel, + novelPath: state.novelPath, + pluginId: state.pluginId, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + }); + + if (!result.ok) { + set({ + loading: false, + fetching: false, + }); + return false; + } + + set({ + loading: false, + fetching: false, + novel: result.novel, + pages: result.pages, + chapters: deps.transformChapters(result.chapters), + batchInformation: result.batchInformation, + firstUnreadChapter: result.firstUnreadChapter, + }); + + return true; + })().finally(() => { + inflightBootstrap = null; + }); + + return inflightBootstrap; + }, + bootstrapNovelSync: () => { + const state = get(); + const result = deps.bootstrapService.bootstrapNovelSync({ + novel: state.novel, + novelPath: state.novelPath, + pluginId: state.pluginId, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + }); + + if (!result.ok) { + return false; + } + + set({ + loading: false, + fetching: false, + novel: result.novel, + pages: result.pages, + chapters: deps.transformChapters(result.chapters), + batchInformation: result.batchInformation, + firstUnreadChapter: result.firstUnreadChapter, + }); + + return true; + }, + + getChapters: async () => { + const state = get(); + if (!state.novel || state.pages.length === 0) { + return; + } + + set({ fetching: true }); + try { + const result = await deps.bootstrapService.getChaptersForPage({ + novel: state.novel, + novelPath: state.novelPath, + pluginId: state.pluginId, + pages: state.pages, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + }); + + set({ + chapters: deps.transformChapters(result.chapters), + batchInformation: result.batchInformation, + firstUnreadChapter: result.firstUnreadChapter, + }); + } finally { + set({ fetching: false }); + } + }, + + refreshNovel: async () => { + set({ loading: true, fetching: true }); + try { + const state = get(); + const refreshed = await deps.bootstrapService.bootstrapNovelAsync({ + novel: undefined, + novelPath: state.novelPath, + pluginId: state.pluginId, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + }); + + if (!refreshed.ok) { + return; + } + + set({ + novel: refreshed.novel, + pages: refreshed.pages, + chapters: deps.transformChapters(refreshed.chapters), + batchInformation: refreshed.batchInformation, + firstUnreadChapter: refreshed.firstUnreadChapter, + }); + } finally { + set({ loading: false, fetching: false }); + } + }, + + setNovel: novelState => set({ novel: novelState }), + setPages: pagesState => set({ pages: pagesState }), + setPageIndex: index => { + set({ pageIndex: index }); + deps.persistPageIndex?.(index); + }, + openPage: async index => { + set({ pageIndex: index }); + deps.persistPageIndex?.(index); + await get().actions.getChapters(); + }, + setNovelSettings: settings => { + set({ novelSettings: settings }); + deps.persistNovelSettings?.(settings); + + const state = get(); + if (state.novel && state.pages.length > 0) { + state.actions.getChapters(); + } + }, + setLastRead: chapter => { + set({ lastRead: chapter }); + deps.persistLastRead?.(chapter); + }, + followNovel: async () => { + const state = get(); + const currentNovel = state.novel; + if (!currentNovel || !deps.switchNovelToLibrary) { + return; + } + + await deps.switchNovelToLibrary(state.novelPath, state.pluginId); + set(inner => { + if (!inner.novel) { + return {}; + } + return { + novel: { + ...inner.novel, + inLibrary: !inner.novel.inLibrary, + }, + }; + }); + }, + }; +}; diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts new file mode 100644 index 0000000000..10599c64ee --- /dev/null +++ b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts @@ -0,0 +1,293 @@ +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { ChapterInfo } from '@database/types'; +import { createBootstrapService } from '../store-helper/bootstrapService'; +import { + bookmarkChaptersAction, + ChapterActionsDependencies, + deleteChapterAction, + deleteChaptersAction, + markChapterReadAction, + markChaptersReadAction, + markChaptersUnreadAction, + markPreviouschaptersReadAction, + markPreviousChaptersUnreadAction, + refreshChaptersAction, + updateChapterProgressAction, +} from './chapterActions'; +import { NovelSettings } from '../types'; +import { + ChapterTextCacheApi, + GetState, + NovelStoreChapterActions, + SetState, +} from './novelStore.types'; + +interface CreateNovelStoreChapterActionsParams { + set: SetState; + get: GetState; + bootstrapService: Pick< + ReturnType, + 'getNextChapterBatch' | 'loadUpToBatch' + >; + chapterActionsDependencies: ChapterActionsDependencies; + transformChapters: (chs: ChapterInfo[]) => ChapterInfo[]; + defaultChapterSort: ChapterOrderKey; +} + +export const createNovelStoreChapterActions = ({ + set, + get, + bootstrapService, + chapterActionsDependencies, + transformChapters, + defaultChapterSort, +}: CreateNovelStoreChapterActionsParams): NovelStoreChapterActions => { + let inflightNextChapterBatch: Promise | null = null; + let inflightLoadUpToBatch: Promise | null = null; + let pendingTargetBatch: number | null = null; + + const mutateChapters = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => { + if (get().novel) { + set(state => ({ chapters: mutation(state.chapters) })); + } + }; + + const setChapters = (chs: ChapterInfo[]) => { + set({ chapters: transformChapters(chs) }); + }; + + const getSettingsSort = (settings: NovelSettings): ChapterOrderKey => + settings.sort || defaultChapterSort; + + const getSettingsFilter = (settings: NovelSettings): ChapterFilterKey[] => + settings.filter ?? []; + + const createChapterTextCache = (): ChapterTextCacheApi => { + return { + read: chapterId => get().chapterTextCache[chapterId], + write: (chapterId, value) => { + set({ + chapterTextCache: { + ...get().chapterTextCache, + [chapterId]: value, + }, + }); + }, + remove: chapterId => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [chapterId]: _ignored, ...rest } = get().chapterTextCache; + set({ + chapterTextCache: rest, + }); + }, + clear: () => { + set({ + chapterTextCache: {}, + }); + }, + }; + }; + + const appendBatch = (batch: number, chapters: ChapterInfo[]) => { + set(curr => { + if (batch <= curr.batchInformation.batch) { + return {}; + } + + return { + batchInformation: { + ...curr.batchInformation, + batch, + }, + chapters: curr.chapters.concat(chapters), + }; + }); + }; + + const queueLoadUpToBatch = (targetBatch: number): Promise => { + pendingTargetBatch = Math.max( + pendingTargetBatch ?? targetBatch, + targetBatch, + ); + + if (inflightLoadUpToBatch) { + return inflightLoadUpToBatch; + } + + inflightLoadUpToBatch = (async () => { + while (pendingTargetBatch !== null) { + const nextTarget = pendingTargetBatch; + pendingTargetBatch = null; + const state = get(); + + if (nextTarget <= state.batchInformation.batch) { + continue; + } + + await bootstrapService.loadUpToBatch({ + targetBatch: nextTarget, + novel: state.novel, + pages: state.pages, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + batchInformation: state.batchInformation, + onBatchLoaded: (batch, chapters) => { + appendBatch(batch, transformChapters(chapters)); + }, + }); + } + })().finally(() => { + inflightLoadUpToBatch = null; + pendingTargetBatch = null; + }); + + return inflightLoadUpToBatch; + }; + + return { + chapterTextCache: createChapterTextCache(), + getNextChapterBatch: async () => { + if (inflightNextChapterBatch) { + return inflightNextChapterBatch; + } + + const state = get(); + inflightNextChapterBatch = (async () => { + const result = await bootstrapService.getNextChapterBatch({ + novel: state.novel, + pages: state.pages, + pageIndex: state.pageIndex, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + batchInformation: state.batchInformation, + }); + + if (!result) { + return; + } + + appendBatch(result.batch, transformChapters(result.chapters)); + })().finally(() => { + inflightNextChapterBatch = null; + }); + + return inflightNextChapterBatch; + }, + + loadUpToBatch: async (targetBatch: number) => { + await queueLoadUpToBatch(targetBatch); + }, + + updateChapter: (index, update) => { + if (get().novel) { + set(state => { + const next = [...state.chapters]; + next[index] = { ...next[index], ...update }; + return { + chapters: next, + }; + }); + } + }, + + setChapters, + + extendChapters: chs => { + set(state => ({ + chapters: state.chapters.concat(transformChapters(chs)), + })); + }, + + bookmarkChapters: chaptersState => { + bookmarkChaptersAction( + chaptersState, + mutateChapters, + chapterActionsDependencies, + ); + }, + + markPreviouschaptersRead: chapterId => { + markPreviouschaptersReadAction( + chapterId, + get().novel, + mutateChapters, + chapterActionsDependencies, + ); + }, + + markChapterRead: chapterId => { + markChapterReadAction( + chapterId, + mutateChapters, + chapterActionsDependencies, + ); + }, + + markChaptersRead: chaptersState => { + markChaptersReadAction( + chaptersState, + mutateChapters, + chapterActionsDependencies, + ); + }, + + markPreviousChaptersUnread: chapterId => { + markPreviousChaptersUnreadAction( + chapterId, + get().novel, + mutateChapters, + chapterActionsDependencies, + ); + }, + + markChaptersUnread: chaptersState => { + markChaptersUnreadAction( + chaptersState, + mutateChapters, + chapterActionsDependencies, + ); + }, + + updateChapterProgress: (chapterId, progress) => { + updateChapterProgressAction( + chapterId, + progress, + mutateChapters, + chapterActionsDependencies, + ); + }, + + deleteChapter: chapter => { + deleteChapterAction( + chapter, + get().novel, + mutateChapters, + chapterActionsDependencies, + ); + }, + + deleteChapters: chaptersState => { + deleteChaptersAction( + chaptersState, + get().novel, + mutateChapters, + chapterActionsDependencies, + ); + }, + + refreshChapters: () => { + const state = get(); + refreshChaptersAction({ + novel: state.novel, + fetching: state.fetching, + settingsSort: getSettingsSort(state.novelSettings), + settingsFilter: getSettingsFilter(state.novelSettings), + currentPage: state.pages[state.pageIndex] ?? '1', + transformChapters, + setChapters, + deps: chapterActionsDependencies, + }); + }, + }; +}; diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts new file mode 100644 index 0000000000..c87764d55f --- /dev/null +++ b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts @@ -0,0 +1,11 @@ +import { ChapterSliceState } from './novelStore.types'; + +export const createInitialChapterSlice = (): ChapterSliceState => ({ + chapters: [], + firstUnreadChapter: undefined, + chapterTextCache: {}, + batchInformation: { + batch: 0, + total: 0, + }, +}); diff --git a/src/hooks/persisted/useNovel/store/novelStore.ts b/src/hooks/persisted/useNovel/store/novelStore.ts new file mode 100644 index 0000000000..7e86663484 --- /dev/null +++ b/src/hooks/persisted/useNovel/store/novelStore.ts @@ -0,0 +1,34 @@ +import { ChapterOrderKey } from '@database/constants'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { NovelSettings } from '../types'; + +export interface CreateNovelStoreParams { + pluginId: string; + novelPath: string; + novel?: NovelInfo; + defaultChapterSort?: ChapterOrderKey; + initialPageIndex?: number; + initialNovelSettings: NovelSettings; + initialLastRead?: ChapterInfo; +} + +export const createNovelSlice = ({ + pluginId, + novelPath, + novel, + initialPageIndex = 0, + initialNovelSettings, + initialLastRead, +}: CreateNovelStoreParams) => { + return { + loading: false, + fetching: false, + pluginId, + novelPath, + novel, + pageIndex: initialPageIndex, + pages: [], + novelSettings: initialNovelSettings, + lastRead: initialLastRead, + }; +}; diff --git a/src/hooks/persisted/useNovel/store/novelStore.types.ts b/src/hooks/persisted/useNovel/store/novelStore.types.ts new file mode 100644 index 0000000000..349c78aec2 --- /dev/null +++ b/src/hooks/persisted/useNovel/store/novelStore.types.ts @@ -0,0 +1,106 @@ +import { StoreApi } from 'zustand/vanilla'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { ChapterActionsDependencies } from './chapterActions'; +import { createBootstrapService } from '../store-helper/bootstrapService'; +import { BatchInfo, NovelSettings } from '../types'; + +type ChapterTextValue = string | Promise; + +export interface ChapterTextCacheApi { + read: (chapterId: number) => ChapterTextValue | undefined; + write: (chapterId: number, value: ChapterTextValue) => void; + remove: (chapterId: number) => void; + clear: () => void; +} +export interface ChapterSliceState { + chapters: ChapterInfo[]; + firstUnreadChapter: ChapterInfo | undefined; + batchInformation: BatchInfo; + chapterTextCache: Record; +} + +export interface NovelStoreData extends ChapterSliceState { + loading: boolean; + fetching: boolean; + + pluginId: string; + novelPath: string; + novel: NovelInfo | undefined; + + pageIndex: number; + pages: string[]; + + novelSettings: NovelSettings; + lastRead: ChapterInfo | undefined; +} + +export interface NovelStoreChapterActions { + chapterTextCache: ChapterTextCacheApi; + getNextChapterBatch: () => Promise; + loadUpToBatch: (targetBatch: number) => Promise; + updateChapter: (index: number, update: Partial) => void; + setChapters: (chs: ChapterInfo[]) => void; + extendChapters: (chs: ChapterInfo[]) => void; + bookmarkChapters: (chapters: ChapterInfo[]) => void; + markPreviouschaptersRead: (chapterId: number) => void; + markChapterRead: (chapterId: number) => void; + markChaptersRead: (chapters: ChapterInfo[]) => void; + markPreviousChaptersUnread: (chapterId: number) => void; + markChaptersUnread: (chapters: ChapterInfo[]) => void; + updateChapterProgress: (chapterId: number, progress: number) => void; + deleteChapter: (chapter: ChapterInfo) => void; + deleteChapters: (chapters: ChapterInfo[]) => void; + refreshChapters: () => void; +} + +export interface NovelStoreNovelActions { + bootstrapNovel: () => Promise; + bootstrapNovelSync: () => boolean; + getChapters: () => Promise; + refreshNovel: () => Promise; + + setNovel: (novel: NovelInfo | undefined) => void; + setPages: (pages: string[]) => void; + setPageIndex: (index: number) => void; + openPage: (index: number) => Promise; + setNovelSettings: (settings: NovelSettings) => void; + setLastRead: (chapter: ChapterInfo) => void; + followNovel: () => Promise; +} + +export type NovelStoreActions = NovelStoreNovelActions & + NovelStoreChapterActions; + +export interface NovelStoreState extends NovelStoreData { + actions: NovelStoreActions; +} + +export type NovelStoreApi = StoreApi; + +export interface NovelStoreDependencies { + bootstrapService: ReturnType; + chapterActionsDependencies: ChapterActionsDependencies; + transformChapters: (chs: ChapterInfo[]) => ChapterInfo[]; + persistPageIndex?: (value: number) => void; + persistNovelSettings?: (value: NovelSettings) => void; + persistLastRead?: (value: ChapterInfo) => void; + switchNovelToLibrary?: (novelPath: string, pluginId: string) => Promise; +} + +export type SetState = { + ( + partial: + | NovelStoreState + | Partial + | (( + state: NovelStoreState, + ) => NovelStoreState | Partial), + replace?: false, + ): void; + ( + state: NovelStoreState | ((state: NovelStoreState) => NovelStoreState), + replace: true, + ): void; +}; + +export type GetState = () => NovelStoreState; diff --git a/src/hooks/persisted/useNovel/types.ts b/src/hooks/persisted/useNovel/types.ts new file mode 100644 index 0000000000..48815857db --- /dev/null +++ b/src/hooks/persisted/useNovel/types.ts @@ -0,0 +1,27 @@ +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; + +export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX'; +export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS'; +export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; + +export const defaultNovelSettings: NovelSettingsWithoutSort = { + showChapterTitles: true, + filter: [], +}; + +export const defaultPageIndex = 0; + +export interface NovelSettingsWithoutSort { + filter: ChapterFilterKey[]; + showChapterTitles: boolean; + sort?: ChapterOrderKey; +} +export interface NovelSettings extends NovelSettingsWithoutSort { + sort: ChapterOrderKey; +} + +export interface BatchInfo { + batch: number; + total: number; + totalChapters?: number; +} diff --git a/src/hooks/persisted/useNovel/useChapterOperations.ts b/src/hooks/persisted/useNovel/useChapterOperations.ts new file mode 100644 index 0000000000..63d913c72d --- /dev/null +++ b/src/hooks/persisted/useNovel/useChapterOperations.ts @@ -0,0 +1,172 @@ +import { useCallback } from 'react'; +import { ChapterFilterKey, ChapterOrderKey } from '@database/constants'; +import { + bookmarkChaptersAction, + deleteChapterAction, + deleteChaptersAction, + markChapterReadAction, + markChaptersReadAction, + markChaptersUnreadAction, + markPreviouschaptersReadAction, + markPreviousChaptersUnreadAction, + refreshChaptersAction, + updateChapterProgressAction, +} from './store/chapterActions'; +import { ChapterInfo, NovelInfo } from '@database/types'; + +export interface UseChapterOperationsParams { + novel: NovelInfo | undefined; + chapters: ChapterInfo[]; + _setChapters: React.Dispatch>; + transformChapters: (chs: ChapterInfo[]) => ChapterInfo[]; + settingsSort: ChapterOrderKey; + settingsFilter: ChapterFilterKey[]; + currentPage: string; + fetching: boolean; +} + +export const useChapterOperations = ({ + novel, + _setChapters, + transformChapters, + settingsSort, + settingsFilter, + currentPage, + fetching, +}: UseChapterOperationsParams) => { + const mutateChapters = useCallback( + (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => { + if (novel) { + _setChapters(mutation); + } + }, + [novel, _setChapters], + ); + + const updateChapter = useCallback( + (index: number, update: Partial) => { + if (novel) { + _setChapters(chs => { + const next = [...chs]; + next[index] = { ...next[index], ...update }; + return next; + }); + } + }, + [novel, _setChapters], + ); + + const transformAndSetChapters = useCallback( + async (chs: ChapterInfo[]) => { + _setChapters(transformChapters(chs)); + }, + [transformChapters, _setChapters], + ); + + const extendChapters = useCallback( + async (chs: ChapterInfo[]) => { + _setChapters(prev => prev.concat(transformChapters(chs))); + }, + [transformChapters, _setChapters], + ); + + const bookmarkChapters = useCallback( + (_chapters: ChapterInfo[]) => { + bookmarkChaptersAction(_chapters, mutateChapters); + }, + [mutateChapters], + ); + + const markPreviouschaptersRead = useCallback( + (chapterId: number) => { + markPreviouschaptersReadAction(chapterId, novel, mutateChapters); + }, + [mutateChapters, novel], + ); + + const markChapterRead = useCallback( + (chapterId: number) => { + markChapterReadAction(chapterId, mutateChapters); + }, + [mutateChapters], + ); + + const updateChapterProgress = useCallback( + (chapterId: number, progress: number) => { + updateChapterProgressAction(chapterId, progress, mutateChapters); + }, + [mutateChapters], + ); + + const markChaptersRead = useCallback( + (_chapters: ChapterInfo[]) => { + markChaptersReadAction(_chapters, mutateChapters); + }, + [mutateChapters], + ); + + const markPreviousChaptersUnread = useCallback( + (chapterId: number) => { + markPreviousChaptersUnreadAction(chapterId, novel, mutateChapters); + }, + [mutateChapters, novel], + ); + + const markChaptersUnread = useCallback( + (_chapters: ChapterInfo[]) => { + markChaptersUnreadAction(_chapters, mutateChapters); + }, + [mutateChapters], + ); + + const deleteChapter = useCallback( + (_chapter: ChapterInfo) => { + deleteChapterAction(_chapter, novel, mutateChapters); + }, + [mutateChapters, novel], + ); + + const deleteChapters = useCallback( + (_chapters: ChapterInfo[]) => { + deleteChaptersAction(_chapters, novel, mutateChapters); + }, + [novel, mutateChapters], + ); + + const refreshChapters = useCallback(() => { + refreshChaptersAction({ + novel, + fetching, + settingsSort, + settingsFilter, + currentPage, + transformChapters, + setChapters: _setChapters, + }); + }, [ + novel, + fetching, + settingsSort, + settingsFilter, + currentPage, + transformChapters, + _setChapters, + ]); + + return { + mutateChapters, + updateChapter, + setChapters: transformAndSetChapters, + extendChapters, + bookmarkChapters, + markPreviouschaptersRead, + markChapterRead, + markChaptersRead, + markPreviousChaptersUnread, + markChaptersUnread, + updateChapterProgress, + deleteChapter, + deleteChapters, + refreshChapters, + }; +}; diff --git a/src/hooks/persisted/useNovelSettings.ts b/src/hooks/persisted/useNovelSettings.ts index d3847f2425..0eee5d6a4b 100644 --- a/src/hooks/persisted/useNovelSettings.ts +++ b/src/hooks/persisted/useNovelSettings.ts @@ -1,89 +1,65 @@ - -import { useMMKVObject } from 'react-native-mmkv'; import { ChapterFilterKey, ChapterFilterPositiveKey, ChapterOrderKey, } from '@database/constants'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useAppSettings } from './useSettings'; import { ChapterFilterObject, FilterStates } from '@database/utils/filter'; -import { useNovelContext } from '@screens/novel/NovelContext'; - -// #region constants - -export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX'; -export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS'; -export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; - -const defaultNovelSettings: NovelSettings = { - showChapterTitles: true, - filter: [], -}; - -// #endregion -// #region types - -export interface NovelSettings { - sort?: ChapterOrderKey; - filter: ChapterFilterKey[]; - showChapterTitles?: boolean; -} +import { + defaultNovelSettings, + NOVEL_PAGE_INDEX_PREFIX, + NOVEL_SETTINGS_PREFIX, +} from './useNovel/types'; +import { useNovelAction, useNovelValue } from '@screens/novel/NovelContext'; -// #endregion -// #region definition useNovel +export { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX }; export const useNovelSettings = () => { - const { novel } = useNovelContext(); const { defaultChapterSort } = useAppSettings(); + const novel = useNovelValue('novel'); + const domainNovelSettings = useNovelValue('novelSettings'); + const writeNovelSettings = useNovelAction('setNovelSettings'); - const [ns, setNovelSettings] = useMMKVObject( - `${NOVEL_SETTINGS_PREFIX}_${novel?.pluginId}_${novel?.path}`, - ); const novelSettings = useMemo( - () => ({ ...defaultNovelSettings, ...ns }), - [ns], + () => ({ ...defaultNovelSettings, ...domainNovelSettings }), + [domainNovelSettings], ); const _sort: ChapterOrderKey = novelSettings.sort ?? defaultChapterSort; const _filter: ChapterFilterKey[] = novelSettings.filter; - const filterManager = useRef(null); // #endregion // #region setters const setChapterSort = useCallback( - async (sort?: ChapterOrderKey) => { + async (sort: ChapterOrderKey) => { if (novel) { - setNovelSettings({ + writeNovelSettings({ showChapterTitles: novelSettings?.showChapterTitles, sort, filter: _filter, }); } }, - [novel, setNovelSettings, novelSettings?.showChapterTitles, _filter], + [novel, writeNovelSettings, novelSettings?.showChapterTitles, _filter], ); const setChapterFilter = useCallback( async (filter?: ChapterFilterKey[]) => { if (novel) { - setNovelSettings({ + writeNovelSettings({ showChapterTitles: novelSettings?.showChapterTitles, sort: _sort, filter: filter ?? [], }); } }, - [novel, setNovelSettings, novelSettings?.showChapterTitles, _sort], + [novel, writeNovelSettings, novelSettings?.showChapterTitles, _sort], + ); + + const filterManager = useRef( + new ChapterFilterObject(_filter, setChapterFilter), ); - useEffect(() => { - if (!filterManager.current) { - filterManager.current = new ChapterFilterObject( - _filter, - setChapterFilter, - ); - } - }, [_filter, setChapterFilter]); const cycleChapterFilter = useCallback( (key: ChapterFilterPositiveKey) => { @@ -114,9 +90,9 @@ export const useNovelSettings = () => { const setShowChapterTitles = useCallback( (v: boolean) => { - setNovelSettings({ ...novelSettings, showChapterTitles: v }); + writeNovelSettings({ ...novelSettings, showChapterTitles: v }); }, - [novelSettings, setNovelSettings], + [novelSettings, writeNovelSettings], ); // #endregion diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index 13e7d07078..7fcc0bc446 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -95,7 +95,7 @@ export const useTheme = (): ThemeColors => { const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR'); const [systemColorScheme, setSystemColorScheme] = useState( - Appearance.getColorScheme(), + Appearance.getColorScheme() ?? 'light', ); useEffect(() => { diff --git a/src/screens/novel/NovelContext.tsx b/src/screens/novel/NovelContext.tsx index 2db08342a8..aca7b0c619 100644 --- a/src/screens/novel/NovelContext.tsx +++ b/src/screens/novel/NovelContext.tsx @@ -1,73 +1,143 @@ import React, { createContext, useContext, useMemo, useRef } from 'react'; -import { useNovel } from '@hooks/persisted'; import { RouteProp } from '@react-navigation/native'; +import { useStore } from 'zustand'; import { ReaderStackParamList } from '@navigators/types'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDeviceOrientation } from '@hooks/index'; +import { useLibraryContext } from '@components/Context/LibraryContext'; +import { useAppSettings } from '@hooks/persisted'; +import { + NovelStoreActions, + NovelStoreApi, + NovelStoreData, + NovelStoreState, +} from '@hooks/persisted/useNovel/store/novelStore.types'; import { NovelInfo } from '@database/types'; +import { createStore } from '@hooks/persisted/useNovel/store/createStore'; -type NovelContextType = ReturnType & { +type Props = { + children: React.ReactNode; + route: + | RouteProp + | RouteProp; +}; + +type NovelLayout = { navigationBarHeight: number; statusBarHeight: number; - chapterTextCache: Map>; }; -const defaultValue = {} as NovelContextType; +const NovelStoreContext = createContext(null); +const NovelLayoutContext = createContext(null); -const NovelContext = createContext(defaultValue); +export function NovelContextProvider({ children, route }: Props) { + const initialNovel = + 'id' in route.params ? (route.params as NovelInfo) : undefined; -export function NovelContextProvider({ - children, - - route, -}: { - children: React.JSX.Element; - - route: - | RouteProp - | RouteProp; -}) { const { path, pluginId } = 'novel' in route.params ? route.params.novel : route.params; + const storeKey = `${pluginId}:${path}`; - const novelHookContent = useNovel( - 'id' in route.params ? (route.params as NovelInfo) : path, - pluginId, - ); + const { switchNovelToLibrary } = useLibraryContext(); + const { defaultChapterSort } = useAppSettings(); + + const switchNovelToLibraryRef = useRef(switchNovelToLibrary); + + const storeRef = useRef<{ + key: string; + store: NovelStoreApi; + } | null>(null); + const queriedNovelRef = useRef(false); + + if (storeRef.current?.key !== storeKey) { + queriedNovelRef.current = false; + + storeRef.current = { + key: storeKey, + store: createStore({ + path, + pluginId, + novel: initialNovel, + defaultChapterSort, + switchNovelToLibrary: switchNovelToLibraryRef.current, + }), + }; + } + const novelStore = storeRef.current.store; const { bottom, top } = useSafeAreaInsets(); const orientation = useDeviceOrientation(); - const NavigationBarHeight = useRef(bottom); - const StatusBarHeight = useRef(top); - const chapterTextCache = useRef>>( - new Map(), - ); - if (bottom < NavigationBarHeight.current && orientation === 'landscape') { - NavigationBarHeight.current = bottom; - } else if (bottom > NavigationBarHeight.current) { - NavigationBarHeight.current = bottom; + const navigationBarHeightRef = useRef(bottom); + const statusBarHeightRef = useRef(top); + + if (bottom < navigationBarHeightRef.current && orientation === 'landscape') { + navigationBarHeightRef.current = bottom; + } else if (bottom > navigationBarHeightRef.current) { + navigationBarHeightRef.current = bottom; } - if (top > StatusBarHeight.current) { - StatusBarHeight.current = top; + + if (top > statusBarHeightRef.current) { + statusBarHeightRef.current = top; } - const contextValue = useMemo( + + const layoutValue = useMemo( () => ({ - ...novelHookContent, - navigationBarHeight: NavigationBarHeight.current, - statusBarHeight: StatusBarHeight.current, - chapterTextCache: chapterTextCache.current, + navigationBarHeight: navigationBarHeightRef.current, + statusBarHeight: statusBarHeightRef.current, }), - [novelHookContent], + [], ); return ( - - {children} - + + + {children} + + ); } -export const useNovelContext = () => { - const context = useContext(NovelContext); +function useNovelStoreApi() { + const store = useContext(NovelStoreContext); + + if (!store) { + throw new Error('useNovelStore must be used inside NovelContextProvider'); + } + + return store; +} + +export function useNovelStore(selector: (state: NovelStoreState) => T): T { + const store = useNovelStoreApi(); + return useStore(store, selector); +} + +export function useNovelState(selector: (state: NovelStoreData) => T): T { + return useNovelStore(state => selector(state)); +} + +export function useNovelValue( + key: K, +): NovelStoreData[K] { + return useNovelStore(state => state[key]); +} + +export function useNovelActions(): NovelStoreActions { + return useNovelStore(state => state.actions); +} + +export function useNovelAction( + key: K, +): NovelStoreActions[K] { + return useNovelStore(state => state.actions[key]); +} + +export function useNovelLayout() { + const context = useContext(NovelLayoutContext); + + if (!context) { + throw new Error('useNovelLayout must be used inside NovelContextProvider'); + } + return context; -}; +} diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index c04f037f6d..7de49587d6 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -18,7 +18,7 @@ import NovelScreenLoading from './components/LoadingAnimation/NovelScreenLoading import { NovelScreenProps } from '@navigators/types'; import { ChapterInfo } from '@database/types'; import { getString } from '@strings/translations'; -import { isNumber, noop } from 'lodash-es'; +import { isNumber } from 'lodash-es'; import NovelAppbar from './components/NovelAppbar'; import { resolveUrl } from '@services/plugin/fetch'; import { @@ -30,17 +30,13 @@ import { MaterialDesignIconName } from '@type/icon'; import NovelScreenList from './components/NovelScreenList'; import { ThemeColors } from '@theme/types'; import { SafeAreaView } from '@components'; -import { useNovelContext } from './NovelContext'; +import { useNovelActions, useNovelValue } from './NovelContext'; import { LegendListRef } from '@legendapp/list'; const Novel = ({ route, navigation }: NovelScreenProps) => { + const novel = useNovelValue('novel'); + const chapters = useNovelValue('chapters'); const { - novel, - chapters, - fetching, - batchInformation, - getNextChapterBatch, - loadUpToBatch, setNovel, bookmarkChapters, markChaptersRead, @@ -49,7 +45,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { markPreviousChaptersUnread, refreshChapters, deleteChapters, - } = useNovelContext(); + } = useNovelActions(); const theme = useTheme(); const { downloadChapters } = useDownload(); @@ -224,29 +220,15 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { } }, [novel, setNovel]); - const stableGetNextBatch = useMemo( - () => - batchInformation.batch < batchInformation.total && !fetching - ? getNextChapterBatch - : noop, - [batchInformation.batch, batchInformation.total, fetching, getNextChapterBatch], - ); - const hideJumpToChapterModal = useCallback( () => showJumpToChapterModal(false), [], ); - const hideEditInfoModal = useCallback( - () => showEditInfoModal(false), - [], - ); + const hideEditInfoModal = useCallback(() => showEditInfoModal(false), []); const clearSelection = useCallback(() => setSelected([]), []); const selectAll = useCallback(() => setSelected(chapters), [chapters]); - const snackbarTheme = useMemo( - () => ({ colors: { primary: theme.primary } }), - [theme.primary], - ); + const snackbarTheme = useMemo(() => ({ colors: theme }), [theme]); const snackbarTextStyle = useMemo( () => ({ color: theme.onSurface }), [theme.onSurface], @@ -322,7 +304,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { routeBaseNovel={route.params} selected={selected} setSelected={setSelected} - getNextChapterBatch={stableGetNextBatch} + deleteDownloadSnackbar={deleteDownloadsSnackbar} /> @@ -350,9 +332,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { novel={novel} chapterListRef={chapterListRef} navigation={navigation} - loadUpToBatch={loadUpToBatch} - totalChapters={batchInformation.totalChapters} - chapters={chapters} /> ({ + useTheme: () => ({ + primary: '#111', + onSurface: '#222', + background: '#333', + onBackground: '#444', + surface: '#555', + surface2: '#666', + }), + useDownload: () => ({ + downloadChapters: mockDownloadChapters, + }), +})); + +jest.mock('@hooks', () => ({ + useBoolean: () => ({ + value: false, + setTrue: jest.fn(), + setFalse: jest.fn(), + }), +})); + +jest.mock('../NovelContext', () => ({ + useNovelValue: (key: string) => mockUseNovelValue(key), + useNovelActions: () => mockUseNovelActions(), +})); + +jest.mock('@services/plugin/fetch', () => ({ + resolveUrl: jest.fn(() => 'https://example.com'), +})); + +jest.mock('@strings/translations', () => ({ + getString: (key: string) => key, +})); + +jest.mock('@database/queries/ChapterQueries', () => ({ + getAllUndownloadedAndUnreadChapters: jest.fn().mockResolvedValue([]), + getAllUndownloadedChapters: jest.fn().mockResolvedValue([]), + updateChapterProgressByIds: (...args: unknown[]) => + mockUpdateChapterProgressByIds(...args), +})); + +jest.mock('../../../database/queries/NovelQueries', () => ({ + pickCustomNovelCover: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../components/NovelAppbar', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => React.createElement(Text, { testID: 'novel-appbar' }, 'appbar'); +}); + +jest.mock('../components/NovelScreenList', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return { + __esModule: true, + default: ({ setSelected }: any) => { + const base = { + id: 1, + novelId: 7, + path: '/chapter/1', + releaseTime: '2026-01-01', + updatedTime: '2026-01-01', + readTime: '2026-01-01', + chapterNumber: 1, + bookmark: false, + progress: 0, + page: '1', + name: 'Chapter 1', + }; + + return React.createElement( + View, + null, + React.createElement( + Pressable, + { + testID: 'select-unread', + onPress: () => + setSelected([ + { ...base, id: 10, unread: true, isDownloaded: false }, + ]), + }, + React.createElement(Text, null, 'select-unread'), + ), + React.createElement( + Pressable, + { + testID: 'select-read', + onPress: () => + setSelected([ + { ...base, id: 11, unread: false, isDownloaded: false }, + ]), + }, + React.createElement(Text, null, 'select-read'), + ), + React.createElement( + Pressable, + { + testID: 'select-undownloaded', + onPress: () => + setSelected([ + { ...base, id: 12, unread: true, isDownloaded: false }, + ]), + }, + React.createElement(Text, null, 'select-undownloaded'), + ), + ); + }, + }; +}); + +jest.mock('../../../components/Actionbar/Actionbar', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return { + Actionbar: ({ active, actions }: any) => { + if (!active) return null; + + return React.createElement( + View, + { testID: 'actionbar' }, + ...actions.map((action: any) => + React.createElement( + Pressable, + { + key: action.icon, + testID: `action-${action.icon}`, + onPress: action.onPress, + }, + React.createElement(Text, null, action.icon), + ), + ), + ); + }, + }; +}); + +jest.mock('@components', () => { + const React = require('react'); + return { + SafeAreaView: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +jest.mock('react-native-paper', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + + const Portal: any = ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children); + Portal.Host = ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children); + + return { + Portal, + Appbar: { + Action: ({ icon, onPress }: any) => + React.createElement( + Pressable, + { testID: `appbar-action-${icon}`, onPress }, + React.createElement(Text, null, icon), + ), + Content: ({ title }: any) => React.createElement(Text, null, title), + }, + Snackbar: ({ visible, children }: any) => + visible ? React.createElement(React.Fragment, null, children) : null, + }; +}); + +jest.mock('../components/JumpToChapterModal', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement(Text, { testID: 'jump-to-chapter-modal' }, 'jump'); +}); + +jest.mock('../components/EditInfoModal', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => React.createElement(Text, { testID: 'edit-info-modal' }, 'edit'); +}); + +jest.mock('../components/DownloadCustomChapterModal', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement( + Text, + { testID: 'download-custom-modal' }, + 'download-custom', + ); +}); + +jest.mock('../components/LoadingAnimation/NovelScreenLoading', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement(Text, { testID: 'novel-screen-loading' }, 'loading'); +}); + +const baseNovel = { + id: 7, + path: '/novels/test', + pluginId: 'plugin.test', + name: 'Test Novel', + inLibrary: false, + totalPages: 1, + isLocal: false, +}; + +const createStore = (overrides: Record = {}) => { + const state = { + novel: baseNovel, + chapters: [], + fetching: false, + batchInformation: { batch: 0, total: 0, totalChapters: 0 }, + getNextChapterBatch: jest.fn(), + loadUpToBatch: jest.fn(), + setNovel: jest.fn(), + bookmarkChapters: jest.fn(), + markChaptersRead: jest.fn(), + markChaptersUnread: jest.fn(), + markPreviouschaptersRead: jest.fn(), + markPreviousChaptersUnread: jest.fn(), + refreshChapters: jest.fn(), + deleteChapters: jest.fn(), + ...overrides, + }; + + return { + getState: () => state, + subscribe: jest.fn(() => () => {}), + state, + }; +}; + +const wireStoreSelectors = (store: ReturnType) => { + mockUseNovelValue.mockImplementation( + (key: keyof typeof store.state) => store.state[key], + ); + mockUseNovelActions.mockReturnValue({ + setNovel: store.state.setNovel, + bookmarkChapters: store.state.bookmarkChapters, + markChaptersRead: store.state.markChaptersRead, + markChaptersUnread: store.state.markChaptersUnread, + markPreviouschaptersRead: store.state.markPreviouschaptersRead, + markPreviousChaptersUnread: store.state.markPreviousChaptersUnread, + refreshChapters: store.state.refreshChapters, + deleteChapters: store.state.deleteChapters, + }); +}; + +const route = { + params: { + name: 'Route Novel', + path: '/novels/test', + pluginId: 'plugin.test', + isLocal: false, + }, +}; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), +}; + +describe('NovelScreen (task 12 context boundary cutover)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses novelStore action selectors for selected unread workflow', () => { + const store = createStore(); + wireStoreSelectors(store); + + render( + // @ts-expect-error narrowed test props + , + ); + + fireEvent.press(screen.getByTestId('select-unread')); + fireEvent.press(screen.getByTestId('action-check')); + + expect(store.state.markChaptersRead).toHaveBeenCalledTimes(1); + }); + + it('preserves selected-read workflow parity (mark unread + reset progress + refresh)', () => { + const store = createStore(); + wireStoreSelectors(store); + + render( + // @ts-expect-error narrowed test props + , + ); + + fireEvent.press(screen.getByTestId('select-read')); + fireEvent.press(screen.getByTestId('action-check-outline')); + + expect(store.state.markChaptersUnread).toHaveBeenCalledTimes(1); + expect(mockUpdateChapterProgressByIds).toHaveBeenCalledWith([11], 0); + expect(store.state.refreshChapters).toHaveBeenCalledTimes(1); + }); + + it('keeps undefined-novel safety path for download action and guarded modals', () => { + const store = createStore({ novel: undefined }); + wireStoreSelectors(store); + + render( + // @ts-expect-error narrowed test props + , + ); + + expect(screen.queryByTestId('jump-to-chapter-modal')).toBeNull(); + expect(screen.queryByTestId('edit-info-modal')).toBeNull(); + expect(screen.queryByTestId('download-custom-modal')).toBeNull(); + + fireEvent.press(screen.getByTestId('select-undownloaded')); + fireEvent.press(screen.getByTestId('action-download-outline')); + + expect(mockDownloadChapters).not.toHaveBeenCalled(); + }); +}); diff --git a/src/screens/novel/components/ChapterItem.tsx b/src/screens/novel/components/ChapterItem.tsx index 9ab5fb225c..1ca9eabc2a 100644 --- a/src/screens/novel/components/ChapterItem.tsx +++ b/src/screens/novel/components/ChapterItem.tsx @@ -8,6 +8,7 @@ import { ThemeColors } from '@theme/types'; import { ChapterInfo } from '@database/types'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; import { getString } from '@strings/translations'; +import dayjs from 'dayjs'; interface ChapterItemProps { chapter: ChapterInfo; @@ -97,6 +98,12 @@ const ChapterItem: React.FC = ({ color: theme.outline, marginStart: chapter.releaseTime ? 5 : 0, } as const; + function parseTime(time?: string | Date | null) { + if (!time) return undefined; + const parsedTime = dayjs(time); + return parsedTime.isValid() ? parsedTime.format('LL') : (time as string); + } + const parsedTime = parseTime(releaseTime); return ( @@ -148,12 +155,12 @@ const ChapterItem: React.FC = ({ - {releaseTime && !isUpdateCard ? ( + {parsedTime && !isUpdateCard ? ( - {releaseTime} + {parsedTime} ) : null} {!isUpdateCard && progress && progress > 0 && chapter.unread ? ( diff --git a/src/screens/novel/components/Info/NovelInfoHeader.tsx b/src/screens/novel/components/Info/NovelInfoHeader.tsx index 491e732562..df3f01b518 100644 --- a/src/screens/novel/components/Info/NovelInfoHeader.tsx +++ b/src/screens/novel/components/Info/NovelInfoHeader.tsx @@ -37,7 +37,6 @@ import { NovelMetaSkeleton, VerticalBarSkeleton, } from '@components/Skeleton/Skeleton'; -import { useNovelContext } from '@screens/novel/NovelContext'; import Animated, { useAnimatedProps, useSharedValue, @@ -50,10 +49,11 @@ import useLoadingColors from '@components/Skeleton/useLoadingColors'; const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); import { ChapterFilterKey } from '@database/constants'; +import { useNovelAction } from '@screens/novel/NovelContext'; interface NovelInfoHeaderProps { chapters: ChapterInfo[]; - deleteDownloadsSnackbar: UseBooleanReturnType; + deleteDownloadSnackbar?: UseBooleanReturnType; fetching: boolean; filter?: ChapterFilterKey[]; firstUnreadChapter?: ChapterInfo; @@ -234,7 +234,7 @@ const showNotAvailable = async () => { const NovelInfoHeader = ({ chapters, - deleteDownloadsSnackbar, + deleteDownloadSnackbar, fetching, filter = [], firstUnreadChapter, @@ -251,7 +251,7 @@ const NovelInfoHeader = ({ trackerSheetRef, }: NovelInfoHeaderProps) => { const { hideBackdrop = false } = useAppSettings(); - const { followNovel } = useNovelContext(); + const followNovel = useNovelAction('followNovel'); const pluginName = useMemo( () => @@ -261,7 +261,15 @@ const NovelInfoHeader = ({ [novel.pluginId], ); - const coverSource = useMemo(() => ({ uri: novel.cover }), [novel.cover]); + const coverSource = useMemo( + () => ({ uri: novel.cover ?? undefined }), + [novel.cover], + ); + + const novelStatus = useMemo( + () => (novel.id !== 'NO_ID' ? novel.status ?? undefined : undefined), + [novel.id, novel.status], + ); const handleTitlePress = useCallback( () => @@ -282,16 +290,20 @@ const NovelInfoHeader = ({ showNotAvailable(); return; } - followNovel(); + followNovel().catch(error => + showToast('Failed updating: ' + (error as Error).message), + ); if (novel.inLibrary && chapters.some(chapter => chapter.isDownloaded)) { - deleteDownloadsSnackbar.setTrue(); + deleteDownloadSnackbar?.setTrue(); + } else { + deleteDownloadSnackbar?.setFalse(); } }, [ isLoading, followNovel, novel.inLibrary, chapters, - deleteDownloadsSnackbar, + deleteDownloadSnackbar, ]); const handleTrackerSheet = useCallback( @@ -363,16 +375,14 @@ const NovelInfoHeader = ({ ) : null} - {(novel.id !== 'NO_ID' - ? translateNovelStatus(novel.status) + {(novelStatus + ? translateNovelStatus(novelStatus) : getString('novelScreen.unknownStatus')) + ' • ' + pluginName} diff --git a/src/screens/novel/components/JumpToChapterModal.tsx b/src/screens/novel/components/JumpToChapterModal.tsx index 0416ed255d..bc3d7d4a21 100644 --- a/src/screens/novel/components/JumpToChapterModal.tsx +++ b/src/screens/novel/components/JumpToChapterModal.tsx @@ -21,30 +21,30 @@ import { LegendListRef, LegendListRenderItemProps, } from '@legendapp/list'; +import { useNovelAction, useNovelValue } from '../NovelContext'; interface JumpToChapterModalProps { hideModal: () => void; modalVisible: boolean; navigation: NovelScreenProps['navigation']; novel: NovelInfo; - chapters: ChapterInfo[]; chapterListRef: React.RefObject; - loadUpToBatch: (batch: number) => Promise; - totalChapters?: number; } const JumpToChapterModal = ({ hideModal, modalVisible, - chapters: loadedChapters, navigation, novel, chapterListRef, - loadUpToBatch, - totalChapters, }: JumpToChapterModalProps) => { const minNumber = 1; - const maxNumber = totalChapters ?? -1; + + const loadedChapters = useNovelValue('chapters'); + const batchInformation = useNovelValue('batchInformation'); + const loadUpToBatch = useNovelAction('loadUpToBatch'); + + const maxNumber = batchInformation.totalChapters ?? -1; const theme = useTheme(); const [mode, setMode] = useState(false); const [openChapter, setOpenChapter] = useState(false); diff --git a/src/screens/novel/components/NovelBottomSheet.tsx b/src/screens/novel/components/NovelBottomSheet.tsx index d80222ed56..f089f1eeb3 100644 --- a/src/screens/novel/components/NovelBottomSheet.tsx +++ b/src/screens/novel/components/NovelBottomSheet.tsx @@ -28,12 +28,20 @@ const ChaptersSettingsSheet = ({ setChapterSort, getChapterFilterState, cycleChapterFilter, + setChapterFilterValue, setShowChapterTitles, sort, showChapterTitles, } = useNovelSettings(); const { left, right } = useSafeAreaInsets(); + const readStatus = getChapterFilterState('read'); + const unreadStatus = + readStatus === 'indeterminate' + ? true + : readStatus + ? 'indeterminate' + : false; const FirstRoute = useCallback( () => ( @@ -49,9 +57,18 @@ const ChaptersSettingsSheet = ({ { - cycleChapterFilter('read'); + switch (readStatus) { + case 'indeterminate': + setChapterFilterValue('read', 'ON'); + break; + case true: + setChapterFilterValue('read', 'OFF'); + break; + default: + setChapterFilterValue('read', 'INDETERMINATE'); + } }} /> ), - [cycleChapterFilter, getChapterFilterState, theme], + [ + cycleChapterFilter, + getChapterFilterState, + readStatus, + setChapterFilterValue, + theme, + unreadStatus, + ], ); const SecondRoute = useCallback( diff --git a/src/screens/novel/components/NovelScreenList.tsx b/src/screens/novel/components/NovelScreenList.tsx index 7c89cea6fb..56f772b2a8 100644 --- a/src/screens/novel/components/NovelScreenList.tsx +++ b/src/screens/novel/components/NovelScreenList.tsx @@ -4,7 +4,6 @@ import NovelInfoHeader from './Info/NovelInfoHeader'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { pickCustomNovelCover } from '@database/queries/NovelQueries'; import { ChapterInfo, NovelInfo } from '@database/types'; -import { useBoolean } from '@hooks/index'; import { useAppSettings, useDownload, useTheme } from '@hooks/persisted'; import { updateNovel, @@ -29,12 +28,13 @@ import * as Haptics from 'expo-haptics'; import { AnimatedFAB } from 'react-native-paper'; import { ChapterListSkeleton } from '@components/Skeleton/Skeleton'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; -import { useNovelContext } from '../NovelContext'; import { LegendList, LegendListRef } from '@legendapp/list'; import FileManager from '@specs/NativeFile'; import { downloadFile } from '@plugins/helpers/fetch'; import { StorageAccessFramework } from 'expo-file-system/legacy'; import PagePaginationControl from './PagePaginationControl'; +import { useNovelActions, useNovelValue } from '../NovelContext'; +import { UseBooleanReturnType } from '@hooks/index'; type NovelScreenListProps = { headerOpacity: SharedValue; @@ -42,13 +42,13 @@ type NovelScreenListProps = { navigation: any; selected: ChapterInfo[]; setSelected: React.Dispatch>; - getNextChapterBatch: () => void; routeBaseNovel: { name: string; path: string; pluginId: string; cover?: string | null; }; + deleteDownloadSnackbar?: UseBooleanReturnType; }; const chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id; @@ -60,27 +60,26 @@ const NovelScreenList = ({ routeBaseNovel, selected, setSelected, - getNextChapterBatch, + deleteDownloadSnackbar, }: NovelScreenListProps) => { + const chapters = useNovelValue('chapters'); + const fetching = useNovelValue('fetching'); + const firstUnreadChapter = useNovelValue('firstUnreadChapter'); + const loading = useNovelValue('loading'); + const pages = useNovelValue('pages'); + const fetchedNovel = useNovelValue('novel'); + const batchInformation = useNovelValue('batchInformation'); + const novelSettings = useNovelValue('novelSettings'); + const pageIndex = useNovelValue('pageIndex'); + const lastRead = useNovelValue('lastRead'); const { - chapters, deleteChapter, - fetching, - firstUnreadChapter, - getNovel, - lastRead, - loading, - novelSettings, - pages, setNovel, - sortAndFilterChapters, - setShowChapterTitles, - novel: fetchedNovel, - batchInformation, - pageIndex, + getNextChapterBatch, openPage, updateChapter, - } = useNovelContext(); + refreshNovel, + } = useNovelActions(); const { pluginId } = routeBaseNovel; const routeNovel: Omit & { id: 'NO_ID' } = { @@ -94,17 +93,12 @@ const NovelScreenList = ({ const [updating, setUpdating] = useState(false); const { useFabForContinueReading, - defaultChapterSort, disableHapticFeedback, downloadNewChapters, refreshNovelMetadata, } = useAppSettings(); - const { - sort = defaultChapterSort, - filter, - showChapterTitles = false, - } = novelSettings; + const { filter, showChapterTitles = false } = novelSettings; const theme = useTheme(); const { top: topInset, bottom: bottomInset } = useSafeAreaInsets(); @@ -136,8 +130,6 @@ const NovelScreenList = ({ const trackerSheetRef = useRef(null); const pageNavigationSheetRef = useRef(null); - const deleteDownloadsSnackbar = useBoolean(); - // Derive selectedIds Set for O(1) lookups const selectedIds = useMemo( () => new Set(selected.map(s => s.id)), @@ -254,7 +246,7 @@ const NovelScreenList = ({ downloadNewChapters, refreshNovelMetadata, }) - .then(() => getNovel()) + .then(() => refreshNovel()) .then(() => showToast( getString('novelScreen.updatedToast', { name: novel.name }), @@ -263,7 +255,13 @@ const NovelScreenList = ({ .catch(error => showToast('Failed updating: ' + error.message)) .finally(() => setUpdating(false)); } - }, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]); + }, [ + novel, + pluginId, + downloadNewChapters, + refreshNovelMetadata, + refreshNovel, + ]); const onRefreshPage = useCallback( async (page: string) => { @@ -272,13 +270,13 @@ const NovelScreenList = ({ updateNovelPage(pluginId, novel.path, novel.path, novel.id, page, { downloadNewChapters, }) - .then(() => getNovel()) + .then(() => refreshNovel()) .then(() => showToast(`Updated page: ${page}`)) .catch(e => showToast('Failed updating: ' + e.message)) .finally(() => setUpdating(false)); } }, - [novel, pluginId, downloadNewChapters, getNovel], + [novel, pluginId, downloadNewChapters, refreshNovel], ); const refreshControlElement = useMemo( @@ -410,7 +408,7 @@ const NovelScreenList = ({ <> {novel.id !== 'NO_ID' ? ( <> {(novel.totalPages ?? 0) > 1 || pages.length > 1 ? ( diff --git a/src/screens/novel/components/PageNavigationBottomSheet.tsx b/src/screens/novel/components/PageNavigationBottomSheet.tsx index 9ca4b5d24b..c40e4f4274 100644 --- a/src/screens/novel/components/PageNavigationBottomSheet.tsx +++ b/src/screens/novel/components/PageNavigationBottomSheet.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { StyleSheet, View, Pressable, Text } from 'react-native'; -import { BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet'; +import { + BottomSheetView, + useBottomSheetScrollableCreator, +} from '@gorhom/bottom-sheet'; import { LegendList, LegendListRenderItemProps } from '@legendapp/list'; import color from 'color'; @@ -25,6 +28,7 @@ export default function PageNavigationBottomSheet({ pageIndex, openPage, }: PageNavigationBottomSheetProps) { + const BottomSheetLegendListScrollable = useBottomSheetScrollableCreator(); const insets = useSafeAreaInsets(); const { left, right } = insets; @@ -87,17 +91,16 @@ export default function PageNavigationBottomSheet({ }, ]} > - - `page_${index}_${item}`} - estimatedItemSize={56} - contentContainerStyle={styles.listContent} - /> - + `page_${index}_${item}`} + estimatedItemSize={56} + contentContainerStyle={styles.listContent} + renderScrollComponent={BottomSheetLegendListScrollable} + /> ); diff --git a/src/screens/novel/components/__tests__/NovelScreenList.test.tsx b/src/screens/novel/components/__tests__/NovelScreenList.test.tsx new file mode 100644 index 0000000000..260c9bb00b --- /dev/null +++ b/src/screens/novel/components/__tests__/NovelScreenList.test.tsx @@ -0,0 +1,349 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import NovelScreenList from '../NovelScreenList'; + +const mockUseNovelValue = jest.fn(); +const mockUseNovelActions = jest.fn(); +const mockDownloadChapter = jest.fn(); +let mockDownloadingChapterIds = new Set(); + +jest.mock('../../NovelContext', () => ({ + useNovelValue: (key: string) => mockUseNovelValue(key), + useNovelActions: () => mockUseNovelActions(), +})); + +jest.mock('@hooks/persisted', () => ({ + useAppSettings: () => ({ + useFabForContinueReading: true, + disableHapticFeedback: true, + downloadNewChapters: false, + refreshNovelMetadata: false, + }), + useDownload: () => ({ + downloadingChapterIds: mockDownloadingChapterIds, + downloadChapter: mockDownloadChapter, + }), + useTheme: () => ({ + primary: '#111', + onPrimary: '#fff', + surface2: '#222', + }), +})); + +jest.mock('@hooks/index', () => ({ + useBoolean: () => ({ + value: false, + setTrue: jest.fn(), + setFalse: jest.fn(), + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); + +jest.mock('@legendapp/list', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + LegendList: ({ data, renderItem, ListHeaderComponent }: any) => + React.createElement( + View, + null, + ListHeaderComponent, + ...(data || []).map((item: any, index: number) => + React.createElement( + React.Fragment, + { key: `row-${item.id ?? index}` }, + renderItem({ item, index }), + ), + ), + ), + }; +}); + +jest.mock('../ChapterItem', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return ({ chapter, onDeleteChapter }: any) => + React.createElement( + View, + { testID: `chapter-item-${chapter.id}` }, + React.createElement( + Pressable, + { + testID: `delete-chapter-${chapter.id}`, + onPress: () => onDeleteChapter(chapter), + }, + React.createElement(Text, null, 'delete'), + ), + ); +}); + +jest.mock('../Info/NovelInfoHeader', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement(Text, { testID: 'novel-info-header' }, 'hdr'); +}); + +jest.mock('../PagePaginationControl', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return ({ onPageChange }: any) => + React.createElement( + View, + null, + React.createElement( + Pressable, + { testID: 'pagination-change-page', onPress: () => onPageChange(1) }, + React.createElement(Text, null, 'change-page'), + ), + ); +}); + +jest.mock('../NovelBottomSheet', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement(Text, { testID: 'novel-bottom-sheet' }, 'nbs'); +}); + +jest.mock('../Tracker/TrackSheet', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => React.createElement(Text, { testID: 'track-sheet' }, 'track'); +}); + +jest.mock('../PageNavigationBottomSheet', () => { + const React = require('react'); + const { Text } = require('react-native'); + return () => + React.createElement(Text, { testID: 'page-navigation-sheet' }, 'navsheet'); +}); + +jest.mock('react-native-paper', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + + return { + AnimatedFAB: ({ onPress, label }: any) => + React.createElement( + Pressable, + { + testID: label ? 'continue-reading-fab' : 'scroll-to-top-fab', + onPress, + }, + React.createElement(Text, null, label || 'fab'), + ), + }; +}); + +jest.mock('@components/Skeleton/Skeleton', () => ({ + ChapterListSkeleton: () => null, +})); + +jest.mock('@database/queries/NovelQueries', () => ({ + pickCustomNovelCover: jest.fn(), +})); + +jest.mock('@services/updates/LibraryUpdateQueries', () => ({ + updateNovel: jest.fn(), + updateNovelPage: jest.fn(), +})); + +jest.mock('@strings/translations', () => ({ + getString: (key: string) => key, +})); + +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@specs/NativeFile', () => ({ + getConstants: () => ({ ExternalCachesDirectoryPath: '/tmp' }), + copyFile: jest.fn(), + unlink: jest.fn(), +})); + +jest.mock('@plugins/helpers/fetch', () => ({ + downloadFile: jest.fn(), +})); + +jest.mock('expo-file-system/legacy', () => ({ + StorageAccessFramework: { + requestDirectoryPermissionsAsync: jest.fn(), + createFileAsync: jest.fn(), + }, +})); + +jest.mock('expo-haptics', () => ({ + impactAsync: jest.fn(), + ImpactFeedbackStyle: { Medium: 'medium' }, +})); + +const baseChapter = { + id: 1, + novelId: 7, + path: '/chapter/1', + name: 'Chapter 1', + releaseTime: '2026-01-01', + updatedTime: '2026-01-01', + readTime: '2026-01-01', + chapterNumber: 1, + bookmark: false, + progress: 0, + page: '1', + unread: true, + isDownloaded: false, +}; + +const baseNovel = { + id: 7, + name: 'Test Novel', + path: '/novel/test', + pluginId: 'plugin.test', + cover: null, + inLibrary: false, + isLocal: false, + totalPages: 2, +}; + +const createStore = (overrides: Record = {}) => { + const state = { + chapters: [baseChapter], + deleteChapter: jest.fn(), + fetching: false, + firstUnreadChapter: { ...baseChapter, id: 2 }, + loading: false, + novelSettings: { filter: [], showChapterTitles: false }, + pages: ['1', '2'], + setNovel: jest.fn(), + novel: baseNovel, + batchInformation: { batch: 0, total: 1, totalChapters: 2 }, + pageIndex: 0, + openPage: jest.fn(), + updateChapter: jest.fn(), + refreshNovel: jest.fn(), + lastRead: undefined, + ...overrides, + }; + + return { + getState: () => state, + subscribe: jest.fn(() => () => {}), + state, + }; +}; + +const wireStoreSelectors = (store: ReturnType) => { + mockUseNovelValue.mockImplementation( + (key: keyof typeof store.state) => store.state[key], + ); + mockUseNovelActions.mockReturnValue({ + deleteChapter: store.state.deleteChapter, + setNovel: store.state.setNovel, + openPage: store.state.openPage, + updateChapter: store.state.updateChapter, + refreshNovel: store.state.refreshNovel, + }); +}; + +const navigation = { navigate: jest.fn() }; +const listRef = { current: { scrollToOffset: jest.fn() } }; +const headerOpacity = { set: jest.fn() }; + +const renderList = () => + render( + , + ); + +describe('NovelScreenList (task 12 context boundary cutover)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDownloadingChapterIds = new Set(); + }); + + it('uses novelStore selector actions', () => { + const store = createStore(); + wireStoreSelectors(store); + + renderList(); + + fireEvent.press(screen.getByTestId('delete-chapter-1')); + + expect(store.state.deleteChapter).toHaveBeenCalledTimes(1); + }); + + it('marks downloaded chapter when an id leaves downloading set', () => { + const store = createStore(); + + mockDownloadingChapterIds = new Set([1]); + wireStoreSelectors(store); + + const view = renderList(); + + mockDownloadingChapterIds = new Set(); + view.rerender( + , + ); + + expect(store.state.updateChapter).toHaveBeenCalledWith(0, { + isDownloaded: true, + }); + }); + + it('uses selector-backed page navigation action from novelStore', () => { + const store = createStore(); + wireStoreSelectors(store); + + renderList(); + + fireEvent.press(screen.getByTestId('pagination-change-page')); + + expect(store.state.openPage).toHaveBeenCalledWith(1); + }); + + it('keeps continue-reading FAB navigation parity with lastRead fallback chain', () => { + const lastRead = { ...baseChapter, id: 42 }; + const store = createStore({ + firstUnreadChapter: { ...baseChapter, id: 99 }, + lastRead, + }); + + wireStoreSelectors(store); + + renderList(); + + fireEvent.press(screen.getByTestId('continue-reading-fab')); + + expect(navigation.navigate).toHaveBeenCalledWith('ReaderStack', { + screen: 'Chapter', + params: { novel: baseNovel, chapter: lastRead }, + }); + }); +}); diff --git a/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx b/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx new file mode 100644 index 0000000000..dac574bb31 --- /dev/null +++ b/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx @@ -0,0 +1,160 @@ +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react-native'; +import ChapterDrawer from '..'; + +const mockUseNovelValue = jest.fn(); +const mockUseNovelActions = jest.fn(); +const mockUseChapterContext = jest.fn(); + +jest.mock('@screens/novel/NovelContext', () => ({ + useNovelValue: (key: string) => mockUseNovelValue(key), + useNovelActions: () => mockUseNovelActions(), +})); + +jest.mock('@screens/reader/ChapterContext', () => ({ + useChapterContext: () => mockUseChapterContext(), +})); + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => ({ + surface: '#111', + outline: '#222', + onSurface: '#333', + onSurfaceVariant: '#444', + }), + useAppSettings: () => ({ + defaultChapterSort: 'positionAsc', + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); + +jest.mock('@strings/translations', () => ({ + getString: (key: string) => key, +})); + +jest.mock('@components/index', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return { + Button: ({ title, onPress }: any) => + React.createElement( + Pressable, + { testID: `btn-${title}`, onPress }, + React.createElement(Text, null, title), + ), + LoadingScreenV2: () => React.createElement(View, { testID: 'loading' }), + }; +}); + +jest.mock('../RenderListChapter', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + + return ({ item, onPress }: any) => + React.createElement( + Pressable, + { testID: `chapter-${item.id}`, onPress }, + React.createElement(Text, null, item.name), + ); +}); + +jest.mock('@legendapp/list', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + + return { + LegendList: ({ data = [], renderItem, onEndReached }: any) => + React.createElement( + View, + null, + ...data.map((item: any, index: number) => + React.createElement( + React.Fragment, + { key: item.id ?? index }, + renderItem({ item, index }), + ), + ), + React.createElement( + Pressable, + { testID: 'legend-end-reached', onPress: () => onEndReached?.() }, + React.createElement(Text, null, 'end'), + ), + ), + }; +}); + +const makeChapter = (id: number, page = '1') => ({ + id, + novelId: 7, + name: `Chapter ${id}`, + path: `/chapter/${id}`, + page, + position: id, + unread: true, + isDownloaded: false, + bookmark: false, + progress: 0, + releaseTime: '2026-01-01', + updatedTime: '2026-01-01', + readTime: '2026-01-01', +}); + +const createStore = (overrides: Record = {}) => { + const state = { + chapters: [makeChapter(1, '1'), makeChapter(2, '2')], + novelSettings: { sort: 'positionAsc', filter: [] }, + pages: ['1', '2'], + fetching: false, + batchInformation: { batch: 0, total: 1, totalChapters: 2 }, + getNextChapterBatch: jest.fn(), + setPageIndex: jest.fn(), + openPage: jest.fn(), + ...overrides, + }; + + return { + getState: () => state, + subscribe: jest.fn(() => () => {}), + state, + }; +}; + +describe('ChapterDrawer (task 12 context boundary cutover)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses novelStore selector-backed page index and pagination batch actions', async () => { + const store = createStore(); + mockUseChapterContext.mockReturnValue({ + chapter: makeChapter(10, '2'), + getChapter: jest.fn(), + setLoading: jest.fn(), + }); + mockUseNovelValue.mockImplementation( + (key: keyof typeof store.state) => store.state[key], + ); + mockUseNovelActions.mockReturnValue({ + getNextChapterBatch: store.state.getNextChapterBatch, + openPage: store.state.openPage, + }); + + render(); + + await waitFor(() => { + expect(store.state.openPage).toHaveBeenCalledWith(1); + }); + + fireEvent.press(screen.getByTestId('legend-end-reached')); + + expect(store.state.getNextChapterBatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/screens/reader/components/ChapterDrawer/index.tsx b/src/screens/reader/components/ChapterDrawer/index.tsx index 7fd7e48b43..5969805b9a 100644 --- a/src/screens/reader/components/ChapterDrawer/index.tsx +++ b/src/screens/reader/components/ChapterDrawer/index.tsx @@ -14,9 +14,9 @@ import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import renderListChapter from './RenderListChapter'; import { useChapterContext } from '@screens/reader/ChapterContext'; -import { useNovelContext } from '@screens/novel/NovelContext'; import { LegendList, LegendListRef, ViewToken } from '@legendapp/list'; import { noop } from 'lodash-es'; +import { useNovelActions, useNovelValue } from '@screens/novel/NovelContext'; type ButtonProperties = { text: string; @@ -30,15 +30,13 @@ type ButtonsProperties = { const ChapterDrawer = () => { const { chapter, getChapter, setLoading } = useChapterContext(); - const { - chapters, - novelSettings, - pages, - fetching, - batchInformation, - getNextChapterBatch, - setPageIndex, - } = useNovelContext(); + const chapters = useNovelValue('chapters'); + const novelSettings = useNovelValue('novelSettings'); + const pages = useNovelValue('pages'); + const fetching = useNovelValue('fetching'); + const batchInformation = useNovelValue('batchInformation'); + const { getNextChapterBatch, openPage } = useNovelActions(); + const theme = useTheme(); const insets = useSafeAreaInsets(); const { defaultChapterSort } = useAppSettings(); @@ -69,8 +67,8 @@ const ChapterDrawer = () => { if (pageIndex === -1) { pageIndex = 0; } - setPageIndex(pageIndex); - }, [chapter, pages, setPageIndex]); + openPage(pageIndex); + }, [chapter, pages, openPage]); const calculateScrollToIndex = useCallback(() => { if (chapters.length < 1) { diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx index 86a8ef2796..597f8a04e8 100644 --- a/src/screens/reader/components/ReaderAppbar.tsx +++ b/src/screens/reader/components/ReaderAppbar.tsx @@ -12,7 +12,7 @@ import Animated, { import { ThemeColors } from '@theme/types'; import { bookmarkChapter } from '@database/queries/ChapterQueries'; import { useChapterContext } from '../ChapterContext'; -import { useNovelContext } from '@screens/novel/NovelContext'; +import { useNovelLayout } from '@screens/novel/NovelContext'; interface ReaderAppbarProps { theme: ThemeColors; @@ -30,7 +30,7 @@ const ReaderAppbar = ({ setBookmarked, }: ReaderAppbarProps) => { const { chapter, novel } = useChapterContext(); - const { statusBarHeight } = useNovelContext(); + const { statusBarHeight } = useNovelLayout(); const entering = () => { 'worklet'; diff --git a/src/screens/reader/components/ReaderFooter.tsx b/src/screens/reader/components/ReaderFooter.tsx index 29bb1fc25b..0f810f1031 100644 --- a/src/screens/reader/components/ReaderFooter.tsx +++ b/src/screens/reader/components/ReaderFooter.tsx @@ -11,8 +11,8 @@ import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/typ import { ChapterScreenProps } from '@navigators/types'; import { useChapterContext } from '../ChapterContext'; import { SCREEN_HEIGHT } from '@gorhom/bottom-sheet'; -import { useNovelContext } from '@screens/novel/NovelContext'; import { useTheme } from '@hooks/persisted'; +import { useNovelLayout } from '@screens/novel/NovelContext'; interface ChapterFooterProps { readerSheetRef: React.RefObject; @@ -37,7 +37,7 @@ const ChapterFooter = ({ borderless: true, radius: 50, }; - const { navigationBarHeight } = useNovelContext(); + const { navigationBarHeight } = useNovelLayout(); const entering = () => { 'worklet'; diff --git a/src/screens/reader/hooks/__tests__/useChapter.test.ts b/src/screens/reader/hooks/__tests__/useChapter.test.ts new file mode 100644 index 0000000000..38927285db --- /dev/null +++ b/src/screens/reader/hooks/__tests__/useChapter.test.ts @@ -0,0 +1,294 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import useChapter from '../useChapter'; +import NativeFile from '@specs/NativeFile'; + +const mockUseNovelActions = jest.fn(); +const mockUseChapterGeneralSettings = jest.fn(); +const mockUseLibrarySettings = jest.fn(); +const mockUseTracker = jest.fn(); +const mockUseTrackedNovel = jest.fn(); +const mockUseFullscreenMode = jest.fn(); + +const mockGetDbChapter = jest.fn(); +const mockGetChapterCount = jest.fn(); +const mockGetNextChapter = jest.fn(); +const mockGetPrevChapter = jest.fn(); +const mockInsertChapters = jest.fn(); +const mockInsertHistory = jest.fn(); +const mockFetchChapter = jest.fn(); +const mockFetchPage = jest.fn(); +const mockSanitizeChapterText = jest.fn(); +const mockParseChapterNumber = jest.fn(); + +jest.mock('@screens/novel/NovelContext', () => ({ + useNovelActions: () => mockUseNovelActions(), +})); + +jest.mock('@hooks/persisted', () => ({ + useChapterGeneralSettings: () => mockUseChapterGeneralSettings(), + useLibrarySettings: () => mockUseLibrarySettings(), + useTracker: () => mockUseTracker(), + useTrackedNovel: (...args: unknown[]) => mockUseTrackedNovel(...args), +})); + +jest.mock('@hooks', () => ({ + useFullscreenMode: () => mockUseFullscreenMode(), +})); + +jest.mock('@database/queries/ChapterQueries', () => ({ + getChapter: (...args: unknown[]) => mockGetDbChapter(...args), + getChapterCount: (...args: unknown[]) => mockGetChapterCount(...args), + getNextChapter: (...args: unknown[]) => mockGetNextChapter(...args), + getPrevChapter: (...args: unknown[]) => mockGetPrevChapter(...args), + insertChapters: (...args: unknown[]) => mockInsertChapters(...args), +})); + +jest.mock('@database/queries/HistoryQueries', () => ({ + insertHistory: (...args: unknown[]) => mockInsertHistory(...args), +})); + +jest.mock('@services/plugin/fetch', () => ({ + fetchChapter: (...args: unknown[]) => mockFetchChapter(...args), + fetchPage: (...args: unknown[]) => mockFetchPage(...args), +})); + +jest.mock('../../utils/sanitizeChapterText', () => ({ + sanitizeChapterText: (...args: unknown[]) => mockSanitizeChapterText(...args), +})); + +jest.mock('@utils/parseChapterNumber', () => ({ + parseChapterNumber: (...args: unknown[]) => mockParseChapterNumber(...args), +})); + +jest.mock('expo-speech', () => ({ + stop: jest.fn(), +})); + +const makeChapter = (id: number, page = '1') => ({ + id, + novelId: 7, + name: `Chapter ${id}`, + path: `/chapter/${id}`, + page, + position: id, + unread: true, + isDownloaded: false, + bookmark: false, + progress: 0, + releaseTime: '2026-01-01', + updatedTime: '2026-01-01', + readTime: '2026-01-01', +}); + +const makeNovel = () => ({ + id: 7, + pluginId: 'plugin.reader', + path: '/novel/test', + name: 'Novel Test', + totalPages: 3, + inLibrary: true, +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + +const createStore = ( + cacheSeed: Record> = {}, +) => { + const cache = new Map>( + Object.entries(cacheSeed).map(([k, v]) => [Number(k), v]), + ); + const chapterTextCache = { + read: jest.fn((chapterId: number) => cache.get(chapterId)), + write: jest.fn((chapterId: number, value: string | Promise) => { + cache.set(chapterId, value); + }), + remove: jest.fn((chapterId: number) => { + cache.delete(chapterId); + }), + clear: jest.fn(() => cache.clear()), + }; + const state = { + markChapterRead: jest.fn(), + updateChapterProgress: jest.fn(), + chapterTextCache, + setLastRead: jest.fn(), + }; + + return { + getState: () => state, + subscribe: jest.fn(() => () => {}), + state, + chapterTextCache, + }; +}; + +describe('useChapter', () => { + const initialChapter = makeChapter(1, '1'); + const nextChapter = makeChapter(2, '1'); + const novel = makeNovel(); + + beforeEach(() => { + jest.clearAllMocks(); + (NativeFile.exists as jest.Mock).mockReturnValue(false); + (NativeFile.readFile as jest.Mock).mockReturnValue(''); + + mockUseChapterGeneralSettings.mockReturnValue({ + autoScroll: false, + autoScrollInterval: 1, + autoScrollOffset: 100, + useVolumeButtons: false, + volumeButtonsOffset: 100, + }); + mockUseLibrarySettings.mockReturnValue({ incognitoMode: false }); + mockUseTracker.mockReturnValue({ tracker: { id: 'tracker' } }); + mockUseTrackedNovel.mockReturnValue({ + trackedNovel: { progress: 1 }, + updateAllTrackedNovels: jest.fn(), + }); + mockUseFullscreenMode.mockReturnValue({ + setImmersiveMode: jest.fn(), + showStatusAndNavBar: jest.fn(), + }); + + mockGetDbChapter.mockResolvedValue(initialChapter); + mockGetChapterCount.mockResolvedValue(1); + mockGetNextChapter.mockResolvedValue(undefined); + mockGetPrevChapter.mockResolvedValue(undefined); + mockInsertChapters.mockResolvedValue(undefined); + mockInsertHistory.mockResolvedValue(undefined); + mockFetchChapter.mockResolvedValue('chapter body'); + mockFetchPage.mockResolvedValue({ chapters: [] }); + mockSanitizeChapterText.mockImplementation( + ( + _pluginId: string, + _novelName: string, + _chapterName: string, + text: string, + ) => `SANITIZED:${text}`, + ); + mockParseChapterNumber.mockReturnValue(5); + }); + + it('uses chapterTextCache on initial load and avoids duplicate fetch for cached chapter text', async () => { + const store = createStore({ [initialChapter.id]: 'cached chapter body' }); + mockUseNovelActions.mockReturnValue(store.state); + + const { result } = renderHook(() => + useChapter({ current: null }, initialChapter, novel), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(mockFetchChapter).not.toHaveBeenCalled(); + expect(result.current.chapterText).toBe('SANITIZED:cached chapter body'); + expect(store.chapterTextCache.write).not.toHaveBeenCalledWith( + initialChapter.id, + expect.anything(), + ); + }); + + it('updates chapter progress, caps at 100, and marks chapter read/tracker progress near completion', async () => { + const store = createStore(); + const updateAllTrackedNovels = jest.fn(); + mockUseTrackedNovel.mockReturnValue({ + trackedNovel: { progress: 2 }, + updateAllTrackedNovels, + }); + mockUseNovelActions.mockReturnValue(store.state); + + const { result } = renderHook(() => + useChapter({ current: null }, initialChapter, novel), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.saveProgress(40); + result.current.saveProgress(130); + }); + + expect(store.state.updateChapterProgress).toHaveBeenNthCalledWith( + 1, + initialChapter.id, + 40, + ); + expect(store.state.updateChapterProgress).toHaveBeenNthCalledWith( + 2, + initialChapter.id, + 100, + ); + expect(store.state.markChapterRead).toHaveBeenCalledTimes(1); + expect(store.state.markChapterRead).toHaveBeenCalledWith(initialChapter.id); + expect(mockParseChapterNumber).toHaveBeenCalledWith( + novel.name, + initialChapter.name, + ); + expect(updateAllTrackedNovels).toHaveBeenCalledWith({ progress: 5 }); + }); + + it('sets error and remains stable when chapter fetch fails', async () => { + const store = createStore(); + mockUseNovelActions.mockReturnValue(store.state); + mockFetchChapter.mockRejectedValueOnce(new Error('network failed')); + + const { result } = renderHook(() => + useChapter({ current: null }, initialChapter, novel), + ); + + await waitFor(() => expect(result.current.error).toBe('network failed')); + expect(result.current.loading).toBe(false); + expect(result.current.chapterText).toBe('SANITIZED:'); + }); + + it('reuses prefetched promise cache to avoid duplicate concurrent fetches for same chapter', async () => { + const store = createStore(); + mockUseNovelActions.mockReturnValue(store.state); + + const deferredNext = createDeferred(); + + mockGetNextChapter.mockImplementation( + async (_novelId: number, position: number) => + position === initialChapter.position ? nextChapter : undefined, + ); + mockFetchChapter.mockImplementation( + async (_pluginId: string, path: string) => { + if (path === nextChapter.path) { + return deferredNext.promise; + } + + return 'initial body'; + }, + ); + + const { result } = renderHook(() => + useChapter({ current: null }, initialChapter, novel), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + const navPromise = result.current.getChapter(nextChapter); + + expect( + mockFetchChapter.mock.calls.filter( + ([, path]) => path === nextChapter.path, + ), + ).toHaveLength(1); + + await act(async () => { + deferredNext.resolve('next body'); + await navPromise; + }); + + expect(result.current.chapter.id).toBe(nextChapter.id); + expect(result.current.chapterText).toBe('SANITIZED:next body'); + }); +}); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index 1ac5716e10..01c6ae5c50 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -34,7 +34,7 @@ import { showToast } from '@utils/showToast'; import { getString } from '@strings/translations'; import NativeVolumeButtonListener from '@specs/NativeVolumeButtonListener'; import NativeFile from '@specs/NativeFile'; -import { useNovelContext } from '@screens/novel/NovelContext'; +import { useNovelActions } from '@screens/novel/NovelContext'; const emmiter = new NativeEventEmitter(NativeVolumeButtonListener); @@ -48,7 +48,8 @@ export default function useChapter( markChapterRead, updateChapterProgress, chapterTextCache, - } = useNovelContext(); + } = useNovelActions(); + const [hidden, setHidden] = useState(true); const [chapter, setChapter] = useState(initialChapter); const [loading, setLoading] = useState(true); @@ -125,7 +126,7 @@ export default function useChapter( async (navChapter?: ChapterInfo) => { try { const chap = navChapter ?? chapter; - const cachedText = chapterTextCache.get(chap.id); + const cachedText = chapterTextCache.read(chap.id); const text = cachedText ?? loadChapterText(chap.id, chap.path); const [nextChapResult, prevChapResult, awaitedText] = await Promise.all( [ @@ -137,14 +138,11 @@ export default function useChapter( let nextChap = nextChapResult; let prevChap = prevChapResult; + const totalPages = novel.totalPages ?? 0; // Pre-fetch adjacent page chapters if at a page boundary const currentPage = Number(chap.page); - if ( - !nextChap && - novel.totalPages > 0 && - currentPage < novel.totalPages - ) { + if (!nextChap && totalPages > 0 && currentPage < totalPages) { const nextPage = String(currentPage + 1); try { const count = await getChapterCount(chap.novelId, nextPage); @@ -189,14 +187,14 @@ export default function useChapter( } catch {} } - if (nextChap && !chapterTextCache.get(nextChap.id)) { - chapterTextCache.set( + if (nextChap && !chapterTextCache.read(nextChap.id)) { + chapterTextCache.write( nextChap.id, loadChapterText(nextChap.id, nextChap.path), ); } if (!cachedText) { - chapterTextCache.set(chap.id, text); + chapterTextCache.write(chap.id, text); } setChapter(chap); setChapterText( diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx index b891db4dbf..59af99fe7b 100644 --- a/src/screens/settings/SettingsAdvancedScreen.tsx +++ b/src/screens/settings/SettingsAdvancedScreen.tsx @@ -2,10 +2,9 @@ import React, { useState } from 'react'; import { Portal, Text, TextInput } from 'react-native-paper'; -import { useTheme, useUserAgent } from '@hooks/persisted'; +import { deleteCachedNovels, useTheme, useUserAgent } from '@hooks/persisted'; import { showToast } from '@utils/showToast'; -import { deleteCachedNovels } from '@hooks/persisted/useNovel'; import { getString } from '@strings/translations'; import { useBoolean } from '@hooks'; import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog'; diff --git a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx index 8eebbd8bb3..cb8daf68a7 100644 --- a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx +++ b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx @@ -47,17 +47,21 @@ const NavigationTab: React.FC = () => { {useVolumeButtons && ( { - if (text) { + if (!isNaN(Number(text))) { setChapterGeneralSettings({ - volumeButtonsOffset: Number(text), + volumeButtonsOffset: Math.round( + Number(text) * screenHeight, + ), }); } }} diff --git a/src/screens/updates/UpdatesScreen.tsx b/src/screens/updates/UpdatesScreen.tsx index 3987ee20a8..da6d05f259 100644 --- a/src/screens/updates/UpdatesScreen.tsx +++ b/src/screens/updates/UpdatesScreen.tsx @@ -94,11 +94,14 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => { acc: { data: UpdateOverview[]; date: string }[], cur: UpdateOverview, ) => { - if (acc.length === 0 || acc.at(-1)?.date !== cur.updateDate) { + if ( + acc.length === 0 || + acc[acc.length - 1]?.date !== cur.updateDate + ) { acc.push({ data: [cur], date: cur.updateDate }); return acc; } - acc.at(-1)?.data.push(cur); + acc[acc.length - 1]?.data.push(cur); return acc; }, [], diff --git a/src/services/Trackers/myAnimeList.ts b/src/services/Trackers/myAnimeList.ts index 3af314f195..77dd98e7bf 100644 --- a/src/services/Trackers/myAnimeList.ts +++ b/src/services/Trackers/myAnimeList.ts @@ -49,11 +49,11 @@ export const myAnimeListTracker: Tracker = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - }, + }, // @ts-expect-error - If no clientId is set, which will only be set in production builds, this will error out. body: new URLSearchParams({ client_id: clientId, grant_type: 'authorization_code', - code, + code: code, code_verifier: challenge, }).toString(), }); diff --git a/src/services/migrate/migrateNovel.ts b/src/services/migrate/migrateNovel.ts index a28ff11d0e..b12513d254 100644 --- a/src/services/migrate/migrateNovel.ts +++ b/src/services/migrate/migrateNovel.ts @@ -8,11 +8,10 @@ import { getNovelChapters } from '@database/queries/ChapterQueries'; import { fetchNovel } from '@services/plugin/fetch'; import { parseChapterNumber } from '@utils/parseChapterNumber'; -import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv'; import { - LAST_READ_PREFIX, - NOVEL_SETTINGS_PREFIX, -} from '@hooks/persisted/useNovel'; + novelPersistence, + type NovelPersistenceInput, +} from '@hooks/persisted/useNovel/store-helper/contracts'; import { sleep } from '@utils/sleep'; import ServiceManager, { BackgroundTaskMetadata, @@ -96,22 +95,21 @@ export const migrateNovel = async ( await tx.delete(novelSchema).where(eq(novelSchema.id, fromNovel.id)); }); - setMMKVObject( - `${NOVEL_SETTINGS_PREFIX}_${toNovel!.pluginId}_${toNovel!.path}`, - getMMKVObject( - `${NOVEL_SETTINGS_PREFIX}_${fromNovel.pluginId}_${fromNovel.path}`, - ), - ); + const fromPersistenceInput: NovelPersistenceInput = { + pluginId: fromNovel.pluginId, + novelPath: fromNovel.path, + }; + const toPersistenceInput: NovelPersistenceInput = { + pluginId: toNovel!.pluginId, + novelPath: toNovel!.path, + }; + + novelPersistence.copySettings(fromPersistenceInput, toPersistenceInput); - const lastRead = getMMKVObject( - `${LAST_READ_PREFIX}_${fromNovel.pluginId}_${fromNovel.path}`, - ); + const lastRead = novelPersistence.readLastRead(fromPersistenceInput); const setLastRead = (chapter: ChapterInfo) => { - setMMKVObject( - `${LAST_READ_PREFIX}_${toNovel!.pluginId}_${toNovel!.path}`, - chapter, - ); + novelPersistence.writeLastRead(toPersistenceInput, chapter); }; fromChapters = sortChaptersByNumber(fromNovel.name, fromChapters); diff --git a/src/services/updates/LibraryUpdateQueries.ts b/src/services/updates/LibraryUpdateQueries.ts index ab841ab8ca..c336bfea5d 100644 --- a/src/services/updates/LibraryUpdateQueries.ts +++ b/src/services/updates/LibraryUpdateQueries.ts @@ -6,8 +6,9 @@ import { downloadFile } from '@plugins/helpers/fetch'; import ServiceManager from '@services/ServiceManager'; import { dbManager } from '@database/db'; import { novelSchema, chapterSchema } from '@database/schema'; -import { eq, and, ne, or, sql } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import NativeFile from '@specs/NativeFile'; +import { insertChapters } from '@database/queries/ChapterQueries'; /** * Update novel metadata in the database including cover image. @@ -81,80 +82,71 @@ const updateNovelChapters = async ( downloadNewChapters?: boolean, page?: string, ) => { - await dbManager.write(async tx => { - for (let position = 0; position < chapters.length; position++) { - const chapter = chapters[position]; - const { - name, - path, - releaseTime, - page: customPage, - chapterNumber, - } = chapter; - const chapterPage = page || customPage || '1'; + if (!chapters.length) { + return; + } - // Check if chapter already exists - const existing = await tx - .select({ id: chapterSchema.id }) + const incomingPaths = Array.from( + new Set(chapters.map(chapter => chapter.path)), + ); + const existingChapters = incomingPaths.length + ? await dbManager + .select({ path: chapterSchema.path }) .from(chapterSchema) .where( - and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.path, path)), + and( + eq(chapterSchema.novelId, novelId), + inArray(chapterSchema.path, incomingPaths), + ), ) - .get(); + .all() + : []; - if (!existing) { - // Insert new chapter - const newChapter = await tx - .insert(chapterSchema) - .values({ - path, - name, - releaseTime: releaseTime || null, - novelId, - updatedTime: sql`datetime('now','localtime')`, - chapterNumber: chapterNumber || null, - page: chapterPage, - position: position, - }) - .returning() - .get(); + const existingPathSet = new Set( + existingChapters.map(chapter => chapter.path), + ); + const newPaths = incomingPaths.filter(path => !existingPathSet.has(path)); - if (newChapter && downloadNewChapters) { - ServiceManager.manager.addTask({ - name: 'DOWNLOAD_CHAPTER', - data: { - chapterId: newChapter.id, - novelName: novelName, - chapterName: name, - }, - }); - } - } else { - // Update existing chapter if metadata changed - tx.update(chapterSchema) - .set({ - name, - releaseTime: releaseTime || null, - updatedTime: sql`datetime('now','localtime')`, - page: chapterPage, - position: position, - }) - .where( - and( - eq(chapterSchema.id, existing.id), - eq(chapterSchema.novelId, novelId), - or( - ne(chapterSchema.name, name), - ne(chapterSchema.releaseTime, releaseTime!), - ne(chapterSchema.page, chapterPage), - ne(chapterSchema.position, position), - ), - ), - ) - .run(); - } - } + await insertChapters(novelId, chapters, { + page, + touchUpdatedTime: true, }); + + if (downloadNewChapters && newPaths.length) { + const insertedNewChapters = await dbManager + .select({ + id: chapterSchema.id, + path: chapterSchema.path, + name: chapterSchema.name, + }) + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + inArray(chapterSchema.path, newPaths), + ), + ) + .all(); + + const chapterNameByPath = new Map( + chapters.map((chapter, index) => [ + chapter.path, + chapter.name || `Chapter ${index + 1}`, + ]), + ); + + for (const insertedChapter of insertedNewChapters) { + ServiceManager.manager.addTask({ + name: 'DOWNLOAD_CHAPTER', + data: { + chapterId: insertedChapter.id, + novelName, + chapterName: + chapterNameByPath.get(insertedChapter.path) || insertedChapter.name, + }, + }); + } + } }; export interface UpdateNovelOptions { @@ -194,9 +186,7 @@ const updateNovel = async ( await updateNovelMetadata(pluginId, novelId, novel); } else if (novel.totalPages) { await updateNovelTotalPages(novelId, novel.totalPages); - await updateNovelTotalPages(novelId, novel.totalPages); } - await updateNovelChapters( novel.name, novelId, diff --git a/src/utils/mmkv/zustand-adapter.ts b/src/utils/mmkv/zustand-adapter.ts new file mode 100644 index 0000000000..8b92198396 --- /dev/null +++ b/src/utils/mmkv/zustand-adapter.ts @@ -0,0 +1,59 @@ +import { MMKVStorage } from './mmkv'; + +/** + * Zustand persist storage adapter for MMKV. + * Implements the storage contract required by zustand's persist middleware. + * + * This adapter bridges zustand's storage interface (getItem, setItem, removeItem) + * with the MMKV native storage backend used in react-native-mmkv. + */ +export const mmkvZustandAdapter = { + /** + * Get a stored value from MMKV by key. + * Returns JSON string for zustand to parse, or null if not found. + */ + getItem: (key: string): string | null => { + try { + const value = MMKVStorage.getString(key); + return value ?? null; + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `[mmkvZustandAdapter] Error getting item for key "${key}":`, + error, + ); + return null; + } + }, + + /** + * Set a value in MMKV storage. + * Zustand passes a JSON string; we store it directly. + */ + setItem: (key: string, value: string): void => { + try { + MMKVStorage.set(key, value); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `[mmkvZustandAdapter] Error setting item for key "${key}":`, + error, + ); + } + }, + + /** + * Remove a value from MMKV storage. + */ + removeItem: (key: string): void => { + try { + MMKVStorage.remove(key); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `[mmkvZustandAdapter] Error removing item for key "${key}":`, + error, + ); + } + }, +}; diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index b3e8c9d28a..f9200e1938 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -516,7 +516,8 @@ "textColor": "Text color", "textColorModal": "Text color", "title": "Reader", - "verticalSeekbarDesc": "Use vertical seekbar" + "verticalSeekbarDesc": "Use vertical seekbar", + "volumeButtonOffset": "Volume button scroll offset (screen heights)" }, "sourceScreen": { "noResultsFound": "No results found" diff --git a/strings/types/index.ts b/strings/types/index.ts index 3fc9480dc3..1301e6f2ea 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -430,6 +430,7 @@ export interface StringMap { 'readerSettings.textColorModal': 'string'; 'readerSettings.title': 'string'; 'readerSettings.verticalSeekbarDesc': 'string'; + 'readerSettings.volumeButtonOffset': 'string'; 'sourceScreen.noResultsFound': 'string'; 'statsScreen.downloadedChapters': 'string'; 'statsScreen.genreDistribution': 'string'; From 4b8fd5eaf0ae8d2ed5f865ee4d437fb4f15eac28 Mon Sep 17 00:00:00 2001 From: CD-Z Date: Thu, 7 May 2026 20:21:47 +0200 Subject: [PATCH 02/12] use new ids --- src/hooks/persisted/useTheme.ts | 44 +++++++++++++++++++++++++++------ src/theme/md3/catppuccin.ts | 2 -- src/theme/md3/defaultTheme.ts | 2 -- src/theme/md3/index.ts | 11 +++++++-- src/theme/md3/lavender.ts | 2 -- src/theme/md3/mignightDusk.ts | 2 -- src/theme/md3/strawberry.ts | 2 -- src/theme/md3/tako.ts | 2 -- src/theme/md3/tealTurquoise.ts | 2 -- src/theme/md3/yinyang.ts | 2 -- src/theme/md3/yotsuba.ts | 2 -- 11 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index 7fcc0bc446..dd72881dfd 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -8,7 +8,6 @@ import { import { overlay } from 'react-native-paper'; import Color from 'color'; -import { defaultTheme } from '@theme/md3/defaultTheme'; import { ThemeColors } from '@theme/types'; import { darkThemes, lightThemes } from '@theme/md3'; @@ -62,17 +61,46 @@ const findThemeById = ( isDark: boolean, ): ThemeColors => { const themeList = isDark ? darkThemes : lightThemes; - + let theme: ThemeColors | undefined; if (themeId !== undefined) { - const theme = themeList.find(t => t.id === themeId); - if (theme) { - return theme; - } + const id = transformThemeId(themeId, isDark); + theme = themeList.find(t => t.id === id); } - return isDark ? defaultTheme.dark : defaultTheme.light; + return theme ?? themeList[0]; }; +// transforms legacy theme IDs to new IDs +function transformThemeId(themeId: number, isDark: boolean): number { + if (themeId > 99) return themeId; + const lightIdMap: Record = { + '1': 100, + '8': 102, + '9': 108, + '10': 101, + '12': 103, + '14': 104, + '16': 105, + '18': 106, + '20': 107, + }; + const darkIdMap: Record = { + '2': 100, + '9': 102, + '10': 108, + '11': 101, + '13': 103, + '15': 104, + '17': 105, + '19': 106, + '21': 107, + }; + if (isDark) { + return darkIdMap[themeId] ?? themeId; + } + return lightIdMap[themeId] ?? themeId; +} + const getBaseTheme = ( themeMode: string, themeId: number | undefined, @@ -95,7 +123,7 @@ export const useTheme = (): ThemeColors => { const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR'); const [systemColorScheme, setSystemColorScheme] = useState( - Appearance.getColorScheme() ?? 'light', + Appearance.getColorScheme() ?? 'unspecified', ); useEffect(() => { diff --git a/src/theme/md3/catppuccin.ts b/src/theme/md3/catppuccin.ts index d7ae348c00..4c530bcc80 100644 --- a/src/theme/md3/catppuccin.ts +++ b/src/theme/md3/catppuccin.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const catppuccinTheme = { light: { - id: 20, name: getString('appearanceScreen.theme.catppuccin'), isDark: false, primary: 'rgb(136, 57, 239)', @@ -39,7 +38,6 @@ export const catppuccinTheme = { backdrop: 'rgba(196, 200, 208, 0.4)', }, dark: { - id: 21, name: getString('appearanceScreen.theme.catppuccin'), isDark: true, primary: 'rgb(203, 166, 247)', diff --git a/src/theme/md3/defaultTheme.ts b/src/theme/md3/defaultTheme.ts index 5fc18f6856..34f8657e7e 100644 --- a/src/theme/md3/defaultTheme.ts +++ b/src/theme/md3/defaultTheme.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const defaultTheme = { light: { - id: 1, name: getString('appearanceScreen.theme.default'), isDark: false, primary: 'rgb(0, 87, 206)', @@ -39,7 +38,6 @@ export const defaultTheme = { backdrop: 'rgba(46, 48, 56, 0.4)', }, dark: { - id: 2, name: getString('appearanceScreen.theme.default'), isDark: true, primary: 'rgb(177, 197, 255)', diff --git a/src/theme/md3/index.ts b/src/theme/md3/index.ts index 044f09e097..b1378e34e3 100644 --- a/src/theme/md3/index.ts +++ b/src/theme/md3/index.ts @@ -8,6 +8,13 @@ import { takoTheme } from './tako'; import { catppuccinTheme } from './catppuccin'; import { yinyangTheme } from './yinyang'; +/** + * Exports for MD3 theme system + * + * IMPORTANT: + * IDs are auto-assigned, so new themes + * need to be added at the end of the list. + */ export const lightThemes = [ defaultTheme.light, midnightDusk.light, @@ -18,7 +25,7 @@ export const lightThemes = [ takoTheme.light, catppuccinTheme.light, yinyangTheme.light, -]; +].map((theme, i) => ({ ...theme, id: 100 + i })); export const darkThemes = [ defaultTheme.dark, midnightDusk.dark, @@ -29,4 +36,4 @@ export const darkThemes = [ takoTheme.dark, catppuccinTheme.dark, yinyangTheme.dark, -]; +].map((theme, i) => ({ ...theme, id: 100 + i })); diff --git a/src/theme/md3/lavender.ts b/src/theme/md3/lavender.ts index d3515ad1c9..e08094c0dc 100644 --- a/src/theme/md3/lavender.ts +++ b/src/theme/md3/lavender.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const lavenderTheme = { light: { - id: 14, name: getString('appearanceScreen.theme.lavender'), isDark: false, primary: 'rgb(121, 68, 173)', @@ -39,7 +38,6 @@ export const lavenderTheme = { backdrop: 'rgba(52, 47, 55, 0.4)', }, dark: { - id: 15, name: getString('appearanceScreen.theme.lavender'), isDark: true, primary: 'rgb(221, 184, 255)', diff --git a/src/theme/md3/mignightDusk.ts b/src/theme/md3/mignightDusk.ts index 8a47708c98..0af08b6c4c 100644 --- a/src/theme/md3/mignightDusk.ts +++ b/src/theme/md3/mignightDusk.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const midnightDusk = { light: { - id: 10, name: getString('appearanceScreen.theme.daybreakBloom'), isDark: false, primary: 'rgb(240, 36, 117)', @@ -39,7 +38,6 @@ export const midnightDusk = { backdrop: 'rgba(58, 45, 47, 0.4)', }, dark: { - id: 11, name: getString('appearanceScreen.theme.midnightDusk'), isDark: true, primary: 'rgb(240, 36, 117)', diff --git a/src/theme/md3/strawberry.ts b/src/theme/md3/strawberry.ts index 612992179f..b617ecbf5e 100644 --- a/src/theme/md3/strawberry.ts +++ b/src/theme/md3/strawberry.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const strawberryDaiquiriTheme = { light: { - id: 16, name: getString('appearanceScreen.theme.strawberry'), isDark: false, primary: 'rgb(182, 30, 64)', @@ -39,7 +38,6 @@ export const strawberryDaiquiriTheme = { backdrop: 'rgba(59, 45, 46, 0.4)', }, dark: { - id: 17, name: getString('appearanceScreen.theme.strawberry'), isDark: true, primary: 'rgb(255, 178, 184)', diff --git a/src/theme/md3/tako.ts b/src/theme/md3/tako.ts index 2502c2f2cd..588fb5d63f 100644 --- a/src/theme/md3/tako.ts +++ b/src/theme/md3/tako.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const takoTheme = { light: { - id: 18, name: getString('appearanceScreen.theme.tako'), isDark: false, primary: '#66577E', @@ -39,7 +38,6 @@ export const takoTheme = { backdrop: 'rgba(51, 47, 55, 0.4)', }, dark: { - id: 19, name: getString('appearanceScreen.theme.tako'), isDark: true, primary: '#F3B375', diff --git a/src/theme/md3/tealTurquoise.ts b/src/theme/md3/tealTurquoise.ts index f9bb458bf9..30619be2cd 100644 --- a/src/theme/md3/tealTurquoise.ts +++ b/src/theme/md3/tealTurquoise.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const tealTurquoise = { light: { - id: 8, name: getString('appearanceScreen.theme.teal'), isDark: false, primary: 'rgb(0, 106, 106)', @@ -39,7 +38,6 @@ export const tealTurquoise = { backdrop: 'rgba(41, 50, 50, 0.4)', }, dark: { - id: 9, name: getString('appearanceScreen.theme.turquoise'), isDark: true, primary: 'rgb(76, 218, 218)', diff --git a/src/theme/md3/yinyang.ts b/src/theme/md3/yinyang.ts index 5f4b934d25..5b8b32e16f 100644 --- a/src/theme/md3/yinyang.ts +++ b/src/theme/md3/yinyang.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const yinyangTheme = { light: { - id: 9, name: getString('appearanceScreen.theme.yinyang'), isDark: false, primary: '#000000', @@ -39,7 +38,6 @@ export const yinyangTheme = { backdrop: 'rgba(0, 0, 0, 0.4)', }, dark: { - id: 10, name: getString('appearanceScreen.theme.yinyang'), isDark: true, primary: '#FFFFFF', diff --git a/src/theme/md3/yotsuba.ts b/src/theme/md3/yotsuba.ts index 0105636e99..1b981769e2 100644 --- a/src/theme/md3/yotsuba.ts +++ b/src/theme/md3/yotsuba.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const yotsubaTheme = { light: { - id: 12, name: getString('appearanceScreen.theme.yotsuba'), isDark: false, primary: 'rgb(174, 50, 0)', @@ -39,7 +38,6 @@ export const yotsubaTheme = { backdrop: 'rgba(59, 45, 41, 0.4)', }, dark: { - id: 13, name: getString('appearanceScreen.theme.yotsuba'), isDark: true, primary: 'rgb(255, 181, 158)', From 3c204ed37059e41f9cf8ab3931f60cbc37755695 Mon Sep 17 00:00:00 2001 From: CD-Z <69157453+CD-Z@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:26:25 +0200 Subject: [PATCH 03/12] refactor in appearance settings --- package.json | 1 + pnpm-lock.yaml | 14 ++ .../SegmentedControl/SegmentedControl.tsx | 12 +- src/components/ThemePicker/ThemePicker.tsx | 192 ++++++++++-------- src/screens/onboarding/ThemeSelectionStep.tsx | 124 +++++------ .../SettingsAppearanceScreen.tsx | 160 +++++++++------ 6 files changed, 292 insertions(+), 211 deletions(-) diff --git a/package.json b/package.json index e48ed0e57b..f5ca6e0fe1 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "react-native-screens": "^4.24.0", "react-native-shimmer-placeholder": "^2.0.9", "react-native-tab-view": "^4.3.0", + "react-native-theme-switch-animation": "^0.8.0", "react-native-url-polyfill": "^3.0.0", "react-native-webview": "^13.16.1", "react-native-worklets": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 202c68ed63..720c12ba6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: react-native-tab-view: specifier: ^4.3.0 version: 4.3.0(react-native-pager-view@8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-theme-switch-animation: + specifier: ^0.8.0 + version: 0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react-native-url-polyfill: specifier: ^3.0.0 version: 3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)) @@ -5360,6 +5363,12 @@ packages: react-native: '*' react-native-pager-view: '>= 6.0.0' + react-native-theme-switch-animation@0.8.0: + resolution: {integrity: sha512-z4f3QGSuUP4tagycls2mekng/7uxAbr75Gn0GGm7JRkrviyao++V2CtJ8VUDx+hSOsgfjEhD9D5JubsGbbHB5w==} + peerDependencies: + react: '*' + react-native: '*' + react-native-url-polyfill@3.0.0: resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==} peerDependencies: @@ -12725,6 +12734,11 @@ snapshots: react-native-pager-view: 8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) use-latest-callback: 0.2.6(react@19.2.4) + react-native-theme-switch-animation@0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) + react-native-url-polyfill@3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)): dependencies: react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx index b743356bf3..59e190b6cb 100644 --- a/src/components/SegmentedControl/SegmentedControl.tsx +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { + View, + Text, + StyleSheet, + Pressable, + GestureResponderEvent, +} from 'react-native'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; import { ThemeColors } from '@theme/types'; @@ -12,7 +18,7 @@ export interface SegmentedControlOption { export interface SegmentedControlProps { options: SegmentedControlOption[]; value: T; - onChange: (value: T) => void; + onChange: (value: T, event: GestureResponderEvent) => void; theme: ThemeColors; showCheckIcon?: boolean; } @@ -52,7 +58,7 @@ export function SegmentedControl({ onChange(option.value)} + onPress={e => onChange(option.value, e)} android_ripple={{ color: theme.rippleColor, borderless: false, diff --git a/src/components/ThemePicker/ThemePicker.tsx b/src/components/ThemePicker/ThemePicker.tsx index 82cfaf8966..8a64a5c4f5 100644 --- a/src/components/ThemePicker/ThemePicker.tsx +++ b/src/components/ThemePicker/ThemePicker.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { + View, + Text, + StyleSheet, + Pressable, + GestureResponderEvent, +} from 'react-native'; import { overlay } from 'react-native-paper'; import color from 'color'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; @@ -8,8 +14,9 @@ import { ThemeColors } from '@theme/types'; interface ThemePickerProps { theme: ThemeColors; currentTheme: ThemeColors; - onPress: () => void; + onPress: (event: GestureResponderEvent) => void; horizontal?: boolean; + isDark?: boolean; } export const ThemePicker = ({ @@ -17,119 +24,124 @@ export const ThemePicker = ({ currentTheme, onPress, horizontal = false, -}: ThemePickerProps) => ( - - - - {currentTheme.id === theme.id ? ( - - ) : null} - - - - +}: ThemePickerProps) => { + return ( + + + + {currentTheme.id !== theme.id ? null : ( + + )} - - + > - - + + + + + + + + + - - - - - - + + + + + + - - + + + + {theme.name} + - - {theme.name} - - -); + ); +}; const styles = StyleSheet.create({ container: { justifyContent: 'center', alignItems: 'center', paddingBottom: 8, - width: '33%', }, horizontalContainer: { width: undefined, marginHorizontal: 4, + paddingBottom: 0, }, card: { borderWidth: 3.6, @@ -143,7 +155,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.2, shadowRadius: 4, // Elevation for Android - elevation: 2, + //elevation: 2, }, flex1: { flex: 1, diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx index 36aae08ee6..2e3fb38484 100644 --- a/src/screens/onboarding/ThemeSelectionStep.tsx +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -1,5 +1,11 @@ import React, { useMemo } from 'react'; -import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; +import { + View, + Text, + Pressable, + StyleSheet, + GestureResponderEvent, +} from 'react-native'; import { useMMKVBoolean, useMMKVNumber, @@ -12,6 +18,9 @@ import { ThemeColors } from '@theme/types'; import { useTheme } from '@hooks/persisted'; import { darkThemes, lightThemes } from '@theme/md3'; import { getString } from '@strings/translations'; +import { LegendList } from '@legendapp/list'; +import Switch from '@components/Switch/Switch'; +import switchTheme from 'react-native-theme-switch-animation'; type ThemeMode = 'light' | 'dark' | 'system'; @@ -23,39 +32,23 @@ const AmoledToggle: React.FC = ({ theme }) => { const [isAmoledBlack = false, setAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); - if (!theme.isDark) { - return null; - } + const toggle = () => setAmoledBlack(!isAmoledBlack); + + if (!theme.isDark) return null; return ( - + {getString('appearanceScreen.pureBlackDarkMode')} - setAmoledBlack(!isAmoledBlack)} - style={[ - styles.toggle, - { - backgroundColor: isAmoledBlack - ? theme.primary - : theme.surfaceVariant, - }, - ]} - > - - - + + ); }; @@ -88,22 +81,43 @@ export default function ThemeSelectionStep() { [], ); - const handleModeChange = (mode: ThemeMode) => { + const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => { setThemeMode(mode); - - if (mode !== 'system') { - const themes = mode === 'dark' ? darkThemes : lightThemes; - const currentThemeInMode = themes.find(t => t.id === theme.id); - - if (!currentThemeInMode) { - setThemeId(themes[0].id); - } - } + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; - const handleThemeSelect = (selectedTheme: ThemeColors) => { + const handleThemeSelect = ( + selectedTheme: ThemeColors, + event: GestureResponderEvent, + ) => { setThemeId(selectedTheme.id); - setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => { + setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + }, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; return ( @@ -117,24 +131,23 @@ export default function ThemeSelectionStep() { theme={theme} /> - {/* Theme List */} - - {availableThemes.map(item => ( - + data={availableThemes} + extraData={theme} + keyExtractor={item => 'theme-' + item.id} + renderItem={({ item }) => ( + handleThemeSelect(item)} + onPress={e => handleThemeSelect(item, e)} /> - ))} - - + )} + /> {/* AMOLED Toggle */} @@ -149,13 +162,6 @@ const styles = StyleSheet.create({ segmentedControlContainer: { marginBottom: 24, }, - themeScrollContent: { - paddingVertical: 16, - paddingHorizontal: 24, - }, - themeItem: { - marginHorizontal: 8, - }, amoledContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index 65b8ca8cf2..6852dabe85 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -1,5 +1,12 @@ import React, { useMemo, useState } from 'react'; -import { ScrollView, Text, StyleSheet, View } from 'react-native'; +import { + ScrollView, + Text, + StyleSheet, + View, + Appearance, + GestureResponderEvent, +} from 'react-native'; import { ThemePicker } from '@components/ThemePicker/ThemePicker'; import type { SegmentedControlOption } from '@components/SegmentedControl'; @@ -18,13 +25,17 @@ import { AppearanceSettingsScreenProps } from '@navigators/types'; import { getString } from '@strings/translations'; import { darkThemes, lightThemes } from '@theme/md3'; import { ThemeColors } from '@theme/types'; +import switchTheme from 'react-native-theme-switch-animation'; type ThemeMode = 'light' | 'dark' | 'system'; const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { const theme = useTheme(); const [, setThemeId] = useMMKVNumber('APP_THEME_ID'); - const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE'); + const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE') as [ + ThemeMode, + (mode: ThemeMode) => void, + ]; const [isAmoledBlack = false, setAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); const [, setCustomAccentColor] = useMMKVString('CUSTOM_ACCENT_COLOR'); @@ -38,7 +49,8 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { setAppSettings, } = useAppSettings(); - const currentMode = themeMode as ThemeMode; + const actualThemeMode: Omit = + themeMode !== 'system' ? themeMode : Appearance.getColorScheme() ?? 'light'; /** * Accent Color Modal @@ -117,26 +129,63 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { [], ); - const handleModeChange = (mode: ThemeMode) => { - setThemeMode(mode); + // const handleModeChange = (mode: ThemeMode) => { + // setThemeMode(mode); - if (mode !== 'system') { - const themes = mode === 'dark' ? darkThemes : lightThemes; - const currentThemeInMode = themes.find(t => t.id === theme.id); + // if (mode !== 'system') { + // const themes = mode === 'dark' ? darkThemes : lightThemes; + // const currentThemeInMode = themes.find(t => t.id === theme.id); - if (!currentThemeInMode) { - setThemeId(themes[0].id); - } - } + // if (!currentThemeInMode) { + // setThemeId(themes[0].id); + // } + // } + // }; + + // const handleThemeSelect = (selectedTheme: ThemeColors) => { + // setThemeId(selectedTheme.id); + // setCustomAccentColor(undefined); + + // if (actualThemeMode !== 'system') { + // setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + // } + // }; + + const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => { + setThemeMode(mode); + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; - const handleThemeSelect = (selectedTheme: ThemeColors) => { + const handleThemeSelect = ( + selectedTheme: ThemeColors, + event: GestureResponderEvent, + ) => { setThemeId(selectedTheme.id); - setCustomAccentColor(undefined); - - if (currentMode !== 'system') { - setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); - } + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; return ( @@ -159,52 +208,38 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { {/* Light Themes */} - + {/* {getString('appearanceScreen.lightTheme')} - - - {lightThemes.map(item => ( - handleThemeSelect(item)} - /> - ))} - - - {/* Dark Themes */} - - {getString('appearanceScreen.darkTheme')} - - - {darkThemes.map(item => ( - handleThemeSelect(item)} - /> - ))} - - + */} + + + {(actualThemeMode === 'light' ? lightThemes : darkThemes).map( + item => ( + handleThemeSelect(item, e)} + /> + ), + )} + + {theme.isDark ? ( Date: Sun, 5 Apr 2026 14:27:01 +0200 Subject: [PATCH 04/12] fix lint --- .../SettingsAppearanceScreen/SettingsAppearanceScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index 6852dabe85..a9d6daa92e 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useState } from 'react'; import { ScrollView, - Text, StyleSheet, View, Appearance, From 296123a0c29da9b125eb113a328da1aad5cdf3b3 Mon Sep 17 00:00:00 2001 From: CD-Z <69157453+CD-Z@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:55:15 +0200 Subject: [PATCH 05/12] remove setThemeMode from handleThemeSelect --- src/screens/onboarding/ThemeSelectionStep.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx index 2e3fb38484..4a5e0063f9 100644 --- a/src/screens/onboarding/ThemeSelectionStep.tsx +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -105,9 +105,7 @@ export default function ThemeSelectionStep() { setThemeId(selectedTheme.id); event.currentTarget.measure((_x1, _y1, width, height, px, py) => { switchTheme({ - switchThemeFunction: () => { - setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); - }, + switchThemeFunction: () => {}, animationConfig: { type: 'circular', duration: 900, From 93309c318b769c8b98fb7ce20d109c3cbd523948 Mon Sep 17 00:00:00 2001 From: CD-Z <69157453+CD-Z@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:02:55 +0200 Subject: [PATCH 06/12] fix type logic --- .../SettingsAppearanceScreen.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index a9d6daa92e..3951687b98 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -48,8 +48,13 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { setAppSettings, } = useAppSettings(); - const actualThemeMode: Omit = - themeMode !== 'system' ? themeMode : Appearance.getColorScheme() ?? 'light'; + const colorScheme = Appearance.getColorScheme() ?? 'light'; + const actualThemeMode: Exclude = + themeMode !== 'system' + ? themeMode + : colorScheme === 'unspecified' + ? 'light' + : colorScheme; /** * Accent Color Modal From 53614f9a97c97013150642fcc65656cc967ad033 Mon Sep 17 00:00:00 2001 From: CD-Z Date: Wed, 6 May 2026 17:34:19 +0200 Subject: [PATCH 07/12] use context for theme --- App.tsx | 15 ++++++++------ __tests-modules__/test-utils.tsx | 13 +++++++----- src/hooks/persisted/__mocks__/useTheme.ts | 6 +++++- src/hooks/persisted/index.ts | 1 + src/hooks/persisted/useTheme.ts | 25 ++++++++++++++++++++--- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/App.tsx b/App.tsx index 0fcfb58fe5..9800c978cb 100644 --- a/App.tsx +++ b/App.tsx @@ -18,6 +18,7 @@ import AppErrorBoundary, { import Main from './src/navigators/Main'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { useInitDatabase } from '@database/db'; +import { ThemeProvider } from '@hooks/persisted/useTheme'; Notifications.setNotificationHandler({ handleNotification: async () => { @@ -49,12 +50,14 @@ const App = () => { - - - -
- - + + + + +
+ + + diff --git a/__tests-modules__/test-utils.tsx b/__tests-modules__/test-utils.tsx index 9e16ced519..ce0305fa04 100644 --- a/__tests-modules__/test-utils.tsx +++ b/__tests-modules__/test-utils.tsx @@ -4,6 +4,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Provider as PaperProvider } from 'react-native-paper'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { ThemeProvider } from '@hooks/persisted/useTheme'; import AppErrorBoundary from '@components/AppErrorBoundary/AppErrorBoundary'; import { NovelContextProvider } from '@screens/novel/NovelContext'; @@ -13,11 +14,13 @@ const AllTheProviders = ({ children }: { children: React.ReactElement }) => { return ( - - - {children} - - + + + + {children} + + + ); diff --git a/src/hooks/persisted/__mocks__/useTheme.ts b/src/hooks/persisted/__mocks__/useTheme.ts index daf8881331..45f9676ad7 100644 --- a/src/hooks/persisted/__mocks__/useTheme.ts +++ b/src/hooks/persisted/__mocks__/useTheme.ts @@ -1,3 +1,5 @@ +import type { PropsWithChildren } from 'react'; + const mockTheme = { primary: '#6200ee', onPrimary: '#ffffff', @@ -26,5 +28,7 @@ const mockTheme = { const useTheme = jest.fn(() => mockTheme); +const ThemeProvider = ({ children }: PropsWithChildren) => children as any; + export default useTheme; -export { mockTheme }; +export { mockTheme, ThemeProvider, useTheme }; diff --git a/src/hooks/persisted/index.ts b/src/hooks/persisted/index.ts index d4437d481d..00cd61ceb9 100644 --- a/src/hooks/persisted/index.ts +++ b/src/hooks/persisted/index.ts @@ -1,4 +1,5 @@ export { useTheme } from './useTheme'; +export { ThemeProvider } from './useTheme'; export { useUpdates, useLastUpdate } from './useUpdates'; export { default as useCategories } from './useCategories'; export { default as useHistory } from './useHistory'; diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index dd72881dfd..253362332e 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -1,4 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; +import { + createElement, + createContext, + useContext, + useEffect, + useMemo, + useState, + PropsWithChildren, +} from 'react'; import { Appearance, ColorSchemeName } from 'react-native'; import { useMMKVBoolean, @@ -116,7 +124,9 @@ const getBaseTheme = ( return findThemeById(themeId, isDark); }; -export const useTheme = (): ThemeColors => { +const ThemeContext = createContext(null); + +export const ThemeProvider = ({ children }: PropsWithChildren) => { const [themeId] = useMMKVNumber('APP_THEME_ID'); const [themeMode = 'system'] = useMMKVString('THEME_MODE'); const [isAmoledBlack = false] = useMMKVBoolean('AMOLED_BLACK'); @@ -134,7 +144,7 @@ export const useTheme = (): ThemeColors => { return () => subscription.remove(); }, []); - const theme = useMemo(() => { + const theme = useMemo(() => { const baseTheme = getBaseTheme(themeMode, themeId, systemColorScheme); const withAmoled = applyAmoledBlack(baseTheme, isAmoledBlack); const withAccent = applyCustomAccent(withAmoled, customAccent); @@ -143,5 +153,14 @@ export const useTheme = (): ThemeColors => { return finalTheme; }, [themeId, themeMode, systemColorScheme, isAmoledBlack, customAccent]); + return createElement(ThemeContext.Provider, { value: theme }, children); +}; + +export const useTheme = (): ThemeColors => { + const theme = useContext(ThemeContext); + if (!theme) { + throw new Error('useTheme must be used within a '); + } + return theme; }; From 9fb7f8b46c8445d049194399a546fdb67afeacd4 Mon Sep 17 00:00:00 2001 From: CD-Z Date: Thu, 7 May 2026 15:35:16 +0200 Subject: [PATCH 08/12] shorten animation --- src/screens/onboarding/ThemeSelectionStep.tsx | 4 ++-- .../SettingsAppearanceScreen/SettingsAppearanceScreen.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx index 4a5e0063f9..982482580b 100644 --- a/src/screens/onboarding/ThemeSelectionStep.tsx +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -88,7 +88,7 @@ export default function ThemeSelectionStep() { switchThemeFunction: () => {}, animationConfig: { type: 'circular', - duration: 900, + duration: 400, startingPoint: { cy: py + height / 2, cx: px + width / 2, @@ -108,7 +108,7 @@ export default function ThemeSelectionStep() { switchThemeFunction: () => {}, animationConfig: { type: 'circular', - duration: 900, + duration: 400, startingPoint: { cy: py + height / 2, cx: px + width / 2, diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index 3951687b98..e0e6d5f19c 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -162,7 +162,7 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { switchThemeFunction: () => {}, animationConfig: { type: 'circular', - duration: 900, + duration: 400, startingPoint: { cy: py + height / 2, cx: px + width / 2, @@ -182,7 +182,7 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { switchThemeFunction: () => {}, animationConfig: { type: 'circular', - duration: 900, + duration: 400, startingPoint: { cy: py + height / 2, cx: px + width / 2, From bcdc9870aeeb5c9e9ecd3a9852a6669e151de81d Mon Sep 17 00:00:00 2001 From: CD-Z Date: Thu, 7 May 2026 16:16:16 +0200 Subject: [PATCH 09/12] adjusted LanguagePickerModal --- .../LanguagePickerModal.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx index eaac82044a..9de615cc2f 100644 --- a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx @@ -7,6 +7,9 @@ import { useTheme } from '@hooks/persisted'; import { Modal, RadioButton } from '@components'; import { getString, setLocale } from '@strings/translations'; import { useMMKVString } from 'react-native-mmkv'; +import { LegendList } from '@legendapp/list'; +import { FlatList } from 'react-native-gesture-handler'; +import { max } from 'lodash-es'; interface LanguagePickerModalProps { visible: boolean; @@ -84,22 +87,31 @@ const LanguagePickerModal: React.FC = ({ return ( - - {getString('appearanceScreen.appLanguage')} + + + {getString('appearanceScreen.appLanguage')} + {getString('appearanceScreen.languagePickerModal.restartNote')} - - {languages.map(item => ( + item.locale} + renderItem={({ item }) => ( handleLanguageSelect(item.locale)} label={item.nativeName} theme={theme} + style={styles.zeroPadding} /> - ))} - + )} + /> ); @@ -111,6 +123,8 @@ const styles = StyleSheet.create({ noteText: { lineHeight: 20, marginBottom: 8, - paddingHorizontal: 24, + //paddingHorizontal: 24, }, + zeroPadding: { paddingHorizontal: 0, marginHorizontal: 0, marginTop: 0 }, + maxHeight: { maxHeight: '60%' }, }); From 695e7022ea31473db9490efa0a22d28934ca7117 Mon Sep 17 00:00:00 2001 From: CD-Z Date: Thu, 7 May 2026 16:30:06 +0200 Subject: [PATCH 10/12] Fix Accent Color picker --- .../ColorPickerModal/ColorPickerModal.tsx | 17 +++++++++++++++-- src/components/List/List.tsx | 11 +++++++---- .../LanguagePickerModal.tsx | 5 +---- .../SettingsAppearanceScreen.tsx | 3 ++- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index a59c7d1cc5..51723c3441 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -2,14 +2,16 @@ import React, { useState } from 'react'; import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { Portal, TextInput } from 'react-native-paper'; -import { Modal } from '@components'; +import { Button, Modal } from '@components'; import { ThemeColors } from '../../theme/types'; +import { Row } from '@components/Common'; +import { getString } from '@strings/translations'; interface ColorPickerModalProps { visible: boolean; title: string; color: string; - onSubmit: (val: string) => void; + onSubmit: (val: string | undefined) => void; closeModal: () => void; theme: ThemeColors; showAccentColors?: boolean; @@ -47,6 +49,10 @@ const ColorPickerModal: React.FC = ({ setError('Enter a valid hex color code'); } }; + const onReset = () => { + onSubmit(undefined); + closeModal(); + }; const accentColors = [ '#EF5350', @@ -112,6 +118,10 @@ const ColorPickerModal: React.FC = ({ error={Boolean(error)} /> {error} + +