Compare commits

...

53 Commits

Author SHA1 Message Date
Rajarshee Chatterjee 3f8071e48e chore: Release v2.0.3 2026-02-18 22:31:06 +05:30
Rajarshee Chatterjee a1c5350e8e fix: Miscellaneous UI Tweaks 2026-02-12 21:23:40 +05:30
Rajarshee Chatterjee 8f237909d5 feat: Show First Unread Chapter in ReadButton and FAB When No Last Read
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:19:52 +05:30
Rajarshee Chatterjee 11ff69d4f5 fix: Pre-Fetch Adjacent Page Chapters in Reader and Library Updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:40:37 +05:30
Rajarshee Chatterjee d15cf98742 fix: Order Chapter Pages Numerically in Queries for Accurate Retrieval (Closes #1742) 2026-02-12 01:01:20 +05:30
Rajarshee Chatterjee 29bd6612e9 refactor: Optimize Library Screen Performance and Fix Category Bug
Decouple selection state from renderScene via SelectionContext to prevent
all TabView tabs from re-rendering on every selection toggle. Extract
LibraryNovelItem as a memo-wrapped component with stable callbacks and
primitive props so only affected items re-render. Replace O(n) array
lookups with Set-based O(1) checks throughout. Memoize all inline
closures, list furniture, and style objects. Fix library[index] bug that
used the wrong array for "Update Category" menu action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:41:59 +05:30
Rajarshee Chatterjee ab1e36d280 refactor: Update Components to Follow Material Design 3 Guidelines
Update 13 components to comply with MD3 specs:
- Switch: rewrite with MD3 dimensions (52x32 track, 16/24 thumb),
  animated thumb size, proper color tokens and border
- BottomTabBar: fix active indicator (64x32, borderRadius 16),
  label weight/color, container padding
- SearchBar: fix placeholder color, explicit borderRadius 28
- Chip: fix fontSize 14, consistent MD3 color tokens
- Actionbar: remove rounded corners, tonal elevation
- Menu: borderRadius 4, elevation 2, surfaceContainer bg
- SegmentedControl: height 40, borderRadius 20
- List: icon/subheader use onSurfaceVariant, description fontSize 14,
  divider uses outlineVariant
- BottomSheet: add 28dp top corner radius
- SwitchItem: add paddingHorizontal 16, description fontSize 14
- ConfirmationDialog: content fontSize 14
- IconButtonV2: disabled color uses onSurface at 38% opacity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:41:00 +05:30
Rajarshee Chatterjee f1fdafd37f fix: Prevent Tab Bar Height Jump and Shadow Flash on Browse Screen
Fixes initial render height jump by adding initialLayout with window dimensions to TabView. Removes shadow flash on tab switch by setting elevation to 0 and adding consistent border styling.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 22:54:35 +05:30
Rajarshee Chatterjee 4c7684a3a7 refactor: Optimize Novel Screen Performance and Fix Download State Tracking
- Optimize Novel screen components with useCallback, useMemo, and memo to prevent unnecessary re-renders
- Replace download queue array iteration with downloadingChapterIds Set for O(1) lookups
- Add loading shimmer to chapter count display (replaces "Loading..." text)
- Fix state mutation bug in useNovel updateChapter (now creates new array)
- Refactor ChapterItem API (onDeleteChapter, onDownloadChapter props for better stability)
- Track download completion via useEffect to update chapter isDownloaded state
- Fix pagination control logic and improve spacing
- Remove unnecessary local state in ChapterDownloadButtons

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 22:48:14 +05:30
Rajarshee Chatterjee 2f45db659b fix: Replace Sync Database Operations With Async to Prevent Database Lock (Closes #1716)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:49:57 +05:30
Rajarshee Chatterjee 9e38ad114d fix: Sanitize EPUB FileName to Remove Filesystem-Unsafe Characters (Closes #1480)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:31:31 +05:30
Rajarshee Chatterjee 45c4ea8ca0 fix: Handle IOException in NativeFile to Prevent EPUB Import Crash Loop (Closes #1315)
Synchronous TurboModule methods throwing IOException (FileNotFoundException)
crash the app process before JS catch blocks execute. Wrap file I/O in
try/catch to convert IOException to Exception for proper bridge serialization,
breaking the crash loop caused by stale tasks in the MMKV queue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:19:07 +05:30
Rajarshee Chatterjee d2b95ae316 fix: Use NativeFile for Custom CSS/JS Import to Fix Content URI Read Failure (Closes #1661)
Replace expo StorageAccessFramework.readAsStringAsync with NativeFile
copyFile+readFile to properly handle content:// URIs from DocumentPicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:57:14 +05:30
Rajarshee Chatterjee 02121152b3 fix: Stabilize Scroll Handler References to Prevent Infinite Re-render Loop (Closes #1679)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:40:14 +05:30
Rajarshee Chatterjee caed965b6b fix: Prevent EPUB Import Crash from DocumentPicker Cache Cleanup (Closes #1725)
Use content:// URI directly instead of copying to cache directory, which
could be cleaned up by the system before the background import task runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:20:16 +05:30
Rajarshee Chatterjee 4088d5966b fix: Close OkHttp Response and Streams in NativeFile.downloadFile (Closes #1740)
Unclosed Response objects leaked HTTP connections and memory buffers,
causing OOM crashes when batch-downloading chapters with images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:11:18 +05:30
Rajarshee Chatterjee a062beee87 fix: Add Max Height to Set Categories Modal (Closes #1738) 2026-02-06 14:56:32 +05:30
Patrick Loser 3095882516 Replace react-native-paper menus with custom implementation (#1733)
* improve custom Menu component

* replace react-native-paper menu

---------

Co-authored-by: CD-Z <github.verify097@passmail.com>
2026-02-01 17:29:23 +01:00
Patrick Loser 4ad0639796 Fix: Chapter loading for jump to chapter modal and chapter drawer
* updated debs

* Jump to chapter now loads not preloaded chapters

* chapter drawer scrolls to current chapter

* Load new chapters when reaching end of chapter drawer

* fix JumpToChapterModal rebase

* fix Chapter drawer current chapter position detection

---------

Co-authored-by: CD-Z <github.verify097@passmail.com>
2026-01-31 13:11:07 +01:00
Rajarshee Chatterjee b53ec3352f feat: Support Additional Plugin Setting Types (Dropdown and Checkboxes) 2026-01-28 22:23:00 +05:30
Rajarshee Chatterjee c40edd5b2c feat: Add Kitsu Tracker (Closes #416) 2026-01-25 14:50:17 +05:30
Rajarshee Chatterjee bdd42caaaf chore: Update Repository Links to Use lneader Instead of LNReader 2026-01-20 12:20:15 +05:30
Rajarshee Chatterjee ab4eee31ce feat: Allow All Safe Tags and Attributes When Sanitizing Chapter HTML (Closes #1715) 2026-01-20 09:26:32 +05:30
Rajarshee Chatterjee 3f9886a2e5 chore: Update Repository Links to Use lneader Instead of LNReader 2026-01-20 08:45:12 +05:30
kingstar1111 198b7443ae feat: Add RTL (Right-to-Left) Language Support (#1717)
feat: RTL support
2026-01-20 08:32:58 +05:30
Rajarshee Chatterjee f9c5ca303d chore: Update Indonesian Translations (Closes #1653) 2026-01-16 09:28:56 +05:30
Rajarshee Chatterjee 9922d43940 fix: Remove Missing Image References During EPUB Export (Closes #1696)
This is a cherry-pick from @IstarVin’s PR #1558. Thanks for the fix and for contributing. I cherry-picked it for the next release since the original PR had merge conflicts.
2026-01-15 09:24:43 +05:30
Rajarshee Chatterjee ae99b077d4 feat: Add @noble/ciphers for Plugins (Closes #1709) 2026-01-15 09:08:43 +05:30
Rajarshee Chatterjee 0cb9da9027 refactor: Replace withTransactionAsync With withExclusiveTransactionAsync for Improved Concurrency Handling (Closes #1636) 2026-01-15 08:20:37 +05:30
Mousica 467a97dcf7 feat: Add Volume Button Offset Setting and Fix Related UI Issues (#1685) 2025-12-24 19:13:52 +05:30
Rajarshee Chatterjee 8d15e418e7 chore: Update Translations (#1631)
* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]
2025-12-08 20:51:08 +05:30
Rajarshee Chatterjee 3849b797c9 fix: Show Plugin Settings When Update Adds New Settings (Closes #1674)
[skip ci]
2025-12-08 20:50:30 +05:30
Rajarshee Chatterjee d43c95f98c chore: Release v2.0.2
[skip ci]
2025-12-07 00:44:30 +05:30
Rajarshee Chatterjee 52d9d9ea1d feat: Continue Reading While App Runs in Background 2025-12-06 19:03:44 +05:30
Rajarshee Chatterjee 2cabc9849c fix: Skip Failed Entities During Backup Creation and Add Error Handling 2025-12-06 15:16:22 +05:30
Rajarshee Chatterjee 17049aca1c feat: Use All Chapters in Jump-to-Chapter Modal 2025-12-06 14:39:17 +05:30
Rajarshee Chatterjee 9608d59efe fix: Update Battery Level on Chapter Change (Closes #1650) 2025-12-06 13:27:19 +05:30
Rajarshee Chatterjee a7942212a0 fix: Show Page Names Instead of Numbers (Closes #1647) 2025-12-06 13:09:39 +05:30
Rajarshee Chatterjee a22aa53c51 fix: Add Unique Keys to All LegendList Implementations 2025-12-06 13:03:00 +05:30
Rajarshee Chatterjee 1ce16dc643 chore: Cleanup Unused Imports 2025-12-06 12:56:51 +05:30
Rajarshee Chatterjee 0ed6e9a463 fix: Start TTS From First Paragraph on Initial Press 2025-12-06 12:55:02 +05:30
Rajarshee Chatterjee b7ecd1d749 chore: Update Website Link (Closes #1666) 2025-12-06 12:48:42 +05:30
Rajarshee Chatterjee 44e177a5ca feat: Allow <ref> Links in Sanitised HTML (Closes #1672) 2025-12-06 12:48:21 +05:30
Rajarshee Chatterjee b09cd3d587 fix: Sort Updates Correctly (Closes #1652) 2025-12-06 12:44:11 +05:30
Rajarshee Chatterjee b70ebcf5f9 chore: Bump Version Patch to 1 in build.gradle (Closes #1662) 2025-12-06 12:20:19 +05:30
Gabriel Vasconcelos 9d58161729 feat(tts): Refactor Reading Logic to Fix Overflow and Add New Settings (#1664)
feat(tts): refactor reading logic and add new settings (voice, speed, pitch, auto page advance)

- Improved how TTS reads page content for smoother playback
- Added settings for voice selection, speed, and pitch control
- Added auto page advance support
- Added scroll-to-top behavior after advancing pages
- Updated UI to expose new TTS configuration options

[skip ci]
2025-12-06 12:18:35 +05:30
Rajarshee Chatterjee e07eed4a95 fix: Prevent App Crash in Offline Mode (Closes #1651) 2025-12-06 12:14:54 +05:30
Patrick Loser e09704c711 fix: Replace react native paper components
* updated debs

* Added Custom Menu component

* Added new BottomTabBar

---------

Co-authored-by: CD-Z <github.verify097@passmail.com>
2025-11-29 12:39:01 +01:00
Rajarshee Chatterjee 3af24f0815 fix: Improve Migration Error Handling 2025-11-16 13:18:58 +05:30
Rajarshee Chatterjee 4c0e7f6013 fix: Add Backward Compatibility to Tasks Queue 2025-11-16 12:36:42 +05:30
Rajarshee Chatterjee ea298273af fix: Categories Not Sorting
Closes #1642
2025-11-15 20:54:55 +05:30
Rajarshee Chatterjee f60a5af3f2 fix: Add Error Handling Around PRAGMA Query 2025-11-15 20:29:05 +05:30
Rajarshee Chatterjee 6ae5c1cdac chore: Release v2.0.1
[skip ci]
2025-11-15 15:37:34 +05:30
163 changed files with 13574 additions and 6574 deletions
+3 -3
View File
@@ -45,7 +45,7 @@ body:
label: LNReader version
description: You can find your LNReader version in **More → About**.
placeholder: |
Example: "2.0.0"
Example: "2.0.3"
validations:
required: true
@@ -86,9 +86,9 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
required: true
- label: I have updated the app to version **[2.0.0](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.3](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -32,9 +32,9 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/LNReader/lnreader-sources/issues/new/choose).
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
required: true
- label: I have updated the app to version **[2.0.0](https://github.com/LNReader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.3](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
COMMIT_COUNT: ${{ env.COMMIT_COUNT }}
COMMIT_ID: ${{ env.COMMIT_ID }}
run: |
sed -i 's/LNReader/LNReader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})/g' android/app/src/main/res/values/strings.xml
sed -i 's/lnreader/lnreader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})/g' android/app/src/main/res/values/strings.xml
cd android && ./gradlew assembleRelease -PcustomAppId=com.rajarsheechatterjee.LNReader.commit_${{ env.COMMIT_ID }} --build-cache
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/LNReader-r${{ env.COMMIT_COUNT }}-${{ env.COMMIT_ID }}.apk
+4
View File
@@ -88,3 +88,7 @@ flake.lock
# pnpm
.pnpm-store
.cursor/
.agents/
.claude/
+12 -12
View File
@@ -14,15 +14,15 @@
<a href="https://discord.gg/QdcWN4MD63">
<img alt="Discord Chat" src="https://img.shields.io/discord/835746409357246465.svg?logo=discord&logoColor=white&logoWidth=20&labelColor=5865F2&color=4752C4&label=discord&style=flat">
</a>
<a href="https://github.com/LNReader/lnreader/releases">
<img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/LNReader/lnreader/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat">
<a href="https://github.com/lnreader/lnreader/releases">
<img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/lnreader/lnreader/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat">
</a>
</div>
<div align="center">
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/LNReader/lnreader/build.yml?labelColor=27303D&style=flat">
<a href="https://github.com/LNReader/lnreader/blob/main/LICENSE">
<img alt="GitHub" src="https://img.shields.io/github/license/LNReader/lnreader?labelColor=27303D&color=1a73e8&style=flat">
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/lnreader/lnreader/build.yml?labelColor=27303D&style=flat">
<a href="https://github.com/lnreader/lnreader/blob/main/LICENSE">
<img alt="GitHub" src="https://img.shields.io/github/license/lnreader/lnreader?labelColor=27303D&color=1a73e8&style=flat">
</a>
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/lnreader">
<img src="https://badges.crowdin.net/lnreader/localized.svg">
@@ -32,16 +32,16 @@
<h2 align="center">Download</h2>
<p align="center">
<a href="https://github.com/LNReader/lnreader/releases/latest">
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/LNReader/lnreader?label=Stable&labelColor=0d7377&color=084c4e&style=flat">
<a href="https://github.com/lnreader/lnreader/releases/latest">
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/lnreader/lnreader?label=Stable&labelColor=0d7377&color=084c4e&style=flat">
</a>
<a href="https://github.com/LNReader/lnreader/releases/latest">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/LNReader/lnreader?include_prereleases&sort=semver&label=Beta&labelColor=3d3d5c&color=2a2a47&style=flat">
<a href="https://github.com/lnreader/lnreader/releases/latest">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/lnreader/lnreader?include_prereleases&sort=semver&label=Beta&labelColor=3d3d5c&color=2a2a47&style=flat">
</a>
</p>
<p align="center">
Get the app from our <a href="https://github.com/LNReader/lnreader/releases">releases page</a>.
Get the app from our <a href="https://github.com/lnreader/lnreader/releases">releases page</a>.
</p>
<p align="center">
@@ -58,7 +58,7 @@
LNReader does not have any affiliation with the content providers available.
Plugin requests should be created at [lnreader-plugins](https://github.com/LNReader/lnreader-plugins).
Plugin requests should be created at [lnreader-plugins](https://github.com/lnreader/lnreader-plugins).
## Translation
@@ -70,4 +70,4 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md)
## License
[MIT](https://github.com/LNReader/lnreader/blob/main/LICENSE)
[MIT](https://github.com/lnreader/lnreader/blob/main/LICENSE)
+1 -1
View File
@@ -75,7 +75,7 @@ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
ext {
versionMajor = 2
versionMinor = 0
versionPatch = 0
versionPatch = 3
}
android {
+149 -20
View File
@@ -140,6 +140,10 @@ window.tts = new (function () {
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
this.elementsRead = 0;
this.totalElements = 0;
this.allReadableElements = []; // Store all readable elements at start
this.textQueue = []; // Flat list of normalized text for native fallback
this.readable = element => {
const ele = element ?? this.currentElement;
@@ -169,22 +173,41 @@ window.tts = new (function () {
};
// if can find a readable node, else stop tts
this.findNextTextNode = () => {
// FIXED: Added proper boundary checks to prevent stack overflow
this.findNextTextNode = (depth = 0) => {
// Prevent deep recursion
if (depth > 500) {
console.warn('TTS: findNextTextNode max depth reached');
return false;
}
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
return false;
} else {
this.started = true;
}
// Safety check: ensure currentElement is valid
if (!this.currentElement || !this.currentElement.nodeName) {
return false;
}
// is read, have to go next or go back
if (this.currentElement.isSameNode(this.prevElement)) {
this.prevElement = this.currentElement;
if (this.currentElement.nextElementSibling) {
this.currentElement = this.currentElement.nextElementSibling;
} else {
return this.findNextTextNode(depth + 1);
} else if (
this.currentElement.parentElement &&
!this.currentElement.parentElement.isSameNode(document.body) &&
!this.currentElement.parentElement.isSameNode(document.documentElement)
) {
this.currentElement = this.currentElement.parentElement;
return this.findNextTextNode(depth + 1);
} else {
return false;
}
return this.findNextTextNode();
} else {
// can read? read it
if (this.readable()) {
@@ -197,50 +220,117 @@ window.tts = new (function () {
// go deep
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.firstElementChild;
return this.findNextTextNode(depth + 1);
} else if (this.currentElement.nextElementSibling) {
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.nextElementSibling;
} else {
return this.findNextTextNode(depth + 1);
} else if (
this.currentElement.parentElement &&
!this.currentElement.parentElement.isSameNode(document.body) &&
!this.currentElement.parentElement.isSameNode(document.documentElement)
) {
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.parentElement;
return this.findNextTextNode(depth + 1);
} else {
return false;
}
return this.findNextTextNode();
}
};
this.next = () => {
try {
this.currentElement?.classList?.remove('highlight');
if (this.findNextTextNode()) {
const text = this.normalizeText(this.currentElement?.innerText);
// Use array-based approach instead of DOM traversal (no recursion!)
while (this.elementsRead < this.totalElements) {
const nextElement = this.allReadableElements[this.elementsRead];
if (!nextElement) break;
const text = this.normalizeText(nextElement.innerText);
if (text) {
// Found valid text - speak it
this.currentElement = nextElement;
this.reading = true;
this.elementsRead++;
this.speak();
return;
} else {
this.next();
// Empty text, skip to next in array (no recursion!)
this.elementsRead++;
}
}
// Reached the end (elementsRead >= totalElements or no more valid elements)
this.reading = false;
const autoPageAdvance =
reader.readerSettings.val.tts?.autoPageAdvance === true;
const hasNextChapter = !!reader.nextChapter;
if (autoPageAdvance && hasNextChapter) {
reader.post({ type: 'next', autoStartTTS: true });
} else {
this.reading = false;
this.stop();
const controller = document.getElementById('TTS-Controller');
if (controller?.firstElementChild) {
controller.firstElementChild.innerHTML = volumnIcon;
}
if (reader.nextChapter) {
reader.post({ type: 'next', autoStartTTS: true });
}
}
} catch (e) {
alert(e);
this.stop();
alert('TTS Error: ' + e.message);
}
};
this.start = element => {
this.stop();
this.currentElement = element ?? reader.chapterElement;
const startElement = element ?? reader.chapterElement;
this.currentElement = startElement;
// Get all readable elements from the chapter
this.allReadableElements = this.getAllReadableElements(
reader.chapterElement,
);
this.totalElements = this.allReadableElements.length;
this.textQueue = this.allReadableElements
.map(el => this.normalizeText(el.innerText))
.filter(text => !!text);
reader.post({
type: 'tts-queue',
data: {
queue: this.textQueue,
startIndex: this.elementsRead,
},
});
// If starting from a specific element, count how many are before it
if (element && element !== reader.chapterElement) {
const startIndex = this.allReadableElements.indexOf(element);
this.elementsRead = startIndex >= 0 ? startIndex : 0;
} else {
this.elementsRead = 0;
}
this.next();
};
// Get all readable elements in order
this.getAllReadableElements = element => {
const elements = [];
const traverse = el => {
if (!el) return;
if (this.readable(el)) {
elements.push(el);
}
for (let i = 0; i < el.children.length; i++) {
traverse(el.children[i]);
}
};
traverse(element);
return elements;
};
this.resume = () => {
if (!this.reading) {
if (
@@ -268,7 +358,16 @@ window.tts = new (function () {
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
this.elementsRead = 0;
this.totalElements = 0;
this.allReadableElements = [];
this.textQueue = [];
reader.post({ type: 'tts-state', data: { isReading: false } });
// Ensure icon updates to stopped state
const controller = document.getElementById('TTS-Controller');
if (controller?.firstElementChild) {
controller.firstElementChild.innerHTML = volumnIcon;
}
};
this.isElementInViewport = element => {
@@ -287,6 +386,7 @@ window.tts = new (function () {
);
};
// UPDATED: Scroll to top or center based on settings with padding for notch/camera
this.scrollToElement = element => {
if (!element) return;
// Check if element is partially visible (at least some part is in viewport)
@@ -301,11 +401,27 @@ window.tts = new (function () {
// Only scroll if element is not visible or barely visible
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
// Check scrollToTop setting (default to true for better reading experience)
const scrollToTop = reader.readerSettings.val.tts?.scrollToTop !== false;
if (scrollToTop) {
// Scroll to top with padding for notch/camera (80px from top)
const elementTop =
element.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementTop - 80; // 80px padding for notch/camera
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
} else {
// Center scroll (original behavior)
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
};
@@ -316,7 +432,11 @@ window.tts = new (function () {
this.currentElement.classList.add('highlight');
const text = this.normalizeText(this.currentElement.innerText);
if (text) {
reader.post({ type: 'speak', data: text });
reader.post({
type: 'speak',
data: text,
index: this.elementsRead - 1,
});
reader.post({ type: 'tts-state', data: { isReading: true } });
} else {
this.next();
@@ -324,6 +444,15 @@ window.tts = new (function () {
};
})();
// Watch for TTSEnable changes and stop TTS when disabled
van.derive(() => {
if (!reader.generalSettings.val.TTSEnable && window.tts) {
if (tts.reading || tts.started) {
tts.stop();
}
}
});
window.pageReader = new (function () {
this.page = van.state(0);
this.totalPages = van.state(0);
+14 -2
View File
@@ -264,6 +264,10 @@ const TTSController = () => {
{
id: 'TTS-Controller',
class: () => `${reader.generalSettings.val.TTSEnable ? '' : 'hidden'}`,
style: () =>
reader.generalSettings.val.TTSEnable
? 'pointer-events: auto;'
: 'pointer-events: none; display: none !important; opacity: 0; transition: none;',
ontouchstart: () => {
if (!controllerElement) {
controllerElement = document.getElementById('TTS-Controller');
@@ -303,7 +307,8 @@ const TTSController = () => {
top = reader.layoutHeight - 120;
}
controllerElement.style.top = `${top}px`;
if (hoverElement) {
// Check if TTS is still enabled before starting
if (hoverElement && reader.generalSettings.val.TTSEnable) {
tts.start(hoverElement);
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
@@ -313,12 +318,19 @@ const TTSController = () => {
},
onclick: e => {
e.stopPropagation();
// Don't allow interaction if TTS is disabled
if (!reader.generalSettings.val.TTSEnable) {
return;
}
if (tts.reading) {
tts.pause();
controllerElement.firstElementChild.innerHTML = resumeIcon;
} else {
} else if (tts.started) {
tts.resume();
controllerElement.firstElementChild.innerHTML = pauseIcon;
} else {
tts.start();
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
},
},
@@ -74,13 +74,21 @@ class NativeFile(context: ReactApplicationContext) :
}
override fun writeFile(path: String, content: String) {
val fw = FileWriter(path)
fw.write(content)
fw.close()
try {
val fw = FileWriter(path)
fw.write(content)
fw.close()
} catch (e: IOException) {
throw Exception("Failed to write file '$path': ${e.message}")
}
}
override fun readFile(path: String): String {
return File(path).bufferedReader().readText()
val file = File(path)
if (!file.exists()) {
throw Exception("File not found: '$path'")
}
return file.bufferedReader().readText()
}
override fun copyFile(filepath: String, destPath: String) {
@@ -97,17 +105,27 @@ class NativeFile(context: ReactApplicationContext) :
destPath: String,
onDone: (() -> Unit)? = null,
) {
val inputStream = getInputStream(filepath)
val outputStream = getOutputStream(destPath)
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
outputStream.write(buffer, 0, length)
}
inputStream.close()
outputStream.close()
if (onDone != null) {
onDone()
try {
val inputStream = getInputStream(filepath)
try {
val outputStream = getOutputStream(destPath)
try {
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
outputStream.write(buffer, 0, length)
}
} finally {
outputStream.close()
}
} finally {
inputStream.close()
}
if (onDone != null) {
onDone()
}
} catch (e: IOException) {
throw Exception("Failed to copy file from '$filepath' to '$destPath': ${e.message}")
}
}
@@ -191,18 +209,21 @@ class NativeFile(context: ReactApplicationContext) :
}
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful || response.body == null) {
promise.reject(Exception("Failed to download load: ${response.code}"))
return
}
try {
val inputStream = decompressStream(response.body!!.byteStream())
FileOutputStream(destPath).use { fos ->
inputStream.copyTo(fos, BUFFER_SIZE)
response.use {
if (!it.isSuccessful || it.body == null) {
promise.reject(Exception("Failed to download: ${it.code}"))
return
}
try {
decompressStream(it.body!!.byteStream()).use { inputStream ->
FileOutputStream(destPath).use { fos ->
inputStream.copyTo(fos, BUFFER_SIZE)
}
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
})
+9 -1
View File
@@ -11,7 +11,15 @@
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="colorControlActivated">#2596be</item>
<item name="colorControlActivated">#2596be</item>
</style>
<style name="SplashScreen_SplashAnimation">
<item name="android:windowExitAnimation">@android:anim/fade_out</item>
</style>
<style name="SplashScreen_SplashTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowAnimationStyle">@style/SplashScreen_SplashAnimation</item>
</style>
<style name="SplashScreenTheme" parent="SplashScreen_SplashTheme">
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+6
View File
@@ -1,5 +1,11 @@
import 'react-native-gesture-handler';
import { registerRootComponent } from 'expo';
import { I18nManager } from 'react-native';
import { i18n } from './strings/translations';
const isRTL = i18n.locale.startsWith('ar') || i18n.locale.startsWith('he');
I18nManager.allowRTL(isRTL);
I18nManager.forceRTL(isRTL);
import App from './App';
+40 -38
View File
@@ -1,6 +1,6 @@
{
"name": "lnreader",
"version": "2.0.0",
"version": "2.0.3",
"private": true,
"scripts": {
"dev:android": "pnpm run generate:env:debug && react-native run-android --appIdSuffix \"debug\" --active-arch-only",
@@ -41,84 +41,86 @@
"dependencies": {
"@cd-z/react-native-epub-creator": "^3.0.0",
"@gorhom/bottom-sheet": "^5.2.6",
"@legendapp/list": "^2.0.14",
"@react-native-community/slider": "^5.0.1",
"@legendapp/list": "^2.0.16",
"@noble/ciphers": "^2.1.1",
"@react-native-community/slider": "^5.1.1",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-documents/picker": "^10.1.7",
"@react-native-google-signin/google-signin": "^16.0.0",
"@react-native-vector-icons/common": "^12.3.0",
"@react-native-vector-icons/material-design-icons": "^12.3.0",
"@react-native/codegen": "^0.81.1",
"@react-native/gradle-plugin": "^0.81.1",
"@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.26",
"@react-navigation/stack": "^7.4.8",
"@react-native-vector-icons/common": "^12.4.0",
"@react-native-vector-icons/material-design-icons": "^12.4.0",
"@react-native/codegen": "^0.81.5",
"@react-native/gradle-plugin": "^0.81.5",
"@react-navigation/bottom-tabs": "^7.8.6",
"@react-navigation/native": "^7.1.21",
"@react-navigation/native-stack": "^7.8.0",
"@react-navigation/stack": "^7.6.7",
"@shopify/flash-list": "2.0.2",
"cheerio": "1.0.0-rc.12",
"color": "^5.0.0",
"dayjs": "^1.11.18",
"expo": "^54.0.2",
"color": "^5.0.3",
"dayjs": "^1.11.19",
"expo": "^54.0.25",
"expo-clipboard": "~8.0.7",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.12",
"expo-file-system": "~19.0.19",
"expo-haptics": "~15.0.7",
"expo-keep-awake": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-linking": "~8.0.9",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.8",
"expo-notifications": "~0.32.11",
"expo-navigation-bar": "~5.0.9",
"expo-notifications": "~0.32.13",
"expo-speech": "~14.0.7",
"expo-sqlite": "~16.0.8",
"expo-web-browser": "~15.0.7",
"expo-sqlite": "~16.0.9",
"expo-web-browser": "~15.0.9",
"htmlparser2": "^10.0.0",
"i18n-js": "^4.5.1",
"lodash-es": "^4.17.21",
"lottie-ios": "^3.5.0",
"lottie-react-native": "^5.1.3",
"protobufjs": "^7.5.4",
"react": "19.1.0",
"react-native": "^0.81.4",
"react-native": "^0.81.5",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^14.1.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.1.13",
"react-native-drawer-layout": "^4.2.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-error-boundary": "^2.0.0",
"react-native-file-access": "^3.2.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-gesture-handler": "^2.29.1",
"react-native-lottie-splash-screen": "^1.1.2",
"react-native-mmkv": "^3.3.1",
"react-native-mmkv": "^3.3.3",
"react-native-pager-view": "^6.9.1",
"react-native-paper": "^5.14.5",
"react-native-reanimated": "^4.1.0",
"react-native-reanimated": "^4.1.5",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "^4.16.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.18.0",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^4.1.3",
"react-native-tab-view": "^4.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-webview": "13.15.0",
"react-native-worklets": "^0.5.1",
"react-native-worklets": "^0.6.1",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.0",
"urlencode": "^2.0.0"
},
"devDependencies": {
"@babel/core": "^7.28.4",
"@babel/core": "^7.28.5",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@react-native-community/cli": "^20.0.2",
"@react-native-community/cli-platform-android": "^20.0.2",
"@react-native-community/cli-platform-ios": "^20.0.2",
"@react-native/babel-preset": "^0.81.1",
"@react-native/eslint-config": "^0.81.1",
"@react-native/metro-config": "^0.81.1",
"@react-native/typescript-config": "^0.81.1",
"@react-native/babel-preset": "^0.81.5",
"@react-native/eslint-config": "^0.81.5",
"@react-native/metro-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@types/color": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/react": "~19.1.12",
"@types/react": "~19.1.17",
"@types/sanitize-html": "^2.16.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -127,7 +129,7 @@
"lint-staged": "^12.5.0",
"prettier": "2.8.8",
"react-native-dotenv": "^3.4.11",
"typescript": "~5.9.2"
"typescript": "~5.9.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
+6171 -2825
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -73,10 +73,8 @@ export const Actionbar: React.FC<ActionbarProps> = ({
const styles = StyleSheet.create({
actionbarContainer: {
alignItems: 'center',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
bottom: 0,
elevation: 1,
elevation: 0,
flexDirection: 'row',
justifyContent: 'space-around',
position: 'absolute',
@@ -10,6 +10,7 @@ import { useBackHandler } from '@hooks/index';
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import BottomSheetBackdrop from './BottomSheetBackdrop';
import { useWindowDimensions } from 'react-native';
import { useTheme } from '@hooks/persisted';
interface BottomSheetProps
extends Omit<BottomSheetModalProps, 'ref' | 'onChange' | 'snapPoints'> {
@@ -18,6 +19,33 @@ interface BottomSheetProps
snapPoints?: number[];
}
// const MD3DragHandle = () => {
// const theme = useTheme();
// return (
// <View style={[handleStyles.container, { backgroundColor: theme.surface }]}>
// <View
// style={[
// handleStyles.handle,
// { backgroundColor: theme.onSurfaceVariant },
// ]}
// />
// </View>
// );
// };
// const handleStyles = StyleSheet.create({
// container: {
// alignItems: 'center',
// paddingVertical: 12,
// },
// handle: {
// width: 32,
// height: 4,
// borderRadius: 2,
// opacity: 0.4,
// },
// });
const BottomSheet: React.FC<BottomSheetProps> = ({
bottomSheetRef,
children,
@@ -29,6 +57,7 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
const indexRef = useRef<number>(null);
const { bottom, top } = useSafeAreaInsets();
const { height } = useWindowDimensions();
const theme = useTheme();
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...backdropProps} />
@@ -61,6 +90,11 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
ref={bottomSheetRef}
backdropComponent={renderBackdrop}
handleComponent={null}
backgroundStyle={{
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
backgroundColor: theme.surface,
}}
containerStyle={[{ paddingBottom: bottom }, containerStyle]}
onChange={index => {
onChange?.(index);
+166
View File
@@ -0,0 +1,166 @@
/* eslint-disable react-native/no-inline-styles */
import React, { useCallback } from 'react';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Pressable, View, StyleSheet } from 'react-native';
import { Text } from 'react-native-paper';
import { ThemeColors } from '@theme/types';
import Animated from 'react-native-reanimated';
interface CustomBottomTabBarProps extends BottomTabBarProps {
theme: ThemeColors;
showLabelsInNav: boolean;
renderIcon: ({
color,
route,
}: {
route: { name: string };
color: string;
}) => React.ReactNode;
}
function CustomBottomTabBar({
navigation,
state,
descriptors,
insets,
theme,
showLabelsInNav,
renderIcon,
}: CustomBottomTabBarProps) {
const getLabelText = useCallback(
(route: any) => {
if (!showLabelsInNav && route.name !== state.routeNames[state.index]) {
return '';
}
const { options } = descriptors[route.key];
const label =
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: typeof options.title === 'string'
? options.title
: route.name;
return label;
},
[descriptors, showLabelsInNav, state.index, state.routeNames],
);
return (
<View
style={[
styles.container,
{
backgroundColor: theme.surface2 || theme.surface,
paddingBottom: insets?.bottom || 0,
},
]}
>
{state.routes.map((route, index) => {
const label = getLabelText(route);
const isFocused = state.index === index;
const showLabel = (showLabelsInNav || isFocused) && label;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name, route.params);
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
const iconColor = isFocused
? theme.onPrimaryContainer
: theme.onSurfaceVariant;
return (
<Pressable
key={route.key}
onPress={onPress}
onLongPress={onLongPress}
style={styles.pressable}
>
{/* Icon */}
<Animated.View
style={[
styles.iconContainer,
{
transitionProperty: ['width', 'backgroundColor'],
transitionDuration: 250,
transitionTimingFunction: 'ease-in-out',
marginBottom: showLabel ? 4 : 20,
width: isFocused ? 64 : 32,
backgroundColor: isFocused
? theme.primaryContainer
: 'transparent',
},
]}
>
{renderIcon({ color: iconColor, route })}
</Animated.View>
{/* Label */}
{showLabel ? (
<Text
style={[
styles.label,
{
color: isFocused ? theme.onSurface : theme.onSurfaceVariant,
fontWeight: '500',
},
]}
numberOfLines={1}
>
{label}
</Text>
) : null}
</Pressable>
);
})}
</View>
);
}
export default CustomBottomTabBar;
export type { CustomBottomTabBarProps };
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingTop: 12,
paddingBottom: 16,
paddingHorizontal: 0,
minHeight: 80,
},
pressable: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 6,
paddingHorizontal: 4,
position: 'relative',
},
iconContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 32,
borderRadius: 16,
},
label: {
height: 16,
fontSize: 12,
textAlign: 'center',
},
});
+5 -5
View File
@@ -42,8 +42,8 @@ export const Checkbox: React.FC<CheckboxProps> = ({
status === 'indeterminate'
? 'indeterminate'
: status
? 'checked'
: 'unchecked'
? 'checked'
: 'unchecked'
}
onPress={onPress}
color={theme.primary}
@@ -85,11 +85,11 @@ export const SortItem = ({ label, status, onPress, theme }: SortItemProps) => (
const styles = StyleSheet.create({
defaultLabel: {
marginLeft: 12,
marginStart: 12,
},
icon: {
alignSelf: 'center',
left: 24,
start: 24,
position: 'absolute',
},
pressable: {
@@ -98,5 +98,5 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 6,
},
sortItem: { paddingVertical: 16, paddingLeft: 64 },
sortItem: { paddingVertical: 16, paddingStart: 64 },
});
+4 -7
View File
@@ -2,7 +2,6 @@ import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { ThemeColors } from '../../theme/types';
import { overlay } from 'react-native-paper';
interface ChipProps {
label: string;
@@ -14,9 +13,7 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
style={[
styles.chipContainer,
{
backgroundColor: theme.isDark
? overlay(1, theme.surface)
: theme.secondaryContainer,
backgroundColor: theme.secondaryContainer,
},
]}
>
@@ -28,7 +25,7 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
style={[
styles.label,
{
color: theme.isDark ? theme.onSurface : theme.onSecondaryContainer,
color: theme.onSecondaryContainer,
},
]}
>
@@ -44,11 +41,11 @@ const styles = StyleSheet.create({
chipContainer: {
borderRadius: 8,
height: 32,
marginRight: 8,
marginEnd: 8,
overflow: 'hidden',
},
label: {
fontSize: 12,
fontSize: 14,
},
pressable: {
alignItems: 'center',
@@ -37,7 +37,7 @@ const styles = StyleSheet.create({
colorPreview: {
borderRadius: 50,
height: 24,
marginRight: 16,
marginEnd: 16,
width: 24,
},
container: {
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
shadowColor: 'transparent',
},
content: {
fontSize: 16,
fontSize: 14,
letterSpacing: 0,
},
});
+5 -1
View File
@@ -41,7 +41,11 @@ const IconButton: React.FC<Props> = ({
<MaterialCommunityIcons
name={name}
size={size}
color={disabled ? theme.outline : color || theme.onSurface}
color={
disabled
? Color(theme.onSurface).alpha(0.38).string()
: color || theme.onSurface
}
/>
</Pressable>
</View>
+10 -9
View File
@@ -33,7 +33,7 @@ const SubHeader = ({
children: ReactNode;
theme: ThemeColors;
}) => (
<PaperList.Subheader style={{ color: theme.primary }}>
<PaperList.Subheader style={{ color: theme.onSurfaceVariant }}>
{children}
</PaperList.Subheader>
);
@@ -57,7 +57,7 @@ const Item: React.FC<ListItemProps> = ({
/>
);
}
}, [icon, theme.primary]);
}, [icon, theme.onSurfaceVariant]);
const rightIcon = useCallback(() => {
if (right) {
return (
@@ -68,7 +68,7 @@ const Item: React.FC<ListItemProps> = ({
/>
);
}
}, [right, theme.primary]);
}, [right, theme.onSurfaceVariant]);
return (
<PaperList.Item
title={title}
@@ -93,7 +93,9 @@ const Item: React.FC<ListItemProps> = ({
};
const Divider = ({ theme }: { theme: ThemeColors }) => (
<PaperDivider style={[styles.divider, { backgroundColor: theme.outline }]} />
<PaperDivider
style={[styles.divider, { backgroundColor: theme.outlineVariant }]}
/>
);
const InfoItem = ({
@@ -109,7 +111,7 @@ const InfoItem = ({
<View style={[styles.infoCtn, style]}>
<MaterialIcon
size={20}
color={theme.onSurfaceVariant}
color={theme.primary}
name={'information-outline'}
/>
<Text style={[styles.infoMsg, { color: theme.onSurfaceVariant }]}>
@@ -171,18 +173,17 @@ const styles = StyleSheet.create({
height: 24,
width: 24,
borderRadius: 50,
marginRight: 16,
marginEnd: 16,
},
description: {
fontSize: 12,
fontSize: 14,
lineHeight: 20,
},
divider: {
height: 1,
opacity: 0.5,
},
iconCtn: {
paddingLeft: 16,
paddingStart: 16,
},
infoCtn: {
paddingHorizontal: 16,
+2 -2
View File
@@ -88,7 +88,7 @@ const styles = StyleSheet.create({
flex: 1,
flexWrap: 'wrap',
fontSize: 15,
marginLeft: 16,
paddingRight: 8,
marginStart: 16,
paddingEnd: 8,
},
});
+260
View File
@@ -0,0 +1,260 @@
import { useTheme } from '@hooks/persisted';
import React, {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Pressable,
StyleSheet,
View,
useWindowDimensions,
StyleProp,
ViewStyle,
TextStyle,
LayoutRectangle,
} from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { Portal } from 'react-native-paper';
import Animated, {
FadeIn,
FadeOut,
withTiming,
ExitAnimationsValues,
EntryAnimationsValues,
withDelay,
} from 'react-native-reanimated';
interface MenuProps {
visible: boolean;
onDismiss: () => void;
anchor: React.ReactNode;
contentStyle?: StyleProp<ViewStyle>;
children: React.ReactNode;
fullWidth?: boolean; // Full width of the anchor
}
interface MenuItemProps {
title: string;
onPress: () => void;
style?: StyleProp<ViewStyle>;
titleStyle?: StyleProp<TextStyle>;
}
const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
visible,
onDismiss,
anchor,
contentStyle,
children,
fullWidth,
}) => {
const theme = useTheme();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const anchorRef = useRef<View>(null);
const duration = 250;
const [menuLayout, setMenuLayout] = useState<LayoutRectangle | null>(null);
const [anchorLayout, setAnchorLayout] = useState<LayoutRectangle>({
x: 0,
y: 0,
width: 0,
height: 0,
});
const backdropEntering = FadeIn.duration(duration);
const menuEntering = (values: EntryAnimationsValues) => {
'worklet';
const animations = {
height: withTiming(values.targetHeight, { duration }),
opacity: withTiming(1, { duration: duration - 100 }),
};
const initialValues = {
height: 0,
opacity: 0,
};
return {
initialValues,
animations,
};
};
const backdropExiting = FadeOut.duration(duration);
const menuExiting = (values: ExitAnimationsValues) => {
'worklet';
const initialValues = {
height: values.currentHeight,
opacity: 1,
};
const animations = {
height: withTiming(0, { duration }),
opacity: withDelay(100, withTiming(0, { duration: duration - 100 })),
};
return {
initialValues,
animations,
};
};
const measureAnchor = useCallback(() => {
anchorRef.current?.measureInWindow((x, y, width, height) => {
setAnchorLayout({ x, y, width, height });
});
}, []);
useLayoutEffect(() => {
if (visible) {
measureAnchor();
} else {
setMenuLayout(null); // Reset layout when closed
}
}, [measureAnchor, visible]);
const menuPosition = useMemo(() => {
if (!menuLayout) return { opacity: 0 };
const leftPos = Math.max(
16,
Math.min(anchorLayout.x, screenWidth - menuLayout.width - 16),
);
let topPos = anchorLayout.y + anchorLayout.height + 24;
const showAbove = topPos + menuLayout.height > screenHeight;
if (showAbove) {
topPos -= anchorLayout.height + menuLayout.height;
}
const maxWidth = fullWidth
? anchorLayout.width
: Math.min(250, screenWidth - 32);
return {
left: leftPos,
top: topPos,
shadowColor: theme.isDark ? '#000' : theme.shadow,
[fullWidth ? 'width' : 'maxWidth']: maxWidth,
};
}, [
anchorLayout.height,
anchorLayout.width,
anchorLayout.x,
anchorLayout.y,
fullWidth,
menuLayout,
screenHeight,
screenWidth,
theme.isDark,
theme.shadow,
]);
return (
<>
<View ref={anchorRef} collapsable={false}>
{anchor}
</View>
{visible && (
<Portal>
{/* Backdrop */}
<Pressable style={StyleSheet.absoluteFillObject} onPress={onDismiss}>
<Animated.View
style={[
styles.backdrop,
theme.isDark ? styles.backdropDark : styles.backdropLight,
]}
entering={backdropEntering}
exiting={backdropExiting}
/>
</Pressable>
{/* Menu */}
<Animated.View
key={menuLayout ? 'ready' : 'measuring'}
style={[
styles.menuContainer,
{ backgroundColor: theme.surface2 || theme.surface },
contentStyle,
menuPosition,
]}
onLayout={e => {
setMenuLayout(e.nativeEvent.layout);
}}
entering={menuLayout ? menuEntering : undefined}
exiting={menuLayout ? menuExiting : undefined}
>
<ScrollView style={{ maxHeight: screenHeight * 0.6 }}>
{children}
</ScrollView>
</Animated.View>
</Portal>
)}
</>
);
};
const MenuItem: React.FC<MenuItemProps> = ({
title,
onPress,
style,
titleStyle,
}) => {
const theme = useTheme();
return (
<Pressable
style={[styles.menuItem, style]}
onPress={onPress}
android_ripple={{ color: theme.rippleColor, foreground: true }}
>
<Animated.Text
style={[styles.menuItemText, { color: theme.onSurface }, titleStyle]}
>
{title}
</Animated.Text>
</Pressable>
);
};
Menu.Item = MenuItem;
const styles = StyleSheet.create({
menuContainer: {
borderRadius: 4,
elevation: 2,
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.2,
shadowRadius: 3,
overflow: 'hidden',
position: 'absolute',
zIndex: 1001,
},
backdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
backdropDark: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
backdropLight: {
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
menuItem: {
paddingHorizontal: 12,
paddingVertical: 12,
minHeight: 48,
justifyContent: 'center',
},
menuItemText: {
fontSize: 16,
fontWeight: '400',
},
});
export default Menu;
+7 -3
View File
@@ -62,7 +62,8 @@ interface INovelCover<TNovel> {
addSkeletonLoading?: boolean;
inActivity?: boolean;
onLongPress: (item: TNovel) => void;
selectedNovelIds: number[];
hasSelection?: boolean;
selectedNovelIds?: number[];
globalSearch?: boolean;
imageRequestInit?: ImageRequestInit;
}
@@ -84,10 +85,13 @@ function NovelCover<
addSkeletonLoading,
inActivity,
onLongPress,
hasSelection,
globalSearch,
selectedNovelIds,
imageRequestInit,
}: INovelCover<TNovel>) {
const selectionActive =
hasSelection ?? (selectedNovelIds != null && selectedNovelIds.length > 0);
const {
displayMode = DisplayModes.Comfortable,
showDownloadBadges = true,
@@ -162,7 +166,7 @@ function NovelCover<
android_ripple={{ color: theme.rippleColor }}
style={styles.opac}
onPress={
selectedNovelIds && selectedNovelIds.length > 0
selectionActive
? selectNovel
: onPress
}
@@ -243,7 +247,7 @@ function NovelCover<
inLibraryBadge={libraryStatus && <InLibraryBadge theme={theme} />}
theme={theme}
onPress={
selectedNovelIds && selectedNovelIds.length > 0 ? selectNovel : onPress
selectionActive ? selectNovel : onPress
}
onLongPress={selectNovel}
isSelected={isSelected}
+4 -1
View File
@@ -23,6 +23,9 @@ interface NovelListProps extends FlatListProps<NovelInfo | NovelItem> {
data: Array<listDataItem>;
}
const novelListKeyExtractor = (item: NovelInfo | NovelItem, index: number) =>
index + '_' + item.path;
const NovelList: React.FC<NovelListProps> = props => {
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
useLibrarySettings();
@@ -74,7 +77,7 @@ const NovelList: React.FC<NovelListProps> = props => {
]}
numColumns={numColumns}
key={numColumns}
keyExtractor={(item, index) => index + '_' + item.path}
keyExtractor={novelListKeyExtractor}
{...props}
data={extendedNovelList}
/>
+1 -1
View File
@@ -57,6 +57,6 @@ const styles = StyleSheet.create({
},
radioButtonLabel: {
fontSize: 16,
marginLeft: 16,
marginStart: 16,
},
});
+2 -2
View File
@@ -48,11 +48,11 @@ export const RadioButton: React.FC<Props> = ({
const styles = StyleSheet.create({
icon: {
alignSelf: 'center',
left: 24,
start: 24,
position: 'absolute',
},
label: {
marginLeft: 12,
marginStart: 12,
},
pressable: {
alignItems: 'center',
+2 -2
View File
@@ -22,8 +22,8 @@ const SafeAreaView: React.FC<SafeAreaViewProps> = ({
},
padding: {
paddingBottom: excludeBottom ? 0 : bottom,
paddingLeft: left,
paddingRight: right,
paddingStart: left,
paddingEnd: right,
paddingTop: excludeTop ? 0 : top,
},
});
+7 -10
View File
@@ -3,10 +3,10 @@ import { Pressable, StyleSheet, View, TextInput } from 'react-native';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
import { ThemeColors } from '../../theme/types';
import { Menu } from 'react-native-paper';
import { Menu } from '@components';
import { MaterialDesignIconName } from '@type/icon';
interface RightIcon {
export interface RightIcon {
iconName: MaterialDesignIconName;
color?: string;
onPress: () => void;
@@ -49,8 +49,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
const [extraMenu, showExtraMenu] = useState(false);
const marginTop = 8;
const marginRight = 16;
const marginLeft = 16;
return (
<View
@@ -58,8 +56,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
styles.searchbarContainer,
{
marginTop,
marginRight,
marginLeft,
backgroundColor: theme.surface2,
},
]}
@@ -86,7 +82,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
ref={searchbarRef}
style={[styles.textInput, { color: theme.onSurface }]}
placeholder={placeholder}
placeholderTextColor={theme.onSurface}
placeholderTextColor={theme.onSurfaceVariant}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
defaultValue={searchText}
@@ -112,7 +108,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
anchorPosition="bottom"
anchor={
<IconButtonV2
name="dots-vertical"
@@ -135,7 +130,9 @@ const Searchbar: React.FC<SearcbarProps> = ({
}}
onPress={() => {
showExtraMenu(false);
button.onPress();
setTimeout(() => {
button.onPress();
}, 0);
}}
/>
))}
@@ -163,7 +160,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
},
searchbarContainer: {
borderRadius: 50,
borderRadius: 28,
marginBottom: 12,
marginHorizontal: 16,
minHeight: 56,
@@ -88,7 +88,7 @@ export function SegmentedControl<T extends string = string>({
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
height: 48,
height: 40,
},
segment: {
flex: 1,
@@ -99,16 +99,16 @@ const styles = StyleSheet.create({
segmentFirst: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderTopLeftRadius: 24,
borderBottomLeftRadius: 24,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
},
segmentMiddle: {
borderRightWidth: 1,
},
segmentLast: {
borderRightWidth: 1,
borderTopRightRadius: 24,
borderBottomRightRadius: 24,
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
},
segmentPressable: {
flex: 1,
+88 -41
View File
@@ -10,73 +10,118 @@ import Animated, {
} from 'react-native-reanimated';
import { useTheme } from '@hooks/persisted';
// MD3 Switch dimensions
const TRACK_WIDTH = 52;
const TRACK_HEIGHT = 32;
const TRACK_RADIUS = TRACK_HEIGHT / 2;
const THUMB_SIZE_OFF = 16;
const THUMB_SIZE_ON = 24;
const TRACK_BORDER_WIDTH = 2;
// Thumb positions: centered vertically, padded from edges
const THUMB_OFFSET_OFF = (TRACK_HEIGHT - THUMB_SIZE_OFF) / 2;
const THUMB_OFFSET_ON =
TRACK_WIDTH - THUMB_SIZE_ON - (TRACK_HEIGHT - THUMB_SIZE_ON) / 2;
interface SwitchProps {
value: boolean;
onValueChange?: () => void;
size?: number;
style?: StyleProp<ViewStyle>;
}
const Switch = ({ value, size = 22, onValueChange, style }: SwitchProps) => {
const Switch = ({ value, onValueChange, style }: SwitchProps) => {
const theme = useTheme();
// Value for Switch Animation
// const switchTranslate = useSharedValue(value ? size : size / 6);
// Background color animation progress
const progress = useSharedValue(value ? size : 0);
const progress = useSharedValue(value ? 1 : 0);
// Animate switch movement
const customSpringStyles = useAnimatedStyle(() => ({
useEffect(() => {
progress.value = withTiming(value ? 1 : 0, { duration: 200 });
}, [progress, value]);
// Animate thumb position
const thumbPositionStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: withSpring(value ? size : size / 6, {
mass: 1,
damping: 15,
stiffness: 120,
overshootClamping: false,
}),
translateX: withSpring(
value ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF,
{
mass: 1,
damping: 15,
stiffness: 120,
overshootClamping: false,
},
),
},
],
}));
// Precompute background color animation
const backgroundColor = useDerivedValue(
// Animate thumb size
const thumbSizeStyle = useAnimatedStyle(() => {
const size = withSpring(value ? THUMB_SIZE_ON : THUMB_SIZE_OFF, {
mass: 1,
damping: 15,
stiffness: 120,
overshootClamping: false,
});
return {
width: size,
height: size,
borderRadius: size,
};
});
// Animate track background color
const trackBgColor = useDerivedValue(
() =>
interpolateColor(
progress.value,
[0, size],
[theme.outline, theme.primary],
[0, 1],
[theme.surfaceVariant, theme.primary],
),
[value],
[theme.surfaceVariant, theme.primary],
);
const backgroundColorStyle = useAnimatedStyle(() => ({
backgroundColor: backgroundColor.value,
const trackColorStyle = useAnimatedStyle(() => ({
backgroundColor: trackBgColor.value,
}));
useEffect(() => {
progress.value = withTiming(value ? size : 0);
}, [progress, size, value]);
// Animate track border
const trackBorderStyle = useAnimatedStyle(() => ({
borderWidth: withTiming(value ? 0 : TRACK_BORDER_WIDTH, { duration: 200 }),
borderColor: theme.outline,
}));
// Animate thumb color
const thumbBgColor = useDerivedValue(
() =>
interpolateColor(
progress.value,
[0, 1],
[theme.outline, theme.onPrimary],
),
[theme.outline, theme.onPrimary],
);
const thumbColorStyle = useAnimatedStyle(() => ({
backgroundColor: thumbBgColor.value,
}));
return (
<Pressable onPress={onValueChange}>
<Animated.View
style={[
styles.container,
styles.track,
style,
{
width: size * 2 + size / 6,
height: size + size / 3,
borderRadius: size,
},
backgroundColorStyle,
trackColorStyle,
trackBorderStyle,
]}
>
<Animated.View
style={[
styles.circle,
customSpringStyles,
{ height: size, width: size, borderRadius: size },
styles.thumb,
thumbPositionStyle,
thumbSizeStyle,
thumbColorStyle,
]}
/>
</Animated.View>
@@ -87,18 +132,20 @@ const Switch = ({ value, size = 22, onValueChange, style }: SwitchProps) => {
export default React.memo(Switch);
const styles = StyleSheet.create({
circle: {
backgroundColor: 'white',
elevation: 4,
thumb: {
elevation: 1,
shadowColor: 'black',
shadowOffset: {
width: 0,
height: 2,
height: 1,
},
shadowOpacity: 0.2,
shadowRadius: 2.5,
shadowOpacity: 0.15,
shadowRadius: 1,
},
container: {
track: {
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
borderRadius: TRACK_RADIUS,
justifyContent: 'center',
},
});
+3 -5
View File
@@ -16,7 +16,6 @@ interface SwitchItemProps {
description?: string;
onPress: () => void;
theme: ThemeColors;
size?: number;
style?: StyleProp<ViewStyle>;
}
@@ -26,7 +25,6 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
onPress,
theme,
value,
size,
style,
}) => (
<Pressable
@@ -46,7 +44,6 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
value={value}
onValueChange={onPress}
style={styles.switch}
size={size}
/>
</Pressable>
);
@@ -58,10 +55,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
description: {
fontSize: 12,
fontSize: 14,
lineHeight: 20,
},
label: {
@@ -72,6 +70,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
switch: {
marginLeft: 8,
marginStart: 8,
},
});
+4 -4
View File
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
checkIcon: {
position: 'absolute',
top: 5,
right: 5,
end: 5,
borderRadius: 50,
padding: 1.6,
zIndex: 1,
@@ -164,7 +164,7 @@ const styles = StyleSheet.create({
topBarAccent: {
width: 44,
height: 10,
marginLeft: 8,
marginStart: 8,
borderRadius: 50,
},
content: {
@@ -186,7 +186,7 @@ const styles = StyleSheet.create({
rowAccentSmall: {
height: 10,
width: 16,
marginLeft: 4,
marginStart: 4,
borderRadius: 50,
},
rowAccentShort: {
@@ -221,5 +221,5 @@ const styles = StyleSheet.create({
fontSize: 12,
paddingVertical: 4,
},
marginLeft: { marginLeft: 4 },
marginLeft: { marginStart: 4 },
});
+2
View File
@@ -18,3 +18,5 @@ export { default as SafeAreaView } from './SafeAreaView/SafeAreaView';
export { default as Modal } from './Modal/Modal';
export { SegmentedControl } from './SegmentedControl';
export { DialogTitle } from './DialogTitle';
export { default as BottomTabBar } from './BottomTabBar';
export { default as Menu } from './Menu';
+19 -5
View File
@@ -31,7 +31,7 @@ export const db = SQLite.openDatabaseSync(dbName);
/**
* Creates the initial database schema for fresh installations
* Sets up all tables, indexes, triggers and sets version to 1
* Sets up all tables, indexes, triggers and sets version to 2
*/
const createInitialSchema = () => {
db.execSync('PRAGMA journal_mode = WAL');
@@ -52,7 +52,7 @@ const createInitialSchema = () => {
db.runSync(createNovelTriggerQueryUpdate);
db.runSync(createNovelTriggerQueryDelete);
db.execSync('PRAGMA user_version = 1');
db.execSync('PRAGMA user_version = 2');
});
};
@@ -65,9 +65,23 @@ export const initializeDatabase = () => {
db.execSync('PRAGMA cache_size = 10000');
db.execSync('PRAGMA foreign_keys = ON');
const userVersion =
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0;
let userVersion = 0;
try {
const result = db.getFirstSync<{ user_version: number }>(
'PRAGMA user_version',
);
userVersion = result?.user_version ?? 0;
} catch (error) {
// If PRAGMA query fails, assume fresh database
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
'Failed to get database version, assuming fresh install:',
error,
);
}
userVersion = 0;
}
if (userVersion === 0) {
createInitialSchema();
+101 -84
View File
@@ -7,10 +7,14 @@ const columnExists = (
tableName: string,
columnName: string,
): boolean => {
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
try {
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
} catch {
return false;
}
};
/**
@@ -23,86 +27,99 @@ export const migration002: Migration = {
version: 2,
description: 'Add counter columns and triggers to Novel table',
migrate: db => {
if (!columnExists(db, 'Novel', 'chaptersDownloaded')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN chaptersDownloaded INTEGER DEFAULT 0
`);
const addColumnSafely = (columnName: string, columnDefinition: string) => {
if (!columnExists(db, 'Novel', columnName)) {
try {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN ${columnName} ${columnDefinition}
`);
} catch (error) {
// Gracefully handle ALTER TABLE failures (e.g., table doesn't exist)
// Columns will be created when table is created in initial schema
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
`Failed to add column ${columnName} to Novel table:`,
error,
);
}
}
}
};
addColumnSafely('chaptersDownloaded', 'INTEGER DEFAULT 0');
addColumnSafely('chaptersUnread', 'INTEGER DEFAULT 0');
addColumnSafely('totalChapters', 'INTEGER DEFAULT 0');
addColumnSafely('lastReadAt', 'TEXT');
addColumnSafely('lastUpdatedAt', 'TEXT');
// Verify all columns exist before running UPDATE queries to prevent SQL errors
const allColumnsExist =
columnExists(db, 'Novel', 'chaptersDownloaded') &&
columnExists(db, 'Novel', 'chaptersUnread') &&
columnExists(db, 'Novel', 'totalChapters') &&
columnExists(db, 'Novel', 'lastReadAt') &&
columnExists(db, 'Novel', 'lastUpdatedAt');
if (allColumnsExist) {
try {
db.runSync(`
UPDATE Novel
SET chaptersDownloaded = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.isDownloaded = 1
)
`);
db.runSync(`
UPDATE Novel
SET chaptersUnread = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.unread = 1
)
`);
db.runSync(`
UPDATE Novel
SET totalChapters = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastReadAt = (
SELECT MAX(readTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastUpdatedAt = (
SELECT MAX(updatedTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
} catch (error) {
// Gracefully handle UPDATE failures - columns already added with defaults
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
'Failed to populate counter columns in Novel table:',
error,
);
}
}
}
if (!columnExists(db, 'Novel', 'chaptersUnread')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN chaptersUnread INTEGER DEFAULT 0
`);
}
if (!columnExists(db, 'Novel', 'totalChapters')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN totalChapters INTEGER DEFAULT 0
`);
}
if (!columnExists(db, 'Novel', 'lastReadAt')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN lastReadAt TEXT
`);
}
if (!columnExists(db, 'Novel', 'lastUpdatedAt')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN lastUpdatedAt TEXT
`);
}
db.runSync(`
UPDATE Novel
SET chaptersDownloaded = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.isDownloaded = 1
)
`);
db.runSync(`
UPDATE Novel
SET chaptersUnread = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.unread = 1
)
`);
db.runSync(`
UPDATE Novel
SET totalChapters = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastReadAt = (
SELECT MAX(readTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastUpdatedAt = (
SELECT MAX(updatedTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
},
};
+32 -33
View File
@@ -1,9 +1,7 @@
import * as SQLite from 'expo-sqlite';
import { BackupCategory, Category, NovelCategory, CCategory } from '../types';
import { showToast } from '@utils/showToast';
import { getString } from '@strings/translations';
import { db } from '@database/db';
import { getAllSync, runSync } from '@database/utils/helpers';
const getCategoriesQuery = `
SELECT
@@ -19,7 +17,7 @@ const getCategoriesQuery = `
type NumberList = `${number}` | `${number},${number}` | undefined;
export const getCategoriesFromDb = () => {
return getAllSync<Category & { novelIds: NumberList }>([getCategoriesQuery]);
return db.getAllSync<Category & { novelIds: NumberList }>(getCategoriesQuery);
};
export const getCategoriesWithCount = (novelIds: number[]) => {
@@ -35,13 +33,14 @@ export const getCategoriesWithCount = (novelIds: number[]) => {
WHERE Category.id != 2
ORDER BY sort
`;
return getAllSync<CCategory>([getCategoriesWithCountQuery]);
return db.getAllSync<CCategory>(getCategoriesWithCountQuery);
};
const createCategoryQuery = 'INSERT INTO Category (name) VALUES (?)';
export const createCategory = (categoryName: string): void =>
runSync([[createCategoryQuery, [categoryName]]]);
export const createCategory = (categoryName: string): void => {
db.runSync(createCategoryQuery, categoryName);
};
const beforeDeleteCategoryQuery = `
UPDATE NovelCategory SET categoryId = (SELECT id FROM Category WHERE sort = 1)
@@ -58,10 +57,8 @@ export const deleteCategoryById = (category: Category): void => {
if (category.sort === 1 || category.id === 2) {
return showToast(getString('categories.cantDeleteDefault'));
}
runSync([
[beforeDeleteCategoryQuery, [category.id]],
[deleteCategoryQuery, [category.id]],
]);
db.runSync(beforeDeleteCategoryQuery, category.id);
db.runSync(deleteCategoryQuery, category.id);
};
const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
@@ -69,7 +66,9 @@ const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
export const updateCategory = (
categoryId: number,
categoryName: string,
): void => runSync([[updateCategoryQuery, [categoryName, categoryId]]]);
): void => {
db.runSync(updateCategoryQuery, categoryName, categoryId);
};
const isCategoryNameDuplicateQuery = `
SELECT COUNT(*) as isDuplicate FROM Category WHERE name = ?
@@ -92,31 +91,31 @@ export const updateCategoryOrderInDb = (categories: Category[]): void => {
if (categories.length && categories[0].id === 2) {
return;
}
runSync(
categories.map(c => {
return [updateCategoryOrderQuery, [c.sort, c.id]];
}),
);
for (const c of categories) {
db.runSync(updateCategoryOrderQuery, c.sort, c.id);
}
};
export const getAllNovelCategories = () =>
getAllSync<NovelCategory>(['SELECT * FROM NovelCategory']);
db.getAllSync<NovelCategory>('SELECT * FROM NovelCategory');
export const _restoreCategory = (category: BackupCategory) => {
const d = category.novelIds.map(novelId => [
'INSERT OR IGNORE INTO NovelCategory (categoryId, novelId) VALUES (?, ?)',
[category.id, novelId],
]) as Array<[string, SQLite.SQLiteBindParams]>;
runSync([
[
'DELETE FROM Category WHERE id = ? OR sort = ?',
[category.id, category.sort],
],
[
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (?, ?, ?)',
[category.id, category.name, category.sort],
],
...d,
]);
db.runSync(
'DELETE FROM Category WHERE id = ? OR sort = ?',
category.id,
category.sort,
);
db.runSync(
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (?, ?, ?)',
category.id,
category.name,
category.sort,
);
for (const novelId of category.novelIds) {
db.runSync(
'INSERT OR IGNORE INTO NovelCategory (categoryId, novelId) VALUES (?, ?)',
category.id,
novelId,
);
}
};
+60 -32
View File
@@ -22,13 +22,13 @@ export const insertChapters = async (
return;
}
await db.withExclusiveTransactionAsync(async tx => {
await db.withTransactionAsync(async () => {
for (let index = 0; index < chapters.length; index++) {
const chapter = chapters[index];
const chapterName = chapter.name ?? 'Chapter ' + (index + 1);
const chapterPage = chapter.page || '1';
const result = await tx.runAsync(
const result = await db.runAsync(
`
INSERT INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position)
SELECT ?, ?, ?, ?, ?, ?, ?
@@ -48,7 +48,7 @@ export const insertChapters = async (
const insertId = result.lastInsertRowId;
if (!insertId || insertId < 0) {
await tx.runAsync(
await db.runAsync(
`
UPDATE Chapter SET
page = ?, position = ?, name = ?, releaseTime = ?, chapterNumber = ?
@@ -209,8 +209,8 @@ export const clearUpdates = () =>
// #region Selectors
export const getCustomPages = (novelId: number) =>
db.getAllSync<{ page: string }>(
'SELECT DISTINCT page from Chapter WHERE novelId = ?',
db.getAllAsync<{ page: string }>(
'SELECT DISTINCT page from Chapter WHERE novelId = ? ORDER BY CAST(page AS INTEGER) ASC',
novelId,
);
@@ -272,11 +272,13 @@ export const getPageChapters = (
);
};
export const getChapterCount = (novelId: number, page: string = '1') =>
db.getFirstSync<{ 'COUNT(*)': number }>(
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
novelId,
page,
export const getChapterCount = async (novelId: number, page: string = '1') =>
(
await db.getFirstAsync<{ 'COUNT(*)': number }>(
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
novelId,
page,
)
)?.['COUNT(*)'] ?? 0;
export const getPageChaptersBatched = (
@@ -286,26 +288,56 @@ export const getPageChaptersBatched = (
page?: string,
batch: number = 0,
) => {
return db.getAllSync<ChapterInfo>(
return db.getAllAsync<ChapterInfo>(
getPageChaptersQuery(sort, filter, 300, 300 * batch),
novelId,
page || '1',
);
};
export const getNovelChaptersByNumber = (
novelId: number,
chapterNumber: number,
) => {
return db.getAllAsync<ChapterInfo>(
'SELECT * FROM Chapter WHERE novelId = ? AND position = ?',
novelId,
chapterNumber - 1,
);
};
export const getFirstUnreadChapter = (
novelId: number,
filter?: string,
page?: string,
) =>
db.getFirstAsync<ChapterInfo>(
`SELECT * FROM Chapter WHERE novelId = ? AND page = ? AND unread = 1 ${filter || ''} ORDER BY position ASC LIMIT 1`,
novelId,
page || '1',
);
export const getNovelChaptersByName = (novelId: number, searchText: string) => {
return db.getAllAsync<ChapterInfo>(
'SELECT * FROM Chapter WHERE novelId = ? AND name LIKE ?',
novelId,
`%${searchText}%`,
);
};
export const getPrevChapter = (
novelId: number,
chapterPosition: number,
page: string,
) =>
db.getFirstAsync<ChapterInfo>(
`SELECT * FROM Chapter
WHERE novelId = ?
`SELECT * FROM Chapter
WHERE novelId = ?
AND (
(position < ? AND page = ?)
OR page < ?
(position < ? AND page = ?)
OR CAST(page AS INTEGER) < CAST(? AS INTEGER)
)
ORDER BY position DESC, page DESC`,
ORDER BY CAST(page AS INTEGER) DESC, position DESC`,
novelId,
chapterPosition,
page,
@@ -318,13 +350,13 @@ export const getNextChapter = (
page: string,
) =>
db.getFirstAsync<ChapterInfo>(
`SELECT * FROM Chapter
WHERE novelId = ?
`SELECT * FROM Chapter
WHERE novelId = ?
AND (
(page = ? AND position > ?)
OR (position = 0 AND page > ?)
(page = ? AND position > ?)
OR (position = 0 AND CAST(page AS INTEGER) > CAST(? AS INTEGER))
)
ORDER BY position ASC, page ASC`,
ORDER BY CAST(page AS INTEGER) ASC, position ASC`,
novelId,
page,
chapterPosition,
@@ -375,7 +407,7 @@ export const getUpdatedOverviewFromDb = () =>
Novel.name AS novelName,
Novel.cover AS novelCover,
Novel.path AS novelPath,
DATE(Chapter.updatedTime) AS updateDate, -- Extract the date from updatedTime
DATE(Chapter.updatedTime) AS updateDate,
COUNT(*) AS updatesPerDay
FROM
Chapter
@@ -387,14 +419,10 @@ WHERE
Chapter.updatedTime IS NOT NULL
GROUP BY
Novel.id,
Novel.name,
Novel.cover,
Novel.path,
DATE(Chapter.updatedTime) -- Group by date and novelId
DATE(Chapter.updatedTime)
ORDER BY
novelId,
updateDate;
updateDate DESC,
novelId;
`);
export const getDetailedUpdatesFromDb = async (
@@ -424,8 +452,8 @@ ORDER BY updatedTime DESC;
return await result;
};
export const isChapterDownloaded = (chapterId: number) =>
!!db.getFirstSync<ChapterInfo>(
export const isChapterDownloaded = async (chapterId: number) =>
!!(await db.getFirstAsync<ChapterInfo>(
'SELECT * FROM Chapter WHERE id = ? AND isDownloaded = 1',
chapterId,
);
));
+7 -9
View File
@@ -1,5 +1,5 @@
import { db } from '@database/db';
import { LibraryNovelInfo, NovelInfo } from '../types';
import { getAllSync } from '../utils/helpers';
export const getLibraryNovelsFromDb = (
sortOrder?: string,
@@ -7,7 +7,7 @@ export const getLibraryNovelsFromDb = (
searchText?: string,
downloadedOnlyMode?: boolean,
excludeLocalNovels?: boolean,
): NovelInfo[] => {
) => {
let query = 'SELECT * FROM Novel WHERE inLibrary = 1';
if (excludeLocalNovels) {
@@ -30,25 +30,25 @@ export const getLibraryNovelsFromDb = (
query += ` ORDER BY ${sortOrder}`;
}
return getAllSync<NovelInfo>([query, [searchText ?? '']]);
return db.getAllAsync<NovelInfo>(query, searchText ? `%${searchText}%` : '');
};
const getNovelOfCategoryQuery =
'SELECT DISTINCT novelId FROM NovelCategory WHERE 1 = 1';
const getNovelsFromIDListQuery = 'SELECT * FROM Novel WHERE inLibrary = 1 ';
export const getLibraryWithCategory = (
export const getLibraryWithCategory = async (
categoryId?: number | null,
onlyUpdateOngoingNovels?: boolean,
excludeLocalNovels?: boolean,
): LibraryNovelInfo[] => {
): Promise<LibraryNovelInfo[]> => {
let categoryQuery = getNovelOfCategoryQuery;
if (categoryId) {
categoryQuery += ` AND categoryId = ${categoryId}`;
}
const idRows = getAllSync<{ novelId: number }>([categoryQuery, []]);
const idRows = await db.getAllAsync<{ novelId: number }>(categoryQuery);
if (!idRows || idRows.length === 0) return [];
@@ -66,7 +66,5 @@ export const getLibraryWithCategory = (
novelQuery += " AND status = 'Ongoing'";
}
const res = getAllSync<LibraryNovelInfo>([novelQuery, []]);
return res;
return db.getAllAsync<LibraryNovelInfo>(novelQuery);
};
+134 -173
View File
@@ -4,14 +4,6 @@ import { fetchNovel } from '@services/plugin/fetch';
import { insertChapters } from './ChapterQueries';
import { showToast } from '@utils/showToast';
import {
getAllAsync,
getFirstAsync,
getFirstSync,
QueryObject,
runAsync,
runSync,
} from '../utils/helpers';
import { getString } from '@strings/translations';
import { BackupNovel, NovelInfo } from '../types';
import { SourceNovel } from '@plugins/types';
@@ -27,18 +19,20 @@ export const insertNovelAndChapters = async (
): Promise<number | undefined> => {
const insertNovelQuery =
'INSERT OR IGNORE INTO Novel (path, pluginId, name, cover, summary, author, artist, status, genres, totalPages) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
const novelId: number | undefined = db.runSync(insertNovelQuery, [
sourceNovel.path,
pluginId,
sourceNovel.name,
sourceNovel.cover || null,
sourceNovel.summary || null,
sourceNovel.author || null,
sourceNovel.artist || null,
sourceNovel.status || null,
sourceNovel.genres || null,
sourceNovel.totalPages || 0,
]).lastInsertRowId;
const novelId: number | undefined = (
await db.runAsync(insertNovelQuery, [
sourceNovel.path,
pluginId,
sourceNovel.name,
sourceNovel.cover || null,
sourceNovel.summary || null,
sourceNovel.author || null,
sourceNovel.artist || null,
sourceNovel.status || null,
sourceNovel.genres || null,
sourceNovel.totalPages || 0,
])
).lastInsertRowId;
if (novelId) {
if (sourceNovel.cover) {
@@ -50,43 +44,38 @@ export const insertNovelAndChapters = async (
sourceNovel.cover,
novelCoverPath,
getPlugin(pluginId)?.imageRequestInit,
).then(() => {
runSync([
[
'UPDATE Novel SET cover = ? WHERE id = ?',
[novelCoverUri, novelId!],
],
]);
});
);
await db.runAsync(
'UPDATE Novel SET cover = ? WHERE id = ?',
novelCoverUri,
novelId,
);
}
await insertChapters(novelId, sourceNovel.chapters);
}
return novelId;
};
export const getAllNovels = async () => {
return getAllAsync<NovelInfo>(['SELECT * FROM Novel']);
export const getAllNovels = () => {
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel');
};
export const getNovelById = async (novelId: number) => {
return getFirstAsync<NovelInfo>([
export const getNovelById = (novelId: number) => {
return db.getFirstAsync<NovelInfo>(
'SELECT * FROM Novel WHERE id = ?',
[novelId],
]);
novelId,
);
};
export const getNovelByPath = (
novelPath: string,
pluginId: string,
): NovelInfo | undefined => {
const res = getFirstSync<NovelInfo>([
) => {
return db.getFirstAsync<NovelInfo>(
'SELECT * FROM Novel WHERE path = ? AND pluginId = ?',
[novelPath, pluginId],
]);
if (!res) {
return undefined;
}
return res;
novelPath,
pluginId,
);
};
// if query is insert novel || add to library => add default category name for it
@@ -98,83 +87,75 @@ export const switchNovelToLibraryQuery = async (
): Promise<NovelInfo | undefined> => {
const novel = await getNovelByPath(novelPath, pluginId);
if (novel) {
const queries: QueryObject[] = [
[
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
[Number(!novel.inLibrary), novel.id],
],
novel.inLibrary
? [
'DELETE FROM NovelCategory WHERE novelId = ?',
[novel.id],
() => showToast(getString('browseScreen.removeFromLibrary')),
]
: [
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
[novel.id],
() => showToast(getString('browseScreen.addedToLibrary')),
],
];
if (novel.pluginId === 'local') {
queries.push([
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, 2)',
[novel.id],
]);
await db.runAsync(
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
Number(!novel.inLibrary),
novel.id,
);
if (novel.inLibrary) {
await db.runAsync(
'DELETE FROM NovelCategory WHERE novelId = ?',
novel.id,
);
showToast(getString('browseScreen.removeFromLibrary'));
} else {
await db.runAsync(
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
novel.id,
);
showToast(getString('browseScreen.addedToLibrary'));
}
if (novel.pluginId === 'local') {
await db.runAsync(
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, 2)',
novel.id,
);
}
await runAsync(queries);
return { ...novel, inLibrary: !novel.inLibrary };
} else {
const sourceNovel = await fetchNovel(pluginId, novelPath);
const novelId = await insertNovelAndChapters(pluginId, sourceNovel);
if (novelId) {
await runAsync([
[
'UPDATE Novel SET inLibrary = 1 WHERE id = ?',
[novelId],
() => showToast(getString('browseScreen.addedToLibrary')),
],
[
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
[novelId],
],
]);
await db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', novelId);
await db.runAsync(
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
novelId,
);
showToast(getString('browseScreen.addedToLibrary'));
}
}
};
// allow to delete local novels
export const removeNovelsFromLibrary = (novelIds: Array<number>) => {
runSync([
[`UPDATE Novel SET inLibrary = 0 WHERE id IN (${novelIds.join(', ')});`],
[`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(', ')});`],
]);
export const removeNovelsFromLibrary = async (novelIds: Array<number>) => {
await db.runAsync(
`UPDATE Novel SET inLibrary = 0 WHERE id IN (${novelIds.join(', ')})`,
);
await db.runAsync(
`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(', ')})`,
);
showToast(getString('browseScreen.removeFromLibrary'));
};
export const getCachedNovels = () => {
return getAllAsync<NovelInfo>(['SELECT * FROM Novel WHERE inLibrary = 0']);
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel WHERE inLibrary = 0');
};
export const deleteCachedNovels = async () => {
runAsync([
[
'DELETE FROM Novel WHERE inLibrary = 0',
[],
() =>
showToast(getString('advancedSettingsScreen.cachedNovelsDeletedToast')),
],
]);
await db.runAsync('DELETE FROM Novel WHERE inLibrary = 0');
showToast(getString('advancedSettingsScreen.cachedNovelsDeletedToast'));
};
const restoreFromBackupQuery =
'INSERT OR REPLACE INTO Novel (path, name, pluginId, cover, summary, author, artist, status, genres, totalPages) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
export const restoreLibrary = async (novel: NovelInfo) => {
const sourceNovel = await fetchNovel(novel.pluginId, novel.path).catch(e => {
throw e;
});
const sourceNovel = await fetchNovel(novel.pluginId, novel.path);
let novelId: number | undefined;
await db.withTransactionAsync(async () => {
db.runAsync(restoreFromBackupQuery, [
const result = await db.runAsync(
restoreFromBackupQuery,
sourceNovel.path,
novel.name,
novel.pluginId,
@@ -185,32 +166,17 @@ export const restoreLibrary = async (novel: NovelInfo) => {
novel.status || '',
novel.genres || '',
sourceNovel.totalPages || 0,
]).then(data => {
novelId = data.lastInsertRowId;
});
);
novelId = result.lastInsertRowId;
});
if (novelId && novelId > 0) {
await new Promise((resolve, reject) => {
runAsync([
[
'INSERT OR REPLACE INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
[novelId!],
() => {
db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', [
novelId!,
]);
resolve(null);
},
() => {
reject(null);
return false;
},
],
]);
}).catch(e => {
throw e;
});
await db.runAsync(
'INSERT OR REPLACE INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
novelId,
);
await db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', novelId);
if (sourceNovel.chapters) {
await insertChapters(novelId, sourceNovel.chapters);
}
@@ -218,23 +184,19 @@ export const restoreLibrary = async (novel: NovelInfo) => {
};
export const updateNovelInfo = async (info: NovelInfo) => {
runAsync([
[
'UPDATE Novel SET name = ?, cover = ?, path = ?, summary = ?, author = ?, artist = ?, genres = ?, status = ?, isLocal = ? WHERE id = ?',
[
info.name,
info.cover || '',
info.path,
info.summary || '',
info.author || '',
info.artist || '',
info.genres || '',
info.status || '',
Number(info.isLocal),
info.id,
],
],
]);
await db.runAsync(
'UPDATE Novel SET name = ?, cover = ?, path = ?, summary = ?, author = ?, artist = ?, genres = ?, status = ?, isLocal = ? WHERE id = ?',
info.name,
info.cover || '',
info.path,
info.summary || '',
info.author || '',
info.artist || '',
info.genres || '',
info.status || '',
Number(info.isLocal),
info.id,
);
};
export const pickCustomNovelCover = async (novel: NovelInfo) => {
@@ -247,9 +209,11 @@ export const pickCustomNovelCover = async (novel: NovelInfo) => {
}
NativeFile.copyFile(image.assets[0].uri, novelCoverUri);
novelCoverUri += '?' + Date.now();
runAsync([
['UPDATE Novel SET cover = ? WHERE id = ?', [novelCoverUri, novel.id]],
]);
await db.runAsync(
'UPDATE Novel SET cover = ? WHERE id = ?',
novelCoverUri,
novel.id,
);
return novelCoverUri;
}
};
@@ -258,48 +222,45 @@ export const updateNovelCategoryById = async (
novelId: number,
categoryIds: number[],
) => {
runAsync(
categoryIds.map(categoryId => {
return [
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
[novelId, categoryId],
];
}),
);
for (const categoryId of categoryIds) {
await db.runAsync(
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
novelId,
categoryId,
);
}
};
export const updateNovelCategories = async (
novelIds: number[],
categoryIds: number[],
): Promise<void> => {
const queries: QueryObject[] = [];
queries.push([
await db.runAsync(
`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(
',',
)}) AND categoryId != 2`,
]);
);
// if no category is selected => set to the default category
if (categoryIds.length) {
novelIds.forEach(novelId => {
categoryIds.forEach(categoryId =>
queries.push([
for (const novelId of novelIds) {
for (const categoryId of categoryIds) {
await db.runAsync(
`INSERT INTO NovelCategory (novelId, categoryId) VALUES (${novelId}, ${categoryId})`,
]),
);
});
);
}
}
} else {
novelIds.forEach(novelId => {
for (const novelId of novelIds) {
// hacky: insert local novel category -> failed -> ignored
queries.push([
`INSERT OR IGNORE INTO NovelCategory (novelId, categoryId)
await db.runAsync(
`INSERT OR IGNORE INTO NovelCategory (novelId, categoryId)
VALUES (
${novelId},
${novelId},
IFNULL((SELECT categoryId FROM NovelCategory WHERE novelId = ${novelId}), (SELECT id FROM Category WHERE sort = 1))
)`,
]);
});
);
}
}
return runSync(queries);
};
const restoreObjectQuery = (table: string, obj: any) => {
@@ -314,17 +275,17 @@ const restoreObjectQuery = (table: string, obj: any) => {
export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
const { chapters, ...novel } = backupNovel;
await runAsync([
['DELETE FROM Novel WHERE id = ?', [novel.id]],
[
restoreObjectQuery('Novel', novel),
Object.values(novel) as string[] | number[],
],
]);
runAsync(
chapters.map(chapter => [
restoreObjectQuery('Chapter', chapter),
Object.values(chapter) as string[] | number[],
]),
await db.runAsync('DELETE FROM Novel WHERE id = ?', novel.id);
await db.runAsync(
restoreObjectQuery('Novel', novel),
...(Object.values(novel) as (string | number)[]),
);
if (chapters.length > 0) {
for (const chapter of chapters) {
await db.runAsync(
restoreObjectQuery('Chapter', chapter),
...(Object.values(chapter) as (string | number)[]),
);
}
}
};
+25 -28
View File
@@ -1,6 +1,6 @@
import { countBy } from 'lodash-es';
import { db } from '@database/db';
import { LibraryStats } from '../types';
import { getAllAsync, getFirstAsync } from '../utils/helpers';
const getLibraryStatsQuery = `
SELECT COUNT(*) as novelsCount, COUNT(DISTINCT pluginId) as sourcesCount
@@ -52,51 +52,48 @@ const getNovelStatusQuery = `
WHERE Novel.inLibrary = 1
`;
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getLibraryStatsQuery]) as any;
export const getLibraryStatsFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getLibraryStatsQuery);
};
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersTotalCountQuery]) as any;
export const getChaptersTotalCountFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersTotalCountQuery);
};
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersReadCountQuery]) as any;
export const getChaptersReadCountFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersReadCountQuery);
};
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersUnreadCountQuery]) as any;
};
export const getChaptersUnreadCountFromDb =
(): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersUnreadCountQuery);
};
export const getChaptersDownloadedCountFromDb =
async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersDownloadedCountQuery]) as any;
(): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersDownloadedCountQuery);
};
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
const genres: string[] = [];
await getAllAsync([getNovelGenresQuery]).then(res => {
(res as any).forEach((item: { genres: string }) => {
const novelGenres = item.genres?.split(/\s*,\s*/);
if (novelGenres?.length) {
genres.push(...novelGenres);
}
});
const res = await db.getAllAsync<{ genres: string }>(getNovelGenresQuery);
res.forEach(item => {
const novelGenres = item.genres?.split(/\s*,\s*/);
if (novelGenres?.length) {
genres.push(...novelGenres);
}
});
return { genres: countBy(genres) };
};
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
const status: string[] = [];
await getAllAsync([getNovelStatusQuery]).then(res => {
(res as any).forEach((item: { status: string }) => {
const novelStatus = item.status?.split(/\s*,\s*/);
if (novelStatus?.length) {
status.push(...novelStatus);
}
});
const res = await db.getAllAsync<{ status: string }>(getNovelStatusQuery);
res.forEach(item => {
const novelStatus = item.status?.split(/\s*,\s*/);
if (novelStatus?.length) {
status.push(...novelStatus);
}
});
return { status: countBy(status) };
};
-103
View File
@@ -1,103 +0,0 @@
import { db } from '@database/db';
import {
SQLiteBindParams,
SQLiteBindValue,
SQLiteRunResult,
} from 'expo-sqlite';
import { noop } from 'lodash-es';
function logError(error: any) {
// eslint-disable-next-line no-console
console.error(error);
}
type query = string;
type SQLiteResultFunction<T> = (data: T) => void;
type SQLiteErrorFunction = (data: any) => void;
export type QueryObject<T = unknown> =
| [query, SQLiteBindParams, SQLiteResultFunction<T>, SQLiteErrorFunction]
| [query, SQLiteBindParams, SQLiteResultFunction<T>]
| [query, SQLiteBindParams]
| [query];
function defaultQuerySync<T = unknown, Array extends boolean = false>(
fn: 'getAllSync' | 'getFirstSync' | 'runSync',
queryObject: QueryObject<Array extends true ? T[] : T>,
errorReturn: Array extends true ? [] : null,
) {
const [query, params = [], callback = noop, catchCallback = logError] =
queryObject;
try {
// @ts-ignore
const result = db[fn](query, params) as Array extends true ? T[] : T;
callback(result);
return result;
} catch (e) {
catchCallback(e);
return errorReturn;
}
}
async function defaultQueryAsync<T = unknown, Array extends boolean = false>(
fn: 'getAllAsync' | 'getFirstAsync' | 'runAsync',
queryObject: QueryObject<Array extends true ? T[] : T>,
errorReturn: Array extends true ? [] : null,
) {
const [query, params = [], callback = noop, catchCallback = logError] =
queryObject;
try {
// @ts-ignore
const result = (await db[fn](query, params)) as Array extends true
? T[]
: T;
callback(result);
return result;
} catch (e) {
catchCallback(e);
return errorReturn;
}
}
export async function runAsync(queryObjects: QueryObject<SQLiteRunResult>[]) {
const promises = [];
for (const queryObject of queryObjects) {
promises.push(
defaultQueryAsync<SQLiteRunResult, false>('runAsync', queryObject, null),
);
}
await Promise.all(promises);
}
export function runSync(queryObjects: QueryObject<SQLiteRunResult>[]) {
for (const queryObject of queryObjects) {
defaultQuerySync<SQLiteRunResult, false>('runSync', queryObject, null);
}
}
export async function getAllAsync<T = unknown>(queryObject: QueryObject<T[]>) {
return defaultQueryAsync<T, true>('getAllAsync', queryObject, []);
}
export function getAllSync<T = unknown>(queryObject: QueryObject<T[]>) {
return defaultQuerySync<T, true>('getAllSync', queryObject, []);
}
export function getFirstAsync<T = unknown>(queryObject: QueryObject<T>) {
return defaultQueryAsync<T, false>('getFirstAsync', queryObject, null);
}
export function getFirstSync<T = unknown>(queryObject: QueryObject<T>) {
return defaultQuerySync<T, false>('getFirstSync', queryObject, null);
}
type params = SQLiteBindValue[];
type TransactionObject = [query, ...params];
export async function transactionAsync(transactionObject: TransactionObject[]) {
await db.withTransactionAsync(async () => {
const promises = transactionObject.map(async ([query, ...params]) => {
db.runAsync(query, ...params);
});
await Promise.all(promises);
});
}
+13 -4
View File
@@ -49,10 +49,19 @@ export class MigrationRunner {
}
private getCurrentVersion(db: SQLiteDatabase): number {
return (
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0
);
try {
const result = db.getFirstSync<{ user_version: number }>(
'PRAGMA user_version',
);
return result?.user_version ?? 0;
} catch (error) {
// If PRAGMA query fails, assume version 0 (fresh database)
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn('Failed to get database version, assuming 0:', error);
}
return 0;
}
}
private setVersion(db: SQLiteDatabase, version: number): void {
+25 -18
View File
@@ -1,9 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { version } from '../../../package.json';
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { showToast } from '@utils/showToast';
import { getErrorMessage } from '@utils/error';
interface GithubUpdate {
isNewVersion: boolean;
@@ -32,7 +30,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
return timeSinceLastCheck >= ONE_DAY_MS;
};
const checkForRelease = async () => {
const checkForRelease = useCallback(async () => {
if (!shouldCheckForUpdate()) {
setChecking(false);
return;
@@ -40,12 +38,23 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
try {
const res = await fetch(latestReleaseUrl);
if (!res.ok) {
setChecking(false);
return;
}
const data = await res.json();
if (!data || !data.tag_name) {
setChecking(false);
return;
}
const release = {
tag_name: data.tag_name,
body: data.body,
downloadUrl: data.assets[0].browser_download_url,
downloadUrl: data.assets?.[0]?.browser_download_url || undefined,
};
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
@@ -53,14 +62,14 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
setLatestRelease(release);
setChecking(false);
} catch (error) {
showToast(`Failed to check for updates: ${getErrorMessage(error)}`);
// Silently fail in offline mode or on network errors
setChecking(false);
}
};
}, []);
const isNewVersion = (versionTag: string) => {
const currentVersion = `${version}`;
const regex = /[^\\d.]/;
const regex = /[^\d.]/;
const newVersion = versionTag.replace(regex, '');
@@ -69,19 +78,17 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
useEffect(() => {
checkForRelease();
}, []);
}, [checkForRelease]);
if (!checking && shouldCheckForUpdate()) {
const data = {
if (!checking && latestRelease?.tag_name) {
return {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
return data;
} else {
return {
latestRelease: undefined,
isNewVersion: false,
};
}
return {
latestRelease: undefined,
isNewVersion: false,
};
};
+6
View File
@@ -20,6 +20,11 @@ export default function useDownload() {
[queue],
) as { task: DownloadChapterTask; meta: BackgroundTaskMetadata }[];
const downloadingChapterIds = useMemo(
() => new Set(downloadQueue.map(c => c.task.data.chapterId)),
[downloadQueue],
);
const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) =>
ServiceManager.manager.addTask({
name: 'DOWNLOAD_CHAPTER',
@@ -49,6 +54,7 @@ export default function useDownload() {
return {
downloadQueue,
downloadingChapterIds,
resumeDowndload,
downloadChapter,
downloadChapters,
+91 -26
View File
@@ -23,6 +23,7 @@ import {
getCustomPages,
getChapterCount,
getPageChaptersBatched,
getFirstUnreadChapter as _getFirstUnreadChapter,
updateChapterProgress as _updateChapterProgress,
} from '@database/queries/ChapterQueries';
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
@@ -66,9 +67,15 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
const [novel, setNovel] = useState<NovelInfo | undefined>(
typeof novelOrPath === 'object' ? novelOrPath : undefined,
);
const [pages, setPages] = useState<string[]>(
novel ? calculatePages(novel) : [],
);
const [pages, setPages] = useState<string[]>(() => {
if (novel && novel.totalPages > 0) {
const tmpPages = Array(novel.totalPages)
.fill(0)
.map((_, idx) => String(idx + 1));
return tmpPages.length > 1 ? tmpPages : ['1'];
}
return [];
});
const { defaultChapterSort } = useAppSettings();
@@ -88,32 +95,27 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
);
const [chapters, _setChapters] = useState<ChapterInfo[]>([]);
const [firstUnreadChapter, setFirstUnreadChapter] = useState<
ChapterInfo | undefined
>();
const [batchInformation, setBatchInformation] = useState<{
batch: number;
total: number;
totalChapters?: number;
}>(
typeof novelOrPath === 'object'
? {
batch: 0,
total: 0,
totalChapters: getChapterCount(novelOrPath.id, pages[pageIndex]),
}
: { batch: 0, total: 0 },
);
}>({ batch: 0, total: 0 });
const settingsSort = novelSettings.sort || defaultChapterSort;
// #endregion
// #region setters
function calculatePages(tmpNovel: NovelInfo) {
async function calculatePages(tmpNovel: NovelInfo) {
let tmpPages: string[];
if (tmpNovel.totalPages > 0) {
tmpPages = Array(tmpNovel.totalPages)
.fill(0)
.map((_, idx) => String(idx + 1));
} else {
tmpPages = getCustomPages(tmpNovel.id).map(c => c.page);
tmpPages = (await getCustomPages(tmpNovel.id)).map(c => c.page);
}
return tmpPages.length > 1 ? tmpPages : ['1'];
@@ -132,8 +134,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
(index: number, update: Partial<ChapterInfo>) => {
if (novel) {
_setChapters(chs => {
chs[index] = { ...chs[index], ...update };
return chs;
const next = [...chs];
next[index] = { ...next[index], ...update };
return next;
});
}
},
@@ -218,20 +221,21 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
// #region getters
const getNovel = useCallback(async () => {
let tmpNovel = getNovelByPath(novelPath, pluginId);
let tmpNovel = await getNovelByPath(novelPath, pluginId);
if (!tmpNovel) {
const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => {
throw new Error(getString('updatesScreen.unableToGetNovel'));
});
await insertNovelAndChapters(pluginId, sourceNovel);
tmpNovel = getNovelByPath(novelPath, pluginId);
tmpNovel = await getNovelByPath(novelPath, pluginId);
if (!tmpNovel) {
return;
}
}
setPages(calculatePages(tmpNovel));
setPages(await calculatePages(tmpNovel));
setNovel(tmpNovel);
}, [novelPath, pluginId]);
@@ -249,11 +253,11 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
page,
] as const;
let chapterCount = getChapterCount(novel.id, page);
let chapterCount = await getChapterCount(novel.id, page);
if (chapterCount) {
try {
newChapters = getPageChaptersBatched(...config) || [];
newChapters = (await getPageChaptersBatched(...config)) || [];
} catch (error) {
console.error('teaser', error);
}
@@ -270,7 +274,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
});
await insertChapters(novel.id, sourceChapters);
newChapters = await _getPageChapters(...config);
chapterCount = getChapterCount(novel.id, page);
chapterCount = await getChapterCount(novel.id, page);
}
setBatchInformation({
@@ -279,6 +283,13 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
totalChapters: chapterCount,
});
setChapters(newChapters);
const unread = await _getFirstUnreadChapter(
novel.id,
novelSettings.filter,
page,
);
setFirstUnreadChapter(unread ?? undefined);
}
}, [
novel,
@@ -291,7 +302,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
settingsSort,
]);
const getNextChapterBatch = useCallback(() => {
const getNextChapterBatch = useCallback(async () => {
const page = pages[pageIndex];
const nextBatch = batchInformation.batch + 1;
if (novel && page && nextBatch <= batchInformation.total) {
@@ -299,13 +310,13 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
try {
newChapters =
getPageChaptersBatched(
(await getPageChaptersBatched(
novel.id,
settingsSort,
novelSettings.filter,
page,
nextBatch,
) || [];
)) || [];
} catch (error) {
console.error('teaser', error);
}
@@ -322,6 +333,50 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
settingsSort,
]);
const loadUpToBatch = useCallback(
async (targetBatch: number) => {
const page = pages[pageIndex];
if (!novel || !page || targetBatch <= batchInformation.batch) {
return;
}
// Load all batches from current + 1 up to targetBatch
for (
let batch = batchInformation.batch + 1;
batch <= targetBatch;
batch++
) {
if (batch > batchInformation.total) break;
let newChapters: ChapterInfo[] = [];
try {
newChapters =
(await getPageChaptersBatched(
novel.id,
settingsSort,
novelSettings.filter,
page,
batch,
)) || [];
} catch (error) {
console.error('Error loading batch', batch, error);
}
setBatchInformation(prev => ({ ...prev, batch }));
extendChapters(newChapters);
}
},
[
batchInformation,
extendChapters,
novel,
novelSettings.filter,
pageIndex,
pages,
settingsSort,
],
);
// #endregion
// #region Mark chapters
@@ -527,6 +582,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
useEffect(() => {
if (novel) {
if (pages.length === 0) {
calculatePages(novel).then(setPages);
}
setLoading(false);
} else {
getNovel().finally(() => {
@@ -538,7 +596,10 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
}, [getNovel, novel]);
useEffect(() => {
if (novel === undefined) return;
if (novel === undefined || pages.length === 0) {
return;
}
setFetching(true);
getChapters()
.catch(e => {
@@ -562,10 +623,12 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
pages,
novel,
lastRead,
firstUnreadChapter,
chapters,
novelSettings,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
getNovel,
setPageIndex,
openPage,
@@ -593,10 +656,12 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
pages,
novel,
lastRead,
firstUnreadChapter,
chapters,
novelSettings,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
getNovel,
setPageIndex,
openPage,
+1
View File
@@ -167,6 +167,7 @@ export default function usePlugins() {
name: _plg.name,
version: _plg.version,
hasUpdate: false,
hasSettings: !!_plg.pluginSettings,
};
if (newPlugin.id === lastUsedPlugin?.id) {
setLastUsedPlugin(newPlugin);
+22 -2
View File
@@ -79,6 +79,7 @@ export interface ChapterGeneralSettings {
swipeGestures: boolean;
showScrollPercentage: boolean;
useVolumeButtons: boolean;
volumeButtonsOffset: number | null;
showBatteryAndTime: boolean;
autoScroll: boolean;
autoScrollInterval: number;
@@ -110,6 +111,8 @@ export interface ChapterReaderSettings {
voice?: Voice;
rate?: number;
pitch?: number;
autoPageAdvance?: boolean;
scrollToTop?: boolean;
};
epubLocation: string;
epubUseAppTheme: boolean;
@@ -172,6 +175,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
swipeGestures: false,
showScrollPercentage: true,
useVolumeButtons: false,
volumeButtonsOffset: null,
showBatteryAndTime: false,
autoScroll: false,
autoScrollInterval: 10,
@@ -180,7 +184,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
removeExtraParagraphSpacing: false,
bionicReading: false,
tapToScroll: false,
TTSEnable: false,
TTSEnable: true,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
@@ -197,6 +201,8 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
tts: {
rate: 1,
pitch: 1,
autoPageAdvance: false,
scrollToTop: true,
},
epubLocation: '',
epubUseAppTheme: false,
@@ -268,9 +274,23 @@ export const useChapterGeneralSettings = () => {
};
export const useChapterReaderSettings = () => {
const [chapterReaderSettings = initialChapterReaderSettings, setSettings] =
const [storedSettings = initialChapterReaderSettings, setSettings] =
useMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS);
// Ensure TTS settings have proper defaults (migration for existing users)
const chapterReaderSettings = {
...storedSettings,
tts: {
...initialChapterReaderSettings.tts,
...storedSettings.tts,
// Explicitly ensure these defaults if undefined
autoPageAdvance: storedSettings.tts?.autoPageAdvance ?? false,
scrollToTop: storedSettings.tts?.scrollToTop ?? true,
rate: storedSettings.tts?.rate ?? 1,
pitch: storedSettings.tts?.pitch ?? 1,
},
};
const setChapterReaderSettings = (values: Partial<ChapterReaderSettings>) =>
setSettings({ ...chapterReaderSettings, ...values });
+1
View File
@@ -56,6 +56,7 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => {
'AniList',
'MyAnimeList',
'MangaUpdates',
'Kitsu',
];
trackerNames.forEach(trackerName => {
+2
View File
@@ -2,6 +2,7 @@ import { AuthenticationResult, Tracker, TrackerName } from '@services/Trackers';
import { aniListTracker } from '@services/Trackers/aniList';
import { myAnimeListTracker } from '@services/Trackers/myAnimeList';
import { mangaUpdatesTracker } from '@services/Trackers/mangaUpdates';
import { kitsuTracker } from '@services/Trackers/kitsu';
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
import { useEffect } from 'react';
import {
@@ -23,6 +24,7 @@ const trackers: Record<TrackerName, Tracker> = {
AniList: aniListTracker,
MyAnimeList: myAnimeListTracker,
MangaUpdates: mangaUpdatesTracker,
Kitsu: kitsuTracker,
};
export const getTracker = (name: TrackerName) => {
+10 -51
View File
@@ -1,8 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import {
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Library from '../screens/library/LibraryScreen';
import Updates from '../screens/updates/UpdatesScreen';
@@ -15,8 +12,7 @@ import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted';
import { BottomNavigatorParamList } from './types';
import Icon from '@react-native-vector-icons/material-design-icons';
import { MaterialDesignIconName } from '@type/icon';
import { CommonActions } from '@react-navigation/native';
import { BottomNavigation } from 'react-native-paper';
import { BottomTabBar } from '@components';
const Tab = createBottomTabNavigator<BottomNavigatorParamList>();
@@ -63,53 +59,16 @@ const BottomNavigator = () => {
[],
);
const renderBottomBar = useCallback(
({ navigation, state, descriptors, insets }: BottomTabBarProps) => (
<BottomNavigation.Bar
theme={{ colors: theme }}
style={{
backgroundColor: theme.surface2,
}}
navigationState={state}
safeAreaInsets={insets}
onTabPress={({ route, preventDefault }) => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (event.defaultPrevented) {
preventDefault();
} else {
navigation.dispatch({
...CommonActions.navigate(route.name, route.params),
target: state.key,
});
}
}}
const renderTabBar = useCallback(
(props: any) => (
<BottomTabBar
{...props}
theme={theme}
showLabelsInNav={showLabelsInNav}
renderIcon={renderIcon}
getLabelText={({ route }) => {
if (
!showLabelsInNav &&
route.name !== state.routeNames[state.index]
) {
return '';
}
const { options } = descriptors[route.key];
const label =
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: typeof options.title === 'string'
? options.title
: route.name;
return label;
}}
/>
),
[renderIcon, showLabelsInNav, theme],
[theme, showLabelsInNav, renderIcon],
);
return (
@@ -123,7 +82,7 @@ const BottomNavigator = () => {
color: theme.onError,
},
})}
tabBar={renderBottomBar}
tabBar={renderTabBar}
>
<Tab.Screen
name="Library"
+1 -1
View File
@@ -1,2 +1,2 @@
export const defaultCover =
'https://github.com/LNReader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
'https://github.com/lnreader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
+17 -13
View File
@@ -1,27 +1,29 @@
import { gcm } from '@noble/ciphers/aes.js';
import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js';
import dayjs from 'dayjs';
import { load } from 'cheerio';
import { Parser } from 'htmlparser2';
import { reverse, uniqBy } from 'lodash-es';
import { newer } from '@utils/compareVersion';
import { encode, decode } from 'urlencode';
import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { newer } from '@utils/compareVersion';
import NativeFile from '@specs/NativeFile';
import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
// packages for plugins
import {
store,
Storage,
LocalStorage,
SessionStorage,
} from './helpers/storage';
import { load } from 'cheerio';
import dayjs from 'dayjs';
import { NovelStatus, Plugin, PluginItem } from './types';
import { defaultCover } from './helpers/constants';
import { downloadFile, fetchApi, fetchProto, fetchText } from './helpers/fetch';
import { FilterTypes } from './types/filterTypes';
import { isUrlAbsolute } from './helpers/isAbsoluteUrl';
import { downloadFile, fetchApi, fetchProto, fetchText } from './helpers/fetch';
import { defaultCover } from './helpers/constants';
import { encode, decode } from 'urlencode';
import { Parser } from 'htmlparser2';
import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries';
import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
import NativeFile from '@specs/NativeFile';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
const packages: Record<string, any> = {
'htmlparser2': { Parser },
@@ -33,6 +35,8 @@ const packages: Record<string, any> = {
'@libs/isAbsoluteUrl': { isUrlAbsolute },
'@libs/filterInputs': { FilterTypes },
'@libs/defaultCover': { defaultCover },
'@libs/aes': { gcm },
'@libs/utils': { utf8ToBytes, bytesToUtf8 },
};
const initPlugin = (pluginId: string, rawCode: string) => {
+47 -1
View File
@@ -1,5 +1,51 @@
import { FilterToValues, Filters } from './filterTypes';
export interface TextSetting {
value: string;
label: string;
type?: 'Text';
}
export interface SwitchSetting {
value: boolean;
label: string;
type: 'Switch';
}
export interface SelectOption {
label: string;
value: string;
}
export interface SelectSetting {
value: string;
label: string;
type: 'Select';
options: SelectOption[];
}
export interface CheckboxOption {
label: string;
value: string;
}
export interface CheckboxGroupSetting {
value: string[];
label: string;
type: 'CheckboxGroup';
options: CheckboxOption[];
}
export type PluginSetting =
| TextSetting
| SwitchSetting
| SelectSetting
| CheckboxGroupSetting;
export interface PluginSettings {
[key: string]: PluginSetting;
}
export interface NovelItem {
id: undefined;
name: string;
@@ -69,7 +115,7 @@ export interface ImageRequestInit {
export interface Plugin extends PluginItem {
imageRequestInit: ImageRequestInit;
filters?: Filters;
pluginSettings: any;
pluginSettings?: PluginSettings;
popularNovels: (
pageNo: number,
options?: PopularNovelsOptions<Filters>,
@@ -143,7 +143,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
setInActivity(prev => ({ ...prev, [item.path]: false }));
}}
selectedNovelIds={[]}
hasSelection={false}
imageRequestInit={imageRequestInit}
/>
);
@@ -169,7 +169,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
{
backgroundColor: theme.primary,
marginBottom: bottom + 16,
marginRight: right + 16,
marginEnd: right + 16,
},
]}
label={getString('common.filter')}
@@ -196,6 +196,6 @@ const styles = StyleSheet.create({
bottom: 0,
margin: 16,
position: 'absolute',
right: 0,
end: 0,
},
});
@@ -16,11 +16,11 @@ import {
FilterToValues,
Filters,
} from '@plugins/types/filterTypes';
import { Button } from '@components/index';
import { Button, Menu } from '@components/index';
import { Checkbox } from '@components/Checkbox/Checkbox';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { useBoolean } from '@hooks';
import { Menu, TextInput, overlay } from 'react-native-paper';
import { TextInput, overlay } from 'react-native-paper';
import { getValueFor } from './filterUtils';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
@@ -100,7 +100,7 @@ const FilterItem: React.FC<FilterItemProps> = ({
return (
<View style={styles.pickerContainer}>
<Menu
style={styles.flex}
fullWidth
visible={isVisible}
contentStyle={{ backgroundColor: theme.surfaceVariant }}
anchor={
@@ -350,45 +350,53 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
bottomSheetRef={filterSheetRef}
snapPoints={[400, 600]}
bottomInset={bottom}
backgroundStyle={styles.transparent}
style={[styles.container, { backgroundColor: overlay(2, theme.surface) }]}
handleComponent={() => (
<View
style={[styles.buttonContainer, { borderBottomColor: theme.outline }]}
>
<Button
title={getString('common.reset')}
onPress={() => {
setSelectedFilters(filters);
clearFilters(filters);
}}
/>
<Button
title={getString('common.filter')}
textColor={theme.onPrimary}
onPress={() => {
setFilters(selectedFilters);
filterSheetRef?.current?.close();
}}
mode="contained"
handleComponent={null}
children={
<View style={styles.flex}>
<View
style={[
styles.buttonContainer,
{ borderBottomColor: theme.outline },
]}
>
<Button
title={getString('common.reset')}
onPress={() => {
setSelectedFilters(filters);
clearFilters(filters);
}}
/>
<Button
title={getString('common.filter')}
textColor={theme.onPrimary}
onPress={() => {
setFilters(selectedFilters);
filterSheetRef?.current?.close();
}}
mode="contained"
/>
</View>
<BottomSheetFlatList
data={
filters &&
(Object.entries(filters) as [string, Filters[string]][])
}
keyExtractor={(item: [string, Filters[string]]) =>
'filter' + item[0]
}
renderItem={({ item }: { item: [string, Filters[string]] }) => (
<FilterItem
theme={theme}
filter={item[1]}
filterKey={item[0]}
selectedFilters={selectedFilters}
setSelectedFilters={setSelectedFilters}
/>
)}
/>
</View>
)}
>
<BottomSheetFlatList
data={filters && Object.entries(filters)}
keyExtractor={item => 'filter' + item[0]}
renderItem={({ item }) => (
<FilterItem
theme={theme}
filter={item[1]}
filterKey={item[0]}
selectedFilters={selectedFilters}
setSelectedFilters={setSelectedFilters}
/>
)}
/>
</BottomSheet>
}
/>
);
};
+9 -5
View File
@@ -53,11 +53,15 @@ const CategoriesScreen = () => {
const systemCategories = categories.filter(cat => cat.id === 1);
const updatedOrderCategories = [...systemCategories, ...data];
requestAnimationFrame(() => {
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
});
const updatedOrderCategories = [...systemCategories, ...data].map(
(category, index) => ({
...category,
sort: index,
}),
);
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
};
const renderItem = ({
@@ -44,9 +44,8 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
styles.cardCtn,
{
backgroundColor: theme.secondaryContainer,
opacity: isActive ? 0.8 : 1,
elevation: isActive ? 8 : 2,
},
isActive && styles.activeCard,
]}
>
<View style={styles.buttonsCtn}>
@@ -77,17 +76,19 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
</Text>
{category.id === 2 && (
<Badge
style={{
backgroundColor: theme.primary,
paddingHorizontal: 8,
}}
style={[
styles.badge,
{
backgroundColor: theme.tertiaryContainer,
},
]}
>
System
</Badge>
)}
</View>
<View style={styles.flex} />
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<View style={[category.id === 2 && styles.disabledOpacity]}>
<IconButton
name="pencil-outline"
color={category.id === 2 ? theme.outline : theme.onSurface}
@@ -97,7 +98,7 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
disabled={category.id === 2}
/>
</View>
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<View style={[category.id === 2 && styles.disabledOpacity]}>
<IconButton
name="delete-outline"
color={category.id === 2 ? theme.outline : theme.onSurface}
@@ -143,24 +144,34 @@ const styles = StyleSheet.create({
paddingVertical: 8,
},
dragHandle: {
marginRight: 4,
marginEnd: 4,
},
flex: {
flex: 1,
},
manageBtn: {
marginLeft: 16,
marginStart: 16,
},
name: {
marginLeft: 16,
marginRight: 8,
marginStart: 16,
marginEnd: 8,
},
nameCtn: {
alignItems: 'center',
flexGrow: 1,
flexDirection: 'row',
marginLeft: 8,
paddingRight: 16,
marginStart: 8,
paddingEnd: 16,
paddingVertical: 4,
},
activeCard: {
opacity: 0.8,
elevation: 8,
},
badge: {
paddingHorizontal: 8,
},
disabledOpacity: {
opacity: 0.4,
},
});
@@ -152,7 +152,7 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
[novelItem.path]: false,
}));
}}
selectedNovelIds={[]}
hasSelection={false}
isSelected={false}
imageRequestInit={imageRequestInit}
/>
@@ -175,6 +175,7 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
item.plugin.site,
navigateToNovel,
navigation,
imageRequestInit,
noResultsColor,
novelInLibrary,
switchNovelToLibrary,
+9
View File
@@ -1,6 +1,8 @@
/* eslint-disable react-native/no-inline-styles */
import React, { useEffect, useMemo } from 'react';
import { useWindowDimensions } from 'react-native';
import { TabView, TabBar } from 'react-native-tab-view';
import Color from 'color';
import { useSearch } from '@hooks';
import { usePlugins, useTheme } from '@hooks/persisted';
@@ -20,6 +22,7 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
const theme = useTheme();
const { searchText, setSearchText, clearSearchbar } = useSearch();
const { languagesFilter } = usePlugins();
const layout = useWindowDimensions();
const searchbarActions = useMemo(
() =>
@@ -66,6 +69,7 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
/>
<TabView
navigationState={{ index, routes }}
initialLayout={{ width: layout.width }}
renderScene={({ route }) => {
if (languagesFilter.length === 0) {
return (
@@ -96,6 +100,11 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
indicatorStyle={{ backgroundColor: theme.primary, height: 3 }}
style={{
backgroundColor: theme.surface,
elevation: 0,
borderBottomWidth: 1,
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
}}
inactiveColor={theme.secondary}
activeColor={theme.primary}
@@ -1,20 +1,12 @@
import React, { useState, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { TextInput } from 'react-native-paper';
import { Button, Modal, SwitchItem } from '@components/index';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { TextInput, overlay } from 'react-native-paper';
import { Button, Modal, SwitchItem, Checkbox, Menu } from '@components/index';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { Storage } from '@plugins/helpers/storage';
interface PluginSetting {
value: string;
label: string;
type?: 'Switch';
}
interface PluginSettings {
[key: string]: PluginSetting;
}
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { PluginSettings } from '@plugins/types';
interface SourceSettingsModal {
visible: boolean;
@@ -36,8 +28,9 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
const theme = useTheme();
const [formValues, setFormValues] = useState<
Record<string, string | boolean>
Record<string, string | boolean | string[]>
>({});
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
useEffect(() => {
if (pluginSettings) {
@@ -66,13 +59,18 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
}
}, [pluginSettings, pluginId]);
const handleChange = (key: string, value: string | boolean) => {
const handleChange = (key: string, value: string | boolean | string[]) => {
setFormValues(prevValues => ({
...prevValues,
[key]: value,
}));
};
const insertOrRemoveIntoArray = (array: string[], val: string): string[] =>
array.indexOf(val) > -1
? array.filter(ele => ele !== val)
: [...array, val];
const handleSave = () => {
const storage = new Storage(pluginId);
Object.entries(formValues).forEach(([key, value]) => {
@@ -112,6 +110,104 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
/>
);
}
if (setting?.type === 'Select') {
const selectedOption = setting.options.find(
opt => opt.value === formValues[key],
);
const isMenuOpen = !!openMenus[key];
return (
<View key={key} style={styles.selectContainer}>
<Menu
fullWidth
visible={isMenuOpen}
contentStyle={{ backgroundColor: theme.surfaceVariant }}
anchor={
<Pressable
onPress={() =>
setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
}
>
<TextInput
mode="outlined"
label={
<Text
style={{
color: isMenuOpen ? theme.primary : theme.onSurface,
backgroundColor: overlay(2, theme.surface),
}}
>
{` ${setting.label} `}
</Text>
}
value={selectedOption?.label || ''}
editable={false}
theme={{ colors: { background: 'transparent' } }}
outlineColor={
isMenuOpen ? theme.primary : theme.onSurface
}
textColor={isMenuOpen ? theme.primary : theme.onSurface}
pointerEvents="none"
/>
</Pressable>
}
onDismiss={() =>
setOpenMenus(prev => ({ ...prev, [key]: false }))
}
>
{setting.options.map(option => (
<Menu.Item
key={option.value}
title={option.label}
titleStyle={{ color: theme.onSurfaceVariant }}
onPress={() => {
handleChange(key, option.value);
setOpenMenus(prev => ({ ...prev, [key]: false }));
}}
/>
))}
</Menu>
</View>
);
}
if (setting?.type === 'CheckboxGroup') {
const value = (formValues[key] || []) as string[];
const isExpanded = !!openMenus[key];
return (
<View key={key}>
<Pressable
style={styles.checkboxHeader}
onPress={() =>
setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
}
android_ripple={{ color: theme.rippleColor }}
>
<Text style={{ color: theme.onSurfaceVariant }}>
{setting.label}
</Text>
<MaterialCommunityIcons
name={isExpanded ? 'chevron-up' : 'chevron-down'}
color={theme.onSurface}
size={24}
/>
</Pressable>
{isExpanded &&
setting.options.map(option => (
<Checkbox
key={option.value}
label={option.label}
theme={theme}
status={value.includes(option.value)}
onPress={() =>
handleChange(
key,
insertOrRemoveIntoArray(value, option.value),
)
}
/>
))}
</View>
);
}
return (
<TextInput
key={key}
@@ -161,4 +257,14 @@ const styles = StyleSheet.create({
marginBottom: 8,
marginTop: 16,
},
selectContainer: {
marginVertical: 8,
},
checkboxHeader: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 16,
},
});
@@ -38,7 +38,7 @@ export const PluginListItem = memo(
const rightActionStyle = useMemo(
() => [styles.buttonGroup, { backgroundColor: theme.primary }],
[theme.error],
[theme.primary],
);
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: theme.surface }],
@@ -155,6 +155,7 @@ export const PluginListItem = memo(
theme,
handlePinPress,
handleDeletePress,
handleWebviewPress,
isPluginPinned,
],
);
@@ -242,7 +243,7 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
details: {
marginLeft: 16,
marginStart: 16,
},
flex: { flex: 1 },
icon: {
@@ -254,7 +255,7 @@ const styles = StyleSheet.create({
lineHeight: 20,
},
pinnedIndicator: {
marginRight: -8,
marginEnd: -8,
},
rightActionsContainer: {
flexDirection: 'row',
@@ -129,7 +129,7 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
details: {
marginLeft: 16,
marginStart: 16,
},
flex: { flex: 1 },
icon: {
+21 -7
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
StyleSheet,
View,
@@ -97,6 +97,25 @@ const BrowseMalScreen = ({ navigation }: BrowseMalScreenProps) => {
);
};
const loadingMore = useRef(false);
const onScroll = useCallback(
({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => {
if (!searchText && !loadingMore.current && isCloseToBottom(nativeEvent)) {
loadingMore.current = true;
setLimit(before => {
const newLimit = before + 50;
getNovels(newLimit).finally(() => {
loadingMore.current = false;
});
return newLimit;
});
}
},
[searchText, getNovels],
);
const ListEmptyComponent = useCallback(
() => (
<ErrorView
@@ -145,12 +164,7 @@ const BrowseMalScreen = ({ navigation }: BrowseMalScreenProps) => {
keyExtractor={(item, index) => item.novelName + index}
renderItem={renderItem}
ListEmptyComponent={ListEmptyComponent}
onScroll={({ nativeEvent }) => {
if (!searchText && isCloseToBottom(nativeEvent)) {
getNovels(limit + 50);
setLimit(before => before + 50);
}
}}
onScroll={onScroll}
ListFooterComponent={
!searchText ? (
<View style={styles.paddingVertical}>
@@ -69,7 +69,7 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
marginLeft: 16,
marginStart: 16,
},
sourceIcon: {
borderRadius: 4,
@@ -72,13 +72,11 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
{history.novelName}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>
{`${getString('historyScreen.chapter')} ${
history.chapterNumber
} • ${dayjs(history.readTime).format('LT').toUpperCase()}` +
`${
history.progress && history.progress > 0
? ' • ' + history.progress + '%'
: ''
{`${getString('historyScreen.chapter')} ${history.chapterNumber
} • ${dayjs(history.readTime).format('LT').toUpperCase()}` +
`${history.progress && history.progress > 0
? ' • ' + history.progress + '%'
: ''
}`}
</Text>
</View>
@@ -118,7 +116,7 @@ const styles = StyleSheet.create({
detailsContainer: {
flex: 1,
justifyContent: 'center',
marginLeft: 16,
marginStart: 16,
},
imageAndNameContainer: {
alignItems: 'center',
+200 -153
View File
@@ -48,6 +48,8 @@ import ServiceManager from '@services/ServiceManager';
import useImport from '@hooks/persisted/useImport';
import { ThemeColors } from '@theme/types';
import { useLibraryContext } from '@components/Context/LibraryContext';
import { xor } from 'lodash-es';
import { SelectionContext } from './SelectionContext';
type State = NavigationState<{
key: string;
@@ -73,7 +75,7 @@ type TabViewLabelProps = {
const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
const { searchText, setSearchText, clearSearchbar } = useSearch();
const theme = useTheme();
const styles = createStyles(theme);
const styles = useMemo(() => createStyles(theme), [theme]);
const { left: leftInset, right: rightInset } = useSafeAreaInsets();
const {
library,
@@ -100,17 +102,33 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
setFalse: closeSetCategoryModal,
} = useBoolean();
const handleClearSearchbar = () => {
clearSearchbar();
};
const [selectedNovelIds, setSelectedNovelIds] = useState<number[]>([]);
const selectedIdsSet = useMemo(
() => new Set(selectedNovelIds),
[selectedNovelIds],
);
const hasSelection = selectedNovelIds.length > 0;
const toggleSelection = useCallback(
(id: number) => setSelectedNovelIds(prev => xor(prev, [id])),
[],
);
const selectionContextValue = useMemo(
() => ({
selectedIdsSet,
hasSelection,
toggleSelection,
setSelectedNovelIds,
}),
[selectedIdsSet, hasSelection, toggleSelection],
);
const currentNovels = useMemo(() => {
if (!categories.length) return [];
const ids = categories[index].novelIds;
return library.filter(l => ids.includes(l.id));
const idsSet = new Set(categories[index].novelIds);
return library.filter(l => idsSet.has(l.id));
}, [categories, index, library]);
useBackHandler(() => {
@@ -139,25 +157,35 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
? getString('libraryScreen.searchbar')
: `${selectedNovelIds.length} selected`;
function openRandom() {
const novels = currentNovels;
const randomNovel = novels[Math.floor(Math.random() * novels.length)];
const openRandom = useCallback(() => {
const randomNovel =
currentNovels[Math.floor(Math.random() * currentNovels.length)];
if (randomNovel) {
navigation.navigate('ReaderStack', {
screen: 'Novel',
params: randomNovel,
});
}
}
}, [currentNovels, navigation]);
const pickAndImport = useCallback(() => {
DocumentPicker.getDocumentAsync({
type: 'application/epub+zip',
copyToCacheDirectory: true,
copyToCacheDirectory: false,
multiple: true,
}).then(importNovel);
}, [importNovel]);
const searchLower = useMemo(() => searchText.toLowerCase(), [searchText]);
const tabBarBorderColor = useMemo(
() =>
Color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
[theme.isDark],
);
const renderTabBar = useCallback(
(props: SceneRendererProps & { navigationState: State }) => {
return categories.length ? (
@@ -168,9 +196,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
style={[
{
backgroundColor: theme.surface,
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
borderBottomColor: tabBarBorderColor,
},
styles.tabBar,
]}
@@ -187,7 +213,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
styles.tabBar,
styles.tabBarIndicator,
styles.tabStyle,
theme.isDark,
tabBarBorderColor,
theme.primary,
theme.rippleColor,
theme.secondary,
@@ -207,14 +233,16 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
title: string;
};
}) => {
const ids = route.novelIds;
const unfilteredNovels = library.filter(l => ids.includes(l.id));
const idsSet = new Set(route.novelIds);
const unfilteredNovels = library.filter(l => idsSet.has(l.id));
const novels = unfilteredNovels.filter(
n =>
n.name.toLowerCase().includes(searchText.toLowerCase()) ||
(n.author?.toLowerCase().includes(searchText.toLowerCase()) ?? false),
);
const novels = searchLower
? unfilteredNovels.filter(
n =>
n.name.toLowerCase().includes(searchLower) ||
(n.author?.toLowerCase().includes(searchLower) ?? false),
)
: unfilteredNovels;
return isLoading ? (
<SourceScreenSkeletonLoading theme={theme} />
@@ -237,8 +265,6 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
categoryId={route.id}
categoryName={route.name}
novels={novels}
selectedNovelIds={selectedNovelIds}
setSelectedNovelIds={setSelectedNovelIds}
pickAndImport={pickAndImport}
navigation={navigation}
/>
@@ -251,7 +277,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
navigation,
pickAndImport,
searchText,
selectedNovelIds,
searchLower,
styles.globalSearchBtn,
theme,
],
@@ -259,6 +285,8 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
const renderLabel = useCallback(
({ route, color }: TabViewLabelProps) => {
const novelIds = route?.novelIds?.filter(id => id !== 0);
return (
<Row>
<Text style={[{ color }, styles.fontWeight500]}>{route.title}</Text>
@@ -272,7 +300,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<Text
style={[styles.badgetText, { color: theme.onSurfaceVariant }]}
>
{route?.novelIds.length}
{novelIds.length}
</Text>
</View>
) : null}
@@ -289,6 +317,123 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
],
);
const handleLeftIconPress = useCallback(() => {
if (selectedNovelIds.length > 0) {
setSelectedNovelIds([]);
}
}, [selectedNovelIds.length]);
const rightIcons = useMemo(
() =>
selectedNovelIds.length
? [
{
iconName: 'select-all' as const,
onPress: () =>
setSelectedNovelIds(currentNovels.map(novel => novel.id)),
},
]
: [
{
iconName: 'filter-variant' as const,
onPress: () => bottomSheetRef.current?.present(),
},
],
[selectedNovelIds.length, currentNovels],
);
const menuButtons = useMemo(
() => [
{
title: getString('libraryScreen.extraMenu.updateLibrary'),
onPress: () =>
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' }),
},
{
title: getString('libraryScreen.extraMenu.updateCategory'),
onPress: () =>
categories[index]?.id !== 2 &&
ServiceManager.manager.addTask({
name: 'UPDATE_LIBRARY',
data: {
categoryId: categories[index].id,
categoryName: categories[index].name,
},
}),
},
{
title: getString('libraryScreen.extraMenu.importEpub'),
onPress: pickAndImport,
},
{
title: getString('libraryScreen.extraMenu.openRandom'),
onPress: openRandom,
},
],
[categories, index, pickAndImport, openRandom],
);
const handleFABPress = useCallback(() => {
if (history?.[0]) {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: {
novel: {
path: history[0].novelPath,
pluginId: history[0].pluginId,
name: history[0].novelName,
} as NovelInfo,
chapter: history[0],
},
});
}
}, [history, navigation]);
const handleEditCategories = useCallback(() => setSelectedNovelIds([]), []);
const handleCategorySuccess = useCallback(() => {
setSelectedNovelIds([]);
refetchLibrary();
}, [refetchLibrary]);
const bottomSheetStyle = useMemo(
() => ({ marginStart: leftInset, marginEnd: rightInset }),
[leftInset, rightInset],
);
const actionbarViewStyle = useMemo(
() => ({ paddingStart: leftInset, paddingEnd: rightInset }),
[leftInset, rightInset],
);
const markAllRead = useCallback(async () => {
await Promise.all(selectedNovelIds.map(id => markAllChaptersRead(id)));
setSelectedNovelIds([]);
refetchLibrary();
}, [selectedNovelIds, refetchLibrary]);
const markAllUnread = useCallback(async () => {
await Promise.all(selectedNovelIds.map(id => markAllChaptersUnread(id)));
setSelectedNovelIds([]);
refetchLibrary();
}, [selectedNovelIds, refetchLibrary]);
const deleteSelected = useCallback(async () => {
await removeNovelsFromLibrary(selectedNovelIds);
setSelectedNovelIds([]);
refetchLibrary();
}, [selectedNovelIds, refetchLibrary]);
const actionbarActions = useMemo(
() => [
{ icon: 'label-outline' as const, onPress: showSetCategoryModal },
{ icon: 'check' as const, onPress: markAllRead },
{ icon: 'check-outline' as const, onPress: markAllUnread },
{ icon: 'delete-outline' as const, onPress: deleteSelected },
],
[showSetCategoryModal, markAllRead, markAllUnread, deleteSelected],
);
const navigationState = useMemo(
() => ({
index,
@@ -305,61 +450,13 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<SafeAreaView excludeBottom>
<SearchbarV2
searchText={searchText}
clearSearchbar={handleClearSearchbar}
clearSearchbar={clearSearchbar}
placeholder={searchbarPlaceholder}
onLeftIconPress={() => {
if (selectedNovelIds.length > 0) {
setSelectedNovelIds([]);
}
}}
onLeftIconPress={handleLeftIconPress}
onChangeText={setSearchText}
leftIcon={selectedNovelIds.length ? 'close' : 'magnify'}
rightIcons={
selectedNovelIds.length
? [
{
iconName: 'select-all',
onPress: () =>
setSelectedNovelIds(currentNovels.map(novel => novel.id)),
},
]
: [
{
iconName: 'filter-variant',
onPress: () => bottomSheetRef.current?.present(),
},
]
}
menuButtons={[
{
title: getString('libraryScreen.extraMenu.updateLibrary'),
onPress: () =>
ServiceManager.manager.addTask({
name: 'UPDATE_LIBRARY',
}),
},
{
title: getString('libraryScreen.extraMenu.updateCategory'),
onPress: () =>
//2 = local category
library[index].id !== 2 &&
ServiceManager.manager.addTask({
name: 'UPDATE_LIBRARY',
data: {
categoryId: library[index].id,
categoryName: library[index].name,
},
}),
},
{
title: getString('libraryScreen.extraMenu.importEpub'),
onPress: pickAndImport,
},
{
title: getString('libraryScreen.extraMenu.openRandom'),
onPress: openRandom,
},
]}
rightIcons={rightIcons}
menuButtons={menuButtons}
theme={theme}
/>
{downloadedOnlyMode ? (
@@ -379,17 +476,19 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
/>
) : null}
<TabView
commonOptions={{
label: renderLabel,
}}
lazy
navigationState={navigationState}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
/>
<SelectionContext.Provider value={selectionContextValue}>
<TabView
commonOptions={{
label: renderLabel,
}}
lazy
navigationState={navigationState}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
/>
</SelectionContext.Provider>
{useLibraryFAB &&
!isHistoryLoading &&
@@ -399,83 +498,31 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<FAB
style={[
styles.fab,
{ backgroundColor: theme.primary, marginRight: rightInset + 16 },
{ backgroundColor: theme.primary, marginEnd: rightInset + 16 },
]}
color={theme.onPrimary}
uppercase={false}
label={getString('common.resume')}
icon="play"
onPress={() => {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: {
novel: {
path: history[0].novelPath,
pluginId: history[0].pluginId,
name: history[0].novelName,
} as NovelInfo,
chapter: history[0],
},
});
}}
onPress={handleFABPress}
/>
) : null}
<SetCategoryModal
novelIds={selectedNovelIds}
closeModal={closeSetCategoryModal}
onEditCategories={() => setSelectedNovelIds([])}
onEditCategories={handleEditCategories}
visible={setCategoryModalVisible}
onSuccess={() => {
setSelectedNovelIds([]);
refetchLibrary();
}}
onSuccess={handleCategorySuccess}
/>
<LibraryBottomSheet
bottomSheetRef={bottomSheetRef}
style={{ marginLeft: leftInset, marginRight: rightInset }}
style={bottomSheetStyle}
/>
<Portal>
<Actionbar
viewStyle={{ paddingLeft: leftInset, paddingRight: rightInset }}
active={selectedNovelIds.length > 0}
actions={[
{
icon: 'label-outline',
onPress: showSetCategoryModal,
},
{
icon: 'check',
onPress: async () => {
const promises: Promise<any>[] = [];
selectedNovelIds.map(id =>
promises.push(markAllChaptersRead(id)),
);
await Promise.all(promises);
setSelectedNovelIds([]);
refetchLibrary();
},
},
{
icon: 'check-outline',
onPress: async () => {
const promises: Promise<any>[] = [];
selectedNovelIds.map(id =>
promises.push(markAllChaptersUnread(id)),
);
await Promise.all(promises);
setSelectedNovelIds([]);
refetchLibrary();
},
},
{
icon: 'delete-outline',
onPress: () => {
removeNovelsFromLibrary(selectedNovelIds);
setSelectedNovelIds([]);
refetchLibrary();
},
},
]}
viewStyle={actionbarViewStyle}
active={hasSelection}
actions={actionbarActions}
/>
</Portal>
</SafeAreaView>
@@ -488,7 +535,7 @@ function createStyles(theme: ThemeColors) {
return StyleSheet.create({
badgeCtn: {
borderRadius: 50,
marginLeft: 2,
marginStart: 4,
paddingHorizontal: 6,
paddingVertical: 2,
position: 'relative',
@@ -500,7 +547,7 @@ function createStyles(theme: ThemeColors) {
bottom: 0,
margin: 16,
position: 'absolute',
right: 0,
end: 0,
},
fontWeight500: {
fontWeight: 500,
+25
View File
@@ -0,0 +1,25 @@
import {
createContext,
Dispatch,
SetStateAction,
useContext,
} from 'react';
interface SelectionContextValue {
selectedIdsSet: Set<number>;
hasSelection: boolean;
toggleSelection: (id: number) => void;
setSelectedNovelIds: Dispatch<SetStateAction<number[]>>;
}
const defaultValue: SelectionContextValue = {
selectedIdsSet: new Set(),
hasSelection: false,
toggleSelection: () => {},
setSelectedNovelIds: () => {},
};
export const SelectionContext =
createContext<SelectionContextValue>(defaultValue);
export const useSelectionContext = () => useContext(SelectionContext);
@@ -53,6 +53,7 @@ const FirstRoute = () => {
estimatedItemSize={4}
extraData={[filter]}
data={libraryFilterList}
keyExtractor={item => `filter_${item.filter}`}
renderItem={({ item }) => (
<Checkbox
label={item.label}
@@ -85,6 +86,7 @@ const SecondRoute = () => {
data={librarySortOrderList}
extraData={[sortOrder]}
estimatedItemSize={5}
keyExtractor={(item, index) => `sort_${index}_${item.ASC}`}
renderItem={({ item }) => (
<SortItem
label={item.label}
@@ -161,6 +163,7 @@ const ThirdRoute = () => {
estimatedItemSize={4}
data={displayModesList}
extraData={[displayMode]}
keyExtractor={item => `display_mode_${item.value}`}
renderItem={({ item }) => (
<RadioButton
label={item.label}
@@ -174,6 +177,12 @@ const ThirdRoute = () => {
);
};
const bottomSheetSceneMap = SceneMap({
first: FirstRoute,
second: SecondRoute,
third: ThirdRoute,
});
const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
bottomSheetRef,
style,
@@ -182,24 +191,40 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
const layout = useWindowDimensions();
const renderTabBar = (props: any) => (
<TabBar
{...props}
indicatorStyle={{ backgroundColor: theme.primary }}
style={[
{
backgroundColor: overlay(2, theme.surface),
borderBottomColor: color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
},
styles.tabBar,
style,
]}
inactiveColor={theme.onSurfaceVariant}
activeColor={theme.primary}
pressColor={color(theme.primary).alpha(0.12).string()}
/>
const borderBottomColor = useMemo(
() =>
color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
[theme.isDark],
);
const renderTabBar = useCallback(
(props: any) => (
<TabBar
{...props}
indicatorStyle={{ backgroundColor: theme.primary }}
style={[
{
backgroundColor: overlay(2, theme.surface),
borderBottomColor,
},
styles.tabBar,
style,
]}
inactiveColor={theme.onSurfaceVariant}
activeColor={theme.primary}
pressColor={theme.rippleColor}
/>
),
[
theme.primary,
theme.surface,
theme.onSurfaceVariant,
theme.rippleColor,
borderBottomColor,
style,
],
);
const [index, setIndex] = useState(0);
@@ -212,12 +237,6 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
[],
);
const renderScene = SceneMap({
first: FirstRoute,
second: SecondRoute,
third: ThirdRoute,
});
const renderCommonOptions = useCallback(
({ route, color: col }: { route: any; color: string }) => (
<Text style={{ color: col }}>{route.title}</Text>
@@ -246,7 +265,7 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
commonOptions={commonOptions}
navigationState={{ index, routes }}
renderTabBar={renderTabBar}
renderScene={renderScene}
renderScene={bottomSheetSceneMap}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
style={styles.tabView}
@@ -1,10 +1,9 @@
import React, { useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { RefreshControl, StyleSheet, View } from 'react-native';
import { xor } from 'lodash-es';
import { EmptyView } from '@components/index';
import NovelCover from '@components/NovelCover';
import NovelList, { NovelListRenderItem } from '@components/NovelList';
import LibraryNovelItem from './LibraryNovelItem';
import { NovelInfo } from '@database/types';
@@ -13,13 +12,13 @@ import { useTheme } from '@hooks/persisted';
import { LibraryScreenProps } from '@navigators/types';
import ServiceManager from '@services/ServiceManager';
import { getPlugin } from '@plugins/pluginManager';
import { useSelectionContext } from '../SelectionContext';
import { ImageRequestInit } from '@plugins/types';
interface Props {
categoryId: number;
categoryName: string;
novels: NovelInfo[];
selectedNovelIds: number[];
setSelectedNovelIds: React.Dispatch<React.SetStateAction<number[]>>;
navigation: LibraryScreenProps['navigation'];
pickAndImport: () => void;
}
@@ -27,93 +26,110 @@ interface Props {
export const LibraryView: React.FC<Props> = ({
categoryId,
categoryName,
selectedNovelIds,
setSelectedNovelIds,
pickAndImport,
navigation,
novels,
}) => {
const theme = useTheme();
const renderItem = ({ item }: { item: NovelInfo }) => {
const imageRequestInit = getPlugin(item.pluginId)?.imageRequestInit;
const { selectedIdsSet, hasSelection, toggleSelection } =
useSelectionContext();
return (
<NovelCover
const onNavigate = useCallback(
(item: NovelInfo) => {
navigation.navigate('ReaderStack', {
screen: 'Novel',
params: item,
});
},
[navigation],
);
const imageRequestInitMap = useMemo(() => {
const map = new Map<string, ImageRequestInit | undefined>();
for (const novel of novels) {
if (!map.has(novel.pluginId)) {
map.set(novel.pluginId, getPlugin(novel.pluginId)?.imageRequestInit);
}
}
return map;
}, [novels]);
const renderItem = useCallback(
({ item }: { item: NovelInfo }) => (
<LibraryNovelItem
item={item}
theme={theme}
isSelected={selectedNovelIds.includes(item.id)}
onLongPress={() =>
setSelectedNovelIds(xor(selectedNovelIds, [item.id]))
}
onPress={() => {
if (selectedNovelIds.length) {
setSelectedNovelIds(xor(selectedNovelIds, [item.id]));
} else {
navigation.navigate('ReaderStack', {
screen: 'Novel',
params: item,
});
}
}}
libraryStatus={false} // yes but actually no :D
selectedNovelIds={selectedNovelIds}
imageRequestInit={imageRequestInit}
isSelected={selectedIdsSet.has(item.id)}
hasSelection={hasSelection}
onSelect={toggleSelection}
onNavigate={onNavigate}
imageRequestInit={imageRequestInitMap.get(item.pluginId)}
/>
);
};
),
[
theme,
selectedIdsSet,
hasSelection,
toggleSelection,
onNavigate,
imageRequestInitMap,
],
);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = () => {
// local category
const onRefresh = useCallback(() => {
if (categoryId === 2) {
return;
}
setRefreshing(true);
ServiceManager.manager.addTask({
name: 'UPDATE_LIBRARY',
data: {
categoryId,
categoryName,
},
data: { categoryId, categoryName },
});
setRefreshing(false);
};
}, [categoryId, categoryName]);
const listEmptyComponent = useMemo(
() => (
<EmptyView
theme={theme}
icon="Σ(ಠ_ಠ)"
description={getString('libraryScreen.empty')}
actions={[
categoryId !== 2
? {
iconName: 'compass-outline',
title: getString('browse'),
onPress: () => navigation.navigate('Browse'),
}
: {
iconName: 'book-arrow-up-outline',
title: getString('advancedSettingsScreen.importEpub'),
onPress: pickAndImport,
},
]}
/>
),
[theme, categoryId, navigation, pickAndImport],
);
const refreshControl = useMemo(
() => (
<RefreshControl
refreshing={false}
onRefresh={onRefresh}
colors={[theme.onPrimary]}
progressBackgroundColor={theme.primary}
/>
),
[onRefresh, theme.onPrimary, theme.primary],
);
return (
<View style={styles.flex}>
<NovelList
data={novels}
extraData={[selectedNovelIds]}
extraData={selectedIdsSet}
renderItem={renderItem as NovelListRenderItem}
ListEmptyComponent={
<EmptyView
theme={theme}
icon="Σ(ಠ_ಠ)"
description={getString('libraryScreen.empty')}
actions={[
categoryId !== 2
? {
iconName: 'compass-outline',
title: getString('browse'),
onPress: () => navigation.navigate('Browse'),
}
: {
iconName: 'book-arrow-up-outline',
title: getString('advancedSettingsScreen.importEpub'),
onPress: pickAndImport,
},
]}
/>
}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[theme.onPrimary]}
progressBackgroundColor={theme.primary}
/>
}
ListEmptyComponent={listEmptyComponent}
refreshControl={refreshControl}
/>
</View>
);
@@ -0,0 +1,52 @@
import React, { memo, useCallback } from 'react';
import NovelCover from '@components/NovelCover';
import { NovelInfo } from '@database/types';
import { ThemeColors } from '@theme/types';
import { ImageRequestInit } from '@plugins/types';
interface LibraryNovelItemProps {
item: NovelInfo;
theme: ThemeColors;
isSelected: boolean;
hasSelection: boolean;
onSelect: (id: number) => void;
onNavigate: (item: NovelInfo) => void;
imageRequestInit: ImageRequestInit | undefined;
}
const LibraryNovelItem = memo(function LibraryNovelItem({
item,
theme,
isSelected,
hasSelection,
onSelect,
onNavigate,
imageRequestInit,
}: LibraryNovelItemProps) {
const handleLongPress = useCallback(() => {
onSelect(item.id);
}, [item.id, onSelect]);
const handlePress = useCallback(() => {
if (hasSelection) {
onSelect(item.id);
} else {
onNavigate(item);
}
}, [hasSelection, item, onSelect, onNavigate]);
return (
<NovelCover
item={item}
theme={theme}
isSelected={isSelected}
hasSelection={hasSelection}
onLongPress={handleLongPress}
onPress={handlePress}
libraryStatus={false}
imageRequestInit={imageRequestInit}
/>
);
});
export default LibraryNovelItem;
+34 -18
View File
@@ -9,7 +9,10 @@ import { Category, NovelInfo } from '@database/types';
import { useLibrarySettings } from '@hooks/persisted';
import { LibrarySortOrder } from '../constants/constants';
import { switchNovelToLibraryQuery } from '@database/queries/NovelQueries';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import ServiceManager, {
BackgroundTask,
QueuedBackgroundTask,
} from '@services/ServiceManager';
import { useMMKVObject } from 'react-native-mmkv';
// type Library = Category & { novels: LibraryNovelInfo[] };
@@ -75,12 +78,18 @@ export const useLibrary = (): UseLibraryReturnType => {
setIsLoading(false);
}, [downloadedOnlyMode, filter, refreshCategories, searchText, sortOrder]);
const libraryLookup = useMemo(() => {
const set = new Set<string>();
for (const novel of library) {
set.add(`${novel.pluginId}::${novel.path}`);
}
return set;
}, [library]);
const novelInLibrary = useCallback(
(pluginId: string, novelPath: string) =>
library?.some(
novel => novel.pluginId === pluginId && novel.path === novelPath,
),
[library],
libraryLookup.has(`${pluginId}::${novelPath}`),
[libraryLookup],
);
const switchNovelToLibrary = useCallback(
@@ -89,8 +98,8 @@ export const useLibrary = (): UseLibraryReturnType => {
// Important to get correct chapters count
// Count is set by sql trigger
refreshCategories();
const novels = getLibraryNovelsFromDb(
await refreshCategories();
const novels = await getLibraryNovelsFromDb(
sortOrder,
filter,
searchText,
@@ -106,17 +115,24 @@ export const useLibrary = (): UseLibraryReturnType => {
getLibrary();
});
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
);
const [taskQueue] = useMMKVObject<
Array<BackgroundTask | QueuedBackgroundTask>
>(ServiceManager.manager.STORE_KEY);
const restoreTasksCount = useMemo(
() =>
taskQueue?.filter(
t =>
t.name === 'LOCAL_RESTORE' ||
t.name === 'DRIVE_RESTORE' ||
t.name === 'SELF_HOST_RESTORE',
).length || 0,
taskQueue?.filter(t => {
/**
* Handle backward compatibility: check for new format first, then old format
*/
const taskName =
(t as QueuedBackgroundTask)?.task?.name ||
(t as BackgroundTask)?.name;
return (
taskName === 'LOCAL_RESTORE' ||
taskName === 'DRIVE_RESTORE' ||
taskName === 'SELF_HOST_RESTORE'
);
}).length || 0,
[taskQueue],
);
const prevRestoreTasksCountRef = useRef(restoreTasksCount);
@@ -126,7 +142,7 @@ export const useLibrary = (): UseLibraryReturnType => {
getLibrary();
}
prevRestoreTasksCountRef.current = restoreTasksCount;
}, [restoreTasksCount]);
}, [getLibrary, restoreTasksCount]);
return {
library,
@@ -146,7 +162,7 @@ export const useLibraryNovels = () => {
const [library, setLibrary] = useState<NovelInfo[]>([]);
const getLibrary = async () => {
const novels = getLibraryNovelsFromDb();
const novels = await getLibraryNovelsFromDb();
setLibrary(novels);
};
+8 -8
View File
@@ -51,7 +51,7 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
title={getString('aboutScreen.whatsNew')}
onPress={() =>
Linking.openURL(
`https://github.com/LNReader/lnreader/releases/tag/v${version}`,
`https://github.com/lnreader/lnreader/releases/tag/v${version}`,
)
}
theme={theme}
@@ -59,8 +59,8 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
<List.Divider theme={theme} />
<List.Item
title={getString('aboutScreen.website')}
description="https://lnreader.github.io"
onPress={() => Linking.openURL('https://lnreader.github.io')}
description="https://lnreader.app"
onPress={() => Linking.openURL('https://lnreader.app')}
theme={theme}
/>
<List.Item
@@ -71,17 +71,17 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
/>
<List.Item
title={getString('aboutScreen.github')}
description="https://github.com/LNReader/lnreader"
description="https://github.com/lnreader/lnreader"
onPress={() =>
Linking.openURL('https://github.com/LNReader/lnreader')
Linking.openURL('https://github.com/lnreader/lnreader')
}
theme={theme}
/>
<List.Item
title={getString('aboutScreen.sources')}
description="https://github.com/LNReader/lnreader-sources"
title={getString('aboutScreen.plugins')}
description="https://github.com/lnreader/lnreader-plugins"
onPress={() =>
Linking.openURL('https://github.com/LNReader/lnreader-sources')
Linking.openURL('https://github.com/lnreader/lnreader-plugins')
}
theme={theme}
/>
-2
View File
@@ -86,7 +86,6 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
<Switch
value={downloadedOnlyMode}
onValueChange={enableDownloadedOnlyMode}
size={24}
/>
</Pressable>
<Pressable
@@ -120,7 +119,6 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
<Switch
value={incognitoMode}
onValueChange={enableIncognitoMode}
size={24}
/>
</Pressable>
<List.Divider theme={theme} />
+1 -2
View File
@@ -4,7 +4,6 @@ import {
FAB,
ProgressBar,
Appbar as MaterialAppbar,
Menu,
overlay,
} from 'react-native-paper';
@@ -12,7 +11,7 @@ import { useTheme } from '@hooks/persisted';
import { showToast } from '../../utils/showToast';
import { getString } from '@strings/translations';
import { Appbar, EmptyView, SafeAreaView } from '@components';
import { Appbar, EmptyView, Menu, SafeAreaView } from '@components';
import { TaskQueueScreenProps } from '@navigators/types';
import ServiceManager, { QueuedBackgroundTask } from '@services/ServiceManager';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+68 -37
View File
@@ -40,6 +40,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
fetching,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
setNovel,
bookmarkChapters,
markChaptersRead,
@@ -62,15 +63,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
const headerOpacity = useSharedValue(0);
// TODO: fix this
// useEffect(() => {
// if (chapters.length !== 0 && !fetching) {
// refreshChapters();
// }
// }, [chapters.length, downloadQueue.length, fetching, refreshChapters]);
// useFocusEffect(refreshChapters);
const downloadChs = useCallback(
async (amount: number | 'all' | 'unread') => {
if (!novel) {
@@ -105,17 +97,19 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
},
[chapters, downloadChapters, novel],
);
const deleteChs = useCallback(() => {
deleteChapters(chapters.filter(c => c.isDownloaded));
}, [chapters, deleteChapters]);
const shareNovel = () => {
const shareNovel = useCallback(() => {
if (!novel) {
return;
}
Share.share({
message: resolveUrl(novel.pluginId, novel.path, true),
});
};
}, [novel]);
const [jumpToChapterModal, showJumpToChapterModal] = useState(false);
const {
@@ -217,7 +211,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
selected,
]);
const setCustomNovelCover = async () => {
const setCustomNovelCover = useCallback(async () => {
if (!novel) {
return;
}
@@ -228,12 +222,58 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
cover: newCover,
});
}
};
}, [novel, setNovel]);
const stableGetNextBatch = useMemo(
() =>
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop,
[batchInformation.batch, batchInformation.total, fetching, getNextChapterBatch],
);
const hideJumpToChapterModal = useCallback(
() => showJumpToChapterModal(false),
[],
);
const hideEditInfoModal = useCallback(
() => showEditInfoModal(false),
[],
);
const clearSelection = useCallback(() => setSelected([]), []);
const selectAll = useCallback(() => setSelected(chapters), [chapters]);
const snackbarTheme = useMemo(
() => ({ colors: { primary: theme.primary } }),
[theme.primary],
);
const snackbarTextStyle = useMemo(
() => ({ color: theme.onSurface }),
[theme.onSurface],
);
const titleStyle = useMemo(
() => ({ color: theme.onSurface }),
[theme.onSurface],
);
const snackbarAction = useMemo(
() => ({
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}),
[chapters, deleteChapters],
);
const styles = useMemo(() => createStyles(theme), [theme]);
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: theme.background }],
[styles.container, theme.background],
);
return (
<Portal.Host>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={containerStyle}>
<Portal>
{selected.length === 0 ? (
<NovelAppbar
@@ -259,18 +299,16 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Appbar.Action
icon="close"
iconColor={theme.onBackground}
onPress={() => setSelected([])}
onPress={clearSelection}
/>
<Appbar.Content
title={`${selected.length}`}
titleStyle={{ color: theme.onSurface }}
titleStyle={titleStyle}
/>
<Appbar.Action
icon="select-all"
iconColor={theme.onBackground}
onPress={() => {
setSelected(chapters);
}}
onPress={selectAll}
/>
</Animated.View>
)}
@@ -284,11 +322,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
getNextChapterBatch={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
getNextChapterBatch={stableGetNextBatch}
/>
</Suspense>
</SafeAreaView>
@@ -298,34 +332,31 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Snackbar
visible={deleteDownloadsSnackbar.value}
onDismiss={deleteDownloadsSnackbar.setFalse}
action={{
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}}
theme={{ colors: { primary: theme.primary } }}
action={snackbarAction}
theme={snackbarTheme}
style={styles.snackbar}
>
<Text style={{ color: theme.onSurface }}>
<Text style={snackbarTextStyle}>
{getString('novelScreen.deleteMessage')}
</Text>
</Snackbar>
</Portal>
<Portal>
{novel && (
{novel ? (
<>
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
chapters={chapters}
hideModal={hideJumpToChapterModal}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
loadUpToBatch={loadUpToBatch}
totalChapters={batchInformation.totalChapters}
chapters={chapters}
/>
<EditInfoModal
modalVisible={editInfoModal}
hideModal={() => showEditInfoModal(false)}
hideModal={hideEditInfoModal}
novel={novel}
setNovel={setNovel}
theme={theme}
@@ -339,7 +370,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
downloadChapters={downloadChapters}
/>
</>
)}
) : null}
</Portal>
</View>
</Portal.Host>
@@ -1,92 +1,72 @@
import React, { useEffect, useRef } from 'react';
import { MD3ThemeType } from '@theme/types';
import React, { useMemo } from 'react';
import { ThemeColors } from '@theme/types';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { Menu, overlay } from 'react-native-paper';
import { overlay } from 'react-native-paper';
import { getString } from '@strings/translations';
import { isChapterDownloaded } from '@database/queries/ChapterQueries';
import { useBoolean } from '@hooks/index';
import { IconButtonV2 } from '@components';
import { IconButtonV2, Menu } from '@components';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import Color from 'color';
interface DownloadButtonProps {
chapterId: number;
isDownloaded: boolean;
isDownloading?: boolean;
theme: MD3ThemeType;
theme: ThemeColors;
deleteChapter: () => void;
downloadChapter: () => void;
setChapterDownloaded?: (value: boolean) => void;
}
export const DownloadButton: React.FC<DownloadButtonProps> = ({
chapterId,
isDownloaded,
isDownloading,
theme,
deleteChapter,
downloadChapter,
setChapterDownloaded,
}) => {
const [downloaded, setDownloaded] = React.useState<boolean | undefined>(
isDownloaded,
);
const {
value: deleteChapterMenuVisible,
setTrue: showDeleteChapterMenu,
setFalse: hideDeleteChapterMenu,
} = useBoolean();
const isFirstRender = useRef(true);
const menuContentStyle = useMemo(
() => ({ backgroundColor: overlay(2, theme.surface) }),
[theme.surface],
);
const menuTitleStyle = useMemo(
() => ({ color: theme.onSurface }),
[theme.onSurface],
);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return; // Skip the first render as it leads to 'Maximum update depth exceeded.' error
}
if (!isDownloading) {
const isDownloadedValue = isChapterDownloaded(chapterId);
setDownloaded(isDownloadedValue);
setChapterDownloaded?.(isDownloadedValue);
}
}, [chapterId, isDownloading, setChapterDownloaded]);
if (isDownloading || downloaded === undefined) {
if (isDownloading) {
return <ChapterDownloadingButton theme={theme} />;
}
return downloaded ? (
<Menu
visible={deleteChapterMenuVisible}
onDismiss={hideDeleteChapterMenu}
anchor={
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
}
contentStyle={{ backgroundColor: overlay(2, theme.surface) }}
>
<Menu.Item
onPress={() => {
deleteChapter();
hideDeleteChapterMenu();
setDownloaded(false);
}}
title={getString('common.delete')}
titleStyle={{ color: theme.onSurface }}
/>
</Menu>
) : (
<DownloadChapterButton
theme={theme}
onPress={() => {
downloadChapter();
setDownloaded(undefined);
}}
/>
);
if (isDownloaded) {
return (
<Menu
visible={deleteChapterMenuVisible}
onDismiss={hideDeleteChapterMenu}
anchor={
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
}
contentStyle={menuContentStyle}
>
<Menu.Item
onPress={() => {
deleteChapter();
hideDeleteChapterMenu();
}}
title={getString('common.delete')}
titleStyle={menuTitleStyle}
/>
</Menu>
);
}
return <DownloadChapterButton theme={theme} onPress={downloadChapter} />;
};
interface theme {
theme: MD3ThemeType;
theme: ThemeColors;
}
type buttonPropType = theme & {
onPress: () => void;
@@ -117,7 +97,7 @@ export const DownloadChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
android_ripple={{ color: theme.rippleColor }}
>
<DownloadIcon theme={theme} />
</Pressable>
@@ -140,7 +120,7 @@ export const DeleteChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
android_ripple={{ color: theme.rippleColor }}
>
<DeleteIcon theme={theme} />
</Pressable>
+111 -84
View File
@@ -1,7 +1,5 @@
/* eslint-disable react-native/no-inline-styles */
import React, { memo, ReactNode } from 'react';
import React, { memo, useCallback, useMemo, ReactNode } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import color from 'color';
import {
ChapterBookmarkButton,
DownloadButton,
@@ -12,88 +10,113 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i
import { getString } from '@strings/translations';
interface ChapterItemProps {
chapter: ChapterInfo;
isDownloading?: boolean;
isBookmarked?: boolean;
chapter: ChapterInfo;
theme: ThemeColors;
showChapterTitles: boolean;
isSelected?: boolean;
downloadChapter: () => void;
deleteChapter: () => void;
onSelectPress?: (chapter: ChapterInfo) => void;
onSelectLongPress?: (chapter: ChapterInfo) => void;
navigateToChapter: (chapter: ChapterInfo) => void;
setChapterDownloaded?: (value: boolean) => void;
left?: ReactNode;
isLocal: boolean;
isUpdateCard?: boolean;
theme: ThemeColors;
showChapterTitles: boolean;
novelName: string;
left?: ReactNode;
onDeleteChapter: (chapter: ChapterInfo) => void;
onDownloadChapter: (chapter: ChapterInfo) => void;
onSelectPress: (chapter: ChapterInfo) => void;
onSelectLongPress?: (chapter: ChapterInfo) => void;
}
const ChapterItem: React.FC<ChapterItemProps> = ({
chapter,
isDownloading,
isBookmarked,
chapter,
isSelected,
isLocal,
isUpdateCard,
theme,
showChapterTitles,
downloadChapter,
deleteChapter,
isSelected,
novelName,
left,
onDeleteChapter,
onDownloadChapter,
onSelectPress,
onSelectLongPress,
navigateToChapter,
setChapterDownloaded,
isLocal,
left,
isUpdateCard,
novelName,
}) => {
const { id, name, unread, releaseTime, bookmark, chapterNumber, progress } =
chapter;
isBookmarked ??= bookmark;
const handlePress = useCallback(
() => onSelectPress(chapter),
[onSelectPress, chapter],
);
const handleLongPress = useCallback(
() => onSelectLongPress?.(chapter),
[onSelectLongPress, chapter],
);
const handleDelete = useCallback(
() => onDeleteChapter(chapter),
[onDeleteChapter, chapter],
);
const handleDownload = useCallback(
() => onDownloadChapter(chapter),
[onDownloadChapter, chapter],
);
const selectedStyle = useMemo(
() =>
isSelected
? [styles.chapterCardContainer, { backgroundColor: theme.rippleColor }]
: styles.chapterCardContainer,
[isSelected, theme.rippleColor],
);
const titleColor = useMemo(
() =>
!unread ? theme.outline : bookmark ? theme.primary : theme.onSurface,
[unread, bookmark, theme.outline, theme.primary, theme.onSurface],
);
const releaseColor = useMemo(
() =>
!unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurfaceVariant,
[unread, bookmark, theme.outline, theme.primary, theme.onSurfaceVariant],
);
const ripple = useMemo(
() => ({ color: theme.rippleColor }),
[theme.rippleColor],
);
return (
<View key={'chapterItem' + id}>
<Pressable
style={[
styles.chapterCardContainer,
isSelected && {
backgroundColor: color(theme.primary).alpha(0.12).string(),
},
]}
onPress={() => {
if (onSelectPress) {
onSelectPress(chapter);
} else {
navigateToChapter(chapter);
}
}}
onLongPress={() => onSelectLongPress?.(chapter)}
android_ripple={{ color: theme.rippleColor }}
style={selectedStyle}
onPress={handlePress}
onLongPress={handleLongPress}
android_ripple={ripple}
>
<View style={styles.row}>
{left}
{isBookmarked ? <ChapterBookmarkButton theme={theme} /> : null}
<View style={{ flex: 1 }}>
<View style={styles.flex1}>
{isUpdateCard ? (
<Text
style={{
fontSize: 14,
color: unread ? theme.onSurface : theme.outline,
}}
style={[
styles.updateCardName,
{ color: unread ? theme.onSurface : theme.outline },
]}
numberOfLines={1}
>
{novelName}
</Text>
) : null}
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<View style={styles.titleRow}>
{unread ? (
<MaterialCommunityIcons
name="circle"
@@ -104,15 +127,11 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
<Text
style={{
fontSize: isUpdateCard ? 12 : 14,
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurface,
flex: 1,
}}
style={[
isUpdateCard ? styles.textSmall : styles.textNormal,
{ color: titleColor },
styles.flex1,
]}
numberOfLines={1}
ellipsizeMode="tail"
>
@@ -123,23 +142,11 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
})}
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}
>
<View style={styles.metaRow}>
{releaseTime && !isUpdateCard ? (
<Text
style={[
{
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurfaceVariant,
marginTop: 4,
},
{ color: releaseColor, marginTop: 4 },
styles.text,
]}
numberOfLines={1}
@@ -149,12 +156,14 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
<Text
style={{
color: theme.outline,
marginLeft: chapter.releaseTime ? 5 : 0,
fontSize: 12,
marginTop: 4,
}}
style={[
styles.text,
{
color: theme.outline,
marginStart: chapter.releaseTime ? 5 : 0,
marginTop: 4,
},
]}
numberOfLines={1}
>
{chapter.releaseTime ? '• ' : null}
@@ -168,11 +177,9 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
<DownloadButton
isDownloading={isDownloading}
isDownloaded={chapter.isDownloaded}
chapterId={chapter.id}
theme={theme}
setChapterDownloaded={setChapterDownloaded}
deleteChapter={deleteChapter}
downloadChapter={downloadChapter}
deleteChapter={handleDelete}
downloadChapter={handleDownload}
/>
) : null}
</Pressable>
@@ -191,6 +198,13 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 8,
},
flex1: {
flex: 1,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
},
row: {
alignItems: 'center',
flex: 1,
@@ -199,7 +213,20 @@ const styles = StyleSheet.create({
text: {
fontSize: 12,
},
textNormal: {
fontSize: 14,
},
textSmall: {
fontSize: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
unreadIcon: {
marginRight: 4,
marginEnd: 4,
},
updateCardName: {
fontSize: 14,
},
});
@@ -137,7 +137,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
epub = new EpubBuilder(
{
title: novel.name,
fileName: novel.name.replace(/\s/g, ''),
fileName: novel.name.replace(/[\\/:*?"<>|\s]/g, '') || 'novel',
language: 'en',
cover: novel.cover,
description: novel.summary,
@@ -157,7 +157,37 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
const chapterFilePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`;
if (NativeFile.exists(chapterFilePath)) {
const chapterContent = NativeFile.readFile(chapterFilePath);
let chapterContent = NativeFile.readFile(chapterFilePath);
const chapterDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}`;
const escapedDir = chapterDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const imagePathRegex = new RegExp(
`file://(${escapedDir}/[^"'\\s]+)`,
'g',
);
for (const match of chapterContent.matchAll(imagePathRegex)) {
const imagePath = match[1];
if (imagePath && !NativeFile.exists(imagePath)) {
const escapedPath = imagePath.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const figureRegex = new RegExp(
`<figure[^>]*>.*?${escapedPath}.*?</figure>`,
'gs',
);
chapterContent = chapterContent.replace(figureRegex, '');
const imgRegex = new RegExp(
`<img[^>]*${escapedPath}[^>]*\\/?>`,
'g',
);
chapterContent = chapterContent.replace(imgRegex, '');
}
}
await epub.addChapter({
title:
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import {
FlatList,
StyleSheet,
@@ -43,27 +43,31 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => (
<View style={styles.novelInfoContainer}>{children}</View>
);
const CoverImage = ({
children,
source,
theme,
hideBackdrop,
}: CoverImageProps) => {
if (hideBackdrop) {
return <View>{children}</View>;
} else {
const CoverImage = memo(
({ children, source, theme, hideBackdrop }: CoverImageProps) => {
const overlayBg = useMemo(
() => color(theme.background).alpha(0.7).string(),
[theme.background],
);
const overlayStyle = useMemo(
() => [{ backgroundColor: overlayBg }, styles.flex1],
[overlayBg],
);
const gradientColors = useMemo(
() => ['rgba(0,0,0,0)', theme.background] as const,
[theme.background],
);
if (hideBackdrop) {
return <View>{children}</View>;
}
return (
<ImageBackground source={source} style={styles.coverImage}>
<View
style={[
{ backgroundColor: color(theme.background).alpha(0.7).string() },
styles.flex1,
]}
>
<View style={overlayStyle}>
{source.uri ? (
<LinearGradient
colors={['rgba(0,0,0,0)', theme.background]}
locations={[0, 1]}
colors={gradientColors}
locations={GRADIENT_LOCATIONS}
style={styles.linearGradient}
>
{children}
@@ -74,8 +78,10 @@ const CoverImage = ({
</View>
</ImageBackground>
);
}
};
},
);
const GRADIENT_LOCATIONS = [0, 1] as const;
const NovelThumbnail = ({
source,
@@ -170,7 +176,7 @@ const FollowButton = ({
<View style={styles.followButtonContainer}>
<Pressable
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
color: theme.rippleColor,
borderless: false,
}}
onPress={onPress}
@@ -234,26 +240,28 @@ const TrackerButton = ({
</View>
);
const NovelGenres = ({
theme,
genres,
}: {
theme: ThemeColors;
genres: string;
}) => {
const data = genres.split(/,\s*/);
const genreKeyExtractor = (_item: string, index: number) => 'genre' + index;
return (
<FlatList
contentContainerStyle={styles.genreContainer}
horizontal
data={data}
keyExtractor={(item, index) => 'genre' + index}
renderItem={({ item }) => <Chip label={item} theme={theme} />}
showsHorizontalScrollIndicator={false}
/>
);
};
const NovelGenres = memo(
({ theme, genres }: { theme: ThemeColors; genres: string }) => {
const data = useMemo(() => genres.split(/,\s*/), [genres]);
const renderGenre = useCallback(
({ item }: { item: string }) => <Chip label={item} theme={theme} />,
[theme],
);
return (
<FlatList
contentContainerStyle={styles.genreContainer}
horizontal
data={data}
keyExtractor={genreKeyExtractor}
renderItem={renderGenre}
showsHorizontalScrollIndicator={false}
/>
);
},
);
export {
NovelInfoContainer,
@@ -317,7 +325,6 @@ const styles = StyleSheet.create({
},
novelInfo: {
fontSize: 14,
marginBottom: 4,
},
novelInfoContainer: {
flexDirection: 'row',
@@ -1,6 +1,5 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import color from 'color';
import * as Clipboard from 'expo-clipboard';
@@ -39,12 +38,24 @@ import {
VerticalBarSkeleton,
} from '@components/Skeleton/Skeleton';
import { useNovelContext } from '@screens/novel/NovelContext';
import Animated, {
useAnimatedProps,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import useLoadingColors from '@components/Skeleton/useLoadingColors';
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
interface NovelInfoHeaderProps {
chapters: ChapterInfo[];
deleteDownloadsSnackbar: UseBooleanReturnType;
fetching: boolean;
filter: string;
firstUnreadChapter?: ChapterInfo;
isLoading: boolean;
lastRead?: ChapterInfo;
navigateToChapter: (chapter: ChapterInfo) => void;
@@ -73,11 +84,155 @@ const getStatusIcon = (status?: string) => {
return 'help';
};
const ChapterCountSkeleton = ({ theme }: { theme: ThemeColors }) => {
const sv = useSharedValue(0);
const { disableLoadingAnimations } = useAppSettings();
const [highlightColor, backgroundColor] = useLoadingColors(theme);
const animatedProps = useAnimatedProps(() => {
return {
left: (sv.value + '%') as `${number}%`,
};
});
React.useEffect(() => {
if (disableLoadingAnimations) return;
sv.value = withRepeat(
withSequence(0, withTiming(160, { duration: 1000 })),
-1,
);
}, [disableLoadingAnimations, sv]);
if (disableLoadingAnimations) {
return (
<View
style={[
styles.chapterCountSkeleton,
{ backgroundColor: backgroundColor },
]}
/>
);
}
const LG = Animated.createAnimatedComponent(LinearGradient);
return (
<View
style={[
styles.chapterCountSkeleton,
{ backgroundColor: backgroundColor },
]}
>
<LG
start={[0, 0]}
end={[1, 0]}
locations={[0, 0.3, 0.7, 1]}
style={[animatedProps, styles.chapterCountGradient]}
colors={['transparent', highlightColor, highlightColor, 'transparent']}
/>
</View>
);
};
const useShimmer = (theme: ThemeColors) => {
const sv = useSharedValue(0);
const { disableLoadingAnimations } = useAppSettings();
const [highlightColor, backgroundColor] = useLoadingColors(theme);
const animatedStyle = useAnimatedProps(() => ({
left: (sv.value + '%') as `${number}%`,
}));
React.useEffect(() => {
if (disableLoadingAnimations) return;
sv.value = withRepeat(
withSequence(0, withTiming(160, { duration: 1000 })),
-1,
);
}, [disableLoadingAnimations, sv]);
return {
animatedStyle,
highlightColor,
backgroundColor,
disableLoadingAnimations,
};
};
const NovelDetailsSkeleton = ({ theme }: { theme: ThemeColors }) => {
const {
animatedStyle,
highlightColor,
backgroundColor,
disableLoadingAnimations,
} = useShimmer(theme);
const shimmer = !disableLoadingAnimations ? (
<AnimatedLinearGradient
start={[0, 0]}
end={[1, 0]}
locations={[0, 0.3, 0.7, 1]}
style={[animatedStyle, styles.infoSkeletonGradient]}
colors={['transparent', highlightColor, highlightColor, 'transparent']}
/>
) : null;
return (
<>
<Row style={styles.infoRow}>
<View style={[styles.infoSkeletonBar, { backgroundColor, width: 130 }]}>
{shimmer}
</View>
</Row>
<Row style={styles.infoRow}>
<View style={[styles.infoSkeletonBar, { backgroundColor, width: 180 }]}>
{shimmer}
</View>
</Row>
</>
);
};
const ButtonGroupSkeleton = ({ theme }: { theme: ThemeColors }) => {
const {
animatedStyle,
highlightColor,
backgroundColor,
disableLoadingAnimations,
} = useShimmer(theme);
const shimmer = !disableLoadingAnimations ? (
<AnimatedLinearGradient
start={[0, 0]}
end={[1, 0]}
locations={[0, 0.3, 0.7, 1]}
style={[animatedStyle, styles.buttonSkeletonGradient]}
colors={['transparent', highlightColor, highlightColor, 'transparent']}
/>
) : null;
return (
<View style={styles.buttonGroupSkeletonContainer}>
<View style={[styles.buttonSkeleton, { backgroundColor }]}>
{shimmer}
</View>
<View style={[styles.buttonSkeleton, { backgroundColor }]}>
{shimmer}
</View>
</View>
);
};
const showNotAvailable = async () => {
showToast('Not available while loading');
};
const NovelInfoHeader = ({
chapters,
deleteDownloadsSnackbar,
fetching,
filter,
firstUnreadChapter,
isLoading = false,
lastRead,
navigateToChapter,
@@ -101,20 +256,64 @@ const NovelInfoHeader = ({
[novel.pluginId],
);
const showNotAvailable = async () => {
showToast('Not available while loading');
};
const coverSource = useMemo(() => ({ uri: novel.cover }), [novel.cover]);
const handleTitlePress = useCallback(
() =>
navigation.replace('GlobalSearchScreen', {
searchText: novel.name,
}),
[navigation, novel.name],
);
const handleTitleLongPress = useCallback(() => {
Clipboard.setStringAsync(novel.name).then(() =>
showToast(getString('common.copiedToClipboard', { name: novel.name })),
);
}, [novel.name]);
const handleFollowNovel = useCallback(() => {
if (isLoading) {
showNotAvailable();
return;
}
followNovel();
if (novel.inLibrary && chapters.some(chapter => chapter.isDownloaded)) {
deleteDownloadsSnackbar.setTrue();
}
}, [
isLoading,
followNovel,
novel.inLibrary,
chapters,
deleteDownloadsSnackbar,
]);
const handleTrackerSheet = useCallback(
() => trackerSheetRef.current?.present(),
[trackerSheetRef],
);
const handleOpenBottomSheet = useCallback(
() => novelBottomSheetRef.current?.present(),
[novelBottomSheetRef],
);
const ripple = useMemo(
() => ({ color: theme.rippleColor }),
[theme.rippleColor],
);
return (
<>
<CoverImage
source={{ uri: novel.cover }}
source={coverSource}
theme={theme}
hideBackdrop={hideBackdrop}
>
<NovelInfoContainer>
<NovelThumbnail
source={{ uri: novel.cover }}
source={coverSource}
theme={theme}
setCustomNovelCover={
isLoading ? showNotAvailable : setCustomNovelCover
@@ -122,88 +321,74 @@ const NovelInfoHeader = ({
saveNovelCover={isLoading ? showNotAvailable : saveNovelCover}
/>
<View style={styles.novelDetails}>
<Row>
<Row style={styles.infoRow}>
<NovelTitle
theme={theme}
onPress={() =>
navigation.replace('GlobalSearchScreen', {
searchText: novel.name,
})
}
onLongPress={() => {
Clipboard.setStringAsync(novel.name).then(() =>
showToast(
getString('common.copiedToClipboard', {
name: novel.name,
}),
),
);
}}
onPress={handleTitlePress}
onLongPress={handleTitleLongPress}
>
{novel.name}
</NovelTitle>
</Row>
{novel.id !== 'NO_ID' && novel.author ? (
<Row>
<MaterialCommunityIcons
name="fountain-pen-tip"
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>{novel.author}</NovelInfo>
</Row>
) : null}
{novel.id !== 'NO_ID' && novel.artist ? (
<Row>
<MaterialCommunityIcons
name="palette-outline"
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>{novel.artist}</NovelInfo>
</Row>
) : null}
<Row>
<MaterialCommunityIcons
name={getStatusIcon(
novel.id !== 'NO_ID' ? novel.status : undefined,
)}
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>
{(novel.id !== 'NO_ID'
? translateNovelStatus(novel.status)
: getString('novelScreen.unknownStatus')) +
' • ' +
pluginName}
</NovelInfo>
</Row>
{isLoading && novel.id === 'NO_ID' ? (
<NovelDetailsSkeleton theme={theme} />
) : (
<>
{novel.id !== 'NO_ID' && novel.author ? (
<Row style={styles.infoRow}>
<MaterialCommunityIcons
name="fountain-pen-tip"
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>{novel.author}</NovelInfo>
</Row>
) : null}
{novel.id !== 'NO_ID' && novel.artist ? (
<Row style={styles.infoRow}>
<MaterialCommunityIcons
name="palette-outline"
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>{novel.artist}</NovelInfo>
</Row>
) : null}
<Row style={styles.infoRow}>
<MaterialCommunityIcons
name={getStatusIcon(
novel.id !== 'NO_ID' ? novel.status : undefined,
)}
size={14}
color={theme.onSurfaceVariant}
style={styles.marginRight}
/>
<NovelInfo theme={theme}>
{(novel.id !== 'NO_ID'
? translateNovelStatus(novel.status)
: getString('novelScreen.unknownStatus')) +
' • ' +
pluginName}
</NovelInfo>
</Row>
</>
)}
</View>
</NovelInfoContainer>
</CoverImage>
<>
<NovelScreenButtonGroup
novel={novel}
handleFollowNovel={
isLoading
? showNotAvailable
: () => {
followNovel();
if (
novel.inLibrary &&
chapters.some(chapter => chapter.isDownloaded)
) {
deleteDownloadsSnackbar.setTrue();
}
}
}
handleTrackerSheet={() => trackerSheetRef.current?.present()}
theme={theme}
/>
{isLoading && novel.id === 'NO_ID' ? (
<ButtonGroupSkeleton theme={theme} />
) : (
<NovelScreenButtonGroup
novel={novel}
handleFollowNovel={handleFollowNovel}
handleTrackerSheet={handleTrackerSheet}
theme={theme}
/>
)}
{isLoading && (!novel.genres || !novel.summary) ? (
<NovelMetaSkeleton />
) : (
@@ -220,7 +405,7 @@ const NovelInfoHeader = ({
)}
<ReadButton
navigateToChapter={navigateToChapter}
chapters={chapters}
firstUnreadChapter={firstUnreadChapter}
lastRead={lastRead}
/>
{isLoading && (!novel.genres || !novel.summary) ? (
@@ -229,23 +414,23 @@ const NovelInfoHeader = ({
<View style={styles.bottomsheetContainer}>
<Pressable
style={styles.bottomsheet}
onPress={() => novelBottomSheetRef.current?.present()}
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
}}
onPress={handleOpenBottomSheet}
android_ripple={ripple}
>
<View style={styles.flex}>
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{!fetching || totalChapters !== undefined
? `${totalChapters} ${getString('novelScreen.chapters')}`
: getString('common.loading')}
</Text>
{fetching && totalChapters === undefined ? (
<ChapterCountSkeleton theme={theme} />
) : (
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{`${totalChapters} ${getString('novelScreen.chapters')}`}
</Text>
)}
</View>
<IconButton
icon="filter-variant"
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
size={24}
onPress={() => novelBottomSheetRef.current?.present()}
onPress={handleOpenBottomSheet}
/>
</Pressable>
</View>
@@ -268,6 +453,19 @@ const styles = StyleSheet.create({
bottomsheetContainer: {
gap: 12,
},
chapterCountGradient: {
height: 20,
position: 'absolute',
transform: [{ translateX: '-100%' }],
width: '60%',
},
chapterCountSkeleton: {
borderRadius: 4,
height: 14,
marginHorizontal: 16,
overflow: 'hidden',
width: 120,
},
chapters: {
fontSize: 14,
paddingHorizontal: 16,
@@ -281,4 +479,36 @@ const styles = StyleSheet.create({
paddingBottom: 16,
paddingLeft: 12,
},
infoRow: {
marginBottom: 8,
},
infoSkeletonBar: {
borderRadius: 4,
height: 14,
overflow: 'hidden',
},
infoSkeletonGradient: {
height: 20,
position: 'absolute',
transform: [{ translateX: '-100%' }],
width: '60%',
},
buttonGroupSkeletonContainer: {
flexDirection: 'row',
marginHorizontal: 16,
paddingTop: 8,
gap: 8,
},
buttonSkeleton: {
borderRadius: 8,
flex: 1,
height: 52,
overflow: 'hidden',
},
buttonSkeletonGradient: {
height: 60,
position: 'absolute',
transform: [{ translateX: '-100%' }],
width: '60%',
},
});
@@ -8,39 +8,39 @@ import Animated, { ZoomIn } from 'react-native-reanimated';
import { StyleSheet } from 'react-native';
interface ReadButtonProps {
chapters: ChapterInfo[];
firstUnreadChapter?: ChapterInfo;
lastRead?: ChapterInfo;
navigateToChapter: (chapter: ChapterInfo) => void;
}
const ReadButton = ({
chapters,
firstUnreadChapter,
lastRead,
navigateToChapter,
}: ReadButtonProps) => {
const { useFabForContinueReading = false } = useAppSettings();
const navigateToLastReadChapter = () => {
if (lastRead) {
navigateToChapter(lastRead);
} else if (chapters.length) {
navigateToChapter(chapters[0]);
const targetChapter = lastRead ?? firstUnreadChapter;
const navigateToTargetChapter = () => {
if (targetChapter) {
navigateToChapter(targetChapter);
}
};
if (!useFabForContinueReading) {
return chapters.length > 0 || lastRead ? (
return targetChapter ? (
<Animated.View entering={ZoomIn.duration(150)}>
<Button
title={
lastRead
? `${getString('novelScreen.continueReading')} ${lastRead.name}`
: getString('novelScreen.startReadingChapters', {
name: chapters[0].name,
name: targetChapter.name,
})
}
style={styles.margin}
onPress={navigateToLastReadChapter}
onPress={navigateToTargetChapter}
mode="contained"
/>
</Animated.View>
@@ -12,6 +12,10 @@ import { Portal, Text } from 'react-native-paper';
import { useTheme } from '@hooks/persisted';
import { ChapterInfo, NovelInfo } from '@database/types';
import { NovelScreenProps } from '@navigators/types';
import {
getNovelChaptersByNumber,
getNovelChaptersByName,
} from '@database/queries/ChapterQueries';
import {
LegendList,
LegendListRef,
@@ -21,22 +25,26 @@ import {
interface JumpToChapterModalProps {
hideModal: () => void;
modalVisible: boolean;
chapters: ChapterInfo[];
navigation: NovelScreenProps['navigation'];
novel: NovelInfo;
chapters: ChapterInfo[];
chapterListRef: React.RefObject<LegendListRef | null>;
loadUpToBatch: (batch: number) => Promise<void>;
totalChapters?: number;
}
const JumpToChapterModal = ({
hideModal,
modalVisible,
chapters,
chapters: loadedChapters,
navigation,
novel,
chapterListRef,
loadUpToBatch,
totalChapters,
}: JumpToChapterModalProps) => {
const minNumber = Math.min(...chapters.map(c => c.chapterNumber || -1));
const maxNumber = Math.max(...chapters.map(c => c.chapterNumber || -1));
const minNumber = 1;
const maxNumber = totalChapters ?? -1;
const theme = useTheme();
const [mode, setMode] = useState(false);
const [openChapter, setOpenChapter] = useState(false);
@@ -65,22 +73,29 @@ const JumpToChapterModal = ({
});
};
const scrollToChapter = (chap: ChapterInfo) => {
const scrollToChapter = async (chap: ChapterInfo) => {
onDismiss();
chapterListRef.current?.scrollToItem({
animated: true,
item: chap,
viewPosition: 0.5,
});
};
const loadedIndex = loadedChapters.findIndex(c => c.id === chap.id);
if (loadedIndex >= 0) {
chapterListRef.current?.scrollToIndex({
animated: true,
index: loadedIndex,
viewPosition: 0.5,
});
return;
}
const scrollToIndex = (index: number) => {
onDismiss();
chapterListRef.current?.scrollToIndex({
animated: true,
index: index,
viewPosition: 0.5,
});
if ((chap.position ?? -1) >= 0) {
const targetBatch = Math.floor(chap.position! / 300);
await loadUpToBatch(targetBatch);
setTimeout(() => {
chapterListRef.current?.scrollToIndex({
animated: true,
index: chap.position!,
viewPosition: 0.5,
});
}, 0);
}
};
const executeFunction = (item: ChapterInfo) => {
@@ -113,44 +128,48 @@ const JumpToChapterModal = ({
);
};
const onSubmit = () => {
const onSubmit = async () => {
if (!mode) {
// Number search
const num = Number(text);
if (num && num >= minNumber && num <= maxNumber) {
if (openChapter) {
const chapter = chapters.find(c => c.chapterNumber === num);
if (chapter) {
const chapters = await getNovelChaptersByNumber(novel!.id, num);
if (chapters.length > 0) {
const chapter = chapters[0];
if (openChapter) {
return navigateToChapter(chapter);
} else {
return scrollToChapter(chapter);
}
} else {
const index = chapters.findIndex(c => c.chapterNumber === num);
return scrollToIndex(index);
}
}
return setError(
getString('novelScreen.jumpToChapterModal.error.validChapterNumber') +
` (${num < minNumber ? '≥ ' + minNumber : '≤ ' + maxNumber})`,
);
} else {
const searchedChapters = chapters.filter(chap =>
chap.name.toLowerCase().includes(text?.toLowerCase()),
// Text search
const chapters = await getNovelChaptersByName(
novel!.id,
text.toLowerCase(),
);
if (!searchedChapters.length) {
if (!chapters.length) {
setError(
getString('novelScreen.jumpToChapterModal.error.validChapterName'),
);
return;
}
if (searchedChapters.length === 1) {
if (chapters.length === 1) {
if (openChapter) {
return navigateToChapter(searchedChapters[0]);
return navigateToChapter(chapters[0]);
} else {
return scrollToChapter(chapters[0]);
}
return scrollToChapter(searchedChapters[0]);
}
return setResult(searchedChapters);
return setResult(chapters);
}
};
@@ -208,14 +227,12 @@ const JumpToChapterModal = ({
value={openChapter}
theme={theme}
onPress={() => setOpenChapter(!openChapter)}
size={20}
/>
<SwitchItem
label={getString('novelScreen.jumpToChapterModal.chapterName')}
value={mode}
theme={theme}
onPress={() => setMode(!mode)}
size={20}
/>
</View>
{result.length ? (
@@ -226,6 +243,7 @@ const JumpToChapterModal = ({
data={result}
extraData={openChapter}
renderItem={renderItem}
keyExtractor={item => `chapter_${item.id}`}
contentContainerStyle={styles.listContentCtn}
/>
</View>
+66 -35
View File
@@ -1,6 +1,7 @@
import React, { memo, useCallback, useMemo, useState } from 'react';
import { getString } from '@strings/translations';
import { Appbar, Menu as DefaultMenu } from 'react-native-paper';
import { Appbar } from 'react-native-paper';
import { Menu as DefaultMenu } from '@components';
import { ThemeColors } from '@theme/types';
import Animated, {
FadeIn,
@@ -16,6 +17,9 @@ import { NovelInfo } from '@database/types';
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
import { MaterialDesignIconName } from '@type/icon';
const AnimatedAppbarAction =
Animated.createAnimatedComponent(Appbar.Action);
const Menu = React.memo(
({
visible,
@@ -30,20 +34,32 @@ const Menu = React.memo(
theme: ThemeColors;
items: { label: string; onPress: () => void }[];
}) => {
const contentStyle = useMemo(
() => ({ backgroundColor: theme.surface2 }),
[theme.surface2],
);
const itemStyle = useMemo(
() => ({ backgroundColor: theme.surface2 }),
[theme.surface2],
);
const titleStyle = useMemo(
() => ({ color: theme.onSurface }),
[theme.onSurface],
);
return (
<DefaultMenu
visible={visible}
onDismiss={onDismiss}
anchor={anchor}
anchorPosition="bottom"
contentStyle={{ backgroundColor: theme.surface2 }}
contentStyle={contentStyle}
>
{items.map((item, index) => (
<DefaultMenu.Item
key={index + item.label}
title={item.label}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
style={itemStyle}
titleStyle={titleStyle}
onPress={() => {
onDismiss();
item.onPress();
@@ -96,6 +112,11 @@ const NovelAppbar = ({
const [downloadMenu, showDownloadMenu] = useState(false);
const [extraMenu, showExtraMenu] = useState(false);
const appbarTheme = useMemo(
() => ({ colors: theme }),
[theme],
);
const AppbarAction = useCallback(
(props: {
icon: MaterialDesignIconName;
@@ -103,20 +124,19 @@ const NovelAppbar = ({
style?: StyleProp<ViewStyle>;
size?: number;
}) => {
const AA = Animated.createAnimatedComponent(Appbar.Action);
return (
<AA
<AnimatedAppbarAction
entering={FadeIn.duration(250)}
// delay to prevent flickering on rerenders
exiting={FadeOut.delay(50).duration(250)}
theme={{ colors: theme }}
theme={appbarTheme}
size={24}
{...props}
/>
);
},
[theme],
[appbarTheme],
);
const downloadMenuItems = useMemo(() => {
return [
{
@@ -150,8 +170,34 @@ const NovelAppbar = ({
];
}, [deleteChapters, downloadChapters, downloadCustomChapterModal]);
const extraMenuItems = useMemo(
() => [
{
label: getString('novelScreen.edit.info'),
onPress: () => showEditInfoModal(true),
},
{
label: getString('novelScreen.edit.cover'),
onPress: () => setCustomNovelCover(),
},
],
[showEditInfoModal, setCustomNovelCover],
);
const openDlMenu = useCallback(() => showDownloadMenu(true), []);
const closeDlMenu = useCallback(() => showDownloadMenu(false), []);
const openExtraMenu = useCallback(() => showExtraMenu(true), []);
const closeExtraMenu = useCallback(() => showExtraMenu(false), []);
const openJumpToChapter = useCallback(
() => showJumpToChapterModal(true),
[showJumpToChapterModal],
);
const headerTheme = useMemo(
() => ({ colors: { ...theme, surface: 'transparent' } }),
[theme],
);
return (
<Animated.View
@@ -159,7 +205,7 @@ const NovelAppbar = ({
exiting={SlideOutUp.duration(250)}
style={headerOpacityStyle}
>
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
<Appbar.Header theme={headerTheme}>
<Appbar.BackAction onPress={goBack} />
<View style={styles.row}>
@@ -167,18 +213,16 @@ const NovelAppbar = ({
<AppbarAction icon="share-variant" onPress={shareNovel} />
<AppbarAction
icon="text-box-search-outline"
onPress={() => {
showJumpToChapterModal(true);
}}
onPress={openJumpToChapter}
/>
{!isLocal && (
{!isLocal ? (
<Menu
theme={theme}
visible={downloadMenu}
onDismiss={closeDlMenu}
anchor={
<Appbar.Action
theme={{ colors: theme }}
theme={appbarTheme}
icon="download-outline"
onPress={openDlMenu}
size={26}
@@ -186,33 +230,20 @@ const NovelAppbar = ({
}
items={downloadMenuItems}
/>
)}
) : null}
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
onDismiss={closeExtraMenu}
anchor={
<Appbar.Action
theme={{ colors: theme }}
theme={appbarTheme}
icon="dots-vertical"
onPress={() => showExtraMenu(true)}
onPress={openExtraMenu}
size={24}
/>
}
theme={theme}
items={[
{
label: getString('novelScreen.edit.info'),
onPress: () => {
showEditInfoModal(true);
},
},
{
label: getString('novelScreen.edit.cover'),
onPress: () => {
setCustomNovelCover();
},
},
]}
items={extraMenuItems}
/>
</View>
</Appbar.Header>
@@ -224,7 +255,7 @@ const styles = StyleSheet.create({
row: {
flexDirection: 'row',
position: 'absolute',
right: 0,
end: 0,
},
});
@@ -63,6 +63,7 @@ export default function NovelDrawer({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `novel_page_${index}_${item}`}
estimatedItemSize={60}
/>
</View>
+300 -201
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import ChapterItem from './ChapterItem';
import NovelInfoHeader from './Info/NovelInfoHeader';
import { useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { pickCustomNovelCover } from '@database/queries/NovelQueries';
import { ChapterInfo, NovelInfo } from '@database/types';
import { useBoolean } from '@hooks/index';
@@ -51,7 +51,7 @@ type NovelScreenListProps = {
};
};
const ListEmptyComponent = () => <ChapterListSkeleton />;
const chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id;
const NovelScreenList = ({
headerOpacity,
@@ -66,6 +66,7 @@ const NovelScreenList = ({
chapters,
deleteChapter,
fetching,
firstUnreadChapter,
getNovel,
lastRead,
loading,
@@ -74,11 +75,11 @@ const NovelScreenList = ({
setNovel,
sortAndFilterChapters,
setShowChapterTitles,
updateChapter,
novel: fetchedNovel,
batchInformation,
pageIndex,
openPage,
updateChapter,
} = useNovelContext();
const { pluginId } = routeBaseNovel;
@@ -108,7 +109,25 @@ const NovelScreenList = ({
const theme = useTheme();
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
const { downloadQueue, downloadChapter } = useDownload();
const { downloadingChapterIds, downloadChapter } = useDownload();
// Mark chapters as downloaded when their download completes
const prevDownloadingRef = useRef(downloadingChapterIds);
useEffect(() => {
const prev = prevDownloadingRef.current;
prevDownloadingRef.current = downloadingChapterIds;
if (prev === downloadingChapterIds) {
return;
}
for (const id of prev) {
if (!downloadingChapterIds.has(id)) {
const index = chapters.findIndex(c => c.id === id);
if (index !== -1) {
updateChapter(index, { isDownloaded: true });
}
}
}
}, [downloadingChapterIds, chapters, updateChapter]);
const [isFabExtended, setIsFabExtended] = useState(true);
const [showScrollToTop, setShowScrollToTop] = useState(false);
@@ -119,20 +138,116 @@ const NovelScreenList = ({
const deleteDownloadsSnackbar = useBoolean();
const onPageScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
// Derive selectedIds Set for O(1) lookups
const selectedIds = useMemo(
() => new Set(selected.map(s => s.id)),
[selected],
);
const isSelectionMode = selected.length > 0;
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
const currentScrollPosition = Math.floor(y) ?? 0;
if (useFabForContinueReading && lastRead) {
setIsFabExtended(currentScrollPosition <= 0);
}
const onPageScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
const screenHeight = Dimensions.get('window').height;
setShowScrollToTop(currentScrollPosition > screenHeight / 2);
};
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
const currentScrollPosition = Math.floor(y) ?? 0;
if (useFabForContinueReading && lastRead) {
setIsFabExtended(currentScrollPosition <= 0);
}
const onRefresh = async () => {
const screenHeight = Dimensions.get('window').height;
setShowScrollToTop(currentScrollPosition > screenHeight / 2);
},
[headerOpacity, useFabForContinueReading, lastRead],
);
// --- Stable callbacks ---
const navigateToChapter = useCallback(
(chapter: ChapterInfo) => {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: { novel, chapter },
});
},
[navigation, novel],
);
const onSelectPress = useCallback(
(chapter: ChapterInfo) => {
if (!isSelectionMode) {
navigateToChapter(chapter);
} else {
setSelected(sel =>
sel.some(it => it.id === chapter.id)
? sel.filter(it => it.id !== chapter.id)
: [...sel, chapter],
);
}
},
[isSelectionMode, navigateToChapter, setSelected],
);
const onSelectLongPress = useCallback(
(chapter: ChapterInfo) => {
setSelected(sel => {
if (sel.length === 0) {
if (!disableHapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
return [...sel, chapter];
}
if (sel.length === chapters.length) {
return sel;
}
const lastSelectedChapter = sel[sel.length - 1];
if (lastSelectedChapter.id === chapter.id) {
return sel;
}
if (lastSelectedChapter.id > chapter.id) {
return [
...sel,
chapter,
...chapters.filter(
(chap: ChapterInfo) =>
(chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) ===
false,
),
];
}
return [
...sel,
chapter,
...chapters.filter(
(chap: ChapterInfo) =>
(chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) ===
false,
),
];
});
},
[chapters, disableHapticFeedback],
);
const handleDeleteChapter = useCallback(
(chapter: ChapterInfo) => {
deleteChapter(chapter);
},
[deleteChapter],
);
const handleDownloadChapter = useCallback(
(chapter: ChapterInfo) => {
if (novel && novel.id !== 'NO_ID') {
downloadChapter(novel, chapter);
}
},
[novel, downloadChapter],
);
const onRefresh = useCallback(async () => {
if (novel.id !== 'NO_ID') {
setUpdating(true);
updateNovel(pluginId, novel.path, novel.id, {
@@ -148,101 +263,42 @@ const NovelScreenList = ({
.catch(error => showToast('Failed updating: ' + error.message))
.finally(() => setUpdating(false));
}
};
}, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]);
const onRefreshPage = async (page: string) => {
if (novel.id !== 'NO_ID') {
setUpdating(true);
updateNovelPage(pluginId, novel.path, novel.id, page, {
downloadNewChapters,
})
.then(() => getNovel())
.then(() => showToast(`Updated page: ${page}`))
.catch(e => showToast('Failed updating: ' + e.message))
.finally(() => setUpdating(false));
}
};
const refreshControl = () => (
<RefreshControl
progressViewOffset={topInset + 32}
onRefresh={onRefresh}
refreshing={updating}
colors={[theme.primary]}
progressBackgroundColor={theme.onPrimary}
/>
const onRefreshPage = useCallback(
async (page: string) => {
if (novel.id !== 'NO_ID') {
setUpdating(true);
updateNovelPage(pluginId, novel.path, novel.id, page, {
downloadNewChapters,
})
.then(() => getNovel())
.then(() => showToast(`Updated page: ${page}`))
.catch(e => showToast('Failed updating: ' + e.message))
.finally(() => setUpdating(false));
}
},
[novel, pluginId, downloadNewChapters, getNovel],
);
const isSelected = (id: number) => {
return selected.some(obj => obj.id === id);
};
const refreshControlElement = useMemo(
() => (
<RefreshControl
progressViewOffset={topInset + 32}
onRefresh={onRefresh}
refreshing={updating}
colors={[theme.primary]}
progressBackgroundColor={theme.onPrimary}
/>
),
[onRefresh, updating, topInset, theme.primary, theme.onPrimary],
);
const onSelectPress = (chapter: ChapterInfo) => {
if (selected.length === 0) {
navigateToChapter(chapter);
} else {
if (isSelected(chapter.id)) {
setSelected(sel => sel.filter(it => it.id !== chapter.id));
} else {
setSelected(sel => [...sel, chapter]);
}
}
};
const onSelectLongPress = (chapter: ChapterInfo) => {
if (selected.length === 0) {
if (!disableHapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
setSelected(sel => [...sel, chapter]);
} else {
if (selected.length === chapters.length) {
return;
}
/**
* Select custom range
*/
const lastSelectedChapter = selected[selected.length - 1];
if (lastSelectedChapter.id !== chapter.id) {
if (lastSelectedChapter.id > chapter.id) {
setSelected(sel => [
...sel,
chapter,
...chapters.filter(
(chap: ChapterInfo) =>
(chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) ===
false,
),
]);
} else {
setSelected(sel => [
...sel,
chapter,
...chapters.filter(
(chap: ChapterInfo) =>
(chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) ===
false,
),
]);
}
}
}
};
const navigateToChapter = (chapter: ChapterInfo) => {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: { novel, chapter },
});
};
const scrollToTop = () => {
const scrollToTop = useCallback(() => {
listRef.current?.scrollToOffset({ offset: 0, animated: true });
};
}, [listRef]);
const setCustomNovelCover = async () => {
const setCustomNovelCover = useCallback(async () => {
if (!novel || novel.id === 'NO_ID') {
return;
}
@@ -253,9 +309,9 @@ const NovelScreenList = ({
cover: newCover,
});
}
};
}, [novel, setNovel]);
const saveNovelCover = async () => {
const saveNovelCover = useCallback(async () => {
if (!novel) {
showToast(getString('novelScreen.coverNotSaved'));
return;
@@ -283,7 +339,6 @@ const NovelScreenList = ({
? imageExtension
: 'png';
// sanitize novel name as app crashes while copying file with ':' character
const novelName = novel.name.replace(/[^a-zA-Z0-9]/g, '_');
const fileName = `${novelName}_${novel.id}.${imageExtension}`;
const coverDestUri = await StorageAccessFramework.createFileAsync(
@@ -307,10 +362,124 @@ const NovelScreenList = ({
FileManager.unlink(tempCoverUri);
}
}
};
}, [novel]);
const onFabPress = useCallback(() => {
const chapter = lastRead ?? firstUnreadChapter;
if (chapter) {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: { novel, chapter },
});
}
}, [lastRead, firstUnreadChapter, novel, navigation]);
const hasMultiplePages = pages.length > 1 || (novel?.totalPages ?? 0) > 1;
const openPageNavDrawer = useCallback(
() => pageNavigationSheetRef.current?.present(),
[],
);
// --- Memoized list components ---
const paginationControl = useMemo(() => {
if (!hasMultiplePages) {
return null;
}
return (
<View>
<PagePaginationControl
pages={pages}
currentPageIndex={pageIndex}
onPageChange={openPage}
onOpenDrawer={openPageNavDrawer}
theme={theme}
/>
</View>
);
}, [hasMultiplePages, pages, pageIndex, openPage, openPageNavDrawer, theme]);
const listEmptyComponent = useMemo(
() => (fetching ? <ChapterListSkeleton /> : null),
[fetching],
);
const listHeader = useMemo(
() => (
<>
<NovelInfoHeader
chapters={chapters}
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
fetching={fetching}
filter={filter}
firstUnreadChapter={firstUnreadChapter}
isLoading={loading}
lastRead={lastRead}
navigateToChapter={navigateToChapter}
navigation={navigation}
novel={novel}
novelBottomSheetRef={novelBottomSheetRef}
onRefreshPage={onRefreshPage}
page={pages.length > 1 ? pages[pageIndex] : undefined}
pageIndex={pageIndex}
pages={pages}
pageNavigationSheetRef={pageNavigationSheetRef}
setCustomNovelCover={setCustomNovelCover}
saveNovelCover={saveNovelCover}
theme={theme}
totalChapters={batchInformation.totalChapters}
trackerSheetRef={trackerSheetRef}
/>
{paginationControl}
</>
),
[
chapters,
deleteDownloadsSnackbar,
fetching,
filter,
firstUnreadChapter,
loading,
lastRead,
navigateToChapter,
navigation,
novel,
onRefreshPage,
pages,
pageIndex,
setCustomNovelCover,
saveNovelCover,
theme,
batchInformation.totalChapters,
paginationControl,
],
);
const scrollToTopFabStyle = useMemo(
() => [
styles.scrollToTopFab,
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
],
[theme.surface2, bottomInset],
);
const continueFabStyle = useMemo(
() => [
styles.fab,
{ backgroundColor: theme.primary, marginBottom: bottomInset },
],
[theme.primary, bottomInset],
);
const continueFabLabel = useMemo(
() =>
lastRead
? getString('common.resume')
: getString('novelScreen.startReadingChapters', { name: '' }).trim(),
[lastRead],
);
return (
<>
<LegendList
@@ -318,91 +487,40 @@ const NovelScreenList = ({
estimatedItemSize={64}
data={chapters}
recycleItems
extraData={[
chapters.length,
selected.length,
novel.id,
loading,
downloadQueue.length,
]}
// ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={
!fetching ? (
hasMultiplePages ? (
<View>
<PagePaginationControl
currentPage={pageIndex + 1}
totalPages={pages.length}
onPageChange={pageNum => openPage(pageNum - 1)}
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
theme={theme}
/>
</View>
) : undefined
) : (
ListEmptyComponent
)
}
renderItem={({ item, index }) => {
ListEmptyComponent={listEmptyComponent}
renderItem={({ item }) => {
if (novel.id === 'NO_ID') {
return null;
}
return (
<ChapterItem
isDownloading={downloadQueue.some(
c => c.task.data.chapterId === item.id,
)}
chapter={item}
isDownloading={downloadingChapterIds.has(item.id)}
isBookmarked={!!item.bookmark}
isLocal={novel.isLocal}
theme={theme}
chapter={item}
showChapterTitles={showChapterTitles}
deleteChapter={() => deleteChapter(item)}
downloadChapter={() => downloadChapter(novel, item)}
isSelected={isSelected(item.id)}
isSelected={selectedIds.has(item.id)}
novelName={novel.name}
onDeleteChapter={handleDeleteChapter}
onDownloadChapter={handleDownloadChapter}
onSelectPress={onSelectPress}
onSelectLongPress={onSelectLongPress}
navigateToChapter={navigateToChapter}
novelName={novel.name}
setChapterDownloaded={(value: boolean) =>
updateChapter?.(index, { isDownloaded: value })
}
/>
);
}}
keyExtractor={item => 'c' + item.id}
keyExtractor={chapterKeyExtractor}
extraData={downloadingChapterIds}
contentContainerStyle={styles.contentContainer}
refreshControl={refreshControl()}
refreshControl={refreshControlElement}
onEndReached={getNextChapterBatch}
onEndReachedThreshold={6}
onScroll={onPageScroll}
scrollEventThrottle={16}
drawDistance={1000}
ListHeaderComponent={
<NovelInfoHeader
chapters={chapters}
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
fetching={fetching}
filter={filter}
isLoading={loading}
lastRead={lastRead}
navigateToChapter={navigateToChapter}
navigation={navigation}
novel={novel}
novelBottomSheetRef={novelBottomSheetRef}
onRefreshPage={onRefreshPage}
page={pages.length > 1 ? pages[pageIndex] : undefined}
pageIndex={pageIndex}
pages={pages}
pageNavigationSheetRef={pageNavigationSheetRef}
setCustomNovelCover={setCustomNovelCover}
saveNovelCover={saveNovelCover}
theme={theme}
totalChapters={batchInformation.totalChapters}
trackerSheetRef={trackerSheetRef}
/>
}
ListHeaderComponent={listHeader}
/>
{novel.id !== 'NO_ID' && (
{novel.id !== 'NO_ID' ? (
<>
<NovelBottomSheet
bottomSheetRef={novelBottomSheetRef}
@@ -423,12 +541,9 @@ const NovelScreenList = ({
openPage={openPage}
/>
) : null}
{showScrollToTop && (
{showScrollToTop ? (
<AnimatedFAB
style={[
styles.scrollToTopFab,
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
]}
style={scrollToTopFabStyle}
color={theme.primary}
icon="arrow-up"
label=""
@@ -436,40 +551,24 @@ const NovelScreenList = ({
onPress={scrollToTop}
visible={showScrollToTop}
/>
)}
{useFabForContinueReading && (lastRead || chapters[0]) ? (
) : null}
{useFabForContinueReading && (lastRead || firstUnreadChapter) ? (
<AnimatedFAB
style={[
styles.fab,
{ backgroundColor: theme.primary, marginBottom: bottomInset },
]}
style={continueFabStyle}
extended={isFabExtended && !loading}
color={theme.onPrimary}
uppercase={false}
label={
lastRead
? getString('common.resume')
: getString('novelScreen.startReadingChapters', {
name: '',
}).trim()
}
label={continueFabLabel}
icon="play"
onPress={() => {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: {
novel: novel,
chapter: lastRead ?? chapters[0],
},
});
}}
onPress={onFabPress}
/>
) : null}
</>
)}
) : null}
</>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
contentContainer: { paddingBottom: 100 },
@@ -34,12 +34,10 @@ export default function PageNavigationBottomSheet({
<View
style={[
styles.pageItemContainer,
{
backgroundColor: isSelected
? theme.isDark
? color(theme.primary).alpha(0.2).string()
: color(theme.primaryContainer).alpha(0.5).string()
: 'transparent',
isSelected && {
backgroundColor: theme.isDark
? color(theme.primary).alpha(0.2).string()
: color(theme.primaryContainer).alpha(0.5).string(),
},
]}
>
@@ -83,8 +81,8 @@ export default function PageNavigationBottomSheet({
styles.contentContainer,
{
backgroundColor: overlay(2, theme.surface),
marginLeft: left,
marginRight: right,
marginStart: left,
marginEnd: right,
paddingBottom: insets.bottom,
},
]}
@@ -95,6 +93,7 @@ export default function PageNavigationBottomSheet({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `page_${index}_${item}`}
estimatedItemSize={56}
contentContainerStyle={styles.listContent}
/>
@@ -7,72 +7,79 @@ import { Row } from '@components/Common';
import { borderColor } from '@theme/colors';
interface PagePaginationControlProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
pages: string[];
currentPageIndex: number;
onPageChange: (pageIndex: number) => void;
onOpenDrawer: () => void;
theme: ThemeColors;
}
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
currentPage,
totalPages,
pages,
currentPageIndex,
onPageChange,
onOpenDrawer,
theme,
}) => {
const pageNumbers = useMemo(() => {
const pages: (number | 'ellipsis')[] = [];
const totalPages = pages.length;
const pageIndices = useMemo(() => {
const indices: (number | 'ellipsis')[] = [];
if (totalPages <= 3) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
for (let i = 0; i < totalPages; i++) {
indices.push(i);
}
} else {
if (currentPage !== 1) {
pages.push(1);
// Always show first page if not current
if (currentPageIndex !== 0) {
indices.push(0);
}
const leftPage = currentPage - 1;
if (leftPage > 1) {
if (leftPage > 2) {
pages.push('ellipsis');
// Show page before current (with ellipsis if there's a gap)
const leftPageIndex = currentPageIndex - 1;
if (leftPageIndex > 0) {
if (leftPageIndex > 1) {
indices.push('ellipsis');
}
pages.push(leftPage);
}
pages.push(currentPage);
// Always show current page
indices.push(currentPageIndex);
if (currentPage < totalPages - 1) {
pages.push('ellipsis');
// Show ellipsis after current only if there's a gap to last page
const rightPageIndex = currentPageIndex + 1;
if (rightPageIndex < totalPages - 1) {
indices.push('ellipsis');
}
if (totalPages !== currentPage) {
pages.push(totalPages);
// Always show last page if not current
if (currentPageIndex !== totalPages - 1) {
indices.push(totalPages - 1);
}
}
return pages;
}, [currentPage, totalPages]);
return indices;
}, [currentPageIndex, totalPages]);
const canGoPrevious = currentPage > 1;
const canGoNext = currentPage < totalPages;
const canGoPrevious = currentPageIndex > 0;
const canGoNext = currentPageIndex < totalPages - 1;
const handlePrevious = () => {
if (canGoPrevious) {
onPageChange(currentPage - 1);
onPageChange(currentPageIndex - 1);
}
};
const handleNext = () => {
if (canGoNext) {
onPageChange(currentPage + 1);
onPageChange(currentPageIndex + 1);
}
};
const handlePagePress = (page: number) => {
if (page !== currentPage) {
onPageChange(page);
const handlePagePress = (pageIndex: number) => {
if (pageIndex !== currentPageIndex) {
onPageChange(pageIndex);
}
};
@@ -101,8 +108,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
</Pressable>
<Row style={styles.pageNumbersRow}>
{pageNumbers.map((page, index) => {
if (page === 'ellipsis') {
{pageIndices.map((pageIndex, index) => {
if (pageIndex === 'ellipsis') {
return (
<Pressable
key={`ellipsis-${index}`}
@@ -124,18 +131,19 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
);
}
const isActive = page === currentPage;
const isActive = pageIndex === currentPageIndex;
const pageName = pages[pageIndex];
return (
<Pressable
key={page}
key={`page-${pageIndex}`}
style={[
styles.button,
{
borderColor: isActive ? 'transparent' : borderColor,
backgroundColor: isActive ? theme.primary : theme.surface,
},
isActive ? styles.activeButton : styles.inactiveButton,
]}
onPress={() => handlePagePress(page)}
onPress={() => handlePagePress(pageIndex)}
android_ripple={{
color: isActive
? color(theme.onPrimary).alpha(0.2).string()
@@ -147,11 +155,12 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
styles.pageText,
{
color: isActive ? theme.onPrimary : theme.onSurface,
fontWeight: isActive ? '600' : '400',
},
isActive ? styles.activePageText : styles.inactivePageText,
]}
numberOfLines={1}
>
{page}
{pageName}
</Text>
</Pressable>
);
@@ -194,13 +203,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
},
container: {
flex: 1,
alignItems: 'center',
flexDirection: 'row',
gap: 8,
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 12,
marginBottom: 16,
},
disabledButton: {
opacity: 0.5,
@@ -220,12 +228,28 @@ const styles = StyleSheet.create({
},
pageNumbersRow: {
alignItems: 'center',
justifyContent: 'space-evenly',
flex: 1,
flexDirection: 'row',
flexShrink: 1,
gap: 8,
},
pageText: {
fontSize: 15,
letterSpacing: 0.15,
maxWidth: 80,
},
activeButton: {
borderColor: 'transparent',
},
inactiveButton: {
borderColor: borderColor,
},
activePageText: {
fontWeight: '600',
},
inactivePageText: {
fontWeight: '400',
},
});
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { Dimensions, FlatList, StyleSheet, Text, View } from 'react-native';
import { Divider, Portal } from 'react-native-paper';
import { NavigationProp, useNavigation } from '@react-navigation/native';
@@ -61,6 +61,7 @@ const SetCategoryModal: React.FC<SetCategoryModalProps> = ({
</Text>
<FlatList
data={categories}
style={styles.categoryList}
renderItem={({ item }) => (
<Checkbox
status={
@@ -127,6 +128,9 @@ const SetCategoryModal: React.FC<SetCategoryModalProps> = ({
export default SetCategoryModal;
const styles = StyleSheet.create({
categoryList: {
maxHeight: Dimensions.get('window').height * 0.4,
},
divider: { height: 1, width: '90%', marginLeft: '5%' },
btnContainer: {
flexDirection: 'row',
@@ -6,6 +6,7 @@ import { RadioButton, RadioButtonGroup } from '@components/RadioButton';
import { useTheme } from '@hooks/persisted';
import {
getAniListScoreFormatting,
getKitsuScoreFormatting,
getMyAnimeListScoreLabel,
} from './constants';
import {
@@ -141,6 +142,41 @@ export const AniListScoreSelector: React.FC<AniListScoreSelectorProps> = ({
);
};
/**
* Kitsu uses a 0.5-10 scale (displayed as half-increments).
* Score values: 0 (no score), 1 (0.5), 2 (1.0), 3 (1.5), ... 20 (10.0)
*/
export const KitsuScoreSelector: React.FC<ScoreSelectorProps> = ({
trackItem,
onUpdateScore,
}) => {
const theme = useTheme();
const formatting = getKitsuScoreFormatting();
const scores = Array.from({ length: formatting.count }, (_, i) => i);
const handleValueChange = (value: string) => {
onUpdateScore(Number(value));
};
return (
<ScrollView>
<RadioButtonGroup
onValueChange={handleValueChange}
value={trackItem.score}
>
{scores.map(score => (
<RadioButton
key={score}
value={score}
label={formatting.label(score)}
theme={theme}
/>
))}
</RadioButtonGroup>
</ScrollView>
);
};
const styles = StyleSheet.create({
errorText: {
fontSize: 12,
@@ -5,6 +5,7 @@ import { Button, DialogTitle, Modal } from '@components';
import { getString } from '@strings/translations';
import {
AniListScoreSelector,
KitsuScoreSelector,
MangaUpdatesScoreSelector,
MyAnimeListScoreSelector,
} from './ScoreSelectors';
@@ -46,6 +47,13 @@ const SetTrackScoreDialog: React.FC<TrackScoreDialogProps> = ({
onUpdateScore={setSelectedScore}
/>
);
case 'Kitsu':
return (
<KitsuScoreSelector
trackItem={{ ...trackItem, score: selectedScore }}
onUpdateScore={setSelectedScore}
/>
);
case 'AniList':
default:
return (
@@ -108,7 +108,7 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
</TouchableRipple>
);
},
[selectedNovel, handleSelectNovel],
[selectedNovel, handleSelectNovel, theme.rippleColor, theme.primary, theme.onSurface],
);
return (
@@ -45,11 +45,14 @@ const TrackSheet: React.FC<TrackSheetProps> = ({ bottomSheetRef, novel }) => {
bottomSheetRef.current?.close();
}, [bottomSheetRef]);
const handleSetSearchTrackDialog = useCallback((tracker: TrackerMetadata) => {
closeBottomSheet();
setActiveTracker(tracker);
setTrackSearchDialog(true);
}, []);
const handleSetSearchTrackDialog = useCallback(
(tracker: TrackerMetadata) => {
closeBottomSheet();
setActiveTracker(tracker);
setTrackSearchDialog(true);
},
[closeBottomSheet],
);
const handleSetStatusDialog = useCallback(
(tracker: TrackerMetadata) => {
@@ -3,7 +3,10 @@ import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { IconButton } from 'react-native-paper';
import { useTheme } from '@hooks/persisted';
import { getAniListScoreFormatting } from './constants';
import {
getAniListScoreFormatting,
getKitsuScoreFormatting,
} from './constants';
import { AddTrackingCardProps, TrackedItemCardProps } from './types';
export const AddTrackingCard: React.FC<AddTrackingCardProps> = ({
@@ -60,12 +63,16 @@ export const TrackedItemCard: React.FC<TrackedItemCardProps> = ({
}
if (tracker.name === 'MangaUpdates') {
// Show decimal for MangaUpdates if it has decimal places
/* Show decimal for MangaUpdates if it has decimal places */
return Number.isInteger(trackItem.score)
? trackItem.score.toString()
: trackItem.score.toFixed(1);
}
if (tracker.name === 'Kitsu') {
return getKitsuScoreFormatting().label(trackItem.score);
}
return trackItem.score;
}, [tracker, trackItem.score]);
@@ -7,6 +7,7 @@ export const TRACKER_ICONS: Record<TrackerName, ImageSourcePropType> = {
MyAnimeList: require('../../../../../assets/mal.png'),
MangaUpdates: require('../../../../../assets/mangaupdates.png'),
AniList: require('../../../../../assets/anilist.png'),
Kitsu: require('../../../../../assets/kitsu.png'),
};
export const STATUS_LABELS: Record<UserListStatus, string> = {
@@ -91,3 +92,25 @@ export const getAniListScoreFormatting = (
};
}
};
/**
* Kitsu uses a 0.5-10 scale (displayed as half-increments).
* Internally stored as ratingTwenty (2-20), displayed as ratingTwenty/2.
* This provides 21 options: 0 (no score), 0.5, 1.0, 1.5, ... 10.0
*/
export const getKitsuScoreFormatting = (): ScoreFormatting => {
return {
count: 21,
label: score => {
if (score === 0) {
return '-';
}
/* Convert index to Kitsu's half-point scale (0.5-10) */
const displayScore = score / 2;
return displayScore.toLocaleString(undefined, {
minimumFractionDigits: displayScore % 1 === 0 ? 0 : 1,
maximumFractionDigits: 1,
});
},
};
};
+1 -1
View File
@@ -122,7 +122,7 @@ export const ChapterContent = ({
}
return (
<View
style={[{ paddingLeft: left, paddingRight: right }, styles.container]}
style={[{ paddingStart: left, paddingEnd: right }, styles.container]}
>
{keepScreenOn ? <KeepScreenAwake /> : null}
{loading ? (
@@ -16,6 +16,7 @@ import renderListChapter from './RenderListChapter';
import { useChapterContext } from '@screens/reader/ChapterContext';
import { useNovelContext } from '@screens/novel/NovelContext';
import { LegendList, LegendListRef, ViewToken } from '@legendapp/list';
import { noop } from 'lodash-es';
type ButtonProperties = {
text: string;
@@ -29,7 +30,15 @@ type ButtonsProperties = {
const ChapterDrawer = () => {
const { chapter, getChapter, setLoading } = useChapterContext();
const { chapters, novelSettings, pages, setPageIndex } = useNovelContext();
const {
chapters,
novelSettings,
pages,
fetching,
batchInformation,
getNextChapterBatch,
setPageIndex,
} = useNovelContext();
const theme = useTheme();
const insets = useSafeAreaInsets();
const { defaultChapterSort } = useAppSettings();
@@ -89,9 +98,9 @@ const ChapterDrawer = () => {
const newBtnLayout = Object.create(defaultButtonLayout);
if (viewableItems.length === 0) return;
const cKey = (scrollToIndex.current ?? 0) + 2;
const vKey = parseInt(viewableItems[0].key, 10);
const visible = vKey <= cKey && cKey <= vKey + viewableItems.length - 1;
const visible = viewableItems
.map(v => v.index)
.includes((scrollToIndex.current ?? 0) + 2);
if (!visible && scrollToIndex.current !== undefined) {
if (
@@ -110,12 +119,7 @@ const ChapterDrawer = () => {
};
}
}
if (cKey <= 2 && vKey <= 4) {
newBtnLayout.up = {
text: curChapter,
index: scrollToIndex.current,
};
}
setButtonProperties(newBtnLayout);
},
[defaultButtonLayout, listAscending],
@@ -136,7 +140,10 @@ const ChapterDrawer = () => {
useEffect(() => {
const next = calculateScrollToIndex();
if (next !== undefined) {
if (scrollToIndex.current === undefined) {
if (
scrollToIndex.current === undefined ||
next !== scrollToIndex.current
) {
scroll(next);
}
scrollToIndex.current = next;
@@ -154,12 +161,14 @@ const ChapterDrawer = () => {
recycleItems
viewabilityConfig={{
minimumViewTime: 100,
viewAreaCoveragePercentThreshold: 95,
itemVisiblePercentThreshold: 90,
}}
onViewableItemsChanged={checkViewableItems}
data={chapters}
extraData={[chapter, scrollToIndex.current]}
keyExtractor={item => (item.position ?? item.id).toString()}
keyExtractor={item =>
`chapter_${item.id}_${item.position ?? 'no_pos'}`
}
renderItem={val =>
renderListChapter({
item: val.item,
@@ -174,6 +183,12 @@ const ChapterDrawer = () => {
}
estimatedItemSize={60}
initialScrollIndex={scrollToIndex.current}
onEndReached={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
onEndReachedThreshold={6}
/>
)}
<View style={styles.footer}>
@@ -220,8 +235,7 @@ const createStylesheet = (theme: ThemeColors, insets: EdgeInsets) => {
drawerElementContainer: {
borderRadius: 50,
margin: 4,
marginLeft: 16,
marginRight: 16,
marginHorizontal: 16,
minHeight: 48,
overflow: 'hidden',
},
@@ -128,7 +128,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
bookmark: {
marginRight: 4,
marginEnd: 4,
},
container: {
flex: 1,

Some files were not shown because too many files have changed in this diff Show More