diff --git a/.gitignore b/.gitignore index 82b92ced..093fac73 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,13 @@ flow-typed # CocoaPods /ios/Pods/ GoogleService-Info.plist +.pnpm-store/* +.worktrees/ + +# React Native Android generated resources (release bundling side effects) +/android/app/src/main/res/**/node_modules_* +/android/app/src/main/res/raw/src_common_constants_strings_*.json +/android/app/src/main/res/**/src_images_logo.png + +# Local device verification screenshots +/tmp-device-screen*.png diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 00000000..3b21e48a --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,71 @@ +# PxView Reader Fixes + +## What This Is + +This repo is an old brownfield React Native Pixiv client that the user is treating as a personal NSFW txt/equb reading app. The current project is not a broad rewrite; it is focused maintenance work to make the existing novel reader faithfully render author content, starting with inline novel images. + +## Core Value + +When opening a Pixiv novel, the reader must preserve the author's intended reading flow, including inline images in their original position. + +## Requirements + +### Validated + +- ✓ User can authenticate into Pixiv and persist session state across app restarts — existing +- ✓ User can browse Pixiv content across recommendation, ranking, search, and detail flows — existing +- ✓ User can open Pixiv novels inside the app and read them with paging, reading direction, and typography settings — existing + +### Active + +- [ ] Inline novel image markers render as images inside the body text at the original author-defined position +- [ ] Inline image failures degrade gracefully without breaking the rest of the novel page +- [ ] Existing novel reader behavior remains intact for chapter headers, jump links, reading direction, font size, and line height + +### Out of Scope + +- Local APK build, install, or startup crash fixes — separate environment/runtime problem and not part of this phase +- React Native or dependency modernization — too broad for the current maintenance goal +- Fullscreen image viewing, zooming, or gallery features for novel images — not required for the user's current need +- Generalized media-system refactor across the whole app — unnecessary for the narrow inline-image goal + +## Context + +The codebase is materially old: React Native `0.63.5`, React `16.13.1`, Redux Saga, React Navigation 5, and `react-native-htmlview` still drive the app. A codebase map already exists under `.planning/codebase/` and confirms a shared mobile monolith with novel fetching via `pixiv.novelWebview`, parsing in `src/common/helpers/novelTextParser.js`, and rendering in `src/components/NovelViewer.js`. + +The user previously got the project to produce an APK only through ad hoc fixes and does not currently trust the local runtime, dependency graph, or build flow. Because validation is weak, current work needs to minimize surface area, stay close to the established reader pipeline, and prefer parser/viewer changes that can be covered by focused tests. + +## Constraints + +- **Tech stack**: Keep the existing React Native `0.63.5` + `react-native-htmlview` reader pipeline — broad rewrites would create too much risk in an old codebase +- **Scope**: First phase is inline novel images only — the user explicitly wants one concrete behavior fixed before any build/runtime cleanup +- **Validation**: Automated and code-level verification matter more than emulator confidence right now — local install/runtime behavior is currently unreliable +- **Compatibility**: Preserve existing novel reader UX such as page order, jump links, and reading settings — this is already working and should not regress + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Keep the existing `novelWebview -> parser -> HtmlView` pipeline | Smallest-risk change in a brittle brownfield app | — Pending | +| Treat inline novel images as the first and only active feature goal | User only cares about this one reading behavior right now | — Pending | +| Exclude build/crash troubleshooting from the first phase | Avoid mixing environment problems with product behavior changes | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `$gsd-transition`): +1. Requirements invalidated? -> Move to Out of Scope with reason +2. Requirements validated? -> Move to Validated with phase reference +3. New requirements emerged? -> Add to Active +4. Decisions to log? -> Add to Key Decisions +5. "What This Is" still accurate? -> Update if drifted + +**After each milestone** (via `$gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check - still the right priority? +3. Audit Out of Scope - reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-04-01 after initialization* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..697b581e --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,54 @@ +# Requirements: PxView Reader Fixes + +**Defined:** 2026-04-01 +**Core Value:** When opening a Pixiv novel, the reader must preserve the author's intended reading flow, including inline images in their original position. + +## v1 Requirements + +### Novel Reader + +- [ ] **READ-01**: User can open a Pixiv novel containing inline image markers without seeing raw placeholder text such as `[loadedimage:24095674]` +- [ ] **READ-02**: User sees each inline novel image at the same position relative to surrounding text where the author inserted it +- [ ] **READ-03**: Inline novel images render inside the existing page flow instead of being moved to a separate media-only page +- [ ] **READ-04**: If an inline image cannot be resolved or loaded, the user can still read the rest of the page and sees an inline fallback at that position +- [ ] **READ-05**: Existing novel-reader behaviors for chapter headers, jump links, reading direction, font size, and line height continue to work after inline-image support is added + +## v2 Requirements + +### Novel Media + +- **MED-01**: User can tap an inline novel image to open a fullscreen viewer +- **MED-02**: User benefits from image prefetching or caching for novels with many inline illustrations + +### Runtime Reliability + +- **RUN-01**: User can install and launch a locally built APK without startup crashes +- **RUN-02**: User can verify inline-image behavior in a repeatable local test workflow + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| React Native dependency upgrades | Too broad for the current maintenance phase | +| Fullscreen novel-image UX | Nice-to-have, not required for the user's immediate need | +| Cross-app media refactor | Would expand far beyond the novel reader path | +| Build-system stabilization | Separate problem that should be handled after the reader behavior is fixed | + +## Traceability + +| Requirement | Phase | Status | +|-------------|-------|--------| +| READ-01 | Phase 1 | Pending | +| READ-02 | Phase 2 | Pending | +| READ-03 | Phase 2 | Pending | +| READ-04 | Phase 2 | Pending | +| READ-05 | Phase 3 | Pending | + +**Coverage:** +- v1 requirements: 5 total +- Mapped to phases: 5 +- Unmapped: 0 ✓ + +--- +*Requirements defined: 2026-04-01* +*Last updated: 2026-04-01 after initial definition* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 00000000..33793fa1 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,69 @@ +# Roadmap: PxView Reader Fixes + +**Created:** 2026-04-01 +**Project:** `PxView Reader Fixes` +**Planning Profile:** YOLO, coarse granularity, sequential execution, balanced agents + +## Summary + +This roadmap is intentionally narrow. It exists to make inline Pixiv novel image markers render correctly inside the existing reader flow without mixing in build stabilization, dependency upgrades, or general reader rewrites. + +**3 phases** | **5 v1 requirements** | **All mapped ✓** + +| # | Phase | Goal | Requirements | UI hint | +|---|-------|------|--------------|---------| +| 1 | Inline Image Parsing And Resolution | Recognize inline image markers and convert them into renderable reader data without surfacing raw placeholders | READ-01 | no | +| 2 | Inline Reader Rendering And Fallbacks | Render inline images in the body flow at the correct position and fail gracefully when image resolution breaks | READ-02, READ-03, READ-04 | yes | +| 3 | Reader Regression Coverage And Finish Pass | Prove existing novel-reader behavior still works after inline-image support is added | READ-05 | no | + +## Phase Details + +### Phase 1: Inline Image Parsing And Resolution + +**Goal:** Extend the novel text pipeline so loaded-image markup becomes a renderable inline node instead of leaking raw placeholder text into the UI. + +**Requirements:** READ-01 + +**Success criteria:** +1. The parser no longer emits raw `[loadedimage:*]` text into the novel body output. +2. Inline image markers are transformed into a stable intermediate representation that the existing reader can detect. +3. The image-resolution path is isolated to the novel reader flow and does not require a cross-app media refactor. + +### Phase 2: Inline Reader Rendering And Fallbacks + +**Goal:** Render inline images in-place inside the current reader flow while keeping the rest of the page readable if an image cannot load. + +**Requirements:** READ-02, READ-03, READ-04 + +**Success criteria:** +1. Inline images appear where the author placed them relative to surrounding text. +2. Inline images remain inside the current page flow and are not broken out into separate media pages. +3. Failed image resolution or loading shows an inline fallback state rather than crashing the reader. +4. Reader remains usable for the rest of the current page even when one image fails. + +### Phase 3: Reader Regression Coverage And Finish Pass + +**Goal:** Protect the existing novel-reader experience against regressions introduced by inline-image support. + +**Requirements:** READ-05 + +**Success criteria:** +1. Automated tests cover the new parser behavior and the custom image-node rendering path. +2. Existing chapter-header and jump-link behavior remains intact after the change. +3. Reading direction, font size, and line-height behavior still work for novels after inline-image support is introduced. + +## Coverage Check + +| Requirement | Phase | +|-------------|-------| +| READ-01 | Phase 1 | +| READ-02 | Phase 2 | +| READ-03 | Phase 2 | +| READ-04 | Phase 2 | +| READ-05 | Phase 3 | + +Coverage result: **5 / 5 requirements mapped** + +## Next Step + +Use `$gsd-discuss-phase 1` to clarify the implementation shape for inline image parsing and resolution before detailed planning or execution. diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 00000000..4b9388dc --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,32 @@ +# State + +## Project Reference + +See: `.planning/PROJECT.md` (updated 2026-04-01) + +**Core value:** When opening a Pixiv novel, the reader must preserve the author's intended reading flow, including inline images in their original position. +**Current focus:** Phase 1 - Inline Image Parsing And Resolution + +## Initialization Status + +- Codebase map: complete +- Project context: complete +- Workflow config: complete +- Requirements: complete +- Roadmap: complete + +## Active Roadmap + +- Phase 1: Inline Image Parsing And Resolution +- Phase 2: Inline Reader Rendering And Fallbacks +- Phase 3: Reader Regression Coverage And Finish Pass + +## Immediate Next Command + +`$gsd-discuss-phase 1` + +## Notes + +- This is a brownfield React Native maintenance track, not a greenfield product build. +- Local build/runtime instability is explicitly out of scope for the current roadmap. +- The first milestone is successful only when inline novel images render in-place without regressing the current reader behavior. diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..78e276ac --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,66 @@ +# Architecture + +## High-Level Shape +- This is a classic React Native monolith: one mobile app, one shared JS codebase, thin native shells in `android/` and `ios/`. +- App bootstrap is `index.js` -> `src/screens/App/Root.js` -> `src/screens/App/App.js`. +- The app chooses between authenticated and unauthenticated navigation trees based on Redux auth state in `src/screens/App/App.js`. + +## Runtime Boot Sequence +- `src/screens/App/Root.js` creates the Redux store/persistor via `src/common/store/configureStore.js`. +- Providers are layered in this order: Redux `Provider`, localization provider, safe-area provider, then Redux Persist gate. +- `src/screens/App/App.js` waits for both persistence rehydration and navigation initial state before rendering the main tree. +- Splash-screen dismissal is coupled to rehydration in `src/screens/App/App.js`. + +## Navigation Model +- Logged-out flow uses `src/navigations/AuthNavigator.js`. +- Logged-in flow uses `src/navigations/AppNavigator.js`. +- `src/navigations/AppNavigator.js` is a native stack whose root screen is `SCREENS.Main`. +- `SCREENS.Main` renders `src/navigations/AppTabNavigator.js`, which defines the bottom-tab shell. +- Tab roots are Recommended, RankingPreview, Trending, NewWorks, and MyPage in `src/navigations/AppTabNavigator.js`. +- Shared detail, reader, comments, search result, and settings screens are pushed above the tab shell in `src/navigations/AppNavigator.js`. + +## State And Data Flow +- Data flow is action-driven Redux with saga side effects. +- Action constants are centrally declared in `src/common/constants/actionTypes.js`. +- Action creators live in `src/common/actions/`. +- Side effects live in `src/common/sagas/`. +- Persistent and request-state reducers live in `src/common/reducers/`. +- Screens and containers consume denormalized selector output from `src/common/selectors/index.js`. + +## Entity Pipeline +- Pixiv responses are normalized with Normalizr in sagas such as `src/common/sagas/recommendedIllusts.js`. +- Shared entity stores live in `src/common/reducers/entities.js`. +- Per-screen/per-query reducers keep ordered IDs, loading flags, timestamps, and pagination cursors. +- Selectors denormalize entities back into render-friendly shapes and apply user-specific filters such as mute/highlight in `src/common/selectors/index.js`. + +## Authentication And Session Lifecycle +- Auth is PKCE-based web login initiated from `src/screens/Auth/Auth.js`. +- The login web view returns an auth code handled by `src/screens/Auth/Login.js`. +- Token exchange, refresh, and logout orchestration live in `src/common/sagas/auth.js`. +- Rehydration kicks off token refresh before the rest of the app proceeds in `src/common/sagas/auth.js`. +- Auth state is persisted as part of the Redux root persist config in `src/common/store/configureStore.js`. + +## Persistence And Rehydration +- The store persists selected slices to filesystem storage, not just AsyncStorage, in `src/common/store/configureStore.js`. +- There is an explicit migration path from older AsyncStorage-based persists in `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- Rehydration is coordinated with auth refresh and language reset in `src/common/sagas/auth.js`. +- Additional manual backup/restore for user settings is separate from redux-persist and lives in `src/screens/MyPage/Backup.js`. + +## UI Layering +- `src/screens/` holds route-level screens. +- `src/containers/` contains connected or semi-connected feature wrappers reused by screens. +- `src/components/` contains reusable presentational and low-level widgets, including the `PX*` component family. +- Theming and global styles are centralized in `src/styles/`. +- Localization is provided through context wrappers in `src/components/Localization/`. + +## Native Boundaries +- Native Android app host code is in `android/app/src/main/java/com/utopia/pxviewr/`. +- The main custom bridge is the Android ugoira image player under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/`. +- JS talks to that bridge via `src/components/UgoiraView.android.js`. +- iOS currently relies on stock React Native host wiring plus project-specific app delegate files under `ios/PxViewR/`. + +## Notable Architectural Traits +- The architecture is highly slice-oriented: many near-identical action/reducer/saga modules for each Pixiv resource family. +- Business logic is still close to the UI in several places, especially screens such as `src/screens/MyPage/Feedback.js` and `src/screens/MyPage/Backup.js`. +- There is no explicit service layer beyond helper modules like `src/common/helpers/apiClient.js`. +- The app favors pragmatic shared-state reuse over strict feature isolation. diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 00000000..628c49d6 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,50 @@ +# Concerns + +## Platform And Dependency Age +- The stack is materially old: React Native `0.63.5`, React `16.13.1`, Jest `25`, ESLint `6`, and many pre-2022 ecosystem packages in `package.json`. +- Upgrading this repo will likely require coordinated navigation, Android Gradle, iOS CocoaPods, and native-module work rather than a small incremental bump. +- Android still disables Hermes in `android/app/build.gradle`, which is another signal that runtime/tooling assumptions are dated. + +## Checked-In Build Artifacts And Secrets Hygiene +- Generated JS bundle artifacts are committed in `android/app/src/main/assets/index.android.bundle` and `android/app/src/main/assets/index.android.bundle.meta`, which can drift from source and create review noise. +- Debug signing material is present in `android/app/debug.keystore` and `android/keystores/debug.keystore.properties`. +- Release signing expects env/project properties in `android/app/build.gradle`; good that secrets are not hardcoded, but the repo should stay disciplined about not checking in release keystores. + +## Auth Flow Fragility +- `src/common/actions/auth.js` still exposes a username/password `login(email, password, isProvisionalAccount)` action shape, but the active login saga in `src/common/sagas/auth.js` only consumes `code` and `codeVerifier`. +- Signup currently dispatches `login(signUpResponse.user_account, signUpResponse.password, true)` from `src/common/sagas/auth.js`, which does not match the code-path the watcher actually expects. +- That mismatch is a likely maintenance trap even if some paths still work via provisional-account behavior. + +## Large Central Modules +- `src/common/selectors/index.js` is extremely large and mixes many unrelated feature selectors in one file. +- `src/common/reducers/index.js` and `src/common/sagas/index.js` are also very wide central registries. +- These files are workable today, but they make targeted refactors and code search noisier as the app grows. + +## Repetition And Drift Risk +- Many slices are copy-paste variants across actions, reducers, and sagas. +- Repetition lowers the learning curve but increases the odds of inconsistent fixes or missing one slice during changes. +- Navigation also shows legacy residue: active stack/tab navigators coexist with older route config files in `src/navigations/routeConfigs/`. + +## Error Handling Quality +- Several flows swallow errors silently, especially `src/screens/MyPage/Backup.js`. +- Saga error handling is inconsistent: some paths dispatch `addError(err)` with raw error objects, others extract messages. +- Production console suppression in `src/screens/App/Root.js` can make field debugging harder if telemetry is incomplete. + +## Persistence And Data Safety +- Auth and user settings are persisted locally in `src/common/store/configureStore.js`, including access/refresh token material inside the auth slice. +- Backup/export in `src/screens/MyPage/Backup.js` writes readable JSON files to device storage, which is convenient but increases privacy risk if the exported file is shared or left behind. +- This matters more because the app handles NSFW reading habits, mute lists, and search history. + +## Android Storage And OS Compatibility +- Backup uses `WRITE_EXTERNAL_STORAGE` from `src/screens/MyPage/Backup.js`, which is brittle on newer Android versions with scoped storage changes. +- The app likely needs a compatibility pass before modern Android target SDK upgrades. + +## Native Maintenance Burden +- Custom native ugoira playback code under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/` adds value, but it is also a long-term upgrade burden. +- Any React Native major-version move will need that bridge verified early. +- iOS still uses older Objective-C app host patterns under `ios/PxViewR/`, which is normal for the repo age but increases upgrade surface area. + +## Testing And Delivery Risk +- Automated coverage is very thin: only `__tests__/sagas/auth.spec.js` is visible. +- There is no visible CI workflow in the repo snapshot. +- The highest-value user journeys for this app, including login, reader behavior, image detail navigation, and save/share flows, are effectively protected by manual testing only. diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 00000000..b938e15a --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,57 @@ +# Conventions + +## Language And Style +- JavaScript is the dominant language; Flow annotations appear in some shared infrastructure files such as `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- Formatting/style rules are enforced through Airbnb + hooks + Prettier in `.eslintrc`. +- Single quotes and trailing commas are preferred per `.eslintrc`. +- JSX lives in `.js` files rather than `.jsx`. + +## Redux Slice Pattern +- Feature slices usually follow a repeatable trio: + - action creators in `src/common/actions/.js` + - reducer in `src/common/reducers/.js` + - saga in `src/common/sagas/.js` +- Action constants are generated with `redux-define` in `src/common/constants/actionTypes.js`. +- Request lifecycles commonly use `.REQUEST`, `.SUCCESS`, `.FAILURE`, with optional `.CLEAR`, `.CLEAR_ALL`, `.ADD`, `.REMOVE`, `.SET`, or `.RESTORE`. +- Reducers usually keep `loading`, `loaded`, `refreshing`, `items`, pagination, and timestamps. + +## Entity And Selector Conventions +- Network responses are normalized before storage, then denormalized in selectors. +- Shared entities are merged into `src/common/reducers/entities.js`. +- Selector factories are preferred for route-bound data needs, using names like `makeGetUserIllustsItems` in `src/common/selectors/index.js`. +- Memoization is customized with shallow equality and domain-specific comparisons in `src/common/selectors/index.js`. + +## UI Composition +- Route screens live under `src/screens/`. +- Reusable presentational pieces live under `src/components/`. +- Connected wrappers and modal bridges live under `src/containers/`. +- The codebase mixes class components and function components; newer/simple surfaces often use hooks while older screens remain class-based. +- The custom `PX*` component family acts as an internal design system, for example `src/components/PXImage.js`, `src/components/PXTouchable.js`, and `src/components/PXSearchBar.js`. + +## Localization And Theme +- Localization is context-driven through `src/components/Localization/LocalizationProvider.js` and `src/components/Localization/connectLocalization.js`. +- Screens/components commonly expect injected `i18n` and `lang` props rather than importing strings directly. +- Theme colors are read from React Native Paper and shared style helpers in `src/styles/index.js`. +- Global string catalogs are JSON-based under `src/common/constants/strings/`. + +## Persistence Conventions +- Important user-state slices are whitelisted in `src/common/store/configureStore.js`. +- Manual backup/restore only covers selected settings/history slices, also in `src/screens/MyPage/Backup.js`. +- Migrations are handled in store setup instead of separate migration modules per slice. + +## Error Handling +- Network sagas usually catch exceptions, dispatch a local failure action, then dispatch `addError(...)`, for example `src/common/sagas/recommendedIllusts.js`. +- Some UI flows swallow errors with empty catches, notably `src/screens/MyPage/Backup.js`. +- Console noise is disabled in production in `src/screens/App/Root.js`. + +## Naming And File Organization +- Files are predominantly PascalCase for components/screens and camelCase for common modules. +- Constants use upper snake case in `src/common/constants/actionTypes.js`. +- Platform-specific component splits use suffixes like `.android.js` and `.ios.js`, for example `src/components/UgoiraView.android.js`. +- Legacy and current navigation code coexist; active navigation is in `src/navigations/*.js`, while `src/navigations/routeConfigs/` looks partially historical. + +## Repeated Implementation Patterns +- Many feature sagas are near-clones with different Pixiv endpoints. +- Many reducers share the same request-success-failure shape. +- Screens often own direct navigation-header configuration and view-specific side effects. +- This repetition makes the codebase easy to pattern-match, but expensive to update globally. diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 00000000..4dcae82f --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,50 @@ +# Integrations + +## Primary External Service: Pixiv +- All core content fetches and account operations run through `pixiv-api-client` instantiated in `src/common/helpers/apiClient.js`. +- Authentication uses Pixiv's web login flow with PKCE from `src/screens/Auth/Auth.js`, `src/screens/Auth/Login.js`, and `src/common/helpers/pkce.js`. +- Deep-link support accepts Pixiv URLs and the `pixiv://` scheme in `src/screens/App/App.js`. +- Shared content screens such as `src/screens/Shared/Detail.js`, `src/screens/Shared/NovelDetail.js`, and `src/screens/Shared/UserDetail.js` are the main consumers of Pixiv-derived entities. + +## Firebase +- Firebase app modules are installed from `@react-native-firebase/app`, `analytics`, `crashlytics`, `database`, and `perf` in `package.json`. +- Android plugin integration is configured in `android/build.gradle` and `android/app/build.gradle`. +- Repo-level Firebase runtime flags live in `firebase.json`. +- `README.md` expects local `google-services.json` in `android/app/` and `GoogleService-Info.plist` in `ios/`; those files are intentionally not checked in. + +## Firebase Analytics And Performance +- Navigation screen views are logged from `src/screens/App/App.js`. +- Detail and profile screens emit additional events from `src/screens/Shared/Detail.js`, `src/screens/Shared/NovelDetail.js`, and `src/screens/Shared/UserDetail.js`. +- Firebase Perf is wired at build level through `android/app/build.gradle` and the iOS pods lockfile `ios/Podfile.lock`. + +## Firebase Realtime Database +- In-app feedback writes directly to Realtime Database path `feedback` from `src/screens/MyPage/Feedback.js`. +- Captured metadata includes device, locale, app version, and optional email in `src/screens/MyPage/Feedback.js`. +- There is no app-side abstraction layer for feedback writes; the screen talks to Firebase directly. + +## Native Platform Integrations +- Custom Android native module/package for ugoira playback is implemented in `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/UgoiraViewPackage.java`. +- JS/native bridge surface for that component is `src/components/UgoiraView.android.js`. +- Android host wiring adds the custom package in `android/app/src/main/java/com/utopia/pxviewr/MainApplication.java`. +- iOS native app lifecycle still uses Objective-C files such as `ios/PxViewR/AppDelegate.m` and `ios/PxViewR/main.m`. + +## Device And OS APIs +- Device metadata uses `react-native-device-info` in `src/common/helpers/apiClient.js`, `src/screens/MyPage/Backup.js`, and `src/screens/MyPage/Feedback.js`. +- Locale detection uses `react-native-localization` and `react-native-localize` in `src/common/helpers/i18n.js` and `src/screens/MyPage/Feedback.js`. +- External storage permissions and file IO use `rn-fetch-blob` in `src/screens/MyPage/Backup.js`. +- Photo library access is enabled by `@react-native-community/cameraroll` from `package.json`. + +## Web And Sharing Surfaces +- Embedded browser login and likely other web content use `react-native-webview` through `src/components/PXWebView.js` and `src/screens/Auth/Login.js`. +- Share flows depend on `react-native-share` from `package.json`. +- App can interpret Pixiv deep links and URL-based navigation in `src/screens/App/App.js` and `src/components/DetailFooter.js`. + +## Persistence And Local Backup +- Redux state persistence uses filesystem-backed storage in `src/common/store/configureStore.js`. +- Legacy persisted state migration is handled in `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- User-controlled backup/restore exports JSON files to device storage from `src/screens/MyPage/Backup.js`. + +## What Is Not Present +- No custom backend service, REST API, GraphQL endpoint, or webhook receiver is present in this repo. +- No push notification provider, payments SDK, ads SDK, or custom server auth layer is visible. +- No cloud storage abstraction beyond Firebase Realtime Database feedback and device-local filesystem export exists in the current codebase. diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 00000000..4b66015f --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,56 @@ +# Stack + +## Summary +- Mobile app built with JavaScript + Flow on React Native, targeting Android and iOS from one shared codebase. +- Main product framing in `README.md`, runtime entry in `index.js`, and app registration in `src/screens/App/Root.js`. +- Project version is `5.3.0` in `package.json`; Android release version is `5.3` / code `404` in `android/app/build.gradle`. + +## Core Runtime +- React `16.13.1` and React Native `0.63.5` from `package.json`. +- Shared app bootstrap runs through `src/screens/App/Root.js` and `src/screens/App/App.js`. +- Flow is enabled via `.flowconfig`; there is no TypeScript footprint. +- Metro config is minimal in `metro.config.js`; Hermes is disabled in `android/app/build.gradle`. + +## State And Async Stack +- Redux, React Redux, Redux Saga, Redux Persist, Reselect, and Normalizr are the main state-management layers in `package.json`. +- Store setup lives in `src/common/store/configureStore.js`. +- Root reducers live in `src/common/reducers/index.js`. +- Root sagas live in `src/common/sagas/index.js`. +- Entity normalization schemas live in `src/common/constants/schemas.js`. + +## Navigation And UI +- Navigation uses React Navigation 5 packages plus `react-native-screens` and material bottom tabs from `package.json`. +- Primary app navigation is composed in `src/navigations/AppNavigator.js` and `src/navigations/AppTabNavigator.js`. +- UI kit is mostly `react-native-paper`, `react-native-elements`, `react-native-vector-icons`, and custom `PX*` components under `src/components/`. +- Theming primitives live in `src/styles/index.js` and `src/styles/variables.js`. + +## Domain And Content Handling +- Pixiv API access is provided by `pixiv-api-client` through `src/common/helpers/apiClient.js`. +- Novel text parsing uses `pixiv-novel-parser` in `src/common/helpers/novelTextParser.js`. +- PKCE login support uses `react-native-pkce-challenge` through `src/common/helpers/pkce.js`. +- NSFW/equb reader behavior is primarily expressed in the shared detail and reader screens under `src/screens/Shared/`. + +## Device, Storage, And Media +- Persistence uses `@react-native-community/async-storage` and `redux-persist-filesystem-storage`. +- File export/import and backup flows use `rn-fetch-blob` in `src/screens/MyPage/Backup.js`. +- Image saving, sharing, photo viewing, and web views rely on packages such as `react-native-share`, `react-native-photo-view-ex`, and `react-native-webview`. +- Animated Pixiv ugoira playback has a custom Android native view under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/` and JS wrappers in `src/components/UgoiraView.android.js` and `src/components/UgoiraView.ios.js`. + +## Observability And Native Tooling +- Firebase Analytics, Crashlytics, Realtime Database, and Performance Monitoring are installed via `@react-native-firebase/*` packages in `package.json`. +- Android applies Google services, Crashlytics, and Firebase Perf plugins in `android/app/build.gradle`. +- Runtime Firebase toggles are declared in `firebase.json`. +- Android debug memory tooling includes LeakCanary in `android/app/build.gradle` and `android/app/src/main/java/com/utopia/pxviewr/MainApplication.java`. +- Flipper is enabled for debug builds in `android/app/src/debug/java/com/utopia/pxviewr/ReactNativeFlipper.java`. + +## Build And Repo Tooling +- Package manager is npm with lockfile `package-lock.json`. +- Main scripts are in `package.json`: `start`, `android`, `ios`, `android-bundle`, `pod-install`, `lint`, and `test`. +- Linting is ESLint + Airbnb + Prettier via `.eslintrc` and the `lint` script in `package.json`. +- Jest is configured inline in `package.json`. +- iOS native workspace/project files live under `ios/` and `PxView.xcworkspace/`. + +## Notable Checked-In Artifacts +- Android release/debug bundle artifacts are committed in `android/app/src/main/assets/index.android.bundle` and `android/app/src/main/assets/index.android.bundle.meta`. +- Android debug keystore material exists in `android/app/debug.keystore` and `android/keystores/debug.keystore.properties`. +- Screenshot and marketing assets are committed under `screenshots/`, `src/images/`, and `donations/`. diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 00000000..02ae5327 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,65 @@ +# Structure + +## Root Layout +- `package.json`, `package-lock.json`, `.eslintrc`, `.flowconfig`, and `metro.config.js` define the JS workspace. +- `index.js` is the JS entry point. +- `README.md` is still the main onboarding document. +- `android/` and `ios/` contain the native projects. +- `src/` contains nearly all shared product logic. + +## Source Tree +- `src/screens/` contains route-level UI grouped mostly by product area: + - `src/screens/App/` + - `src/screens/Auth/` + - `src/screens/Recommended/` + - `src/screens/Ranking/` + - `src/screens/Trending/` + - `src/screens/NewWorks/` + - `src/screens/MyPage/` + - `src/screens/Shared/` +- `src/navigations/` contains top-level navigator definitions and some legacy route config fragments under `src/navigations/routeConfigs/`. +- `src/components/` contains reusable view primitives, headers, overlays, lists, and the `Localization/` helpers. +- `src/containers/` contains stateful wrappers, modal containers, and feature-specific connected helpers. + +## Shared Application Core +- `src/common/actions/` contains action creators, generally one file per domain slice. +- `src/common/reducers/` contains Redux reducers, mirroring action filenames closely. +- `src/common/sagas/` contains side effects, again mirroring slice names. +- `src/common/selectors/index.js` centralizes selector factories and derived-state logic. +- `src/common/store/` contains store setup and persist migration code. +- `src/common/helpers/` contains low-level helpers such as API client, PKCE, search period logic, i18n setup, and novel parsing. +- `src/common/constants/` contains action constants, schemas, strings, and shared enumerations. +- `src/common/config/` switches between `env.dev.js` and `env.prod.js`. + +## Naming Patterns +- Screen files are PascalCase and generally named after the route or feature, for example `src/screens/Shared/NovelDetail.js`. +- Action, reducer, and saga files use matching camelCase filenames such as: + - `src/common/actions/recommendedIllusts.js` + - `src/common/reducers/recommendedIllusts.js` + - `src/common/sagas/recommendedIllusts.js` +- Reusable UI primitives often use the `PX` prefix, for example `src/components/PXImage.js` and `src/components/PXSnackbar.js`. +- Selector factories often use the `makeGet...` naming convention in `src/common/selectors/index.js`. + +## Platform-Specific Layout +- Android app code lives under `android/app/src/main/`. +- Android custom native view code is isolated under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/`. +- Android assets include bundled JS and fonts under `android/app/src/main/assets/`. +- iOS app sources live under `ios/PxViewR/`, with tests under `ios/PxViewRTests/`. +- Workspace/project metadata exists both under `ios/PxViewR.xcodeproj/` and top-level `PxView.xcworkspace/`. + +## Assets And Content +- Static images live in `src/images/`. +- Screenshots for docs/store listings live in `screenshots/android/` and `screenshots/ios/`. +- Donation and privacy-policy markdown content lives in `donations/` and `privacy-policy/`. +- Localized strings are JSON files under `src/common/constants/strings/`. + +## Testing And Mocks +- Tests currently live in `__tests__/`, with only saga coverage visible. +- Jest mocks live in `__mocks__/`. +- There is no visible `.github/` directory or CI-specific test orchestration in the repo snapshot. + +## Repo Shape Observations +- The codebase is wide rather than deep: many parallel feature files instead of a smaller number of large shared abstractions. +- `src/screens/Shared/` is a major hub for cross-feature route implementations. +- `src/common/selectors/index.js` is a very large central module rather than a set of per-feature selector files. +- Some generated or build-output files are committed, notably `android/app/src/main/assets/index.android.bundle`. diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 00000000..028ce76c --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,56 @@ +# Testing + +## Current Coverage Snapshot +- Jest is the only explicit test runner configured, via the `test` and `test:watch` scripts in `package.json`. +- Jest config is embedded directly in `package.json`. +- Visible automated test coverage is limited to a single saga spec: `__tests__/sagas/auth.spec.js`. +- Mock modules live in `__mocks__/react-native-localization.js` and `__mocks__/react-native-device-info.js`. + +## What Is Being Tested +- `__tests__/sagas/auth.spec.js` exercises auth saga control flow using `@redux-saga/testing-utils`. +- The auth tests validate generator behavior for: + - authorize/token exchange + - refresh token flow + - logout behavior + - signup flow + - rehydrate flow +- Tests focus on yielded saga effects rather than mounted UI behavior. + +## What Is Not Covered +- No visible component tests for `src/components/`. +- No screen tests for `src/screens/`. +- No selector tests for `src/common/selectors/index.js`. +- No reducer tests for the many Redux slices. +- No native Android/iOS unit tests beyond the default iOS test target scaffold under `ios/PxViewRTests/`. +- No end-to-end mobile automation is present in the repo. + +## Mocking Strategy +- Tests rely on static mocks and saga-effect assertions rather than full app integration. +- The auth test imports the real helper module path `src/common/helpers/apiClient` but asserts yielded `apply(...)` effects instead of making network calls. +- Device and localization dependencies are mocked through the `__mocks__/` directory. + +## Verification Workflow In Practice +- Expected local checks from `README.md` are: + - `npm test` + - `npm run lint` +- Native smoke testing is likely manual through: + - `npm run android` + - `npm run ios` +- Because the app is React Native and content-heavy, visual/manual checks on detail, reader, search, and login flows are still important. + +## Testability Characteristics +- Saga slices are fairly testable because side effects are isolated in `src/common/sagas/`. +- Selectors are also testable in principle, but `src/common/selectors/index.js` is large and would benefit from being split for focused unit tests. +- UI layers mix hooks, class components, navigation, and localization injection, which raises the setup cost for component tests. + +## Major Gaps +- No regression coverage for the Pixiv reader/detail experiences that define the product. +- No explicit tests around NSFW/equb content parsing in `src/common/helpers/novelTextParser.js`. +- No tests around persistence migration in `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- No visible CI gate to ensure tests/lint actually run on every change. + +## Recommended Next Testing Areas +- Add selector tests for the mute/highlight filtering logic in `src/common/selectors/index.js`. +- Add reducer tests for high-risk persisted settings and entity mutation in `src/common/reducers/entities.js`. +- Add helper tests for PKCE, deep-link parsing, and novel parsing helpers. +- Add smoke coverage for login, detail, and reader navigation flows before major refactors. diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 00000000..c5d7e08b --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,37 @@ +{ + "model_profile": "balanced", + "commit_docs": true, + "parallelization": false, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "git": { + "branching_strategy": "none", + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": false, + "plan_check": true, + "verifier": true, + "nyquist_validation": false, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false + }, + "hooks": { + "context_warnings": true + }, + "agent_skills": {}, + "resolve_model_ids": "omit", + "mode": "yolo", + "granularity": "coarse" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..cb407f3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ + +## Project + +**PxView Reader Fixes** + +This repo is an old brownfield React Native Pixiv client that the user is treating as a personal NSFW txt/equb reading app. The current project is not a broad rewrite; it is focused maintenance work to make the existing novel reader faithfully render author content, starting with inline novel images. + +**Core Value:** When opening a Pixiv novel, the reader must preserve the author's intended reading flow, including inline images in their original position. + +### Constraints + +- **Tech stack**: Keep the existing React Native `0.63.5` + `react-native-htmlview` reader pipeline — broad rewrites would create too much risk in an old codebase +- **Scope**: First phase is inline novel images only — the user explicitly wants one concrete behavior fixed before any build/runtime cleanup +- **Validation**: Automated and code-level verification matter more than emulator confidence right now — local install/runtime behavior is currently unreliable +- **Compatibility**: Preserve existing novel reader UX such as page order, jump links, and reading settings — this is already working and should not regress + + + +## Technology Stack + +## Summary +- Mobile app built with JavaScript + Flow on React Native, targeting Android and iOS from one shared codebase. +- Main product framing in `README.md`, runtime entry in `index.js`, and app registration in `src/screens/App/Root.js`. +- Project version is `5.3.0` in `package.json`; Android release version is `5.3` / code `404` in `android/app/build.gradle`. +## Core Runtime +- React `16.13.1` and React Native `0.63.5` from `package.json`. +- Shared app bootstrap runs through `src/screens/App/Root.js` and `src/screens/App/App.js`. +- Flow is enabled via `.flowconfig`; there is no TypeScript footprint. +- Metro config is minimal in `metro.config.js`; Hermes is disabled in `android/app/build.gradle`. +## State And Async Stack +- Redux, React Redux, Redux Saga, Redux Persist, Reselect, and Normalizr are the main state-management layers in `package.json`. +- Store setup lives in `src/common/store/configureStore.js`. +- Root reducers live in `src/common/reducers/index.js`. +- Root sagas live in `src/common/sagas/index.js`. +- Entity normalization schemas live in `src/common/constants/schemas.js`. +## Navigation And UI +- Navigation uses React Navigation 5 packages plus `react-native-screens` and material bottom tabs from `package.json`. +- Primary app navigation is composed in `src/navigations/AppNavigator.js` and `src/navigations/AppTabNavigator.js`. +- UI kit is mostly `react-native-paper`, `react-native-elements`, `react-native-vector-icons`, and custom `PX*` components under `src/components/`. +- Theming primitives live in `src/styles/index.js` and `src/styles/variables.js`. +## Domain And Content Handling +- Pixiv API access is provided by `pixiv-api-client` through `src/common/helpers/apiClient.js`. +- Novel text parsing uses `pixiv-novel-parser` in `src/common/helpers/novelTextParser.js`. +- PKCE login support uses `react-native-pkce-challenge` through `src/common/helpers/pkce.js`. +- NSFW/equb reader behavior is primarily expressed in the shared detail and reader screens under `src/screens/Shared/`. +## Device, Storage, And Media +- Persistence uses `@react-native-community/async-storage` and `redux-persist-filesystem-storage`. +- File export/import and backup flows use `rn-fetch-blob` in `src/screens/MyPage/Backup.js`. +- Image saving, sharing, photo viewing, and web views rely on packages such as `react-native-share`, `react-native-photo-view-ex`, and `react-native-webview`. +- Animated Pixiv ugoira playback has a custom Android native view under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/` and JS wrappers in `src/components/UgoiraView.android.js` and `src/components/UgoiraView.ios.js`. +## Observability And Native Tooling +- Firebase Analytics, Crashlytics, Realtime Database, and Performance Monitoring are installed via `@react-native-firebase/*` packages in `package.json`. +- Android applies Google services, Crashlytics, and Firebase Perf plugins in `android/app/build.gradle`. +- Runtime Firebase toggles are declared in `firebase.json`. +- Android debug memory tooling includes LeakCanary in `android/app/build.gradle` and `android/app/src/main/java/com/utopia/pxviewr/MainApplication.java`. +- Flipper is enabled for debug builds in `android/app/src/debug/java/com/utopia/pxviewr/ReactNativeFlipper.java`. +## Build And Repo Tooling +- Package manager is npm with lockfile `package-lock.json`. +- Main scripts are in `package.json`: `start`, `android`, `ios`, `android-bundle`, `pod-install`, `lint`, and `test`. +- Linting is ESLint + Airbnb + Prettier via `.eslintrc` and the `lint` script in `package.json`. +- Jest is configured inline in `package.json`. +- iOS native workspace/project files live under `ios/` and `PxView.xcworkspace/`. +## Notable Checked-In Artifacts +- Android release/debug bundle artifacts are committed in `android/app/src/main/assets/index.android.bundle` and `android/app/src/main/assets/index.android.bundle.meta`. +- Android debug keystore material exists in `android/app/debug.keystore` and `android/keystores/debug.keystore.properties`. +- Screenshot and marketing assets are committed under `screenshots/`, `src/images/`, and `donations/`. + + + +## Conventions + +## Language And Style +- JavaScript is the dominant language; Flow annotations appear in some shared infrastructure files such as `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- Formatting/style rules are enforced through Airbnb + hooks + Prettier in `.eslintrc`. +- Single quotes and trailing commas are preferred per `.eslintrc`. +- JSX lives in `.js` files rather than `.jsx`. +## Redux Slice Pattern +- Feature slices usually follow a repeatable trio: +- Action constants are generated with `redux-define` in `src/common/constants/actionTypes.js`. +- Request lifecycles commonly use `.REQUEST`, `.SUCCESS`, `.FAILURE`, with optional `.CLEAR`, `.CLEAR_ALL`, `.ADD`, `.REMOVE`, `.SET`, or `.RESTORE`. +- Reducers usually keep `loading`, `loaded`, `refreshing`, `items`, pagination, and timestamps. +## Entity And Selector Conventions +- Network responses are normalized before storage, then denormalized in selectors. +- Shared entities are merged into `src/common/reducers/entities.js`. +- Selector factories are preferred for route-bound data needs, using names like `makeGetUserIllustsItems` in `src/common/selectors/index.js`. +- Memoization is customized with shallow equality and domain-specific comparisons in `src/common/selectors/index.js`. +## UI Composition +- Route screens live under `src/screens/`. +- Reusable presentational pieces live under `src/components/`. +- Connected wrappers and modal bridges live under `src/containers/`. +- The codebase mixes class components and function components; newer/simple surfaces often use hooks while older screens remain class-based. +- The custom `PX*` component family acts as an internal design system, for example `src/components/PXImage.js`, `src/components/PXTouchable.js`, and `src/components/PXSearchBar.js`. +## Localization And Theme +- Localization is context-driven through `src/components/Localization/LocalizationProvider.js` and `src/components/Localization/connectLocalization.js`. +- Screens/components commonly expect injected `i18n` and `lang` props rather than importing strings directly. +- Theme colors are read from React Native Paper and shared style helpers in `src/styles/index.js`. +- Global string catalogs are JSON-based under `src/common/constants/strings/`. +## Persistence Conventions +- Important user-state slices are whitelisted in `src/common/store/configureStore.js`. +- Manual backup/restore only covers selected settings/history slices, also in `src/screens/MyPage/Backup.js`. +- Migrations are handled in store setup instead of separate migration modules per slice. +## Error Handling +- Network sagas usually catch exceptions, dispatch a local failure action, then dispatch `addError(...)`, for example `src/common/sagas/recommendedIllusts.js`. +- Some UI flows swallow errors with empty catches, notably `src/screens/MyPage/Backup.js`. +- Console noise is disabled in production in `src/screens/App/Root.js`. +## Naming And File Organization +- Files are predominantly PascalCase for components/screens and camelCase for common modules. +- Constants use upper snake case in `src/common/constants/actionTypes.js`. +- Platform-specific component splits use suffixes like `.android.js` and `.ios.js`, for example `src/components/UgoiraView.android.js`. +- Legacy and current navigation code coexist; active navigation is in `src/navigations/*.js`, while `src/navigations/routeConfigs/` looks partially historical. +## Repeated Implementation Patterns +- Many feature sagas are near-clones with different Pixiv endpoints. +- Many reducers share the same request-success-failure shape. +- Screens often own direct navigation-header configuration and view-specific side effects. +- This repetition makes the codebase easy to pattern-match, but expensive to update globally. + + + +## Architecture + +## High-Level Shape +- This is a classic React Native monolith: one mobile app, one shared JS codebase, thin native shells in `android/` and `ios/`. +- App bootstrap is `index.js` -> `src/screens/App/Root.js` -> `src/screens/App/App.js`. +- The app chooses between authenticated and unauthenticated navigation trees based on Redux auth state in `src/screens/App/App.js`. +## Runtime Boot Sequence +- `src/screens/App/Root.js` creates the Redux store/persistor via `src/common/store/configureStore.js`. +- Providers are layered in this order: Redux `Provider`, localization provider, safe-area provider, then Redux Persist gate. +- `src/screens/App/App.js` waits for both persistence rehydration and navigation initial state before rendering the main tree. +- Splash-screen dismissal is coupled to rehydration in `src/screens/App/App.js`. +## Navigation Model +- Logged-out flow uses `src/navigations/AuthNavigator.js`. +- Logged-in flow uses `src/navigations/AppNavigator.js`. +- `src/navigations/AppNavigator.js` is a native stack whose root screen is `SCREENS.Main`. +- `SCREENS.Main` renders `src/navigations/AppTabNavigator.js`, which defines the bottom-tab shell. +- Tab roots are Recommended, RankingPreview, Trending, NewWorks, and MyPage in `src/navigations/AppTabNavigator.js`. +- Shared detail, reader, comments, search result, and settings screens are pushed above the tab shell in `src/navigations/AppNavigator.js`. +## State And Data Flow +- Data flow is action-driven Redux with saga side effects. +- Action constants are centrally declared in `src/common/constants/actionTypes.js`. +- Action creators live in `src/common/actions/`. +- Side effects live in `src/common/sagas/`. +- Persistent and request-state reducers live in `src/common/reducers/`. +- Screens and containers consume denormalized selector output from `src/common/selectors/index.js`. +## Entity Pipeline +- Pixiv responses are normalized with Normalizr in sagas such as `src/common/sagas/recommendedIllusts.js`. +- Shared entity stores live in `src/common/reducers/entities.js`. +- Per-screen/per-query reducers keep ordered IDs, loading flags, timestamps, and pagination cursors. +- Selectors denormalize entities back into render-friendly shapes and apply user-specific filters such as mute/highlight in `src/common/selectors/index.js`. +## Authentication And Session Lifecycle +- Auth is PKCE-based web login initiated from `src/screens/Auth/Auth.js`. +- The login web view returns an auth code handled by `src/screens/Auth/Login.js`. +- Token exchange, refresh, and logout orchestration live in `src/common/sagas/auth.js`. +- Rehydration kicks off token refresh before the rest of the app proceeds in `src/common/sagas/auth.js`. +- Auth state is persisted as part of the Redux root persist config in `src/common/store/configureStore.js`. +## Persistence And Rehydration +- The store persists selected slices to filesystem storage, not just AsyncStorage, in `src/common/store/configureStore.js`. +- There is an explicit migration path from older AsyncStorage-based persists in `src/common/store/getStoredStateMigrateToFileSystemStorage.js`. +- Rehydration is coordinated with auth refresh and language reset in `src/common/sagas/auth.js`. +- Additional manual backup/restore for user settings is separate from redux-persist and lives in `src/screens/MyPage/Backup.js`. +## UI Layering +- `src/screens/` holds route-level screens. +- `src/containers/` contains connected or semi-connected feature wrappers reused by screens. +- `src/components/` contains reusable presentational and low-level widgets, including the `PX*` component family. +- Theming and global styles are centralized in `src/styles/`. +- Localization is provided through context wrappers in `src/components/Localization/`. +## Native Boundaries +- Native Android app host code is in `android/app/src/main/java/com/utopia/pxviewr/`. +- The main custom bridge is the Android ugoira image player under `android/app/src/main/java/com/utopia/pxviewr/UgoiraView/`. +- JS talks to that bridge via `src/components/UgoiraView.android.js`. +- iOS currently relies on stock React Native host wiring plus project-specific app delegate files under `ios/PxViewR/`. +## Notable Architectural Traits +- The architecture is highly slice-oriented: many near-identical action/reducer/saga modules for each Pixiv resource family. +- Business logic is still close to the UI in several places, especially screens such as `src/screens/MyPage/Feedback.js` and `src/screens/MyPage/Backup.js`. +- There is no explicit service layer beyond helper modules like `src/common/helpers/apiClient.js`. +- The app favors pragmatic shared-state reuse over strict feature isolation. + + + +## GSD Workflow Enforcement + +Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. + +Use these entry points: +- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks +- `/gsd:debug` for investigation and bug fixing +- `/gsd:execute-phase` for planned phase work + +Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. + + + + + +## Developer Profile + +> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile. +> This section is managed by `generate-claude-profile` -- do not edit manually. + diff --git a/__tests__/components/NovelInlineImage.spec.js b/__tests__/components/NovelInlineImage.spec.js new file mode 100644 index 00000000..faf3d53c --- /dev/null +++ b/__tests__/components/NovelInlineImage.spec.js @@ -0,0 +1,498 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; + +const illustDetail = jest.fn(); + +jest.mock('../../src/common/helpers/apiClient', () => ({ + __esModule: true, + default: { + illustDetail: (...args) => illustDetail(...args), + }, +})); + +jest.mock('../../src/components/PXImage', () => { + const React = require('react'); + const { Image } = require('react-native'); + + return function MockPXImage(props) { + return React.createElement(Image, { + ...props, + testID: props.testID || `px-image-${props.uri}`, + }); + }; +}); + +const NovelInlineImage = require('../../src/components/NovelInlineImage').default; + +const flushPromises = () => Promise.resolve(); + +const findHostNodeByAccessibilityLabel = (root, accessibilityLabel, type) => + root.find( + (node) => + node.type === type && + node.props.accessibilityLabel === accessibilityLabel, + ); + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe('NovelInlineImage', () => { + beforeEach(() => { + illustDetail.mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('shows loading while the image request is in flight', async () => { + const deferred = createDeferred(); + illustDetail.mockReturnValueOnce(deferred.promise); + + const tree = renderer.create(); + + expect(tree.toJSON().type).toBe('Text'); + expect(JSON.stringify(tree.toJSON())).toContain('Loading image...'); + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Text', + ).props.children, + ).toBe('Loading image...'); + }); + + it('falls back when the image request resolves without a URL', async () => { + illustDetail.mockResolvedValueOnce({ illust: {} }); + + let tree; + await act(async () => { + tree = renderer.create(); + await flushPromises(); + }); + + expect(tree.toJSON().type).toBe('Text'); + expect(JSON.stringify(tree.toJSON())).toContain('Image unavailable'); + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Text', + ).props.children, + ).toBe('Image unavailable (illust detail has no usable url)'); + }); + + it('falls back when the image request rejects', async () => { + illustDetail.mockRejectedValueOnce(new Error('request failed')); + + let tree; + await act(async () => { + tree = renderer.create(); + await flushPromises(); + }); + + expect(tree.toJSON().type).toBe('Text'); + expect(JSON.stringify(tree.toJSON())).toContain('Image unavailable'); + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Text', + ).props.children, + ).toBe('Image unavailable (illust detail request failed)'); + }); + + it('forwards stable props to PXImage and falls back if the rendered image reports an error', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + width: 120, + height: 60, + image_urls: { large: 'https://example.com/image.jpg' }, + }, + }); + + let tree; + await act(async () => { + tree = renderer.create(); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Image', + ); + + expect(imageNode.props.uri).toBe('https://example.com/image.jpg'); + expect(imageNode.props.resizeMode).toBe('contain'); + expect(imageNode.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: '#f2f2f2' }), + expect.objectContaining({ aspectRatio: 2 }), + ]), + ); + + await act(async () => { + imageNode.props.onError(); + }); + + expect(tree.toJSON().type).toBe('Text'); + expect(JSON.stringify(tree.toJSON())).toContain('Image unavailable'); + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Text', + ).props.children, + ).toBe('Image unavailable (image request failed)'); + }); + + it('renders loaded images without a block wrapper and preserves the accessibility label', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/image.jpg' }, + }, + }); + + let tree; + await act(async () => { + tree = renderer.create(); + await flushPromises(); + }); + + expect(tree.toJSON().type).toBe('Image'); + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Image', + ); + + expect(imageNode.props.uri).toBe('https://example.com/image.jpg'); + }); + + it('renders uploaded novel images from embedded image metadata without calling illustDetail', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(imageNode.props.uri).toBe( + 'https://example.com/uploaded-original.jpg', + ); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('renders uploaded novel images when Pixiv only provides sized textEmbeddedImages urls', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(imageNode.props.uri).toBe('https://example.com/uploaded-1200.jpg'); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('renders uploaded novel images when metadata is only discoverable by matching embedded image values', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(imageNode.props.uri).toBe( + 'https://example.com/uploaded-by-value.jpg', + ); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('renders uploaded novel images when embedded metadata uses illustId instead of id', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(imageNode.props.uri).toBe( + 'https://example.com/uploaded-by-illust-id.jpg', + ); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('renders uploaded novel images when glossary-style metadata uses imageId and coverUrl', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(imageNode.props.uri).toBe( + 'https://example.com/uploaded-by-glossary-cover-url.jpg', + ); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('shows when uploadedimage metadata is completely missing', async () => { + let tree; + + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Text', + ).props.children, + ).toBe('Image unavailable (no embedded image metadata)'); + }); + + it('uses the requested pixivimage page when resolving multi-page illustrations', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + meta_pages: [ + { image_urls: { original: 'https://example.com/page-1.jpg' } }, + { image_urls: { original: 'https://example.com/page-2.jpg' } }, + ], + }, + }); + + let tree; + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24095674', + 'Image', + ); + + expect(imageNode.props.uri).toBe('https://example.com/page-2.jpg'); + }); + + it('ignores stale responses after the illust id changes', async () => { + const first = createDeferred(); + const second = createDeferred(); + illustDetail + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const tree = renderer.create(); + + await act(async () => { + tree.update(); + second.resolve({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/second.jpg' }, + }, + }); + await second.promise; + await flushPromises(); + }); + + await act(async () => { + first.resolve({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/first.jpg' }, + }, + }); + await first.promise; + await flushPromises(); + }); + + expect(JSON.stringify(tree.toJSON())).toContain('px-image-https://example.com/second.jpg'); + expect(JSON.stringify(tree.toJSON())).not.toContain('px-image-https://example.com/first.jpg'); + }); + + it('ignores in-flight responses after unmount', async () => { + const deferred = createDeferred(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + illustDetail.mockReturnValueOnce(deferred.promise); + + const tree = renderer.create(); + + tree.unmount(); + + await act(async () => { + deferred.resolve({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/unmounted.jpg' }, + }, + }); + await deferred.promise; + }); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('shows when a resolved uploaded image url still fails to load', async () => { + let tree; + await act(async () => { + tree = renderer.create( + , + ); + await flushPromises(); + }); + + const imageNode = findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Image', + ); + + await act(async () => { + imageNode.props.onError(); + }); + + expect( + findHostNodeByAccessibilityLabel( + tree.root, + 'novel-inline-image-24115550', + 'Text', + ).props.children, + ).toBe('Image unavailable (image request failed)'); + }); +}); diff --git a/__tests__/components/NovelViewer.spec.js b/__tests__/components/NovelViewer.spec.js new file mode 100644 index 00000000..2f9a0cff --- /dev/null +++ b/__tests__/components/NovelViewer.spec.js @@ -0,0 +1,871 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { Linking } from 'react-native'; + +const illustDetail = jest.fn(); + +jest.mock('../../src/common/helpers/apiClient', () => ({ + __esModule: true, + default: { + illustDetail: (...args) => illustDetail(...args), + }, +})); + +jest.mock('react-native-reanimated', () => ({ + __esModule: true, + default: {}, +})); +jest.mock('react-native-tab-view', () => { + const React = require('react'); + const MockTabView = ({ navigationState, renderScene }) => + renderScene({ + route: navigationState.routes[navigationState.index], + }); + + return { + __esModule: true, + TabView: MockTabView, + TabBar: () => null, + ScrollPager: MockTabView, + }; +}); +jest.mock('react-native-tab-view-viewpager-adapter', () => 'ViewPagerAdapter'); +jest.mock('../../src/components/PXTabView', () => { + const MockPXTabView = (props) => { + const { navigationState, renderScene } = props; + return renderScene({ + route: navigationState.routes[navigationState.index], + }); + }; + + return MockPXTabView; +}); +jest.mock('../../src/components/PXImage', () => { + const React = require('react'); + const { Image } = require('react-native'); + + return function MockPXImage(props) { + return React.createElement(Image, props); + }; +}); +const { + default: NovelViewer, + chunkHtmlPreservingTags, +} = require('../../src/components/NovelViewer'); + +const flushPromises = () => Promise.resolve(); + +const flattenStyle = (style) => (Array.isArray(style) ? style : [style]).filter(Boolean); + +const hasStyleEntry = (style, expectedStyle) => + flattenStyle(style).some((entry) => + Object.entries(expectedStyle).every(([key, value]) => entry[key] === value), + ); + +const findTextNode = (root, text) => + root.find((node) => node.type === 'Text' && node.props.children === text); + +const findPressTarget = (node) => { + let current = node; + while (current) { + if (typeof current.props.onPress === 'function') { + return current; + } + current = current.parent; + } + + return null; +}; + +const findNodeWithTextProps = (node, expectedStyle) => { + let current = node; + while (current) { + if ( + current.props.selectable === true && + hasStyleEntry(current.props.style, expectedStyle) + ) { + return current; + } + current = current.parent; + } + + return null; +}; + +const findAncestor = (node, predicate) => { + let current = node; + while (current) { + if (predicate(current)) { + return current; + } + current = current.parent; + } + + return null; +}; + +const findHostNodeByAccessibilityLabel = (root, accessibilityLabel, type) => + root.find( + (node) => + node.type === type && + node.props.accessibilityLabel === accessibilityLabel, + ); + +const subtreeContainsText = (node, text) => { + if (!node) { + return false; + } + + if (node.props && node.props.children === text) { + return true; + } + + return React.Children.toArray(node.props && node.props.children).some( + (child) => { + if (child === text) { + return true; + } + + if (!React.isValidElement(child)) { + return false; + } + + return subtreeContainsText(child, text); + }, + ); +}; + +const hasTextWithViewDescendant = (node) => { + if (!node || !node.children) { + return false; + } + + if ( + node.type === 'Text' && + node.children.some( + (child) => + child && + typeof child === 'object' && + (child.type === 'View' || hasTextWithViewDescendant(child)), + ) + ) { + return true; + } + + return node.children.some( + (child) => + child && typeof child === 'object' && hasTextWithViewDescendant(child), + ); +}; + +describe('NovelViewer inline images', () => { + beforeEach(() => { + illustDetail.mockReset(); + illustDetail.mockResolvedValue({ illust: {} }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the real inline image component for px-image nodes in the page flow', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + width: 120, + height: 60, + image_urls: { large: 'https://example.com/inline.jpg' }, + }, + }); + + let instance; + await act(async () => { + instance = renderer.create( + after"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const inlineImage = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Image', + ); + + expect(inlineImage.props.uri).toBe('https://example.com/inline.jpg'); + expect(inlineImage.props.resizeMode).toBe('contain'); + }); + + it('keeps px-image tags intact when chunking long HTML pages', async () => { + const prefix = 'a'.repeat(2998); + const page = `${prefix}tail`; + let instance; + + await act(async () => { + instance = renderer.create( + {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + expect(JSON.stringify(instance.toJSON())).toContain('novel-inline-image-24095674'); + }); + + it('keeps px-image regions intact across chunk boundaries', () => { + const prefix = 'a'.repeat(2950); + const imageText = `${prefix}tail`; + const chunks = chunkHtmlPreservingTags(imageText); + + expect( + chunks.some( + (chunk) => + chunk.includes("") && + chunk.includes(''), + ), + ).toBe(true); + }); + + it('keeps protected-region chunks within the htmlview safety bound', () => { + const chapterText = `${'b'.repeat(4000)}`; + const chunks = chunkHtmlPreservingTags(chapterText); + + expect(chunks.every((chunk) => chunk.length <= 3000)).toBe(true); + expect( + chunks.every( + (chunk) => chunk.includes('') && chunk.includes(''), + ), + ).toBe(true); + }); + + it('keeps chapter regions intact across chunk boundaries', () => { + const prefix = 'a'.repeat(2950); + const chapterText = `${prefix}Chapter heading ${'b'.repeat( + 80, + )}`; + const chunks = chunkHtmlPreservingTags(chapterText); + + expect(chunks.some((chunk) => chunk.includes('') && chunk.includes(''))).toBe( + true, + ); + }); + + it('keeps jump regions intact across chunk boundaries', () => { + const prefix = 'a'.repeat(2950); + const jumpText = `${prefix}Jump label ${'c'.repeat( + 80, + )}`; + const chunks = chunkHtmlPreservingTags(jumpText); + + expect(chunks.some((chunk) => chunk.includes(''))).toBe(true); + }); + + it('keeps anchor-wrapped px-image regions intact across chunk boundaries', () => { + const prefix = 'a'.repeat(2950); + const anchorText = `${prefix}beforeafter`; + const chunks = chunkHtmlPreservingTags(anchorText); + + expect(chunks.every((chunk) => chunk.length <= 3000)).toBe(true); + expect( + chunks.some( + (chunk) => + chunk.includes("") && + chunk.includes("") && + chunk.includes('') && + chunk.includes(''), + ), + ).toBe(true); + }); + + it('keeps long anchor regions within the htmlview safety bound', () => { + const anchorText = `${'b'.repeat( + 4000, + )}end`; + const chunks = chunkHtmlPreservingTags(anchorText); + + expect(chunks.every((chunk) => chunk.length <= 3000)).toBe(true); + expect( + chunks.every( + (chunk) => + chunk.includes("") && + chunk.includes(''), + ), + ).toBe(true); + }); + + it('does not nest inline image views inside chapter text nodes', async () => { + let instance; + + await act(async () => { + instance = renderer.create( + beforeafter", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const tree = instance.toJSON(); + + expect(JSON.stringify(tree)).toContain('novel-inline-image-24095674'); + expect(hasTextWithViewDescendant(tree)).toBe(false); + }); + + it('does not nest inline image views inside anchor text containers', async () => { + let instance; + + await act(async () => { + instance = renderer.create( + beforeafter", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const tree = instance.toJSON(); + + expect(JSON.stringify(tree)).toContain('novel-inline-image-24095674'); + expect(hasTextWithViewDescendant(tree)).toBe(false); + }); + + it('renders inline image fallbacks from the real inline image component when loading fails', async () => { + illustDetail.mockResolvedValueOnce({ illust: {} }); + + let instance; + await act(async () => { + instance = renderer.create( + after"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const fallback = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Text', + ); + + expect(fallback.props.children).toBe( + 'Image unavailable (illust detail has no usable url)', + ); + }); + + it('renders uploaded novel images from embedded image metadata in the page flow', async () => { + let instance; + await act(async () => { + instance = renderer.create( + after", + ]} + embeddedImages={{ + 24115550: { + width: 400, + height: 200, + urls: { original: 'https://example.com/uploaded-inline.jpg' }, + }, + }} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const inlineImage = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24115550', + 'Image', + ); + + expect(inlineImage.props.uri).toBe('https://example.com/uploaded-inline.jpg'); + expect(illustDetail).not.toHaveBeenCalled(); + }); + + it('renders chapter text after the custom chapter renderer rewrite', () => { + const tree = renderer + .create( + Chapter titlebody']} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ) + .toJSON(); + + const json = JSON.stringify(tree); + expect(json).toContain('Chapter title'); + expect(json).not.toContain(''); + }); + + it('keeps jump-link behavior after the custom chapter renderer rewrite', () => { + const onPressPageLink = jest.fn(); + const instance = renderer.create( + 2ページへ"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={onPressPageLink} + openModal={() => {}} + />, + ); + + const jumpNode = instance.root.find( + (node) => typeof node.props.onPress === 'function', + ); + + act(() => { + jumpNode.props.onPress(); + }); + + expect(onPressPageLink).toHaveBeenCalledWith('2'); + }); + + it('preserves inherited text props for plain anchor text', () => { + const openURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + const instance = renderer.create( + Example link"]} + index={0} + fontSize={18} + lineHeight={1.8} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + const linkText = findTextNode(instance.root, 'Example link'); + const linkPressTarget = findPressTarget(linkText); + const linkTextPropsTarget = findNodeWithTextProps(linkText, { + fontSize: 18, + lineHeight: 32.4, + }); + + expect(linkPressTarget).not.toBeNull(); + expect(linkTextPropsTarget).not.toBeNull(); + + act(() => { + linkPressTarget.props.onPress(); + }); + + expect(openURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('preserves inherited text props for plain jump text', () => { + const onPressPageLink = jest.fn(); + const instance = renderer.create( + Next page"]} + index={0} + fontSize={18} + lineHeight={1.8} + onIndexChange={() => {}} + onPressPageLink={onPressPageLink} + openModal={() => {}} + />, + ); + + const jumpText = findTextNode(instance.root, 'Next page'); + const jumpPressTarget = findPressTarget(jumpText); + const jumpTextPropsTarget = findNodeWithTextProps(jumpText, { + fontSize: 18, + lineHeight: 32.4, + }); + + expect(jumpPressTarget).not.toBeNull(); + expect(jumpTextPropsTarget).not.toBeNull(); + + act(() => { + jumpPressTarget.props.onPress(); + }); + + expect(onPressPageLink).toHaveBeenCalledWith('2'); + }); + + it('preserves anchor press behavior for nested markup inside anchor content', () => { + const openURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + const instance = renderer.create( + Bold link"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + const linkText = findTextNode(instance.root, 'Bold link'); + const linkPressTarget = findPressTarget(linkText); + + expect(linkPressTarget).not.toBeNull(); + + act(() => { + linkPressTarget.props.onPress(); + }); + + expect(openURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('preserves jump press behavior for nested markup inside jump content', () => { + const onPressPageLink = jest.fn(); + const instance = renderer.create( + Next page"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={onPressPageLink} + openModal={() => {}} + />, + ); + + const jumpText = findTextNode(instance.root, 'Next page'); + const jumpPressTarget = findPressTarget(jumpText); + + expect(jumpPressTarget).not.toBeNull(); + + act(() => { + jumpPressTarget.props.onPress(); + }); + + expect(onPressPageLink).toHaveBeenCalledWith('2'); + }); + + it('preserves chapter styling for nested markup inside chapter content', () => { + const instance = renderer.create( + Chapter title']} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + const chapterText = findTextNode(instance.root, 'Chapter title'); + const chapterTextPropsTarget = findNodeWithTextProps(chapterText, { + fontSize: 20, + fontWeight: 'bold', + }); + + expect(chapterTextPropsTarget).not.toBeNull(); + }); + + it('decodes html entities in custom chapter text rendering', () => { + const instance = renderer.create( + Fish & Chips']} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + expect(findTextNode(instance.root, 'Fish & Chips')).toBeDefined(); + }); + + it('decodes html entities in custom jump text rendering', () => { + const instance = renderer.create( + Tom & Jerry"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + expect(findTextNode(instance.root, 'Tom & Jerry')).toBeDefined(); + }); + + it('decodes html entities in custom anchor text and href rendering', () => { + const openURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + const instance = renderer.create( + A & B", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + + const linkText = findTextNode(instance.root, 'A & B'); + const linkPressTarget = findPressTarget(linkText); + + expect(linkPressTarget).not.toBeNull(); + + act(() => { + linkPressTarget.props.onPress(); + }); + + expect(openURL).toHaveBeenCalledWith( + 'https://example.com?first=1&second=2', + ); + }); + + it('keeps anchor text and inline images in the same text flow container', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/anchor-inline.jpg' }, + }, + }); + const openURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + let instance; + await act(async () => { + instance = renderer.create( + beforeafter", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const inlineImage = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Image', + ); + const inlineTextContainer = findAncestor( + inlineImage, + (node) => + node.type === 'Text' && + subtreeContainsText(node, 'before') && + subtreeContainsText(node, 'after'), + ); + + expect(inlineTextContainer).not.toBeNull(); + + act(() => { + inlineTextContainer.props.onPress(); + }); + + expect(openURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('preserves anchor presses when the tap starts from the inline image node itself', async () => { + illustDetail.mockResolvedValueOnce({ + illust: { + width: 100, + height: 100, + image_urls: { large: 'https://example.com/anchor-direct.jpg' }, + }, + }); + const openURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + let instance; + + await act(async () => { + instance = renderer.create( + ", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const inlineImage = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Image', + ); + const linkPressTarget = findPressTarget(inlineImage); + + expect(linkPressTarget).not.toBeNull(); + + act(() => { + linkPressTarget.props.onPress(); + }); + + expect(openURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('preserves jump presses when the tap starts from the inline image fallback itself', async () => { + illustDetail.mockResolvedValueOnce({ illust: {} }); + const onPressPageLink = jest.fn(); + let instance; + + await act(async () => { + instance = renderer.create( + ", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={onPressPageLink} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const fallback = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Text', + ); + const jumpPressTarget = findPressTarget(fallback); + + expect(fallback.props.children).toBe( + 'Image unavailable (illust detail has no usable url)', + ); + expect(jumpPressTarget).not.toBeNull(); + + act(() => { + jumpPressTarget.props.onPress(); + }); + + expect(onPressPageLink).toHaveBeenCalledWith('2'); + }); + + it('keeps chapter text and inline images in the same text flow container', async () => { + let instance; + + await act(async () => { + instance = renderer.create( + beforeafter", + ]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + openModal={() => {}} + />, + ); + await flushPromises(); + }); + + const inlineImage = findHostNodeByAccessibilityLabel( + instance.root, + 'novel-inline-image-24095674', + 'Text', + ); + const inlineTextContainer = findAncestor( + inlineImage, + (node) => + node.type === 'Text' && + subtreeContainsText(node, 'before') && + subtreeContainsText(node, 'after'), + ); + + expect(inlineTextContainer).not.toBeNull(); + }); +}); diff --git a/__tests__/helpers/novelAjaxParser.spec.js b/__tests__/helpers/novelAjaxParser.spec.js new file mode 100644 index 00000000..b58dcad1 --- /dev/null +++ b/__tests__/helpers/novelAjaxParser.spec.js @@ -0,0 +1,75 @@ +const extractNovelAjaxData = require('../../src/common/helpers/novelAjaxParser') + .default; + +describe('extractNovelAjaxData', () => { + it('extracts content and embedded images from ajax novel response body', () => { + expect( + extractNovelAjaxData({ + error: false, + body: { + id: '123', + content: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }, + }), + ).toEqual({ + id: '123', + content: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }); + }); + + it('extracts nested novel payload by novel id', () => { + expect( + extractNovelAjaxData( + { + error: false, + body: { + novel: { + 123: { + id: '123', + text: 'nested text', + textEmbeddedImages: { + 24115550: { + urls: { + original: + 'https://i.pximg.net/novel-upload-original-nested.jpg', + }, + }, + }, + }, + }, + }, + }, + '123', + ), + ).toEqual({ + id: '123', + text: 'nested text', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original-nested.jpg', + }, + }, + }, + }); + }); + + it('returns null when ajax response does not contain a usable body', () => { + expect(extractNovelAjaxData({ error: true, body: null })).toBe(null); + expect(extractNovelAjaxData(null)).toBe(null); + }); +}); diff --git a/__tests__/helpers/novelTextParser.spec.js b/__tests__/helpers/novelTextParser.spec.js new file mode 100644 index 00000000..cdb8f330 --- /dev/null +++ b/__tests__/helpers/novelTextParser.spec.js @@ -0,0 +1,67 @@ +import parseNovelText from '../../src/common/helpers/novelTextParser'; +import { renderTextWithInlineImages } from '../../src/common/helpers/novelInlineImage'; + +describe('parseNovelText inline images', () => { + it('converts loadedimage markup into a px-image node', () => { + const result = parseNovelText('before[loadedimage:24095674]after'); + + expect(result).toEqual([ + "beforeafter", + ]); + }); + + it('converts uploadedimage markup into a px-image node', () => { + const result = parseNovelText('before[uploadedimage:24115550]after'); + + expect(result).toEqual([ + "beforeafter", + ]); + }); + + it('converts pixivimage markup into a px-image node and preserves page numbers', () => { + const result = parseNovelText('before[pixivimage:24095674-2]after'); + + expect(result).toEqual([ + "beforeafter", + ]); + }); + + it('keeps newpage behavior when image markers are present', () => { + const result = parseNovelText('one[loadedimage:24095674][newpage]two'); + + expect(result).toEqual([ + "one", + 'two', + ]); + }); + + it('still escapes less-than characters in plain text output', () => { + const result = parseNovelText('before { + const result = parseNovelText('before[chapter:Title]after'); + + expect(result).toEqual(['beforeTitle<test>after']); + }); + + it('renders multiple inline images and keeps edge placement intact', () => { + const result = renderTextWithInlineImages( + '[loadedimage:1]mid[loadedimage:2]', + ); + + expect(result).toBe( + "mid", + ); + }); + + it('preserves ruby text inside jumpuri titles', () => { + const result = parseNovelText( + '[[jumpuri:[[rb:base>ruby]] > https://example.com]]', + ); + + expect(result).toEqual(["base(ruby)"]); + }); +}); diff --git a/__tests__/helpers/novelWebviewDebug.spec.js b/__tests__/helpers/novelWebviewDebug.spec.js new file mode 100644 index 00000000..3634a940 --- /dev/null +++ b/__tests__/helpers/novelWebviewDebug.spec.js @@ -0,0 +1,60 @@ +const buildNovelWebviewDebugInfo = require('../../src/common/helpers/novelWebviewDebug').default; + +describe('buildNovelWebviewDebugInfo', () => { + it('summarizes the raw html markers and parsed embedded image count', () => { + const html = ` + + + before[uploadedimage:24115550]after + "textEmbeddedImages" + `; + + const info = buildNovelWebviewDebugInfo(html, { + id: '123', + text: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { original: 'https://example.com/uploaded.jpg' }, + }, + }, + }); + + expect(info).toEqual({ + hasMetaPreloadData: true, + hasNextData: true, + hasUploadedImageLiteral: true, + hasTextEmbeddedImagesLiteral: true, + embeddedImageCount: 1, + legacyCandidateCount: 0, + legacyEmbeddedCounts: [], + glossaryCount: 0, + glossaryIds: [], + illustCount: 0, + illustIds: [], + parsedKeys: ['id', 'text', 'textEmbeddedImages'], + summary: + 'meta=Y next=Y uploaded=Y textEmbeddedImages=Y embeddedCount=1 legacyCandidates=0 legacyEmbedded=none glossaryCount=0 glossaryIds=none illustCount=0 illustIds=none parsedKeys=id|text|textEmbeddedImages', + }); + }); + + it('includes legacy candidate counts in the summary', () => { + const html = ` + + `; + + const info = buildNovelWebviewDebugInfo(html, { + id: '123', + text: 'before[uploadedimage:24115550]after', + }); + + expect(info.legacyCandidateCount).toBe(2); + expect(info.legacyEmbeddedCounts).toEqual([0, 1]); + expect(info.summary).toContain('legacyCandidates=2'); + expect(info.summary).toContain('legacyEmbedded=0|1'); + }); +}); diff --git a/__tests__/helpers/novelWebviewImageCandidates.spec.js b/__tests__/helpers/novelWebviewImageCandidates.spec.js new file mode 100644 index 00000000..e924f1f6 --- /dev/null +++ b/__tests__/helpers/novelWebviewImageCandidates.spec.js @@ -0,0 +1,41 @@ +const { + extractUploadedImageCandidatesFromHtml, +} = require('../../src/common/helpers/novelWebviewImageCandidates'); + +describe('extractUploadedImageCandidatesFromHtml', () => { + it('extracts an uploaded image url from raw html near the uploaded image id', () => { + const text = 'before[uploadedimage:24115550]after'; + const rawHtml = ` + + + + + + `; + + expect(extractUploadedImageCandidatesFromHtml(rawHtml, text)).toEqual({ + 24115550: { + height: 800, + originalUrl: + 'https://i.pximg.net/novel-cover-original/img/2026/04/05/00/00/00/24115550_p0.jpg', + width: 1200, + }, + }); + }); + + it('returns an empty object when no uploaded image ids exist in the text', () => { + expect( + extractUploadedImageCandidatesFromHtml( + 'plain', + 'plain text only', + ), + ).toEqual({}); + }); +}); diff --git a/__tests__/helpers/novelWebviewParser.spec.js b/__tests__/helpers/novelWebviewParser.spec.js new file mode 100644 index 00000000..e23c9d31 --- /dev/null +++ b/__tests__/helpers/novelWebviewParser.spec.js @@ -0,0 +1,115 @@ +const extractNovelWebviewData = require('../../src/common/helpers/novelWebviewParser').default; + +describe('extractNovelWebviewData', () => { + it('extracts the requested novel entry from meta-preload-data content', () => { + const html = ` + + + + + + `; + + expect(extractNovelWebviewData(html, '123')).toEqual({ + id: '123', + text: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + '1200x1200': 'https://i.pximg.net/novel-upload-1200.jpg', + }, + }, + }, + }); + }); + + it('extracts the requested novel entry from __NEXT_DATA__ server state', () => { + const serverState = JSON.stringify({ + novel: { + 123: { + id: '123', + text: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }, + }, + }); + const html = ` + + + + + + `; + + expect(extractNovelWebviewData(html, '123')).toEqual({ + id: '123', + text: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }); + }); + + it('falls back to the legacy novel object extraction', () => { + const html = ` + + `; + + expect(extractNovelWebviewData(html, '123')).toEqual({ + id: '123', + text: 'plain text', + }); + }); + + it('prefers the legacy novel candidate with embedded images over thinner matches', () => { + const html = ` + + `; + + expect(extractNovelWebviewData(html, '123')).toEqual({ + id: '123', + text: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }); + }); +}); diff --git a/__tests__/sagas/novelText.spec.js b/__tests__/sagas/novelText.spec.js new file mode 100644 index 00000000..0428a114 --- /dev/null +++ b/__tests__/sagas/novelText.spec.js @@ -0,0 +1,172 @@ +import { apply, put } from 'redux-saga/effects'; +import { + fetchNovelTextSuccess, + fetchNovelTextFailure, +} from '../../src/common/actions/novelText'; +import { addError } from '../../src/common/actions/error'; +import pixiv from '../../src/common/helpers/apiClient'; +import { handleFetchNovelText } from '../../src/common/sagas/novelText'; + +describe('handleFetchNovelText', () => { + const novelId = '123'; + const action = { + payload: { + novelId, + }, + }; + + test('prefers ajax novel data when it includes uploaded image metadata', () => { + const generator = handleFetchNovelText(action); + const ajaxUrl = `https://www.pixiv.net/ajax/novel/${novelId}`; + const ajaxOptions = { + headers: { + Accept: 'application/json', + Referer: `https://www.pixiv.net/novel/show.php?id=${novelId}`, + }, + }; + const ajaxResponse = { + error: false, + body: { + id: novelId, + content: 'before[uploadedimage:24115550]after', + textEmbeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + }, + }; + + expect(generator.next().value).toEqual( + apply(pixiv, pixiv.requestUrl, [ajaxUrl, ajaxOptions]), + ); + expect(generator.next(ajaxResponse).value).toEqual( + put({ + type: 'PIXIV/NOVEL_TEXT_SUCCESS', + payload: expect.objectContaining({ + novelId, + text: 'before[uploadedimage:24115550]after', + embeddedImages: { + 24115550: { + urls: { + original: 'https://i.pximg.net/novel-upload-original.jpg', + }, + }, + }, + debugInfo: { + embeddedImageCount: 1, + parsedKeys: ['content', 'id', 'textEmbeddedImages'], + source: 'ajax', + summary: + 'source=ajax embeddedCount=1 uploadedCount=1 parsedKeys=content|id|textEmbeddedImages', + uploadedImageCount: 1, + }, + timestamp: expect.any(Number), + }), + }), + ); + expect(generator.next().done).toBe(true); + }); + + test('falls back to webview when ajax misses uploaded image metadata', () => { + const generator = handleFetchNovelText(action); + const ajaxUrl = `https://www.pixiv.net/ajax/novel/${novelId}`; + const ajaxOptions = { + headers: { + Accept: 'application/json', + Referer: `https://www.pixiv.net/novel/show.php?id=${novelId}`, + }, + }; + const ajaxResponse = { + error: false, + body: { + id: novelId, + content: 'before[uploadedimage:24115550]after', + textEmbeddedImages: {}, + }, + }; + const webviewRawResponse = ` + + `; + + expect(generator.next().value).toEqual( + apply(pixiv, pixiv.requestUrl, [ajaxUrl, ajaxOptions]), + ); + expect(generator.next(ajaxResponse).value).toEqual( + apply(pixiv, pixiv.novelWebview, [novelId, true]), + ); + expect(generator.next(webviewRawResponse).value).toEqual( + put({ + type: 'PIXIV/NOVEL_TEXT_SUCCESS', + payload: expect.objectContaining({ + novelId, + text: 'before[uploadedimage:24115550]after', + embeddedImages: expect.objectContaining({ + 24115550: expect.objectContaining({ + originalUrl: 'https://i.pximg.net/webview.jpg', + }), + }), + debugInfo: expect.objectContaining({ + ajaxFallback: expect.objectContaining({ + reason: 'missing uploaded image metadata', + }), + }), + timestamp: expect.any(Number), + }), + }), + ); + expect(generator.next().done).toBe(true); + }); + + test('falls back to webview data when ajax request fails', () => { + const generator = handleFetchNovelText(action); + const ajaxUrl = `https://www.pixiv.net/ajax/novel/${novelId}`; + const ajaxOptions = { + headers: { + Accept: 'application/json', + Referer: `https://www.pixiv.net/novel/show.php?id=${novelId}`, + }, + }; + const webviewRawResponse = ` + + `; + + expect(generator.next().value).toEqual( + apply(pixiv, pixiv.requestUrl, [ajaxUrl, ajaxOptions]), + ); + expect(generator.throw(new Error('ajax failed')).value).toEqual( + apply(pixiv, pixiv.novelWebview, [novelId, true]), + ); + expect(generator.next(webviewRawResponse).value).toEqual( + put(fetchNovelTextSuccess('plain text', novelId, {}, expect.any(Object))), + ); + }); + + test('dispatches failure when both ajax and webview requests fail', () => { + const generator = handleFetchNovelText(action); + const ajaxUrl = `https://www.pixiv.net/ajax/novel/${novelId}`; + const ajaxOptions = { + headers: { + Accept: 'application/json', + Referer: `https://www.pixiv.net/novel/show.php?id=${novelId}`, + }, + }; + const error = new Error('boom'); + + expect(generator.next().value).toEqual( + apply(pixiv, pixiv.requestUrl, [ajaxUrl, ajaxOptions]), + ); + expect(generator.throw(error).value).toEqual( + apply(pixiv, pixiv.novelWebview, [novelId, true]), + ); + expect(generator.throw(error).value).toEqual( + put(fetchNovelTextFailure(novelId)), + ); + expect(generator.next().value).toEqual(put(addError(error))); + expect(generator.next().done).toBe(true); + }); +}); diff --git a/docs/superpowers/plans/2026-04-01-novel-inline-images-implementation.md b/docs/superpowers/plans/2026-04-01-novel-inline-images-implementation.md new file mode 100644 index 00000000..ef656e6d --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-novel-inline-images-implementation.md @@ -0,0 +1,292 @@ +# Novel Inline Images Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Pixiv novel inline image markers render as images in the existing reader flow without regressing current novel-reader behavior. + +**Architecture:** Keep the current `pixiv.novelWebview -> parseNovelText -> NovelViewer` pipeline. Extend the parser to emit an inline image node, add a thin image-resolution helper, and teach `NovelViewer` to render the node inside the existing page flow with graceful fallback behavior. + +**Tech Stack:** React Native 0.63.5, React 16.13.1, Redux, Redux Saga, `react-native-htmlview`, Jest 25 + +--- + +### Task 1: Parser And Inline Image Metadata + +**Files:** +- Create: `__tests__/helpers/novelTextParser.spec.js` +- Create: `src/common/helpers/novelInlineImage.js` +- Modify: `src/common/helpers/novelTextParser.js` + +- [ ] **Step 1: Write the failing parser test** + +```javascript +import parseNovelText from '../../src/common/helpers/novelTextParser'; + +describe('parseNovelText inline images', () => { + it('converts loadedimage markup into a px-image node', () => { + const result = parseNovelText('before[loadedimage:24095674]after'); + + expect(result).toEqual([ + "beforeafter", + ]); + }); + + it('keeps newpage behavior when image markers are present', () => { + const result = parseNovelText('one[loadedimage:24095674][newpage]two'); + + expect(result).toEqual([ + "one", + 'two', + ]); + }); +}); +``` + +- [ ] **Step 2: Run the parser test to verify it fails** + +Run: `npm test -- --runInBand __tests__/helpers/novelTextParser.spec.js` + +Expected: FAIL because `parseNovelText` currently leaves the marker as raw text or ignores it entirely. + +- [ ] **Step 3: Add the minimal inline-image helper** + +```javascript +export const createInlineImageTag = (illustId) => + ``; + +export const getInlineImageIdFromNode = (node) => + node && node.attribs ? node.attribs['data-illust-id'] : null; +``` + +- [ ] **Step 4: Extend the parser to emit inline image tags** + +```javascript +import { Parser as NovelParser } from 'pixiv-novel-parser'; +import { createInlineImageTag } from './novelInlineImage'; + +// inside the existing tag-handling branch +} else if (p.name === 'loadedimage') { + text += createInlineImageTag(String(p.id || p.illustId || p.imageId)); +} else if (p.name === 'newpage') { +``` + +If the actual parser payload uses a different property name, update the test fixture and implementation together to match the real `pixiv-novel-parser` output. + +- [ ] **Step 5: Run the parser test to verify it passes** + +Run: `npm test -- --runInBand __tests__/helpers/novelTextParser.spec.js` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add __tests__/helpers/novelTextParser.spec.js src/common/helpers/novelInlineImage.js src/common/helpers/novelTextParser.js +git commit -m "feat: parse novel inline image markers" +``` + +### Task 2: Inline Image Rendering In NovelViewer + +**Files:** +- Create: `__tests__/components/NovelViewer.spec.js` +- Create: `src/components/NovelInlineImage.js` +- Modify: `src/components/NovelViewer.js` + +- [ ] **Step 1: Write the failing viewer test** + +```javascript +import React from 'react'; +import renderer from 'react-test-renderer'; +import NovelViewer from '../../src/components/NovelViewer'; + +describe('NovelViewer inline images', () => { + it('renders inline image nodes inside the page flow', () => { + const tree = renderer + .create( + after"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + />, + ) + .toJSON(); + + expect(JSON.stringify(tree)).toContain('24095674'); + }); +}); +``` + +- [ ] **Step 2: Run the viewer test to verify it fails** + +Run: `npm test -- --runInBand __tests__/components/NovelViewer.spec.js` + +Expected: FAIL because `NovelViewer` does not currently recognize `px-image` nodes. + +- [ ] **Step 3: Add a focused inline-image component** + +```javascript +import React, { useMemo, useState } from 'react'; +import { View, Image } from 'react-native'; +import { Text } from 'react-native-paper'; + +const NovelInlineImage = ({ illustId, resolveImageUrl }) => { + const [failed, setFailed] = useState(false); + const uri = useMemo(() => resolveImageUrl(illustId), [illustId, resolveImageUrl]); + + if (!uri || failed) { + return {'图片加载失败'}; + } + + return ( + + setFailed(true)} + /> + + ); +}; + +export default NovelInlineImage; +``` + +Use a deterministic `resolveImageUrl` prop instead of hiding network fetch logic inside the component. + +- [ ] **Step 4: Wire `NovelViewer` to render `px-image` nodes** + +```javascript +import NovelInlineImage from './NovelInlineImage'; +import { getInlineImageIdFromNode } from '../common/helpers/novelInlineImage'; + +handleRenderNode = (node, index, siblings, parent, defaultRenderer) => { + if (node.name === 'px-image') { + const illustId = getInlineImageIdFromNode(node); + return ( + + ); + } + + // existing chapter and jump logic stays below +}; +``` + +Add `resolveInlineImageUrl` in `NovelViewer` as the thinnest possible adapter that turns an illustration ID into a display URL. If that turns out to require async state instead of a pure function, move the resolution state into `NovelInlineImage` but keep the network logic isolated from the rest of the viewer. + +- [ ] **Step 5: Run the viewer test to verify it passes** + +Run: `npm test -- --runInBand __tests__/components/NovelViewer.spec.js` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add __tests__/components/NovelViewer.spec.js src/components/NovelInlineImage.js src/components/NovelViewer.js src/common/helpers/novelInlineImage.js +git commit -m "feat: render inline images in novel viewer" +``` + +### Task 3: Regression Coverage And Reader Hardening + +**Files:** +- Modify: `__tests__/helpers/novelTextParser.spec.js` +- Modify: `__tests__/components/NovelViewer.spec.js` +- Modify: `src/components/NovelViewer.js` + +- [ ] **Step 1: Add failing regression tests for legacy reader behavior** + +```javascript +it('still renders chapter nodes', () => { + const tree = renderer + .create( + Titlebody']} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={() => {}} + />, + ) + .toJSON(); + + expect(JSON.stringify(tree)).toContain('Title'); +}); + +it('still renders jump links', () => { + const onPressPageLink = jest.fn(); + const instance = renderer.create( + 2ページへ"]} + index={0} + fontSize={16} + lineHeight={1.6} + onIndexChange={() => {}} + onPressPageLink={onPressPageLink} + />, + ); + + expect(instance.toJSON()).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run the targeted test suite** + +Run: `npm test -- --runInBand __tests__/helpers/novelTextParser.spec.js __tests__/components/NovelViewer.spec.js` + +Expected: FAIL if inline-image changes regressed existing node handling or still miss fallback behavior. + +- [ ] **Step 3: Harden the viewer for fallback and layout** + +```javascript + +``` + +```javascript +if (node.name === 'chapter') { + // existing chapter rendering +} +if (node.name === 'jump') { + // existing jump rendering +} +if (node.name === 'px-image') { + // inline image rendering +} +``` + +Keep the existing custom-node order explicit so inline images do not accidentally shadow chapter or jump behavior. + +- [ ] **Step 4: Run the targeted suite again** + +Run: `npm test -- --runInBand __tests__/helpers/novelTextParser.spec.js __tests__/components/NovelViewer.spec.js` + +Expected: PASS + +- [ ] **Step 5: Run the full current Jest suite** + +Run: `npm test -- --runInBand` + +Expected: PASS for the existing auth saga test plus the new parser/viewer tests. + +- [ ] **Step 6: Commit** + +```bash +git add __tests__/helpers/novelTextParser.spec.js __tests__/components/NovelViewer.spec.js src/components/NovelViewer.js src/components/NovelInlineImage.js +git commit -m "test: cover novel inline image regressions" +``` diff --git a/docs/superpowers/specs/2026-04-01-novel-inline-images-design.md b/docs/superpowers/specs/2026-04-01-novel-inline-images-design.md new file mode 100644 index 00000000..3ed288d1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-novel-inline-images-design.md @@ -0,0 +1,181 @@ +# Novel Inline Images Design + +## Summary + +This phase adds inline image rendering to the existing novel reader so Pixiv novel image markers such as `[loadedimage:24095674]` appear inside the body text at the author-intended position. The goal is to preserve the current novel reader architecture and behavior while extending the parser and renderer to support inline media. + +This phase does not address local build instability, APK startup crashes, dependency upgrades, or broader reader refactors. + +## Problem Statement + +The current novel reading flow fetches novel text and parses it into per-page HTML strings, but image markers are not handled as first-class content. As a result, inline image references surface as raw placeholder text instead of actual images. + +Relevant existing flow: + +- [`src/common/sagas/novelText.js`](D:\Andrew\Code\Andrew\pxview\src\common\sagas\novelText.js) fetches novel text with `pixiv.novelWebview(novelId)`. +- [`src/common/helpers/novelTextParser.js`](D:\Andrew\Code\Andrew\pxview\src\common\helpers\novelTextParser.js) converts Pixiv novel markup into HTML-like strings split by page. +- [`src/components/NovelViewer.js`](D:\Andrew\Code\Andrew\pxview\src\components\NovelViewer.js) renders those strings with `react-native-htmlview`. +- [`src/screens/Shared/NovelReader.js`](D:\Andrew\Code\Andrew\pxview\src\screens\Shared\NovelReader.js) manages page index, direction, and viewer settings. + +## Goals + +- Render inline novel images inside the existing novel body flow. +- Preserve author-intended placement of inline images relative to surrounding text. +- Keep existing page navigation, reading direction, font sizing, line height, and jump-link behavior intact. +- Fail gracefully when an inline image cannot be resolved or loaded. + +## Non-Goals + +- Fix local build or APK startup issues. +- Upgrade React Native, navigation, or other old dependencies. +- Replace the current HTML-based novel rendering pipeline. +- Add image lightbox/fullscreen viewing. +- Add aggressive preloading, caching, or gallery behavior for novel images. + +## Recommended Approach + +Retain the current `novelWebview -> parseNovelText -> NovelViewer` pipeline and extend it to support inline image placeholders. + +The parser will detect loaded-image markup and emit a dedicated inline tag in the generated HTML-like content. The viewer will intercept that tag through its custom `renderNode` hook and render a native image component in the document flow. + +This approach is preferred because it: + +- minimizes changes to the old codebase, +- preserves the current paging model, +- keeps feature risk localized to the novel reader path, and +- avoids a large rewrite of the reader into a custom AST renderer. + +## Data Flow + +### 1. Novel Text Fetch + +No phase-one change to text fetch behavior is required. The existing saga in [`src/common/sagas/novelText.js`](D:\Andrew\Code\Andrew\pxview\src\common\sagas\novelText.js) remains the entry point. + +### 2. Parsing + +[`src/common/helpers/novelTextParser.js`](D:\Andrew\Code\Andrew\pxview\src\common\helpers\novelTextParser.js) will be extended to recognize loaded-image markup and convert it into an internal inline HTML tag such as: + +```html + +``` + +The parser output remains an array of page strings so [`src/common/selectors/index.js`](D:\Andrew\Code\Andrew\pxview\src\common\selectors\index.js) and [`src/screens/Shared/NovelReader.js`](D:\Andrew\Code\Andrew\pxview\src\screens\Shared\NovelReader.js) do not need a structural redesign. + +### 3. Image Resolution + +A small helper layer will resolve illustration IDs to renderable image URLs. This should be a narrow utility dedicated to the novel-inline-image use case rather than a generic media system. + +Likely inputs and outputs: + +- input: illustration ID extracted from novel markup +- output: a stable display URL suitable for `Image` rendering in React Native + +If the API client already exposes enough illustration detail to derive image URLs, reuse it. If not, add the thinnest possible request/helper needed to retrieve those URLs. + +### 4. Rendering + +[`src/components/NovelViewer.js`](D:\Andrew\Code\Andrew\pxview\src\components\NovelViewer.js) will extend `handleRenderNode` to detect the `px-image` node and return a React Native image block rendered inline with the text content. + +Expected rendering behavior: + +- image appears in the natural content order, +- image width fits the reader content column, +- image height preserves aspect ratio, +- surrounding text remains selectable where already supported, +- chapter and jump-link rendering continue to work as before. + +## UI Behavior + +- Inline images render where the author inserted them in the novel body. +- Images share the same page flow as text; they are not split into a separate media page by design. +- If an image resolves slowly, the reader should show a compact loading placeholder in place. +- If an image fails to resolve or load, the reader should show a lightweight fallback message in the same position, not crash and not remove the surrounding text. + +## Error Handling + +Failure modes to handle explicitly: + +- parser sees malformed image markup, +- image ID resolves to no valid URL, +- image request fails, +- image render fails for one item on the page. + +Required behavior: + +- do not crash the reader, +- keep the rest of the page readable, +- render a small fallback placeholder where the image belongs, +- avoid surfacing raw Pixiv markup back to the user after parsing. + +## File-Level Change Plan + +Primary edits: + +- [`src/common/helpers/novelTextParser.js`](D:\Andrew\Code\Andrew\pxview\src\common\helpers\novelTextParser.js) + Add parsing support for inline image markers and emit a dedicated inline tag. +- [`src/components/NovelViewer.js`](D:\Andrew\Code\Andrew\pxview\src\components\NovelViewer.js) + Add custom node handling, inline image component rendering, and loading/failure placeholders. + +Likely supporting additions: + +- a new helper under `src/common/helpers/` for illustration URL resolution, +- a small reusable image-render subcomponent if `NovelViewer.js` becomes too crowded, +- possible small touchpoints in selectors if the viewer needs preprocessed metadata rather than only page strings. + +## Testing Strategy + +Automated tests should focus on the new logic rather than broad app behavior. + +### Parser Tests + +Add or extend tests to verify: + +- `[loadedimage:24095674]` no longer survives as raw placeholder text, +- the parser emits the expected inline tag, +- text before and after the image remains in the correct order, +- page splitting still behaves correctly when images appear near `newpage` markers. + +### Viewer Tests + +Add focused rendering tests to verify: + +- `px-image` nodes render a React Native image component, +- fallback UI renders on failure, +- existing `chapter` and `jump` behavior remains unchanged. + +### Manual Acceptance + +Manual acceptance for this phase is intentionally narrow: + +- open a novel that contains inline images, +- confirm the images appear inside the text at the intended position, +- confirm surrounding text remains readable, +- confirm failed images do not break the page or crash the reader. + +## Risks And Constraints + +- The project uses an old React Native stack, so new rendering code should avoid modern assumptions or heavy dependencies. +- `react-native-htmlview` is old and may have quirks when custom-rendering nonstandard nodes. +- The available Pixiv client API surface may not expose image URLs exactly where needed; this may require a small adapter layer. +- Because local build/runtime validation is currently weak, the first pass should emphasize unit-testable logic and minimal surface area. + +## Phase Exit Criteria + +This phase is complete when: + +- inline novel image markers render as images in the existing reader, +- images appear in the original text position, +- page reading remains usable with current settings and navigation, +- failures degrade gracefully, +- parser/viewer tests cover the new behavior. + +## Deferred Work + +Explicitly deferred to later phases: + +- fixing local build setup, +- debugging startup crashes, +- dependency modernization, +- tap-to-zoom or fullscreen image viewing, +- media caching and prefetch, +- broader reader architecture cleanup. diff --git a/src/common/actions/novelText.js b/src/common/actions/novelText.js index 953aebc6..880d2a36 100644 --- a/src/common/actions/novelText.js +++ b/src/common/actions/novelText.js @@ -1,11 +1,18 @@ import { NOVEL_TEXT } from '../constants/actionTypes'; -export function fetchNovelTextSuccess(text, novelId) { +export function fetchNovelTextSuccess( + text, + novelId, + embeddedImages = {}, + debugInfo = null, +) { return { type: NOVEL_TEXT.SUCCESS, payload: { novelId, text, + embeddedImages, + debugInfo, timestamp: Date.now(), }, }; diff --git a/src/common/actions/readingSettings.js b/src/common/actions/readingSettings.js index a6e68005..f7e183af 100644 --- a/src/common/actions/readingSettings.js +++ b/src/common/actions/readingSettings.js @@ -2,12 +2,19 @@ import { READING_SETTINGS } from '../constants/actionTypes'; -export function setSettings({ imageReadingDirection, novelReadingDirection }) { +export function setSettings({ + imageReadingDirection, + novelReadingDirection, + sliderSide, + sliderPercentageSide, +}) { return { type: READING_SETTINGS.SET, payload: { imageReadingDirection, novelReadingDirection, + sliderSide, + sliderPercentageSide, }, }; } diff --git a/src/common/constants/strings/en.json b/src/common/constants/strings/en.json index 8a079eaf..5f3828ea 100644 --- a/src/common/constants/strings/en.json +++ b/src/common/constants/strings/en.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "Left to right", "readingSettingsDirectionNovel": "Novel reading direction", "readingSettingsDirectionRightToLeft": "Right to left", + "readingSettingsSliderSide": "Slider side", + "readingSettingsSliderPercentageSide": "Percentage badge side", + "readingSettingsSliderSideLeft": "Left", + "readingSettingsSliderSideRight": "Right", "recommended": "Recommend", "recommendedUsers": "Recommended Users", "recommendedUsersFind": "Recommended Users", diff --git a/src/common/constants/strings/ja.json b/src/common/constants/strings/ja.json index 21f6e301..e5430cfa 100644 --- a/src/common/constants/strings/ja.json +++ b/src/common/constants/strings/ja.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "左から右へ", "readingSettingsDirectionNovel": "小説閲覧方向", "readingSettingsDirectionRightToLeft": "右から左へ", + "readingSettingsSliderSide": "スライダー位置", + "readingSettingsSliderPercentageSide": "%表示位置", + "readingSettingsSliderSideLeft": "左", + "readingSettingsSliderSideRight": "右", "recommended": "おすすめ", "recommendedUsers": "おすすめユーザー", "recommendedUsersFind": "おすすめのユーザーを見る", @@ -236,4 +240,4 @@ "userNovels": "小説作品", "viewMore": "もっと見る", "worksCount": "件の作品" -} \ No newline at end of file +} diff --git a/src/common/constants/strings/zh-HK.json b/src/common/constants/strings/zh-HK.json index d170853c..7db4c044 100644 --- a/src/common/constants/strings/zh-HK.json +++ b/src/common/constants/strings/zh-HK.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "從左至右", "readingSettingsDirectionNovel": "小説阅读方向", "readingSettingsDirectionRightToLeft": "從右至左", + "readingSettingsSliderSide": "滑塊位置", + "readingSettingsSliderPercentageSide": "百分比位置", + "readingSettingsSliderSideLeft": "靠左", + "readingSettingsSliderSideRight": "靠右", "recommended": "推薦", "recommendedUsers": "推薦用戶", "recommendedUsersFind": "推薦用戶", @@ -236,4 +240,4 @@ "userNovels": "小說", "viewMore": "查看更多", "worksCount": "件作品" -} \ No newline at end of file +} diff --git a/src/common/constants/strings/zh-MO.json b/src/common/constants/strings/zh-MO.json index d170853c..7db4c044 100644 --- a/src/common/constants/strings/zh-MO.json +++ b/src/common/constants/strings/zh-MO.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "從左至右", "readingSettingsDirectionNovel": "小説阅读方向", "readingSettingsDirectionRightToLeft": "從右至左", + "readingSettingsSliderSide": "滑塊位置", + "readingSettingsSliderPercentageSide": "百分比位置", + "readingSettingsSliderSideLeft": "靠左", + "readingSettingsSliderSideRight": "靠右", "recommended": "推薦", "recommendedUsers": "推薦用戶", "recommendedUsersFind": "推薦用戶", @@ -236,4 +240,4 @@ "userNovels": "小說", "viewMore": "查看更多", "worksCount": "件作品" -} \ No newline at end of file +} diff --git a/src/common/constants/strings/zh-TW.json b/src/common/constants/strings/zh-TW.json index d170853c..7db4c044 100644 --- a/src/common/constants/strings/zh-TW.json +++ b/src/common/constants/strings/zh-TW.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "從左至右", "readingSettingsDirectionNovel": "小説阅读方向", "readingSettingsDirectionRightToLeft": "從右至左", + "readingSettingsSliderSide": "滑塊位置", + "readingSettingsSliderPercentageSide": "百分比位置", + "readingSettingsSliderSideLeft": "靠左", + "readingSettingsSliderSideRight": "靠右", "recommended": "推薦", "recommendedUsers": "推薦用戶", "recommendedUsersFind": "推薦用戶", @@ -236,4 +240,4 @@ "userNovels": "小說", "viewMore": "查看更多", "worksCount": "件作品" -} \ No newline at end of file +} diff --git a/src/common/constants/strings/zh.json b/src/common/constants/strings/zh.json index 1d278b34..bb48149c 100644 --- a/src/common/constants/strings/zh.json +++ b/src/common/constants/strings/zh.json @@ -146,6 +146,10 @@ "readingSettingsDirectionLeftToRight": "从左至右", "readingSettingsDirectionNovel": "小説阅读方向", "readingSettingsDirectionRightToLeft": "从右至左", + "readingSettingsSliderSide": "滑块位置", + "readingSettingsSliderPercentageSide": "百分比位置", + "readingSettingsSliderSideLeft": "靠左", + "readingSettingsSliderSideRight": "靠右", "recommended": "推荐", "recommendedUsers": "推荐用户", "recommendedUsersFind": "推荐用户", @@ -236,4 +240,4 @@ "userNovels": "小说", "viewMore": "查看更多", "worksCount": "件作品" -} \ No newline at end of file +} diff --git a/src/common/helpers/novelAjaxParser.js b/src/common/helpers/novelAjaxParser.js new file mode 100644 index 00000000..94d52a6f --- /dev/null +++ b/src/common/helpers/novelAjaxParser.js @@ -0,0 +1,104 @@ +const getCollectionLength = (value) => { + if (!value) { + return 0; + } + if (Array.isArray(value)) { + return value.length; + } + if (typeof value === 'object') { + return Object.keys(value).length; + } + return 0; +}; + +const scoreCandidate = (candidate) => { + if (!candidate || typeof candidate !== 'object') { + return -1; + } + const text = candidate.text || candidate.content || ''; + const embeddedImages = + candidate.textEmbeddedImages || candidate.embeddedImages; + return [ + text ? 100 : 0, + /\[uploadedimage:/i.test(text) ? 60 : 0, + getCollectionLength(embeddedImages) + ? 40 + getCollectionLength(embeddedImages) + : 0, + getCollectionLength(candidate.illusts), + getCollectionLength(candidate.glossaryItems), + Object.keys(candidate).length, + ].reduce((total, value) => total + value, 0); +}; + +const getBestObjectValueCandidate = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return Object.values(value).reduce((bestCandidate, candidate) => { + if (!candidate || typeof candidate !== 'object') { + return bestCandidate; + } + if (!bestCandidate) { + return candidate; + } + return scoreCandidate(candidate) > scoreCandidate(bestCandidate) + ? candidate + : bestCandidate; + }, null); +}; + +const extractNovelAjaxData = (response, novelId = null) => { + if (!response || response.error || !response.body) { + return null; + } + + const { body } = response; + const normalizedNovelId = novelId != null ? String(novelId) : null; + const byIdInBody = + normalizedNovelId && + body && + typeof body === 'object' && + body[normalizedNovelId] && + typeof body[normalizedNovelId] === 'object' + ? body[normalizedNovelId] + : null; + const bodyNovel = body && typeof body === 'object' ? body.novel : null; + const byIdInNovel = + normalizedNovelId && + bodyNovel && + typeof bodyNovel === 'object' && + bodyNovel[normalizedNovelId] && + typeof bodyNovel[normalizedNovelId] === 'object' + ? bodyNovel[normalizedNovelId] + : null; + const bodyData = body && typeof body === 'object' ? body.data : null; + const bodyPayload = body && typeof body === 'object' ? body.payload : null; + + const candidates = [ + body, + byIdInBody, + bodyNovel, + byIdInNovel, + bodyData, + bodyPayload, + getBestObjectValueCandidate(body), + getBestObjectValueCandidate(bodyNovel), + getBestObjectValueCandidate(bodyData), + getBestObjectValueCandidate(bodyPayload), + ].filter(Boolean); + + if (!candidates.length) { + return null; + } + + return candidates.reduce((bestCandidate, candidate) => { + if (!bestCandidate) { + return candidate; + } + return scoreCandidate(candidate) > scoreCandidate(bestCandidate) + ? candidate + : bestCandidate; + }, null); +}; + +export default extractNovelAjaxData; diff --git a/src/common/helpers/novelInlineImage.js b/src/common/helpers/novelInlineImage.js new file mode 100644 index 00000000..a42608c5 --- /dev/null +++ b/src/common/helpers/novelInlineImage.js @@ -0,0 +1,39 @@ +const INLINE_IMAGE_PATTERN = /\[(loadedimage|uploadedimage):([\d-]+)\]/g; + +export const createInlineImageTag = ( + illustId, + imageKind = 'loadedimage', + pageNumber = null, +) => { + const attributes = [`data-illust-id='${illustId}'`]; + + if (imageKind) { + attributes.push(`data-image-kind='${imageKind}'`); + } + + if (pageNumber !== null && pageNumber !== undefined) { + attributes.push(`data-page-number='${pageNumber}'`); + } + + return ``; +}; + +export const renderTextWithInlineImages = (text) => { + if (!text) { + return ''; + } + + let output = ''; + let lastIndex = 0; + + text.replace(INLINE_IMAGE_PATTERN, (match, imageKind, illustId, offset) => { + output += text.slice(lastIndex, offset).replace(/ { // const parsedNovelText = NovelParser.parse( @@ -9,13 +13,13 @@ const parseNovelText = (novelText) => { let text = ''; parsedNovelText.forEach((p, index) => { if (p.type === 'text') { - text += p.val.replace(/ { if (pp.type === 'text') { - text += pp.val.replace(/ { text += ``; p.title.forEach((pp) => { if (pp.type === 'text') { - text += pp.val.replace(/ (value ? 'Y' : 'N'); + +const getCollectionValues = (value) => { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'object') { + return Object.values(value); + } + + return []; +}; + +const getCandidateIds = (item) => + [ + item && item.id, + item && item.illustId, + item && item.imageId, + item && item.novelImageId, + item && item.illust_id, + item && item.image_id, + ] + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)); + +const buildNovelWebviewDebugInfo = (rawHtml, response) => { + const html = typeof rawHtml === 'string' ? rawHtml : ''; + const parsed = response && typeof response === 'object' ? response : {}; + const embeddedImages = + parsed.textEmbeddedImages || parsed.embeddedImages || null; + const legacyCandidates = extractLegacyNovelCandidates(html, parsed.id); + const legacyEmbeddedCounts = legacyCandidates + .map((candidate) => + Object.keys( + candidate.textEmbeddedImages || candidate.embeddedImages || {}, + ).length, + ) + .slice(0, 3); + const illustValues = getCollectionValues(parsed.illusts); + const illustIds = illustValues + .flatMap((item) => getCandidateIds(item)) + .slice(0, 5); + const glossaryValues = getCollectionValues(parsed.glossaryItems); + const glossaryIds = glossaryValues + .flatMap((item) => getCandidateIds(item)) + .slice(0, 5); + const parsedKeys = Object.keys(parsed).sort(); + + return { + hasMetaPreloadData: /meta-preload-data/i.test(html), + hasNextData: /__NEXT_DATA__/i.test(html), + hasUploadedImageLiteral: /\[uploadedimage:/i.test(html), + hasTextEmbeddedImagesLiteral: /textEmbeddedImages/i.test(html), + embeddedImageCount: embeddedImages ? Object.keys(embeddedImages).length : 0, + legacyCandidateCount: legacyCandidates.length, + legacyEmbeddedCounts, + glossaryCount: glossaryValues.length, + glossaryIds, + illustCount: illustValues.length, + illustIds, + parsedKeys, + summary: [ + `meta=${stringifyFlag(/meta-preload-data/i.test(html))}`, + `next=${stringifyFlag(/__NEXT_DATA__/i.test(html))}`, + `uploaded=${stringifyFlag(/\[uploadedimage:/i.test(html))}`, + `textEmbeddedImages=${stringifyFlag(/textEmbeddedImages/i.test(html))}`, + `embeddedCount=${embeddedImages ? Object.keys(embeddedImages).length : 0}`, + `legacyCandidates=${legacyCandidates.length}`, + `legacyEmbedded=${ + legacyEmbeddedCounts.length ? legacyEmbeddedCounts.join('|') : 'none' + }`, + `glossaryCount=${glossaryValues.length}`, + `glossaryIds=${glossaryIds.length ? glossaryIds.join('|') : 'none'}`, + `illustCount=${illustValues.length}`, + `illustIds=${illustIds.length ? illustIds.join('|') : 'none'}`, + `parsedKeys=${ + parsedKeys.length ? parsedKeys.slice(0, 8).join('|') : 'none' + }`, + ].join(' '), + }; +}; + +export default buildNovelWebviewDebugInfo; diff --git a/src/common/helpers/novelWebviewImageCandidates.js b/src/common/helpers/novelWebviewImageCandidates.js new file mode 100644 index 00000000..52eb100e --- /dev/null +++ b/src/common/helpers/novelWebviewImageCandidates.js @@ -0,0 +1,137 @@ +const UPLOADED_IMAGE_PATTERN = /\[uploadedimage:(\d+)\]/g; +const IMAGE_URL_PATTERN = /https?:\/\/[^"'\\<>\s]+(?:jpg|jpeg|png|webp)/gi; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const parseNumericField = (snippet, fieldName) => { + const match = snippet.match( + new RegExp(`${escapeRegExp(fieldName)}["']?\\s*[:=]\\s*(\\d+)`, 'i'), + ); + return match ? parseInt(match[1], 10) : undefined; +}; + +const extractUploadedImageIds = (text) => { + if (!text) { + return []; + } + + const ids = []; + text.replace(UPLOADED_IMAGE_PATTERN, (match, imageId) => { + ids.push(imageId); + return match; + }); + return ids; +}; + +const extractUploadedImageCandidatesFromHtml = (rawHtml, text) => { + if (!rawHtml || !text) { + return {}; + } + + const uploadedImageIds = extractUploadedImageIds(text); + return uploadedImageIds.reduce((candidates, imageId) => { + const imageIdMatch = rawHtml.search(new RegExp(escapeRegExp(imageId))); + if (imageIdMatch === -1) { + return candidates; + } + + const snippet = rawHtml.slice( + Math.max(0, imageIdMatch - 1500), + Math.min(rawHtml.length, imageIdMatch + 5000), + ); + const urlMatch = snippet.match(IMAGE_URL_PATTERN); + if (!urlMatch || !urlMatch.length) { + return candidates; + } + + candidates[imageId] = { + originalUrl: urlMatch[0], + width: parseNumericField(snippet, 'width'), + height: parseNumericField(snippet, 'height'), + }; + return candidates; + }, {}); +}; + +const extractBalancedObject = (value, startIndex) => { + if (!value || value[startIndex] !== '{') { + return null; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let index = startIndex; index < value.length; index += 1) { + const currentChar = value[index]; + + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (currentChar === '\\') { + isEscaped = true; + } else if (currentChar === '"') { + inString = false; + } + // eslint-disable-next-line no-continue + continue; + } + + if (currentChar === '"') { + inString = true; + // eslint-disable-next-line no-continue + continue; + } + + if (currentChar === '{') { + depth += 1; + } else if (currentChar === '}') { + depth -= 1; + if (depth === 0) { + return value.slice(startIndex, index + 1); + } + } + } + + return null; +}; + +const extractTextEmbeddedImagesFromHtml = (rawHtml) => { + if (!rawHtml || typeof rawHtml !== 'string') { + return {}; + } + + // Search for textEmbeddedImages key anywhere in the HTML + const pattern = /["']?textEmbeddedImages["']?\s*:\s*\{/g; + let match = pattern.exec(rawHtml); + + while (match) { + const objectStart = rawHtml.indexOf('{', match.index + match[0].indexOf(':')); + if (objectStart === -1) { + match = pattern.exec(rawHtml); + // eslint-disable-next-line no-continue + continue; + } + + const objectValue = extractBalancedObject(rawHtml, objectStart); + if (objectValue) { + try { + const parsed = JSON.parse(objectValue); + if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) { + return parsed; + } + } catch (e) { + // try next match + } + } + + match = pattern.exec(rawHtml); + } + + return {}; +}; + +module.exports = { + extractUploadedImageCandidatesFromHtml, + extractTextEmbeddedImagesFromHtml, +}; diff --git a/src/common/helpers/novelWebviewParser.js b/src/common/helpers/novelWebviewParser.js new file mode 100644 index 00000000..4e3fdea8 --- /dev/null +++ b/src/common/helpers/novelWebviewParser.js @@ -0,0 +1,234 @@ +import entities from 'entities'; + +const safeJsonParse = (value) => { + if (!value || typeof value !== 'string') { + return null; + } + + try { + return JSON.parse(value); + } catch (err) { + return null; + } +}; + +const selectNovelEntry = (novelState, novelId) => { + if (!novelState) { + return null; + } + + if (novelState.text || novelState.content) { + return novelState; + } + + const normalizedNovelId = novelId != null ? String(novelId) : null; + if (normalizedNovelId && novelState[normalizedNovelId]) { + return novelState[normalizedNovelId]; + } + + if ( + normalizedNovelId && + novelState[parseInt(normalizedNovelId, 10)] && + !Number.isNaN(parseInt(normalizedNovelId, 10)) + ) { + return novelState[parseInt(normalizedNovelId, 10)]; + } + + const firstNovelKey = Object.keys(novelState)[0]; + return firstNovelKey ? novelState[firstNovelKey] : null; +}; + +const extractMetaTagContent = (rawHtml) => { + const metaTagMatch = rawHtml.match( + /]*id=(['"])meta-preload-data\1[^>]*>/i, + ); + if (!metaTagMatch) { + return null; + } + + const contentMatch = metaTagMatch[0].match(/content=(['"])([\s\S]*?)\1/i); + return contentMatch ? entities.decodeHTML(contentMatch[2]) : null; +}; + +const extractNovelFromMetaPreloadData = (rawHtml, novelId) => { + const preloadData = safeJsonParse(extractMetaTagContent(rawHtml)); + if (!preloadData) { + return null; + } + + return selectNovelEntry(preloadData.novel || preloadData, novelId); +}; + +const extractNovelFromNextData = (rawHtml, novelId) => { + const nextDataMatch = rawHtml.match( + /]*id=(['"])__NEXT_DATA__\1[^>]*>([\s\S]*?)<\/script>/i, + ); + if (!nextDataMatch) { + return null; + } + + const nextData = safeJsonParse(entities.decodeHTML(nextDataMatch[2])); + if (!nextData) { + return null; + } + + const serverState = + nextData.props && + nextData.props.pageProps && + nextData.props.pageProps.serverSerializedPreloadedState; + + const parsedServerState = + typeof serverState === 'string' ? safeJsonParse(serverState) : serverState; + + if (!parsedServerState) { + return null; + } + + return selectNovelEntry(parsedServerState.novel || parsedServerState, novelId); +}; + +const getCollectionLength = (value) => { + if (!value) { + return 0; + } + + if (Array.isArray(value)) { + return value.length; + } + + if (typeof value === 'object') { + return Object.keys(value).length; + } + + return 0; +}; + +const extractBalancedObject = (value, startIndex) => { + if (!value || value[startIndex] !== '{') { + return null; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let index = startIndex; index < value.length; index += 1) { + const currentChar = value[index]; + + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (currentChar === '\\') { + isEscaped = true; + } else if (currentChar === '"') { + inString = false; + } + continue; + } + + if (currentChar === '"') { + inString = true; + continue; + } + + if (currentChar === '{') { + depth += 1; + } else if (currentChar === '}') { + depth -= 1; + if (depth === 0) { + return value.slice(startIndex, index + 1); + } + } + } + + return null; +}; + +const scoreLegacyCandidate = (candidate, requestedNovelId) => { + if (!candidate || typeof candidate !== 'object') { + return -1; + } + + const normalizedRequestedId = + requestedNovelId != null ? String(requestedNovelId) : null; + const normalizedCandidateId = + candidate.id != null ? String(candidate.id) : null; + const embeddedImages = + candidate.textEmbeddedImages || candidate.embeddedImages || null; + const embeddedCount = getCollectionLength(embeddedImages); + const illustCount = getCollectionLength(candidate.illusts); + const glossaryCount = getCollectionLength(candidate.glossaryItems); + const text = candidate.text || candidate.content || ''; + + return [ + normalizedRequestedId && + normalizedCandidateId === normalizedRequestedId + ? 100 + : 0, + text ? 25 : 0, + /\[uploadedimage:/i.test(text) ? 50 : 0, + embeddedCount ? 80 + embeddedCount : 0, + illustCount ? 10 + illustCount : 0, + glossaryCount ? 10 + glossaryCount : 0, + Object.keys(candidate).length, + ].reduce((total, value) => total + value, 0); +}; + +export const extractLegacyNovelCandidates = (rawHtml, novelId) => { + if (!rawHtml || typeof rawHtml !== 'string') { + return []; + } + + const candidates = []; + const legacyPattern = /novel\s*:\s*{/g; + let match = legacyPattern.exec(rawHtml); + + while (match) { + const objectStartIndex = rawHtml.indexOf('{', match.index); + const objectValue = extractBalancedObject(rawHtml, objectStartIndex); + const parsedValue = safeJsonParse(objectValue); + const selectedEntry = selectNovelEntry(parsedValue, novelId); + + if (selectedEntry) { + candidates.push(selectedEntry); + } + + legacyPattern.lastIndex = + objectStartIndex >= 0 ? objectStartIndex + 1 : legacyPattern.lastIndex; + match = legacyPattern.exec(rawHtml); + } + + return candidates; +}; + +const extractNovelFromLegacyState = (rawHtml, novelId) => { + const legacyCandidates = extractLegacyNovelCandidates(rawHtml, novelId); + if (!legacyCandidates.length) { + return null; + } + + return legacyCandidates.reduce((bestCandidate, candidate) => { + if (!bestCandidate) { + return candidate; + } + + return scoreLegacyCandidate(candidate, novelId) > + scoreLegacyCandidate(bestCandidate, novelId) + ? candidate + : bestCandidate; + }, null); +}; + +const extractNovelWebviewData = (rawHtml, novelId) => { + if (!rawHtml || typeof rawHtml !== 'string') { + return null; + } + + return ( + extractNovelFromMetaPreloadData(rawHtml, novelId) || + extractNovelFromNextData(rawHtml, novelId) || + extractNovelFromLegacyState(rawHtml, novelId) + ); +}; + +export default extractNovelWebviewData; diff --git a/src/common/helpers/pkce.js b/src/common/helpers/pkce.js index c17b6ab1..291fca80 100644 --- a/src/common/helpers/pkce.js +++ b/src/common/helpers/pkce.js @@ -1,10 +1,20 @@ +import AsyncStorage from '@react-native-community/async-storage'; import { asyncPkceChallenge } from 'react-native-pkce-challenge'; +const PKCE_STORAGE_KEY = '@pxview/pkce'; + class PKCE { - static getPKCE = () => { + static getPKCE = async () => { if (this.pkce) { return this.pkce; } + + const persistedPkce = await AsyncStorage.getItem(PKCE_STORAGE_KEY); + if (persistedPkce) { + this.pkce = JSON.parse(persistedPkce); + return this.pkce; + } + return PKCE.generatePKCE(); }; @@ -14,8 +24,14 @@ class PKCE { codeChallenge, codeVerifier, }; + await AsyncStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify(this.pkce)); return this.pkce; } + + static async clearPKCE() { + this.pkce = null; + await AsyncStorage.removeItem(PKCE_STORAGE_KEY); + } } export default PKCE; diff --git a/src/common/reducers/novelText.js b/src/common/reducers/novelText.js index f64a65f8..c426a911 100644 --- a/src/common/reducers/novelText.js +++ b/src/common/reducers/novelText.js @@ -26,6 +26,8 @@ export default function novelText(state = {}, action) { loading: false, loaded: true, refreshing: false, + debugInfo: action.payload.debugInfo, + embeddedImages: action.payload.embeddedImages, text: action.payload.text, timestamp: action.payload.timestamp, }, diff --git a/src/common/reducers/readingSettings.js b/src/common/reducers/readingSettings.js index ad35e7b5..c597b649 100644 --- a/src/common/reducers/readingSettings.js +++ b/src/common/reducers/readingSettings.js @@ -4,6 +4,8 @@ import { READING_DIRECTION_TYPES } from '../constants'; const initState = { imageReadingDirection: READING_DIRECTION_TYPES.LEFT_TO_RIGHT, novelReadingDirection: READING_DIRECTION_TYPES.LEFT_TO_RIGHT, + sliderSide: 'right', + sliderPercentageSide: 'right', }; export default function readingSettings(state = initState, action) { @@ -19,12 +21,29 @@ export default function readingSettings(state = initState, action) { action.payload.novelReadingDirection !== undefined ? action.payload.novelReadingDirection : state.novelReadingDirection, + sliderSide: + action.payload.sliderSide !== undefined + ? action.payload.sliderSide + : state.sliderSide, + sliderPercentageSide: + action.payload.sliderPercentageSide !== undefined + ? action.payload.sliderPercentageSide + : state.sliderPercentageSide, }; case READING_SETTINGS.RESTORE: - return { - ...state, - ...action.payload.state, - }; + { + const nextState = { + ...state, + ...action.payload.state, + }; + if ( + nextState.sliderPercentageSide === undefined && + nextState.sliderSide !== undefined + ) { + nextState.sliderPercentageSide = nextState.sliderSide; + } + return nextState; + } default: return state; } diff --git a/src/common/sagas/novelText.js b/src/common/sagas/novelText.js index 3d06a219..28eb0098 100644 --- a/src/common/sagas/novelText.js +++ b/src/common/sagas/novelText.js @@ -5,13 +5,207 @@ import { } from '../actions/novelText'; import { addError } from '../actions/error'; import pixiv from '../helpers/apiClient'; +import extractNovelAjaxData from '../helpers/novelAjaxParser'; +import buildNovelWebviewDebugInfo from '../helpers/novelWebviewDebug'; +import { + extractUploadedImageCandidatesFromHtml, + extractTextEmbeddedImagesFromHtml, +} from '../helpers/novelWebviewImageCandidates'; +import extractNovelWebviewData from '../helpers/novelWebviewParser'; import { NOVEL_TEXT } from '../constants/actionTypes'; +const getEmbeddedImageCandidateIds = (embeddedImage) => + [ + embeddedImage && embeddedImage.id, + embeddedImage && embeddedImage.illustId, + embeddedImage && embeddedImage.imageId, + embeddedImage && embeddedImage.novelImageId, + embeddedImage && embeddedImage.illust_id, + embeddedImage && embeddedImage.image_id, + ] + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)); + +const hasEmbeddedImageForId = (embeddedImages, imageId) => { + if (!embeddedImages || !imageId) { + return false; + } + + const normalizedImageId = String(imageId); + if (embeddedImages[normalizedImageId] || embeddedImages[imageId]) { + return true; + } + + const numericImageId = parseInt(normalizedImageId, 10); + if (!Number.isNaN(numericImageId) && embeddedImages[numericImageId]) { + return true; + } + + return Object.values(embeddedImages).some((item) => + getEmbeddedImageCandidateIds(item).includes(normalizedImageId), + ); +}; + +const extractUploadedImageIdsFromText = (text) => { + if (!text) { + return []; + } + + const uploadedImageIds = new Set(); + text.replace(/\[uploadedimage:(\d+)\]/gi, (fullMatch, imageId) => { + uploadedImageIds.add(String(imageId)); + return fullMatch; + }); + return Array.from(uploadedImageIds); +}; + +const mergeImageCollection = (target, collection) => { + if (!collection) { + return target; + } + + if (Array.isArray(collection)) { + collection.forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + const candidateIds = getEmbeddedImageCandidateIds(item); + if (!candidateIds.length) { + return; + } + candidateIds.forEach((candidateId) => { + if (!target[candidateId]) { + target[candidateId] = item; + } + }); + }); + return target; + } + + if (typeof collection === 'object') { + Object.keys(collection).forEach((key) => { + const item = collection[key]; + if (item && typeof item === 'object') { + target[String(key)] = item; + } + }); + } + + return target; +}; + +const extractEmbeddedImages = (response) => { + const merged = {}; + if (!response || typeof response !== 'object') { + return merged; + } + + mergeImageCollection(merged, response.textEmbeddedImages); + mergeImageCollection(merged, response.embeddedImages); + mergeImageCollection(merged, response.illusts); + mergeImageCollection(merged, response.glossaryItems); + mergeImageCollection(merged, response.novelImages); + + return merged; +}; + export function* handleFetchNovelText(action) { const { novelId } = action.payload; try { - const response = yield apply(pixiv, pixiv.novelWebview, [novelId]); - yield put(fetchNovelTextSuccess(response.text, novelId)); + let ajaxText = ''; + let ajaxEmbeddedImages = {}; + let ajaxFallbackReason = null; + + const ajaxUrl = `https://www.pixiv.net/ajax/novel/${novelId}`; + const ajaxOptions = { + headers: { + Accept: 'application/json', + Referer: `https://www.pixiv.net/novel/show.php?id=${novelId}`, + }, + }; + + try { + const ajaxResponse = yield apply(pixiv, pixiv.requestUrl, [ + ajaxUrl, + ajaxOptions, + ]); + const response = extractNovelAjaxData(ajaxResponse, novelId); + + if (response) { + const text = response.text || response.content || ''; + const embeddedImages = extractEmbeddedImages(response); + const uploadedImageIds = extractUploadedImageIdsFromText(text); + const uploadedImageCount = uploadedImageIds.length; + const parsedKeys = Object.keys(response).sort(); + const debugInfo = { + source: 'ajax', + embeddedImageCount: Object.keys(embeddedImages).length, + uploadedImageCount, + parsedKeys, + summary: `source=ajax embeddedCount=${ + Object.keys(embeddedImages).length + } uploadedCount=${uploadedImageCount} parsedKeys=${parsedKeys + .slice(0, 8) + .join('|')}`, + }; + const hasMissingUploadedImageMetadata = + uploadedImageIds.length > 0 && + uploadedImageIds.some( + (uploadedImageId) => + !hasEmbeddedImageForId(embeddedImages, uploadedImageId), + ); + + if (!hasMissingUploadedImageMetadata) { + yield put( + fetchNovelTextSuccess(text, novelId, embeddedImages, debugInfo), + ); + return; + } + + ajaxText = text; + ajaxEmbeddedImages = embeddedImages; + ajaxFallbackReason = 'missing uploaded image metadata'; + } + } catch (ajaxError) { + ajaxFallbackReason = 'ajax request failed'; + // Fall through to the legacy webview endpoint for older or restricted flows. + } + + const rawResponse = yield apply(pixiv, pixiv.novelWebview, [novelId, true]); + const response = extractNovelWebviewData(rawResponse, novelId) || {}; + const debugInfo = buildNovelWebviewDebugInfo(rawResponse, response); + const text = (response && (response.text || response.content)) || ''; + const embeddedImagesFromResponse = extractEmbeddedImages(response); + const uploadedImageCandidates = extractUploadedImageCandidatesFromHtml( + rawResponse, + text, + ); + const textEmbeddedImagesFromHtml = extractTextEmbeddedImagesFromHtml( + rawResponse, + ); + const embeddedImages = { + ...ajaxEmbeddedImages, + ...embeddedImagesFromResponse, + ...textEmbeddedImagesFromHtml, + ...uploadedImageCandidates, + }; + const finalDebugInfo = ajaxFallbackReason + ? { + ...debugInfo, + ajaxFallback: { + reason: ajaxFallbackReason, + }, + summary: `${debugInfo.summary} ajaxFallback=${ajaxFallbackReason}`, + } + : debugInfo; + yield put( + fetchNovelTextSuccess( + text || ajaxText, + novelId, + embeddedImages, + finalDebugInfo, + ), + ); } catch (err) { yield put(fetchNovelTextFailure(novelId)); yield put(addError(err)); diff --git a/src/components/NovelInlineImage.js b/src/components/NovelInlineImage.js new file mode 100644 index 00000000..fd7e8787 --- /dev/null +++ b/src/components/NovelInlineImage.js @@ -0,0 +1,385 @@ +import React, { Component } from 'react'; +import { Image, StyleSheet, Text } from 'react-native'; +import PXImage from './PXImage'; +import pixiv from '../common/helpers/apiClient'; +import { globalStyleVariables } from '../styles'; + +const styles = StyleSheet.create({ + image: { + backgroundColor: '#f2f2f2', + }, + fallback: { + color: '#666', + fontStyle: 'italic', + }, +}); + +const getImageIdFromProps = (props) => props.imageId || props.illustId; + +const getImageKindFromProps = (props) => props.imageKind || 'loadedimage'; + +const getEmbeddedImageCandidateIds = (embeddedImage) => + [ + embeddedImage && embeddedImage.id, + embeddedImage && embeddedImage.illustId, + embeddedImage && embeddedImage.imageId, + embeddedImage && embeddedImage.novelImageId, + embeddedImage && embeddedImage.illust_id, + embeddedImage && embeddedImage.image_id, + ] + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)); + +const findEmbeddedImageById = (embeddedImages, imageId) => { + if (!embeddedImages || !imageId) { + return null; + } + + const normalizedImageId = String(imageId); + const numericImageId = parseInt(imageId, 10); + + if (Array.isArray(embeddedImages)) { + return ( + embeddedImages.find( + (item) => + item && + getEmbeddedImageCandidateIds(item).includes(normalizedImageId), + ) || null + ); + } + + if (embeddedImages[imageId]) { + return embeddedImages[imageId]; + } + + if (!Number.isNaN(numericImageId) && embeddedImages[numericImageId]) { + return embeddedImages[numericImageId]; + } + + const embeddedImageValues = Object.values(embeddedImages); + return ( + embeddedImageValues.find( + (item) => + item && getEmbeddedImageCandidateIds(item).includes(normalizedImageId), + ) || null + ); +}; + +const resolveEmbeddedImageUrl = (embeddedImage) => { + if (!embeddedImage) { + return null; + } + + const { urls } = embeddedImage; + if (!urls) { + return ( + embeddedImage.originalUrl || + embeddedImage.url || + embeddedImage.coverUrl || + embeddedImage.thumbnailUrl || + null + ); + } + + return ( + urls.original || + urls['1200x1200'] || + urls['600x600'] || + urls['480mw'] || + urls['240mw'] || + urls['128x128'] || + urls.regular || + urls.large || + urls.medium || + urls.small || + null + ); +}; + +const resolveEmbeddedImageAspectRatio = (embeddedImage) => { + if (!embeddedImage) { + return null; + } + + const width = + embeddedImage.width || embeddedImage.originalWidth || embeddedImage.w; + const height = + embeddedImage.height || embeddedImage.originalHeight || embeddedImage.h; + + return width && height ? width / height : null; +}; + +const resolveIllustImageUrl = (illust, pageNumber) => { + if (!illust) { + return null; + } + + if (illust.meta_pages && illust.meta_pages.length) { + const pageIndex = + pageNumber && pageNumber > 0 + ? Math.min(pageNumber - 1, illust.meta_pages.length - 1) + : 0; + const page = illust.meta_pages[pageIndex]; + if (page && page.image_urls) { + return ( + page.image_urls.original || + page.image_urls.large || + page.image_urls.medium || + null + ); + } + } + + if (illust.meta_single_page && illust.meta_single_page.original_image_url) { + return illust.meta_single_page.original_image_url; + } + + if (illust.image_urls && illust.image_urls.large) { + return illust.image_urls.large; + } + + return null; +}; + +class NovelInlineImage extends Component { + constructor(props) { + super(props); + const imageId = getImageIdFromProps(props); + this.state = { + failureReason: null, + imageUrl: null, + isFailed: false, + isLoading: Boolean(imageId), + imageAspectRatio: 1, + }; + } + + componentDidMount() { + this.loadImage(); + } + + componentDidUpdate(prevProps) { + const imageId = getImageIdFromProps(this.props); + const prevImageId = getImageIdFromProps(prevProps); + const imageKind = getImageKindFromProps(this.props); + const prevImageKind = getImageKindFromProps(prevProps); + if ( + imageId !== prevImageId || + imageKind !== prevImageKind || + this.props.pageNumber !== prevProps.pageNumber || + this.props.embeddedImages !== prevProps.embeddedImages + ) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState( + { + failureReason: null, + imageUrl: null, + isFailed: false, + isLoading: Boolean(imageId), + imageAspectRatio: 1, + }, + this.loadImage, + ); + } + } + + componentWillUnmount() { + this.unmounted = true; + } + + loadImage = async () => { + const requestId = (this.requestId || 0) + 1; + this.requestId = requestId; + const { embeddedImages, pageNumber } = this.props; + const imageId = getImageIdFromProps(this.props); + const imageKind = getImageKindFromProps(this.props); + + if (!imageId) { + this.setState({ + failureReason: 'missing image id', + isLoading: false, + isFailed: true, + }); + return; + } + + const embeddedImage = findEmbeddedImageById(embeddedImages, imageId); + const embeddedImageUrl = resolveEmbeddedImageUrl(embeddedImage); + const embeddedImageAspectRatio = resolveEmbeddedImageAspectRatio( + embeddedImage, + ); + + if (embeddedImageUrl) { + if (embeddedImageAspectRatio) { + // Metadata had real dimensions — fast path, no network needed + this.setState({ + failureReason: null, + imageUrl: embeddedImageUrl, + isLoading: false, + isFailed: false, + imageAspectRatio: embeddedImageAspectRatio, + }); + } else { + // API returned URLs only — must fetch intrinsic size with Referer header + // Keep isLoading: true so the "Loading image..." placeholder stays visible + Image.getSizeWithHeaders( + embeddedImageUrl, + { referer: 'http://www.pixiv.net' }, + (width, height) => { + if (this.unmounted || requestId !== this.requestId) { + return; + } + this.setState({ + failureReason: null, + imageUrl: embeddedImageUrl, + isLoading: false, + isFailed: false, + imageAspectRatio: width && height ? width / height : 1, + }); + }, + () => { + if (this.unmounted || requestId !== this.requestId) { + return; + } + // getSize failed — show image and measure via onLoad fallback + this.setState({ + failureReason: null, + imageUrl: embeddedImageUrl, + isLoading: false, + isFailed: false, + imageAspectRatio: null, + }); + }, + ); + } + return; + } + + if (imageKind === 'uploadedimage') { + this.setState({ + failureReason: embeddedImage + ? 'embedded image metadata has no usable url' + : 'no embedded image metadata', + isLoading: false, + isFailed: true, + }); + return; + } + + try { + const response = await pixiv.illustDetail(imageId); + if (this.unmounted || requestId !== this.requestId) { + return; + } + + const illust = response && response.illust; + const imageUrl = resolveIllustImageUrl(illust, pageNumber); + const width = illust && illust.width; + const height = illust && illust.height; + const imageAspectRatio = + width && height ? width / height : this.state.imageAspectRatio; + + this.setState({ + failureReason: imageUrl ? null : 'illust detail has no usable url', + imageUrl, + isLoading: false, + isFailed: !imageUrl, + imageAspectRatio, + }); + } catch (err) { + if (!this.unmounted && requestId === this.requestId) { + this.setState({ + failureReason: 'illust detail request failed', + isLoading: false, + isFailed: true, + }); + } + } + }; + + handleImageError = () => { + this.setState({ + failureReason: 'image request failed', + isFailed: true, + isLoading: false, + }); + }; + + handleImageLoad = (event) => { + const { imageAspectRatio } = this.state; + if (imageAspectRatio !== null) { + return; + } + const source = event && event.nativeEvent && event.nativeEvent.source; + if (source && source.width && source.height) { + this.setState({ imageAspectRatio: source.width / source.height }); + } else { + this.setState({ imageAspectRatio: 1 }); + } + }; + + renderContent() { + const { debugInfo, maxWidth } = this.props; + const imageId = getImageIdFromProps(this.props); + const { + failureReason, + imageUrl, + isFailed, + isLoading, + imageAspectRatio, + } = this.state; + + if (isLoading) { + return ( + + Loading image... + + ); + } + + if (!imageUrl || isFailed) { + const diagnosticSuffix = + failureReason === 'no embedded image metadata' && + debugInfo && + debugInfo.summary + ? `; ${debugInfo.summary}` + : ''; + return ( + + {`Image unavailable${ + failureReason ? ` (${failureReason}${diagnosticSuffix})` : '' + }`} + + ); + } + + const imageWidth = maxWidth || globalStyleVariables.WINDOW_WIDTH - 20; + const imageStyle = + imageAspectRatio !== null + ? { width: imageWidth, aspectRatio: imageAspectRatio } + : { width: imageWidth, height: imageWidth }; + + return ( + + ); + } + + render() { + return this.renderContent(); + } +} + +export default NovelInlineImage; diff --git a/src/components/NovelViewer.js b/src/components/NovelViewer.js index fd333ab5..2f8532f6 100644 --- a/src/components/NovelViewer.js +++ b/src/components/NovelViewer.js @@ -1,8 +1,18 @@ import React, { Component } from 'react'; -import { View, StyleSheet, ScrollView } from 'react-native'; +import { + View, + StyleSheet, + ScrollView, + Linking, + TouchableOpacity, + Modal, + Dimensions, +} from 'react-native'; import HtmlView from 'react-native-htmlview'; +import entities from 'entities'; import { Text } from 'react-native-paper'; import PXTabView from './PXTabView'; +import NovelInlineImage from './NovelInlineImage'; import { MODAL_TYPES } from '../common/constants'; import { globalStyleVariables } from '../styles'; @@ -23,6 +33,439 @@ const styles = StyleSheet.create({ }, }); +const MAX_HTML_CHUNK_LENGTH = 3000; +const PROTECTED_TAGS = new Set(['a', 'chapter', 'jump', 'px-image']); +const SPLITTABLE_PROTECTED_TAGS = new Set(['a', 'chapter', 'jump']); + +const getTagInfo = (token) => { + const match = token.match(/^<\/?([a-zA-Z0-9-]+)/); + if (!match) { + return null; + } + + return { + name: match[1], + isClosing: token[1] === '/', + }; +}; + +const tokenizeHtml = (html) => html.match(/<[^>]+>|[^<]+/g) || []; +const decodeHtmlText = (text = '') => entities.decodeHTML(text); + +const collectProtectedRegion = (tokens, startIndex) => { + const openingTag = tokens[startIndex]; + const openingTagInfo = getTagInfo(openingTag); + if (!openingTagInfo || openingTagInfo.isClosing) { + return null; + } + + const innerTokens = []; + let depth = 1; + + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const tagInfo = getTagInfo(token); + + if (tagInfo && tagInfo.name === openingTagInfo.name) { + depth += tagInfo.isClosing ? -1 : 1; + if (!depth) { + return { + closeTag: token, + innerHtml: innerTokens.join(''), + nextIndex: index, + openTag: openingTag, + tagName: openingTagInfo.name, + text: `${openingTag}${innerTokens.join('')}${token}`, + }; + } + } + + innerTokens.push(token); + } + + return { + closeTag: '', + innerHtml: innerTokens.join(''), + nextIndex: tokens.length - 1, + openTag: openingTag, + tagName: openingTagInfo.name, + text: `${openingTag}${innerTokens.join('')}`, + }; +}; + +const normalizeProtectedRegions = (html, maxLength) => { + const tokens = tokenizeHtml(html); + let output = ''; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const tagInfo = getTagInfo(token); + + if (tagInfo && !tagInfo.isClosing && PROTECTED_TAGS.has(tagInfo.name)) { + const region = collectProtectedRegion(tokens, index); + if (!region) { + output += token; + continue; + } + + if ( + region.text.length > maxLength && + SPLITTABLE_PROTECTED_TAGS.has(region.tagName) + ) { + const wrapperLength = region.openTag.length + region.closeTag.length; + const innerMaxLength = maxLength - wrapperLength; + + if (innerMaxLength > 0) { + const innerChunks = chunkHtmlPreservingTags( + region.innerHtml, + innerMaxLength, + ); + output += innerChunks + .map((chunk) => `${region.openTag}${chunk}${region.closeTag}`) + .join(''); + } else { + output += region.text; + } + } else { + output += region.text; + } + + index = region.nextIndex; + continue; + } + + output += token; + } + + return output; +}; + +export const chunkHtmlPreservingTags = ( + html, + maxLength = MAX_HTML_CHUNK_LENGTH, +) => { + if (!html) { + return []; + } + + const normalizedHtml = normalizeProtectedRegions(html, maxLength); + const tokens = tokenizeHtml(normalizedHtml); + const chunks = []; + let current = ''; + let protectedDepth = 0; + + const flushCurrent = () => { + if (current) { + chunks.push(current); + current = ''; + } + }; + + tokens.forEach((token, tokenIndex) => { + const tagInfo = getTagInfo(token); + if (tagInfo) { + const isProtectedTag = PROTECTED_TAGS.has(tagInfo.name); + if (!tagInfo.isClosing && isProtectedTag) { + const protectedRegion = !protectedDepth + ? collectProtectedRegion(tokens, tokenIndex) + : null; + const protectedRegionLength = protectedRegion + ? protectedRegion.text.length + : token.length; + + if ( + !protectedDepth && + current.length && + current.length + protectedRegionLength > maxLength + ) { + flushCurrent(); + } + current += token; + protectedDepth += 1; + return; + } + + current += token; + if (tagInfo.isClosing && isProtectedTag && protectedDepth > 0) { + protectedDepth -= 1; + } + + if (!protectedDepth && current.length >= maxLength) { + flushCurrent(); + } + return; + } + + if (protectedDepth) { + current += token; + return; + } + + let remaining = token; + while (remaining.length) { + if (current.length === maxLength) { + flushCurrent(); + } + + const spaceLeft = maxLength - current.length; + const nextLength = Math.min(spaceLeft, remaining.length); + current += remaining.slice(0, nextLength); + remaining = remaining.slice(nextLength); + + if (current.length === maxLength) { + flushCurrent(); + } + } + }); + + flushCurrent(); + return chunks; +}; + +// --- Modal slider constants --- +const MODAL_TRACK_WIDTH = 4; +const MODAL_THUMB_W = 28; +const MODAL_THUMB_H = 60; +const MODAL_SLIDER_WIDTH = 100; +const MODAL_SLIDER_RIGHT_PAD = 20; +const SCREEN_HEIGHT = Dimensions.get('window').height; + +class NovelPage extends Component { + constructor(props) { + super(props); + this.scrollRef = React.createRef(); + this.modalDragStartY = 0; + this.modalDragStartScrollY = 0; + this.isModalDragging = false; + this.state = { + scrollY: 0, + contentHeight: 0, + containerHeight: 0, + modalOpen: false, + modalDragging: false, + }; + } + + getScrollable() { + const { contentHeight, containerHeight } = this.state; + return Math.max(0, contentHeight - containerHeight); + } + + getRatio() { + const { scrollY } = this.state; + const scrollable = this.getScrollable(); + if (!scrollable) return 0; + return Math.max(0, Math.min(1, scrollY / scrollable)); + } + + handleScroll = (e) => { + this.setState({ scrollY: e.nativeEvent.contentOffset.y }); + }; + + // --- Modal navigation --- + openModal = () => { + this.setState({ modalOpen: true }); + }; + + closeModal = () => { + this.isModalDragging = false; + this.setState({ modalOpen: false, modalDragging: false }); + }; + + getModalTrackHeight() { + return SCREEN_HEIGHT - 200; + } + + getModalThumbTop() { + const ratio = this.getRatio(); + const trackH = this.getModalTrackHeight(); + return ratio * (trackH - MODAL_THUMB_H); + } + + handleModalGrant = (e) => { + this.isModalDragging = true; + this.setState({ modalDragging: true }); + // Stop momentum + const { scrollY } = this.state; + if (this.scrollRef.current) { + this.scrollRef.current.scrollTo({ y: scrollY, animated: false }); + } + this.modalDragStartScrollY = scrollY; + this.modalDragStartY = e.nativeEvent.pageY; + }; + + handleModalMove = (e) => { + if (!this.isModalDragging) return; + const scrollable = this.getScrollable(); + if (!scrollable) return; + const trackH = this.getModalTrackHeight(); + const maxTop = trackH - MODAL_THUMB_H; + if (!maxTop) return; + const dy = e.nativeEvent.pageY - this.modalDragStartY; + const scrollDelta = (dy / maxTop) * scrollable; + const nextY = Math.max( + 0, + Math.min(this.modalDragStartScrollY + scrollDelta, scrollable), + ); + if (this.scrollRef.current) { + this.scrollRef.current.scrollTo({ y: nextY, animated: false }); + } + this.setState({ scrollY: nextY }); + }; + + handleModalRelease = () => { + this.isModalDragging = false; + this.setState({ modalDragging: false }); + }; + + getSliderSide() { + const { sliderSide } = this.props; + return sliderSide === 'left' ? 'left' : 'right'; + } + + getPercentageSide() { + const { sliderPercentageSide, sliderSide } = this.props; + if (sliderPercentageSide === 'left' || sliderPercentageSide === 'right') { + return sliderPercentageSide; + } + return sliderSide === 'left' ? 'left' : 'right'; + } + + renderPercentPill() { + const scrollable = this.getScrollable(); + if (scrollable <= 10) return null; + const pct = (this.getRatio() * 100).toFixed(1); + const side = this.getPercentageSide(); + const pos = side === 'left' ? { left: 14 } : { right: 14 }; + return ( + + {pct}% + + ); + } + + renderModalNav() { + const { modalOpen, modalDragging } = this.state; + if (!modalOpen) return null; + const trackH = this.getModalTrackHeight(); + const modalThumbTop = this.getModalThumbTop(); + const side = this.getSliderSide(); + const isLeft = side === 'left'; + + const sliderArea = ( + true} + onResponderGrant={this.handleModalGrant} + onResponderMove={this.handleModalMove} + onResponderRelease={this.handleModalRelease} + > + + + + + ); + + const closeArea = ( + + + 点击退出导航 + + + ); + + return ( + + + {isLeft ? sliderArea : closeArea} + {isLeft ? closeArea : sliderArea} + + + ); + } + + render() { + const { children } = this.props; + + return ( + + this.setState({ contentHeight: h })} + onLayout={(e) => + this.setState({ containerHeight: e.nativeEvent.layout.height }) + } + > + {children} + + + {this.renderPercentPill()} + {this.renderModalNav()} + + ); + } +} + class NovelViewer extends Component { constructor(props) { super(props); @@ -48,27 +491,153 @@ class NovelViewer extends Component { } } - handleRenderNode = (node, index, siblings, parent, defaultRenderer) => { - const { onPressPageLink } = this.props; - if (node.name === 'chapter') { + getHtmlTextComponentProps = (textProps = {}) => { + const { fontSize, lineHeight } = this.props; + const { style, ...restTextProps } = textProps; + + return { + selectable: true, + ...restTextProps, + style: [ + { + fontSize, + lineHeight: fontSize * lineHeight, + }, + style, + ].filter(Boolean), + }; + }; + + renderInlineSafeTextContainer = ( + node, + index, + parent, + defaultRenderer, + textProps = {}, + ) => { + const mergedTextProps = this.getHtmlTextComponentProps(textProps); + const { style: textStyle, ...restTextProps } = mergedTextProps; + + if (node.children.length === 1 && node.children[0].type === 'text') { return ( - - {node.children.length === 1 && node.children[0].type === 'text' - ? node.children[0].data - : defaultRenderer(node.children, parent)} + + {decodeHtmlText(node.children[0].data)} ); } + + const renderedChildren = node.children.reduce( + (children, child, childIndex) => { + const childKey = `${index}-${childIndex}`; + + if (child.type === 'text') { + children.push(decodeHtmlText(child.data)); + return children; + } + + const renderedChild = + this.handleRenderNode( + child, + childKey, + node.children, + node, + defaultRenderer, + ) || defaultRenderer([child], node); + + React.Children.toArray(renderedChild).forEach( + (renderedEntry, renderedEntryIndex) => { + const renderedKey = `${childKey}-${renderedEntryIndex}`; + + if ( + typeof renderedEntry === 'string' || + typeof renderedEntry === 'number' + ) { + children.push(renderedEntry); + return; + } + + if (!React.isValidElement(renderedEntry)) { + return; + } + + children.push( + React.cloneElement(renderedEntry, { + key: renderedEntry.key || renderedKey, + }), + ); + }, + ); + + return children; + }, + [], + ); + + return ( + + {renderedChildren} + + ); + }; + + renderChapterNode = (node, index, parent, defaultRenderer) => + this.renderInlineSafeTextContainer(node, index, parent, defaultRenderer, { + style: styles.novelChapter, + }); + + renderAnchorNode = (node, index, parent, defaultRenderer) => { + const { href } = node.attribs || {}; + + return this.renderInlineSafeTextContainer( + node, + index, + parent, + defaultRenderer, + { + onPress: href ? () => Linking.openURL(decodeHtmlText(href)) : undefined, + style: styles.pageLink, + }, + ); + }; + + handleRenderNode = (node, index, siblings, parent, defaultRenderer) => { + const { debugInfo, embeddedImages, onPressPageLink } = this.props; + if (node.name === 'chapter') { + return this.renderChapterNode(node, index, parent, defaultRenderer); + } if (node.name === 'jump') { const { page } = node.attribs; + return this.renderInlineSafeTextContainer( + node, + index, + parent, + defaultRenderer, + { + onPress: () => onPressPageLink(page), + style: styles.pageLink, + }, + ); + } + if (node.name === 'a') { + return this.renderAnchorNode(node, index, parent, defaultRenderer); + } + if (node.name === 'px-image') { + const imageId = node.attribs && node.attribs['data-illust-id']; + const imageKind = node.attribs && node.attribs['data-image-kind']; + const parsedPageNumber = parseInt( + node.attribs && node.attribs['data-page-number'], + 10, + ); return ( - onPressPageLink(page)} - > - {defaultRenderer(node.children, parent)} - + debugInfo={debugInfo} + imageId={imageId} + imageKind={imageKind} + pageNumber={Number.isNaN(parsedPageNumber) ? null : parsedPageNumber} + embeddedImages={embeddedImages} + maxWidth={globalStyleVariables.WINDOW_WIDTH - 20} + /> ); } // other nodes render by default renderer @@ -90,28 +659,25 @@ class NovelViewer extends Component { const { novelId, fontSize, lineHeight, items, index } = this.props; const sceneIndex = routes.indexOf(route); const item = items[sceneIndex]; - const pagedItem = item.match(/(.|[\r\n]){1,3000}/g) || []; - // render text by chunks to prevent over text limit https://github.com/facebook/react-native/issues/15663 + const pagedItem = chunkHtmlPreservingTags(item); + // render text by chunks to prevent over text limit while preserving HTML tags return ( - - + + {pagedItem.map((t, i) => ( ))} - - + + ); }; diff --git a/src/components/PXWebView.js b/src/components/PXWebView.js index cf1e48e7..5bdb6fc0 100644 --- a/src/components/PXWebView.js +++ b/src/components/PXWebView.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { View } from 'react-native'; +import { View, Linking } from 'react-native'; import WebView from 'react-native-webview'; import ProgressBar from 'react-native-progress/Bar'; import { withTheme } from 'react-native-paper'; @@ -25,8 +25,78 @@ class PXWebView extends Component { }); }; + getIntentFallbackUrl = (url) => { + if (!url || !/^intent:\/\//i.test(url)) { + return null; + } + + const [intentBody, intentMeta] = url.split('#Intent;'); + if (!intentBody || !intentMeta) { + return null; + } + + const pathWithQuery = intentBody.replace(/^intent:\/\//i, ''); + const schemeMatch = intentMeta.match(/(?:^|;)scheme=([^;]+)/i); + if (!schemeMatch || !schemeMatch[1] || !pathWithQuery) { + return null; + } + + return `${schemeMatch[1]}://${pathWithQuery}`; + }; + + openExternalUrl = async (url) => { + try { + await Linking.openURL(url); + return; + } catch (error) { + const fallbackUrl = this.getIntentFallbackUrl(url); + if (fallbackUrl && fallbackUrl !== url) { + try { + await Linking.openURL(fallbackUrl); + } catch (fallbackError) { + // noop: swallow to avoid breaking WebView render cycle + } + } + } + }; + + handleOnShouldStartLoadWithRequest = (request) => { + const { onShouldStartLoadWithRequest } = this.props; + if (onShouldStartLoadWithRequest) { + const shouldContinue = onShouldStartLoadWithRequest(request); + if (!shouldContinue) { + return false; + } + } + + const { url } = request; + if (!url) { + return true; + } + + const normalizedUrl = url.toLowerCase(); + const isWebUrl = + normalizedUrl.startsWith('http://') || + normalizedUrl.startsWith('https://') || + normalizedUrl.startsWith('about:blank') || + normalizedUrl.startsWith('data:'); + + if (isWebUrl) { + return true; + } + + this.openExternalUrl(url); + + return false; + }; + render() { - const { source, theme, ...otherProps } = this.props; + const { + source, + theme, + onShouldStartLoadWithRequest, + ...otherProps + } = this.props; const { loading } = this.state; return ( diff --git a/src/containers/NovelSettingsModal.js b/src/containers/NovelSettingsModal.js index 92d5127f..2a891c45 100644 --- a/src/containers/NovelSettingsModal.js +++ b/src/containers/NovelSettingsModal.js @@ -4,6 +4,7 @@ import { View, StyleSheet, TouchableWithoutFeedback, + TouchableOpacity, Modal, } from 'react-native'; import { connect } from 'react-redux'; @@ -13,6 +14,7 @@ import Icon from 'react-native-vector-icons/FontAwesome'; import { connectLocalization } from '../components/Localization'; import * as modalActionCreators from '../common/actions/modal'; import * as novelSettingsActionCreators from '../common/actions/novelSettings'; +import * as readingSettingsActionCreators from '../common/actions/readingSettings'; import { globalStyleVariables } from '../styles/index'; const styles = StyleSheet.create({ @@ -35,18 +37,20 @@ const styles = StyleSheet.create({ flex: 1, marginLeft: 5, }, + toggleButton: { + flex: 1, + marginLeft: 5, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 6, + }, + toggleValue: { + fontWeight: '500', + }, }); -class NovelSettingsModal extends Component { - static propTypes = { - // userId: PropTypes.number.isRequired, - // isFollow: PropTypes.bool.isRequired, - // fetchUserFollowDetail: PropTypes.func.isRequired, - // clearUserFollowDetail: PropTypes.func.isRequired, - closeModal: PropTypes.func.isRequired, - setProperties: PropTypes.func.isRequired, - }; - +export class NovelSettingsModal extends Component { constructor(props) { super(props); this.state = {}; @@ -67,9 +71,38 @@ class NovelSettingsModal extends Component { setProperties({ lineHeight: value }); }; + handleOnToggleSliderSide = () => { + const { + readingSettings: { sliderSide }, + setReadingSettings, + } = this.props; + setReadingSettings({ + sliderSide: sliderSide === 'left' ? 'right' : 'left', + }); + }; + + handleOnToggleSliderPercentageSide = () => { + const { + readingSettings: { sliderPercentageSide }, + setReadingSettings, + } = this.props; + setReadingSettings({ + sliderPercentageSide: + sliderPercentageSide === 'left' ? 'right' : 'left', + }); + }; + + mapSliderSideName = (sliderSide) => { + const { i18n } = this.props; + return sliderSide === 'left' + ? i18n.readingSettingsSliderSideLeft + : i18n.readingSettingsSliderSideRight; + }; + render() { const { novelSettings: { fontSize, lineHeight }, + readingSettings: { sliderSide, sliderPercentageSide }, i18n, theme, } = this.props; @@ -118,6 +151,34 @@ class NovelSettingsModal extends Component { onSlidingComplete={this.handleOnLineHeightSlidingComplete} /> + + + + {i18n.readingSettingsSliderSide} + + {this.mapSliderSideName(sliderSide)} + + + + + + + {i18n.readingSettingsSliderPercentageSide} + + {this.mapSliderSideName(sliderPercentageSide)} + + + @@ -127,16 +188,31 @@ class NovelSettingsModal extends Component { } } +NovelSettingsModal.propTypes = { + closeModal: PropTypes.func.isRequired, + i18n: PropTypes.object.isRequired, + novelSettings: PropTypes.object.isRequired, + readingSettings: PropTypes.object.isRequired, + setReadingSettings: PropTypes.func.isRequired, + setProperties: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, +}; + export default withTheme( connectLocalization( connect( (state) => { - const { novelSettings } = state; + const { novelSettings, readingSettings } = state; return { novelSettings, + readingSettings, }; }, - { ...modalActionCreators, ...novelSettingsActionCreators }, + { + ...modalActionCreators, + ...novelSettingsActionCreators, + setReadingSettings: readingSettingsActionCreators.setSettings, + }, )(NovelSettingsModal), ), ); diff --git a/src/screens/Auth/Login.js b/src/screens/Auth/Login.js index be99f716..f98bbb4f 100644 --- a/src/screens/Auth/Login.js +++ b/src/screens/Auth/Login.js @@ -9,12 +9,25 @@ const Login = ({ route }) => { const { url } = route.params; useEffect(() => { - if (route?.params?.code) { - if (route.params?.code) { - const { codeVerifier } = PKCE.getPKCE(); + let isMounted = true; + + const handleLoginCallback = async () => { + if (!route?.params?.code) { + return; + } + + const { codeVerifier } = await PKCE.getPKCE(); + + if (isMounted) { dispatch(tokenRequest(route.params?.code, codeVerifier)); } - } + }; + + handleLoginCallback(); + + return () => { + isMounted = false; + }; }, [dispatch, route]); return ( diff --git a/src/screens/MyPage/ReadingSettings.js b/src/screens/MyPage/ReadingSettings.js index 4efdc8f7..7d6ed7a7 100644 --- a/src/screens/MyPage/ReadingSettings.js +++ b/src/screens/MyPage/ReadingSettings.js @@ -5,6 +5,7 @@ import { useTheme } from 'react-native-paper'; import { useLocalization } from '../../components/Localization'; import PXListItem from '../../components/PXListItem'; import { openModal } from '../../common/actions/modal'; +import { setSettings } from '../../common/actions/readingSettings'; import { MODAL_TYPES, READING_DIRECTION_TYPES, @@ -14,9 +15,12 @@ import { globalStyles } from '../../styles'; const ReadingSettings = () => { const dispatch = useDispatch(); - const { imageReadingDirection, novelReadingDirection } = useSelector( - (state) => state.readingSettings, - ); + const { + imageReadingDirection, + novelReadingDirection, + sliderSide, + sliderPercentageSide, + } = useSelector((state) => state.readingSettings); const theme = useTheme(); const { i18n } = useLocalization(); @@ -49,6 +53,11 @@ const ReadingSettings = () => { } }; + const mapSliderSideName = (side) => + side === 'left' + ? i18n.readingSettingsSliderSideLeft + : i18n.readingSettingsSliderSideRight; + return ( { description={mapReadingDirectionName(novelReadingDirection)} onPress={handleOnPressOpenNovelReadingDirectionSettingsModal} /> + + dispatch( + setSettings({ + sliderSide: sliderSide === 'left' ? 'right' : 'left', + }), + ) + } + /> + + dispatch( + setSettings({ + sliderPercentageSide: + sliderPercentageSide === 'left' ? 'right' : 'left', + }), + ) + } + /> ); }; diff --git a/src/screens/Shared/NovelReader.js b/src/screens/Shared/NovelReader.js index 23122f57..2814c9cf 100644 --- a/src/screens/Shared/NovelReader.js +++ b/src/screens/Shared/NovelReader.js @@ -128,11 +128,15 @@ class NovelReader extends Component { )} @@ -154,6 +158,11 @@ export default withTheme( parsedNovelText, novelSettings, novelReadingDirection: readingSettings.novelReadingDirection, + sliderSide: readingSettings.sliderSide || 'right', + sliderPercentageSide: + readingSettings.sliderPercentageSide || + readingSettings.sliderSide || + 'right', }; }; },