Compare commits

..

1 Commits

Author SHA1 Message Date
Rajarshee Chatterjee 1e478b2cc8 chore: Release v2.0.1
[skip ci]
2025-11-15 14:52:54 +05:30
163 changed files with 6574 additions and 13574 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.3"
Example: "2.0.1"
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 plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/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.3](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.1](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.3](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.1](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,7 +88,3 @@ 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 = 3
versionPatch = 0
}
android {
+20 -149
View File
@@ -140,10 +140,6 @@ 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;
@@ -173,41 +169,22 @@ window.tts = new (function () {
};
// if can find a readable node, else stop tts
// 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;
}
this.findNextTextNode = () => {
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;
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;
this.currentElement = this.currentElement.parentElement;
}
return this.findNextTextNode();
} else {
// can read? read it
if (this.readable()) {
@@ -220,117 +197,50 @@ 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;
return this.findNextTextNode(depth + 1);
} else if (
this.currentElement.parentElement &&
!this.currentElement.parentElement.isSameNode(document.body) &&
!this.currentElement.parentElement.isSameNode(document.documentElement)
) {
} else {
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');
// 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 (this.findNextTextNode()) {
const text = this.normalizeText(this.currentElement?.innerText);
if (text) {
// Found valid text - speak it
this.currentElement = nextElement;
this.reading = true;
this.elementsRead++;
this.speak();
return;
} else {
// Empty text, skip to next in array (no recursion!)
this.elementsRead++;
this.next();
}
}
// 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) {
this.stop();
alert('TTS Error: ' + e.message);
alert(e);
}
};
this.start = element => {
this.stop();
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.currentElement = element ?? reader.chapterElement;
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 (
@@ -358,16 +268,7 @@ 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 => {
@@ -386,7 +287,6 @@ 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)
@@ -401,27 +301,11 @@ window.tts = new (function () {
// Only scroll if element is not visible or barely visible
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
// 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',
});
}
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
};
@@ -432,11 +316,7 @@ window.tts = new (function () {
this.currentElement.classList.add('highlight');
const text = this.normalizeText(this.currentElement.innerText);
if (text) {
reader.post({
type: 'speak',
data: text,
index: this.elementsRead - 1,
});
reader.post({ type: 'speak', data: text });
reader.post({ type: 'tts-state', data: { isReading: true } });
} else {
this.next();
@@ -444,15 +324,6 @@ 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);
+2 -14
View File
@@ -264,10 +264,6 @@ 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');
@@ -307,8 +303,7 @@ const TTSController = () => {
top = reader.layoutHeight - 120;
}
controllerElement.style.top = `${top}px`;
// Check if TTS is still enabled before starting
if (hoverElement && reader.generalSettings.val.TTSEnable) {
if (hoverElement) {
tts.start(hoverElement);
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
@@ -318,18 +313,11 @@ 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 if (tts.started) {
tts.resume();
controllerElement.firstElementChild.innerHTML = pauseIcon;
} else {
tts.start();
tts.resume();
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
},
@@ -74,21 +74,13 @@ class NativeFile(context: ReactApplicationContext) :
}
override fun writeFile(path: String, content: String) {
try {
val fw = FileWriter(path)
fw.write(content)
fw.close()
} catch (e: IOException) {
throw Exception("Failed to write file '$path': ${e.message}")
}
val fw = FileWriter(path)
fw.write(content)
fw.close()
}
override fun readFile(path: String): String {
val file = File(path)
if (!file.exists()) {
throw Exception("File not found: '$path'")
}
return file.bufferedReader().readText()
return File(path).bufferedReader().readText()
}
override fun copyFile(filepath: String, destPath: String) {
@@ -105,27 +97,17 @@ class NativeFile(context: ReactApplicationContext) :
destPath: String,
onDone: (() -> Unit)? = null,
) {
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}")
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()
}
}
@@ -209,21 +191,18 @@ class NativeFile(context: ReactApplicationContext) :
}
override fun onResponse(call: Call, response: Response) {
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)
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)
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
})
+1 -9
View File
@@ -11,15 +11,7 @@
<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>
</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>
<item name="colorControlActivated">#2596be</item>
</style>
<style name="SplashScreenTheme" parent="SplashScreen_SplashTheme">
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

-6
View File
@@ -1,11 +1,5 @@
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';
+41 -43
View File
@@ -1,6 +1,6 @@
{
"name": "lnreader",
"version": "2.0.3",
"version": "2.0.1",
"private": true,
"scripts": {
"dev:android": "pnpm run generate:env:debug && react-native run-android --appIdSuffix \"debug\" --active-arch-only",
@@ -41,86 +41,84 @@
"dependencies": {
"@cd-z/react-native-epub-creator": "^3.0.0",
"@gorhom/bottom-sheet": "^5.2.6",
"@legendapp/list": "^2.0.16",
"@noble/ciphers": "^2.1.1",
"@react-native-community/slider": "^5.1.1",
"@legendapp/list": "^2.0.14",
"@react-native-community/slider": "^5.0.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.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",
"@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",
"@shopify/flash-list": "2.0.2",
"cheerio": "1.0.0-rc.12",
"color": "^5.0.3",
"dayjs": "^1.11.19",
"expo": "^54.0.25",
"color": "^5.0.0",
"dayjs": "^1.11.18",
"expo": "^54.0.2",
"expo-clipboard": "~8.0.7",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.19",
"expo-file-system": "~19.0.12",
"expo-haptics": "~15.0.7",
"expo-keep-awake": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.9",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.9",
"expo-notifications": "~0.32.13",
"expo-navigation-bar": "~5.0.8",
"expo-notifications": "~0.32.11",
"expo-speech": "~14.0.7",
"expo-sqlite": "~16.0.9",
"expo-web-browser": "~15.0.9",
"expo-sqlite": "~16.0.8",
"expo-web-browser": "~15.0.7",
"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.5",
"react-native": "^0.81.4",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^14.1.1",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.2.0",
"react-native-drawer-layout": "^4.1.13",
"react-native-edge-to-edge": "^1.7.0",
"react-native-error-boundary": "^2.0.0",
"react-native-error-boundary": "^2.0.1",
"react-native-file-access": "^3.2.0",
"react-native-gesture-handler": "^2.29.1",
"react-native-gesture-handler": "^2.28.0",
"react-native-lottie-splash-screen": "^1.1.2",
"react-native-mmkv": "^3.3.3",
"react-native-mmkv": "^3.3.1",
"react-native-pager-view": "^6.9.1",
"react-native-paper": "^5.14.5",
"react-native-reanimated": "^4.1.5",
"react-native-reanimated": "^4.1.0",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.18.0",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "^4.16.0",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^4.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-tab-view": "^4.1.3",
"react-native-url-polyfill": "^2.0.1",
"react-native-webview": "13.15.0",
"react-native-worklets": "^0.6.1",
"react-native-worklets": "^0.5.1",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.0",
"urlencode": "^2.0.0"
"urlencode": "^2.0.1"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/core": "^7.28.4",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/preset-env": "^7.28.5",
"@babel/preset-env": "^7.28.3",
"@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.5",
"@react-native/eslint-config": "^0.81.5",
"@react-native/metro-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@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",
"@types/color": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/react": "~19.1.17",
"@types/react": "~19.1.12",
"@types/sanitize-html": "^2.16.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -129,7 +127,7 @@
"lint-staged": "^12.5.0",
"prettier": "2.8.8",
"react-native-dotenv": "^3.4.11",
"typescript": "~5.9.3"
"typescript": "~5.9.2"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
+2825 -6171
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -73,8 +73,10 @@ export const Actionbar: React.FC<ActionbarProps> = ({
const styles = StyleSheet.create({
actionbarContainer: {
alignItems: 'center',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
bottom: 0,
elevation: 0,
elevation: 1,
flexDirection: 'row',
justifyContent: 'space-around',
position: 'absolute',
@@ -10,7 +10,6 @@ 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'> {
@@ -19,33 +18,6 @@ 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,
@@ -57,7 +29,6 @@ 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} />
@@ -90,11 +61,6 @@ 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
@@ -1,166 +0,0 @@
/* 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: {
marginStart: 12,
marginLeft: 12,
},
icon: {
alignSelf: 'center',
start: 24,
left: 24,
position: 'absolute',
},
pressable: {
@@ -98,5 +98,5 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 6,
},
sortItem: { paddingVertical: 16, paddingStart: 64 },
sortItem: { paddingVertical: 16, paddingLeft: 64 },
});
+7 -4
View File
@@ -2,6 +2,7 @@ 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;
@@ -13,7 +14,9 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
style={[
styles.chipContainer,
{
backgroundColor: theme.secondaryContainer,
backgroundColor: theme.isDark
? overlay(1, theme.surface)
: theme.secondaryContainer,
},
]}
>
@@ -25,7 +28,7 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
style={[
styles.label,
{
color: theme.onSecondaryContainer,
color: theme.isDark ? theme.onSurface : theme.onSecondaryContainer,
},
]}
>
@@ -41,11 +44,11 @@ const styles = StyleSheet.create({
chipContainer: {
borderRadius: 8,
height: 32,
marginEnd: 8,
marginRight: 8,
overflow: 'hidden',
},
label: {
fontSize: 14,
fontSize: 12,
},
pressable: {
alignItems: 'center',
@@ -37,7 +37,7 @@ const styles = StyleSheet.create({
colorPreview: {
borderRadius: 50,
height: 24,
marginEnd: 16,
marginRight: 16,
width: 24,
},
container: {
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
shadowColor: 'transparent',
},
content: {
fontSize: 14,
fontSize: 16,
letterSpacing: 0,
},
});
+1 -5
View File
@@ -41,11 +41,7 @@ const IconButton: React.FC<Props> = ({
<MaterialCommunityIcons
name={name}
size={size}
color={
disabled
? Color(theme.onSurface).alpha(0.38).string()
: color || theme.onSurface
}
color={disabled ? theme.outline : color || theme.onSurface}
/>
</Pressable>
</View>
+9 -10
View File
@@ -33,7 +33,7 @@ const SubHeader = ({
children: ReactNode;
theme: ThemeColors;
}) => (
<PaperList.Subheader style={{ color: theme.onSurfaceVariant }}>
<PaperList.Subheader style={{ color: theme.primary }}>
{children}
</PaperList.Subheader>
);
@@ -57,7 +57,7 @@ const Item: React.FC<ListItemProps> = ({
/>
);
}
}, [icon, theme.onSurfaceVariant]);
}, [icon, theme.primary]);
const rightIcon = useCallback(() => {
if (right) {
return (
@@ -68,7 +68,7 @@ const Item: React.FC<ListItemProps> = ({
/>
);
}
}, [right, theme.onSurfaceVariant]);
}, [right, theme.primary]);
return (
<PaperList.Item
title={title}
@@ -93,9 +93,7 @@ const Item: React.FC<ListItemProps> = ({
};
const Divider = ({ theme }: { theme: ThemeColors }) => (
<PaperDivider
style={[styles.divider, { backgroundColor: theme.outlineVariant }]}
/>
<PaperDivider style={[styles.divider, { backgroundColor: theme.outline }]} />
);
const InfoItem = ({
@@ -111,7 +109,7 @@ const InfoItem = ({
<View style={[styles.infoCtn, style]}>
<MaterialIcon
size={20}
color={theme.primary}
color={theme.onSurfaceVariant}
name={'information-outline'}
/>
<Text style={[styles.infoMsg, { color: theme.onSurfaceVariant }]}>
@@ -173,17 +171,18 @@ const styles = StyleSheet.create({
height: 24,
width: 24,
borderRadius: 50,
marginEnd: 16,
marginRight: 16,
},
description: {
fontSize: 14,
fontSize: 12,
lineHeight: 20,
},
divider: {
height: 1,
opacity: 0.5,
},
iconCtn: {
paddingStart: 16,
paddingLeft: 16,
},
infoCtn: {
paddingHorizontal: 16,
+2 -2
View File
@@ -88,7 +88,7 @@ const styles = StyleSheet.create({
flex: 1,
flexWrap: 'wrap',
fontSize: 15,
marginStart: 16,
paddingEnd: 8,
marginLeft: 16,
paddingRight: 8,
},
});
-260
View File
@@ -1,260 +0,0 @@
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;
+3 -7
View File
@@ -62,8 +62,7 @@ interface INovelCover<TNovel> {
addSkeletonLoading?: boolean;
inActivity?: boolean;
onLongPress: (item: TNovel) => void;
hasSelection?: boolean;
selectedNovelIds?: number[];
selectedNovelIds: number[];
globalSearch?: boolean;
imageRequestInit?: ImageRequestInit;
}
@@ -85,13 +84,10 @@ 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,
@@ -166,7 +162,7 @@ function NovelCover<
android_ripple={{ color: theme.rippleColor }}
style={styles.opac}
onPress={
selectionActive
selectedNovelIds && selectedNovelIds.length > 0
? selectNovel
: onPress
}
@@ -247,7 +243,7 @@ function NovelCover<
inLibraryBadge={libraryStatus && <InLibraryBadge theme={theme} />}
theme={theme}
onPress={
selectionActive ? selectNovel : onPress
selectedNovelIds && selectedNovelIds.length > 0 ? selectNovel : onPress
}
onLongPress={selectNovel}
isSelected={isSelected}
+1 -4
View File
@@ -23,9 +23,6 @@ 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();
@@ -77,7 +74,7 @@ const NovelList: React.FC<NovelListProps> = props => {
]}
numColumns={numColumns}
key={numColumns}
keyExtractor={novelListKeyExtractor}
keyExtractor={(item, index) => index + '_' + item.path}
{...props}
data={extendedNovelList}
/>
+1 -1
View File
@@ -57,6 +57,6 @@ const styles = StyleSheet.create({
},
radioButtonLabel: {
fontSize: 16,
marginStart: 16,
marginLeft: 16,
},
});
+2 -2
View File
@@ -48,11 +48,11 @@ export const RadioButton: React.FC<Props> = ({
const styles = StyleSheet.create({
icon: {
alignSelf: 'center',
start: 24,
left: 24,
position: 'absolute',
},
label: {
marginStart: 12,
marginLeft: 12,
},
pressable: {
alignItems: 'center',
+2 -2
View File
@@ -22,8 +22,8 @@ const SafeAreaView: React.FC<SafeAreaViewProps> = ({
},
padding: {
paddingBottom: excludeBottom ? 0 : bottom,
paddingStart: left,
paddingEnd: right,
paddingLeft: left,
paddingRight: right,
paddingTop: excludeTop ? 0 : top,
},
});
+10 -7
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 '@components';
import { Menu } from 'react-native-paper';
import { MaterialDesignIconName } from '@type/icon';
export interface RightIcon {
interface RightIcon {
iconName: MaterialDesignIconName;
color?: string;
onPress: () => void;
@@ -49,6 +49,8 @@ const Searchbar: React.FC<SearcbarProps> = ({
const [extraMenu, showExtraMenu] = useState(false);
const marginTop = 8;
const marginRight = 16;
const marginLeft = 16;
return (
<View
@@ -56,6 +58,8 @@ const Searchbar: React.FC<SearcbarProps> = ({
styles.searchbarContainer,
{
marginTop,
marginRight,
marginLeft,
backgroundColor: theme.surface2,
},
]}
@@ -82,7 +86,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
ref={searchbarRef}
style={[styles.textInput, { color: theme.onSurface }]}
placeholder={placeholder}
placeholderTextColor={theme.onSurfaceVariant}
placeholderTextColor={theme.onSurface}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
defaultValue={searchText}
@@ -108,6 +112,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
anchorPosition="bottom"
anchor={
<IconButtonV2
name="dots-vertical"
@@ -130,9 +135,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
}}
onPress={() => {
showExtraMenu(false);
setTimeout(() => {
button.onPress();
}, 0);
button.onPress();
}}
/>
))}
@@ -160,7 +163,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
},
searchbarContainer: {
borderRadius: 28,
borderRadius: 50,
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: 40,
height: 48,
},
segment: {
flex: 1,
@@ -99,16 +99,16 @@ const styles = StyleSheet.create({
segmentFirst: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
borderTopLeftRadius: 24,
borderBottomLeftRadius: 24,
},
segmentMiddle: {
borderRightWidth: 1,
},
segmentLast: {
borderRightWidth: 1,
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
borderTopRightRadius: 24,
borderBottomRightRadius: 24,
},
segmentPressable: {
flex: 1,
+41 -88
View File
@@ -10,118 +10,73 @@ 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, onValueChange, style }: SwitchProps) => {
const Switch = ({ value, size = 22, onValueChange, style }: SwitchProps) => {
const theme = useTheme();
// Value for Switch Animation
// const switchTranslate = useSharedValue(value ? size : size / 6);
const progress = useSharedValue(value ? 1 : 0);
// Background color animation progress
const progress = useSharedValue(value ? size : 0);
useEffect(() => {
progress.value = withTiming(value ? 1 : 0, { duration: 200 });
}, [progress, value]);
// Animate thumb position
const thumbPositionStyle = useAnimatedStyle(() => ({
// Animate switch movement
const customSpringStyles = useAnimatedStyle(() => ({
transform: [
{
translateX: withSpring(
value ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF,
{
mass: 1,
damping: 15,
stiffness: 120,
overshootClamping: false,
},
),
translateX: withSpring(value ? size : size / 6, {
mass: 1,
damping: 15,
stiffness: 120,
overshootClamping: false,
}),
},
],
}));
// 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(
// Precompute background color animation
const backgroundColor = useDerivedValue(
() =>
interpolateColor(
progress.value,
[0, 1],
[theme.surfaceVariant, theme.primary],
[0, size],
[theme.outline, theme.primary],
),
[theme.surfaceVariant, theme.primary],
[value],
);
const trackColorStyle = useAnimatedStyle(() => ({
backgroundColor: trackBgColor.value,
const backgroundColorStyle = useAnimatedStyle(() => ({
backgroundColor: backgroundColor.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,
}));
useEffect(() => {
progress.value = withTiming(value ? size : 0);
}, [progress, size, value]);
return (
<Pressable onPress={onValueChange}>
<Animated.View
style={[
styles.track,
styles.container,
style,
trackColorStyle,
trackBorderStyle,
{
width: size * 2 + size / 6,
height: size + size / 3,
borderRadius: size,
},
backgroundColorStyle,
]}
>
<Animated.View
style={[
styles.thumb,
thumbPositionStyle,
thumbSizeStyle,
thumbColorStyle,
styles.circle,
customSpringStyles,
{ height: size, width: size, borderRadius: size },
]}
/>
</Animated.View>
@@ -132,20 +87,18 @@ const Switch = ({ value, onValueChange, style }: SwitchProps) => {
export default React.memo(Switch);
const styles = StyleSheet.create({
thumb: {
elevation: 1,
circle: {
backgroundColor: 'white',
elevation: 4,
shadowColor: 'black',
shadowOffset: {
width: 0,
height: 1,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 1,
shadowOpacity: 0.2,
shadowRadius: 2.5,
},
track: {
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
borderRadius: TRACK_RADIUS,
container: {
justifyContent: 'center',
},
});
+5 -3
View File
@@ -16,6 +16,7 @@ interface SwitchItemProps {
description?: string;
onPress: () => void;
theme: ThemeColors;
size?: number;
style?: StyleProp<ViewStyle>;
}
@@ -25,6 +26,7 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
onPress,
theme,
value,
size,
style,
}) => (
<Pressable
@@ -44,6 +46,7 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
value={value}
onValueChange={onPress}
style={styles.switch}
size={size}
/>
</Pressable>
);
@@ -55,11 +58,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
description: {
fontSize: 14,
fontSize: 12,
lineHeight: 20,
},
label: {
@@ -70,6 +72,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
switch: {
marginStart: 8,
marginLeft: 8,
},
});
+4 -4
View File
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
checkIcon: {
position: 'absolute',
top: 5,
end: 5,
right: 5,
borderRadius: 50,
padding: 1.6,
zIndex: 1,
@@ -164,7 +164,7 @@ const styles = StyleSheet.create({
topBarAccent: {
width: 44,
height: 10,
marginStart: 8,
marginLeft: 8,
borderRadius: 50,
},
content: {
@@ -186,7 +186,7 @@ const styles = StyleSheet.create({
rowAccentSmall: {
height: 10,
width: 16,
marginStart: 4,
marginLeft: 4,
borderRadius: 50,
},
rowAccentShort: {
@@ -221,5 +221,5 @@ const styles = StyleSheet.create({
fontSize: 12,
paddingVertical: 4,
},
marginLeft: { marginStart: 4 },
marginLeft: { marginLeft: 4 },
});
-2
View File
@@ -18,5 +18,3 @@ 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';
+5 -19
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 2
* Sets up all tables, indexes, triggers and sets version to 1
*/
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 = 2');
db.execSync('PRAGMA user_version = 1');
});
};
@@ -65,23 +65,9 @@ export const initializeDatabase = () => {
db.execSync('PRAGMA cache_size = 10000');
db.execSync('PRAGMA foreign_keys = ON');
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;
}
const userVersion =
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0;
if (userVersion === 0) {
createInitialSchema();
+84 -101
View File
@@ -7,14 +7,10 @@ const columnExists = (
tableName: string,
columnName: string,
): boolean => {
try {
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
} catch {
return false;
}
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
};
/**
@@ -27,99 +23,86 @@ export const migration002: Migration = {
version: 2,
description: 'Add counter columns and triggers to Novel table',
migrate: db => {
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', 'chaptersDownloaded')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN chaptersDownloaded INTEGER DEFAULT 0
`);
}
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
)
`);
},
};
+33 -32
View File
@@ -1,7 +1,9 @@
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
@@ -17,7 +19,7 @@ const getCategoriesQuery = `
type NumberList = `${number}` | `${number},${number}` | undefined;
export const getCategoriesFromDb = () => {
return db.getAllSync<Category & { novelIds: NumberList }>(getCategoriesQuery);
return getAllSync<Category & { novelIds: NumberList }>([getCategoriesQuery]);
};
export const getCategoriesWithCount = (novelIds: number[]) => {
@@ -33,14 +35,13 @@ export const getCategoriesWithCount = (novelIds: number[]) => {
WHERE Category.id != 2
ORDER BY sort
`;
return db.getAllSync<CCategory>(getCategoriesWithCountQuery);
return getAllSync<CCategory>([getCategoriesWithCountQuery]);
};
const createCategoryQuery = 'INSERT INTO Category (name) VALUES (?)';
export const createCategory = (categoryName: string): void => {
db.runSync(createCategoryQuery, categoryName);
};
export const createCategory = (categoryName: string): void =>
runSync([[createCategoryQuery, [categoryName]]]);
const beforeDeleteCategoryQuery = `
UPDATE NovelCategory SET categoryId = (SELECT id FROM Category WHERE sort = 1)
@@ -57,8 +58,10 @@ export const deleteCategoryById = (category: Category): void => {
if (category.sort === 1 || category.id === 2) {
return showToast(getString('categories.cantDeleteDefault'));
}
db.runSync(beforeDeleteCategoryQuery, category.id);
db.runSync(deleteCategoryQuery, category.id);
runSync([
[beforeDeleteCategoryQuery, [category.id]],
[deleteCategoryQuery, [category.id]],
]);
};
const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
@@ -66,9 +69,7 @@ const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
export const updateCategory = (
categoryId: number,
categoryName: string,
): void => {
db.runSync(updateCategoryQuery, categoryName, categoryId);
};
): void => runSync([[updateCategoryQuery, [categoryName, categoryId]]]);
const isCategoryNameDuplicateQuery = `
SELECT COUNT(*) as isDuplicate FROM Category WHERE name = ?
@@ -91,31 +92,31 @@ export const updateCategoryOrderInDb = (categories: Category[]): void => {
if (categories.length && categories[0].id === 2) {
return;
}
for (const c of categories) {
db.runSync(updateCategoryOrderQuery, c.sort, c.id);
}
runSync(
categories.map(c => {
return [updateCategoryOrderQuery, [c.sort, c.id]];
}),
);
};
export const getAllNovelCategories = () =>
db.getAllSync<NovelCategory>('SELECT * FROM NovelCategory');
getAllSync<NovelCategory>(['SELECT * FROM NovelCategory']);
export const _restoreCategory = (category: BackupCategory) => {
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,
);
}
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,
]);
};
+32 -60
View File
@@ -22,13 +22,13 @@ export const insertChapters = async (
return;
}
await db.withTransactionAsync(async () => {
await db.withExclusiveTransactionAsync(async tx => {
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 db.runAsync(
const result = await tx.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 db.runAsync(
await tx.runAsync(
`
UPDATE Chapter SET
page = ?, position = ?, name = ?, releaseTime = ?, chapterNumber = ?
@@ -209,8 +209,8 @@ export const clearUpdates = () =>
// #region Selectors
export const getCustomPages = (novelId: number) =>
db.getAllAsync<{ page: string }>(
'SELECT DISTINCT page from Chapter WHERE novelId = ? ORDER BY CAST(page AS INTEGER) ASC',
db.getAllSync<{ page: string }>(
'SELECT DISTINCT page from Chapter WHERE novelId = ?',
novelId,
);
@@ -272,13 +272,11 @@ export const getPageChapters = (
);
};
export const getChapterCount = async (novelId: number, page: string = '1') =>
(
await db.getFirstAsync<{ 'COUNT(*)': number }>(
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
novelId,
page,
)
export const getChapterCount = (novelId: number, page: string = '1') =>
db.getFirstSync<{ 'COUNT(*)': number }>(
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
novelId,
page,
)?.['COUNT(*)'] ?? 0;
export const getPageChaptersBatched = (
@@ -288,56 +286,26 @@ export const getPageChaptersBatched = (
page?: string,
batch: number = 0,
) => {
return db.getAllAsync<ChapterInfo>(
return db.getAllSync<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 CAST(page AS INTEGER) < CAST(? AS INTEGER)
(position < ? AND page = ?)
OR page < ?
)
ORDER BY CAST(page AS INTEGER) DESC, position DESC`,
ORDER BY position DESC, page DESC`,
novelId,
chapterPosition,
page,
@@ -350,13 +318,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 CAST(page AS INTEGER) > CAST(? AS INTEGER))
(page = ? AND position > ?)
OR (position = 0 AND page > ?)
)
ORDER BY CAST(page AS INTEGER) ASC, position ASC`,
ORDER BY position ASC, page ASC`,
novelId,
page,
chapterPosition,
@@ -407,7 +375,7 @@ export const getUpdatedOverviewFromDb = () =>
Novel.name AS novelName,
Novel.cover AS novelCover,
Novel.path AS novelPath,
DATE(Chapter.updatedTime) AS updateDate,
DATE(Chapter.updatedTime) AS updateDate, -- Extract the date from updatedTime
COUNT(*) AS updatesPerDay
FROM
Chapter
@@ -419,10 +387,14 @@ WHERE
Chapter.updatedTime IS NOT NULL
GROUP BY
Novel.id,
DATE(Chapter.updatedTime)
Novel.name,
Novel.cover,
Novel.path,
DATE(Chapter.updatedTime) -- Group by date and novelId
ORDER BY
updateDate DESC,
novelId;
novelId,
updateDate;
`);
export const getDetailedUpdatesFromDb = async (
@@ -452,8 +424,8 @@ ORDER BY updatedTime DESC;
return await result;
};
export const isChapterDownloaded = async (chapterId: number) =>
!!(await db.getFirstAsync<ChapterInfo>(
export const isChapterDownloaded = (chapterId: number) =>
!!db.getFirstSync<ChapterInfo>(
'SELECT * FROM Chapter WHERE id = ? AND isDownloaded = 1',
chapterId,
));
);
+9 -7
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 db.getAllAsync<NovelInfo>(query, searchText ? `%${searchText}%` : '');
return getAllSync<NovelInfo>([query, [searchText ?? '']]);
};
const getNovelOfCategoryQuery =
'SELECT DISTINCT novelId FROM NovelCategory WHERE 1 = 1';
const getNovelsFromIDListQuery = 'SELECT * FROM Novel WHERE inLibrary = 1 ';
export const getLibraryWithCategory = async (
export const getLibraryWithCategory = (
categoryId?: number | null,
onlyUpdateOngoingNovels?: boolean,
excludeLocalNovels?: boolean,
): Promise<LibraryNovelInfo[]> => {
): LibraryNovelInfo[] => {
let categoryQuery = getNovelOfCategoryQuery;
if (categoryId) {
categoryQuery += ` AND categoryId = ${categoryId}`;
}
const idRows = await db.getAllAsync<{ novelId: number }>(categoryQuery);
const idRows = getAllSync<{ novelId: number }>([categoryQuery, []]);
if (!idRows || idRows.length === 0) return [];
@@ -66,5 +66,7 @@ export const getLibraryWithCategory = async (
novelQuery += " AND status = 'Ongoing'";
}
return db.getAllAsync<LibraryNovelInfo>(novelQuery);
const res = getAllSync<LibraryNovelInfo>([novelQuery, []]);
return res;
};
+171 -132
View File
@@ -4,6 +4,14 @@ 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';
@@ -19,20 +27,18 @@ 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 = (
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;
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;
if (novelId) {
if (sourceNovel.cover) {
@@ -44,38 +50,43 @@ export const insertNovelAndChapters = async (
sourceNovel.cover,
novelCoverPath,
getPlugin(pluginId)?.imageRequestInit,
);
await db.runAsync(
'UPDATE Novel SET cover = ? WHERE id = ?',
novelCoverUri,
novelId,
);
).then(() => {
runSync([
[
'UPDATE Novel SET cover = ? WHERE id = ?',
[novelCoverUri, novelId!],
],
]);
});
}
await insertChapters(novelId, sourceNovel.chapters);
}
return novelId;
};
export const getAllNovels = () => {
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel');
export const getAllNovels = async () => {
return getAllAsync<NovelInfo>(['SELECT * FROM Novel']);
};
export const getNovelById = (novelId: number) => {
return db.getFirstAsync<NovelInfo>(
export const getNovelById = async (novelId: number) => {
return getFirstAsync<NovelInfo>([
'SELECT * FROM Novel WHERE id = ?',
novelId,
);
[novelId],
]);
};
export const getNovelByPath = (
novelPath: string,
pluginId: string,
) => {
return db.getFirstAsync<NovelInfo>(
): NovelInfo | undefined => {
const res = getFirstSync<NovelInfo>([
'SELECT * FROM Novel WHERE path = ? AND pluginId = ?',
novelPath,
pluginId,
);
[novelPath, pluginId],
]);
if (!res) {
return undefined;
}
return res;
};
// if query is insert novel || add to library => add default category name for it
@@ -87,75 +98,83 @@ export const switchNovelToLibraryQuery = async (
): Promise<NovelInfo | undefined> => {
const novel = await getNovelByPath(novelPath, pluginId);
if (novel) {
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'));
}
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') {
await db.runAsync(
queries.push([
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, 2)',
novel.id,
);
[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 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'));
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],
],
]);
}
}
};
// allow to delete local novels
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(', ')})`,
);
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(', ')});`],
]);
showToast(getString('browseScreen.removeFromLibrary'));
};
export const getCachedNovels = () => {
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel WHERE inLibrary = 0');
return getAllAsync<NovelInfo>(['SELECT * FROM Novel WHERE inLibrary = 0']);
};
export const deleteCachedNovels = async () => {
await db.runAsync('DELETE FROM Novel WHERE inLibrary = 0');
showToast(getString('advancedSettingsScreen.cachedNovelsDeletedToast'));
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);
const sourceNovel = await fetchNovel(novel.pluginId, novel.path).catch(e => {
throw e;
});
let novelId: number | undefined;
await db.withTransactionAsync(async () => {
const result = await db.runAsync(
restoreFromBackupQuery,
db.runAsync(restoreFromBackupQuery, [
sourceNovel.path,
novel.name,
novel.pluginId,
@@ -166,17 +185,32 @@ export const restoreLibrary = async (novel: NovelInfo) => {
novel.status || '',
novel.genres || '',
sourceNovel.totalPages || 0,
);
novelId = result.lastInsertRowId;
]).then(data => {
novelId = data.lastInsertRowId;
});
});
if (novelId && novelId > 0) {
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);
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;
});
if (sourceNovel.chapters) {
await insertChapters(novelId, sourceNovel.chapters);
}
@@ -184,19 +218,23 @@ export const restoreLibrary = async (novel: NovelInfo) => {
};
export const updateNovelInfo = async (info: NovelInfo) => {
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,
);
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) => {
@@ -209,11 +247,9 @@ export const pickCustomNovelCover = async (novel: NovelInfo) => {
}
NativeFile.copyFile(image.assets[0].uri, novelCoverUri);
novelCoverUri += '?' + Date.now();
await db.runAsync(
'UPDATE Novel SET cover = ? WHERE id = ?',
novelCoverUri,
novel.id,
);
runAsync([
['UPDATE Novel SET cover = ? WHERE id = ?', [novelCoverUri, novel.id]],
]);
return novelCoverUri;
}
};
@@ -222,45 +258,48 @@ export const updateNovelCategoryById = async (
novelId: number,
categoryIds: number[],
) => {
for (const categoryId of categoryIds) {
await db.runAsync(
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
novelId,
categoryId,
);
}
runAsync(
categoryIds.map(categoryId => {
return [
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
[novelId, categoryId],
];
}),
);
};
export const updateNovelCategories = async (
novelIds: number[],
categoryIds: number[],
): Promise<void> => {
await db.runAsync(
const queries: QueryObject[] = [];
queries.push([
`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(
',',
)}) AND categoryId != 2`,
);
]);
// if no category is selected => set to the default category
if (categoryIds.length) {
for (const novelId of novelIds) {
for (const categoryId of categoryIds) {
await db.runAsync(
novelIds.forEach(novelId => {
categoryIds.forEach(categoryId =>
queries.push([
`INSERT INTO NovelCategory (novelId, categoryId) VALUES (${novelId}, ${categoryId})`,
);
}
}
]),
);
});
} else {
for (const novelId of novelIds) {
novelIds.forEach(novelId => {
// hacky: insert local novel category -> failed -> ignored
await db.runAsync(
`INSERT OR IGNORE INTO NovelCategory (novelId, categoryId)
queries.push([
`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) => {
@@ -275,17 +314,17 @@ const restoreObjectQuery = (table: string, obj: any) => {
export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
const { chapters, ...novel } = backupNovel;
await db.runAsync('DELETE FROM Novel WHERE id = ?', novel.id);
await db.runAsync(
restoreObjectQuery('Novel', novel),
...(Object.values(novel) as (string | number)[]),
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[],
]),
);
if (chapters.length > 0) {
for (const chapter of chapters) {
await db.runAsync(
restoreObjectQuery('Chapter', chapter),
...(Object.values(chapter) as (string | number)[]),
);
}
}
};
+28 -25
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,48 +52,51 @@ const getNovelStatusQuery = `
WHERE Novel.inLibrary = 1
`;
export const getLibraryStatsFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getLibraryStatsQuery);
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getLibraryStatsQuery]) as any;
};
export const getChaptersTotalCountFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersTotalCountQuery);
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersTotalCountQuery]) as any;
};
export const getChaptersReadCountFromDb = (): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersReadCountQuery);
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersReadCountQuery]) as any;
};
export const getChaptersUnreadCountFromDb =
(): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersUnreadCountQuery);
};
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersUnreadCountQuery]) as any;
};
export const getChaptersDownloadedCountFromDb =
(): Promise<LibraryStats | null> => {
return db.getFirstAsync<LibraryStats>(getChaptersDownloadedCountQuery);
async (): Promise<LibraryStats> => {
return getFirstAsync([getChaptersDownloadedCountQuery]) as any;
};
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
const genres: string[] = [];
const res = await db.getAllAsync<{ genres: string }>(getNovelGenresQuery);
res.forEach(item => {
const novelGenres = item.genres?.split(/\s*,\s*/);
if (novelGenres?.length) {
genres.push(...novelGenres);
}
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);
}
});
});
return { genres: countBy(genres) };
};
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
const status: string[] = [];
const res = await db.getAllAsync<{ status: string }>(getNovelStatusQuery);
res.forEach(item => {
const novelStatus = item.status?.split(/\s*,\s*/);
if (novelStatus?.length) {
status.push(...novelStatus);
}
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);
}
});
});
return { status: countBy(status) };
};
+103
View File
@@ -0,0 +1,103 @@
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);
});
}
+4 -13
View File
@@ -49,19 +49,10 @@ export class MigrationRunner {
}
private getCurrentVersion(db: SQLiteDatabase): number {
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;
}
return (
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0
);
}
private setVersion(db: SQLiteDatabase, version: number): void {
+18 -25
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } 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;
@@ -30,7 +32,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
return timeSinceLastCheck >= ONE_DAY_MS;
};
const checkForRelease = useCallback(async () => {
const checkForRelease = async () => {
if (!shouldCheckForUpdate()) {
setChecking(false);
return;
@@ -38,23 +40,12 @@ 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 || undefined,
downloadUrl: data.assets[0].browser_download_url,
};
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
@@ -62,14 +53,14 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
setLatestRelease(release);
setChecking(false);
} catch (error) {
// Silently fail in offline mode or on network errors
showToast(`Failed to check for updates: ${getErrorMessage(error)}`);
setChecking(false);
}
}, []);
};
const isNewVersion = (versionTag: string) => {
const currentVersion = `${version}`;
const regex = /[^\d.]/;
const regex = /[^\\d.]/;
const newVersion = versionTag.replace(regex, '');
@@ -78,17 +69,19 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
useEffect(() => {
checkForRelease();
}, [checkForRelease]);
}, []);
if (!checking && latestRelease?.tag_name) {
return {
if (!checking && shouldCheckForUpdate()) {
const data = {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
}
return {
latestRelease: undefined,
isNewVersion: false,
};
return data;
} else {
return {
latestRelease: undefined,
isNewVersion: false,
};
}
};
-6
View File
@@ -20,11 +20,6 @@ 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',
@@ -54,7 +49,6 @@ export default function useDownload() {
return {
downloadQueue,
downloadingChapterIds,
resumeDowndload,
downloadChapter,
downloadChapters,
+26 -91
View File
@@ -23,7 +23,6 @@ import {
getCustomPages,
getChapterCount,
getPageChaptersBatched,
getFirstUnreadChapter as _getFirstUnreadChapter,
updateChapterProgress as _updateChapterProgress,
} from '@database/queries/ChapterQueries';
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
@@ -67,15 +66,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
const [novel, setNovel] = useState<NovelInfo | undefined>(
typeof novelOrPath === 'object' ? novelOrPath : undefined,
);
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 [pages, setPages] = useState<string[]>(
novel ? calculatePages(novel) : [],
);
const { defaultChapterSort } = useAppSettings();
@@ -95,27 +88,32 @@ 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;
}>({ batch: 0, total: 0 });
}>(
typeof novelOrPath === 'object'
? {
batch: 0,
total: 0,
totalChapters: getChapterCount(novelOrPath.id, pages[pageIndex]),
}
: { batch: 0, total: 0 },
);
const settingsSort = novelSettings.sort || defaultChapterSort;
// #endregion
// #region setters
async function calculatePages(tmpNovel: NovelInfo) {
function calculatePages(tmpNovel: NovelInfo) {
let tmpPages: string[];
if (tmpNovel.totalPages > 0) {
tmpPages = Array(tmpNovel.totalPages)
.fill(0)
.map((_, idx) => String(idx + 1));
} else {
tmpPages = (await getCustomPages(tmpNovel.id)).map(c => c.page);
tmpPages = getCustomPages(tmpNovel.id).map(c => c.page);
}
return tmpPages.length > 1 ? tmpPages : ['1'];
@@ -134,9 +132,8 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
(index: number, update: Partial<ChapterInfo>) => {
if (novel) {
_setChapters(chs => {
const next = [...chs];
next[index] = { ...next[index], ...update };
return next;
chs[index] = { ...chs[index], ...update };
return chs;
});
}
},
@@ -221,21 +218,20 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
// #region getters
const getNovel = useCallback(async () => {
let tmpNovel = await getNovelByPath(novelPath, pluginId);
let tmpNovel = getNovelByPath(novelPath, pluginId);
if (!tmpNovel) {
const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => {
throw new Error(getString('updatesScreen.unableToGetNovel'));
});
await insertNovelAndChapters(pluginId, sourceNovel);
tmpNovel = await getNovelByPath(novelPath, pluginId);
tmpNovel = getNovelByPath(novelPath, pluginId);
if (!tmpNovel) {
return;
}
}
setPages(await calculatePages(tmpNovel));
setPages(calculatePages(tmpNovel));
setNovel(tmpNovel);
}, [novelPath, pluginId]);
@@ -253,11 +249,11 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
page,
] as const;
let chapterCount = await getChapterCount(novel.id, page);
let chapterCount = getChapterCount(novel.id, page);
if (chapterCount) {
try {
newChapters = (await getPageChaptersBatched(...config)) || [];
newChapters = getPageChaptersBatched(...config) || [];
} catch (error) {
console.error('teaser', error);
}
@@ -274,7 +270,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
});
await insertChapters(novel.id, sourceChapters);
newChapters = await _getPageChapters(...config);
chapterCount = await getChapterCount(novel.id, page);
chapterCount = getChapterCount(novel.id, page);
}
setBatchInformation({
@@ -283,13 +279,6 @@ 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,
@@ -302,7 +291,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
settingsSort,
]);
const getNextChapterBatch = useCallback(async () => {
const getNextChapterBatch = useCallback(() => {
const page = pages[pageIndex];
const nextBatch = batchInformation.batch + 1;
if (novel && page && nextBatch <= batchInformation.total) {
@@ -310,13 +299,13 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
try {
newChapters =
(await getPageChaptersBatched(
getPageChaptersBatched(
novel.id,
settingsSort,
novelSettings.filter,
page,
nextBatch,
)) || [];
) || [];
} catch (error) {
console.error('teaser', error);
}
@@ -333,50 +322,6 @@ 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
@@ -582,9 +527,6 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
useEffect(() => {
if (novel) {
if (pages.length === 0) {
calculatePages(novel).then(setPages);
}
setLoading(false);
} else {
getNovel().finally(() => {
@@ -596,10 +538,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
}, [getNovel, novel]);
useEffect(() => {
if (novel === undefined || pages.length === 0) {
return;
}
if (novel === undefined) return;
setFetching(true);
getChapters()
.catch(e => {
@@ -623,12 +562,10 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
pages,
novel,
lastRead,
firstUnreadChapter,
chapters,
novelSettings,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
getNovel,
setPageIndex,
openPage,
@@ -656,12 +593,10 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
pages,
novel,
lastRead,
firstUnreadChapter,
chapters,
novelSettings,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
getNovel,
setPageIndex,
openPage,
-1
View File
@@ -167,7 +167,6 @@ export default function usePlugins() {
name: _plg.name,
version: _plg.version,
hasUpdate: false,
hasSettings: !!_plg.pluginSettings,
};
if (newPlugin.id === lastUsedPlugin?.id) {
setLastUsedPlugin(newPlugin);
+2 -22
View File
@@ -79,7 +79,6 @@ export interface ChapterGeneralSettings {
swipeGestures: boolean;
showScrollPercentage: boolean;
useVolumeButtons: boolean;
volumeButtonsOffset: number | null;
showBatteryAndTime: boolean;
autoScroll: boolean;
autoScrollInterval: number;
@@ -111,8 +110,6 @@ export interface ChapterReaderSettings {
voice?: Voice;
rate?: number;
pitch?: number;
autoPageAdvance?: boolean;
scrollToTop?: boolean;
};
epubLocation: string;
epubUseAppTheme: boolean;
@@ -175,7 +172,6 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
swipeGestures: false,
showScrollPercentage: true,
useVolumeButtons: false,
volumeButtonsOffset: null,
showBatteryAndTime: false,
autoScroll: false,
autoScrollInterval: 10,
@@ -184,7 +180,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
removeExtraParagraphSpacing: false,
bionicReading: false,
tapToScroll: false,
TTSEnable: true,
TTSEnable: false,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
@@ -201,8 +197,6 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
tts: {
rate: 1,
pitch: 1,
autoPageAdvance: false,
scrollToTop: true,
},
epubLocation: '',
epubUseAppTheme: false,
@@ -274,23 +268,9 @@ export const useChapterGeneralSettings = () => {
};
export const useChapterReaderSettings = () => {
const [storedSettings = initialChapterReaderSettings, setSettings] =
const [chapterReaderSettings = 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,7 +56,6 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => {
'AniList',
'MyAnimeList',
'MangaUpdates',
'Kitsu',
];
trackerNames.forEach(trackerName => {
-2
View File
@@ -2,7 +2,6 @@ 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 {
@@ -24,7 +23,6 @@ const trackers: Record<TrackerName, Tracker> = {
AniList: aniListTracker,
MyAnimeList: myAnimeListTracker,
MangaUpdates: mangaUpdatesTracker,
Kitsu: kitsuTracker,
};
export const getTracker = (name: TrackerName) => {
+51 -10
View File
@@ -1,5 +1,8 @@
import React, { useCallback, useMemo } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import {
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs';
import Library from '../screens/library/LibraryScreen';
import Updates from '../screens/updates/UpdatesScreen';
@@ -12,7 +15,8 @@ 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 { BottomTabBar } from '@components';
import { CommonActions } from '@react-navigation/native';
import { BottomNavigation } from 'react-native-paper';
const Tab = createBottomTabNavigator<BottomNavigatorParamList>();
@@ -59,16 +63,53 @@ const BottomNavigator = () => {
[],
);
const renderTabBar = useCallback(
(props: any) => (
<BottomTabBar
{...props}
theme={theme}
showLabelsInNav={showLabelsInNav}
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,
});
}
}}
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;
}}
/>
),
[theme, showLabelsInNav, renderIcon],
[renderIcon, showLabelsInNav, theme],
);
return (
@@ -82,7 +123,7 @@ const BottomNavigator = () => {
color: theme.onError,
},
})}
tabBar={renderTabBar}
tabBar={renderBottomBar}
>
<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';
+12 -16
View File
@@ -1,29 +1,27 @@
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 { 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 },
@@ -35,8 +33,6 @@ 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) => {
+1 -47
View File
@@ -1,51 +1,5 @@
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;
@@ -115,7 +69,7 @@ export interface ImageRequestInit {
export interface Plugin extends PluginItem {
imageRequestInit: ImageRequestInit;
filters?: Filters;
pluginSettings?: PluginSettings;
pluginSettings: any;
popularNovels: (
pageNo: number,
options?: PopularNovelsOptions<Filters>,
@@ -143,7 +143,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
setInActivity(prev => ({ ...prev, [item.path]: false }));
}}
hasSelection={false}
selectedNovelIds={[]}
imageRequestInit={imageRequestInit}
/>
);
@@ -169,7 +169,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
{
backgroundColor: theme.primary,
marginBottom: bottom + 16,
marginEnd: right + 16,
marginRight: right + 16,
},
]}
label={getString('common.filter')}
@@ -196,6 +196,6 @@ const styles = StyleSheet.create({
bottom: 0,
margin: 16,
position: 'absolute',
end: 0,
right: 0,
},
});
@@ -16,11 +16,11 @@ import {
FilterToValues,
Filters,
} from '@plugins/types/filterTypes';
import { Button, Menu } from '@components/index';
import { Button } from '@components/index';
import { Checkbox } from '@components/Checkbox/Checkbox';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { useBoolean } from '@hooks';
import { TextInput, overlay } from 'react-native-paper';
import { Menu, 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
fullWidth
style={styles.flex}
visible={isVisible}
contentStyle={{ backgroundColor: theme.surfaceVariant }}
anchor={
@@ -350,53 +350,45 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
bottomSheetRef={filterSheetRef}
snapPoints={[400, 600]}
bottomInset={bottom}
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}
/>
)}
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"
/>
</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>
);
};
+5 -9
View File
@@ -53,15 +53,11 @@ const CategoriesScreen = () => {
const systemCategories = categories.filter(cat => cat.id === 1);
const updatedOrderCategories = [...systemCategories, ...data].map(
(category, index) => ({
...category,
sort: index,
}),
);
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
const updatedOrderCategories = [...systemCategories, ...data];
requestAnimationFrame(() => {
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
});
};
const renderItem = ({
@@ -44,8 +44,9 @@ 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}>
@@ -76,19 +77,17 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
</Text>
{category.id === 2 && (
<Badge
style={[
styles.badge,
{
backgroundColor: theme.tertiaryContainer,
},
]}
style={{
backgroundColor: theme.primary,
paddingHorizontal: 8,
}}
>
System
</Badge>
)}
</View>
<View style={styles.flex} />
<View style={[category.id === 2 && styles.disabledOpacity]}>
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<IconButton
name="pencil-outline"
color={category.id === 2 ? theme.outline : theme.onSurface}
@@ -98,7 +97,7 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
disabled={category.id === 2}
/>
</View>
<View style={[category.id === 2 && styles.disabledOpacity]}>
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<IconButton
name="delete-outline"
color={category.id === 2 ? theme.outline : theme.onSurface}
@@ -144,34 +143,24 @@ const styles = StyleSheet.create({
paddingVertical: 8,
},
dragHandle: {
marginEnd: 4,
marginRight: 4,
},
flex: {
flex: 1,
},
manageBtn: {
marginStart: 16,
marginLeft: 16,
},
name: {
marginStart: 16,
marginEnd: 8,
marginLeft: 16,
marginRight: 8,
},
nameCtn: {
alignItems: 'center',
flexGrow: 1,
flexDirection: 'row',
marginStart: 8,
paddingEnd: 16,
marginLeft: 8,
paddingRight: 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,
}));
}}
hasSelection={false}
selectedNovelIds={[]}
isSelected={false}
imageRequestInit={imageRequestInit}
/>
@@ -175,7 +175,6 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
item.plugin.site,
navigateToNovel,
navigation,
imageRequestInit,
noResultsColor,
novelInLibrary,
switchNovelToLibrary,
-9
View File
@@ -1,8 +1,6 @@
/* 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';
@@ -22,7 +20,6 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
const theme = useTheme();
const { searchText, setSearchText, clearSearchbar } = useSearch();
const { languagesFilter } = usePlugins();
const layout = useWindowDimensions();
const searchbarActions = useMemo(
() =>
@@ -69,7 +66,6 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
/>
<TabView
navigationState={{ index, routes }}
initialLayout={{ width: layout.width }}
renderScene={({ route }) => {
if (languagesFilter.length === 0) {
return (
@@ -100,11 +96,6 @@ 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,12 +1,20 @@
import React, { useState, useEffect } from 'react';
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 { StyleSheet, Text, View } from 'react-native';
import { TextInput } from 'react-native-paper';
import { Button, Modal, SwitchItem } from '@components/index';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { Storage } from '@plugins/helpers/storage';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { PluginSettings } from '@plugins/types';
interface PluginSetting {
value: string;
label: string;
type?: 'Switch';
}
interface PluginSettings {
[key: string]: PluginSetting;
}
interface SourceSettingsModal {
visible: boolean;
@@ -28,9 +36,8 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
const theme = useTheme();
const [formValues, setFormValues] = useState<
Record<string, string | boolean | string[]>
Record<string, string | boolean>
>({});
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
useEffect(() => {
if (pluginSettings) {
@@ -59,18 +66,13 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
}
}, [pluginSettings, pluginId]);
const handleChange = (key: string, value: string | boolean | string[]) => {
const handleChange = (key: string, value: string | boolean) => {
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]) => {
@@ -110,104 +112,6 @@ 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}
@@ -257,14 +161,4 @@ 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.primary],
[theme.error],
);
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: theme.surface }],
@@ -155,7 +155,6 @@ export const PluginListItem = memo(
theme,
handlePinPress,
handleDeletePress,
handleWebviewPress,
isPluginPinned,
],
);
@@ -243,7 +242,7 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
details: {
marginStart: 16,
marginLeft: 16,
},
flex: { flex: 1 },
icon: {
@@ -255,7 +254,7 @@ const styles = StyleSheet.create({
lineHeight: 20,
},
pinnedIndicator: {
marginEnd: -8,
marginRight: -8,
},
rightActionsContainer: {
flexDirection: 'row',
@@ -129,7 +129,7 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
details: {
marginStart: 16,
marginLeft: 16,
},
flex: { flex: 1 },
icon: {
+7 -21
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
StyleSheet,
View,
@@ -97,25 +97,6 @@ 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
@@ -164,7 +145,12 @@ const BrowseMalScreen = ({ navigation }: BrowseMalScreenProps) => {
keyExtractor={(item, index) => item.novelName + index}
renderItem={renderItem}
ListEmptyComponent={ListEmptyComponent}
onScroll={onScroll}
onScroll={({ nativeEvent }) => {
if (!searchText && isCloseToBottom(nativeEvent)) {
getNovels(limit + 50);
setLimit(before => before + 50);
}
}}
ListFooterComponent={
!searchText ? (
<View style={styles.paddingVertical}>
@@ -69,7 +69,7 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
marginStart: 16,
marginLeft: 16,
},
sourceIcon: {
borderRadius: 4,
@@ -72,11 +72,13 @@ 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>
@@ -116,7 +118,7 @@ const styles = StyleSheet.create({
detailsContainer: {
flex: 1,
justifyContent: 'center',
marginStart: 16,
marginLeft: 16,
},
imageAndNameContainer: {
alignItems: 'center',
+153 -200
View File
@@ -48,8 +48,6 @@ 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;
@@ -75,7 +73,7 @@ type TabViewLabelProps = {
const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
const { searchText, setSearchText, clearSearchbar } = useSearch();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const styles = createStyles(theme);
const { left: leftInset, right: rightInset } = useSafeAreaInsets();
const {
library,
@@ -102,33 +100,17 @@ 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 idsSet = new Set(categories[index].novelIds);
return library.filter(l => idsSet.has(l.id));
const ids = categories[index].novelIds;
return library.filter(l => ids.includes(l.id));
}, [categories, index, library]);
useBackHandler(() => {
@@ -157,35 +139,25 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
? getString('libraryScreen.searchbar')
: `${selectedNovelIds.length} selected`;
const openRandom = useCallback(() => {
const randomNovel =
currentNovels[Math.floor(Math.random() * currentNovels.length)];
function openRandom() {
const novels = currentNovels;
const randomNovel = novels[Math.floor(Math.random() * novels.length)];
if (randomNovel) {
navigation.navigate('ReaderStack', {
screen: 'Novel',
params: randomNovel,
});
}
}, [currentNovels, navigation]);
}
const pickAndImport = useCallback(() => {
DocumentPicker.getDocumentAsync({
type: 'application/epub+zip',
copyToCacheDirectory: false,
copyToCacheDirectory: true,
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 ? (
@@ -196,7 +168,9 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
style={[
{
backgroundColor: theme.surface,
borderBottomColor: tabBarBorderColor,
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
},
styles.tabBar,
]}
@@ -213,7 +187,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
styles.tabBar,
styles.tabBarIndicator,
styles.tabStyle,
tabBarBorderColor,
theme.isDark,
theme.primary,
theme.rippleColor,
theme.secondary,
@@ -233,16 +207,14 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
title: string;
};
}) => {
const idsSet = new Set(route.novelIds);
const unfilteredNovels = library.filter(l => idsSet.has(l.id));
const ids = route.novelIds;
const unfilteredNovels = library.filter(l => ids.includes(l.id));
const novels = searchLower
? unfilteredNovels.filter(
n =>
n.name.toLowerCase().includes(searchLower) ||
(n.author?.toLowerCase().includes(searchLower) ?? false),
)
: unfilteredNovels;
const novels = unfilteredNovels.filter(
n =>
n.name.toLowerCase().includes(searchText.toLowerCase()) ||
(n.author?.toLowerCase().includes(searchText.toLowerCase()) ?? false),
);
return isLoading ? (
<SourceScreenSkeletonLoading theme={theme} />
@@ -265,6 +237,8 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
categoryId={route.id}
categoryName={route.name}
novels={novels}
selectedNovelIds={selectedNovelIds}
setSelectedNovelIds={setSelectedNovelIds}
pickAndImport={pickAndImport}
navigation={navigation}
/>
@@ -277,7 +251,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
navigation,
pickAndImport,
searchText,
searchLower,
selectedNovelIds,
styles.globalSearchBtn,
theme,
],
@@ -285,8 +259,6 @@ 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>
@@ -300,7 +272,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<Text
style={[styles.badgetText, { color: theme.onSurfaceVariant }]}
>
{novelIds.length}
{route?.novelIds.length}
</Text>
</View>
) : null}
@@ -317,123 +289,6 @@ 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,
@@ -450,13 +305,61 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<SafeAreaView excludeBottom>
<SearchbarV2
searchText={searchText}
clearSearchbar={clearSearchbar}
clearSearchbar={handleClearSearchbar}
placeholder={searchbarPlaceholder}
onLeftIconPress={handleLeftIconPress}
onLeftIconPress={() => {
if (selectedNovelIds.length > 0) {
setSelectedNovelIds([]);
}
}}
onChangeText={setSearchText}
leftIcon={selectedNovelIds.length ? 'close' : 'magnify'}
rightIcons={rightIcons}
menuButtons={menuButtons}
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,
},
]}
theme={theme}
/>
{downloadedOnlyMode ? (
@@ -476,19 +379,17 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
/>
) : null}
<SelectionContext.Provider value={selectionContextValue}>
<TabView
commonOptions={{
label: renderLabel,
}}
lazy
navigationState={navigationState}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
/>
</SelectionContext.Provider>
<TabView
commonOptions={{
label: renderLabel,
}}
lazy
navigationState={navigationState}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
/>
{useLibraryFAB &&
!isHistoryLoading &&
@@ -498,31 +399,83 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
<FAB
style={[
styles.fab,
{ backgroundColor: theme.primary, marginEnd: rightInset + 16 },
{ backgroundColor: theme.primary, marginRight: rightInset + 16 },
]}
color={theme.onPrimary}
uppercase={false}
label={getString('common.resume')}
icon="play"
onPress={handleFABPress}
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],
},
});
}}
/>
) : null}
<SetCategoryModal
novelIds={selectedNovelIds}
closeModal={closeSetCategoryModal}
onEditCategories={handleEditCategories}
onEditCategories={() => setSelectedNovelIds([])}
visible={setCategoryModalVisible}
onSuccess={handleCategorySuccess}
onSuccess={() => {
setSelectedNovelIds([]);
refetchLibrary();
}}
/>
<LibraryBottomSheet
bottomSheetRef={bottomSheetRef}
style={bottomSheetStyle}
style={{ marginLeft: leftInset, marginRight: rightInset }}
/>
<Portal>
<Actionbar
viewStyle={actionbarViewStyle}
active={hasSelection}
actions={actionbarActions}
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();
},
},
]}
/>
</Portal>
</SafeAreaView>
@@ -535,7 +488,7 @@ function createStyles(theme: ThemeColors) {
return StyleSheet.create({
badgeCtn: {
borderRadius: 50,
marginStart: 4,
marginLeft: 2,
paddingHorizontal: 6,
paddingVertical: 2,
position: 'relative',
@@ -547,7 +500,7 @@ function createStyles(theme: ThemeColors) {
bottom: 0,
margin: 16,
position: 'absolute',
end: 0,
right: 0,
},
fontWeight500: {
fontWeight: 500,
-25
View File
@@ -1,25 +0,0 @@
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,7 +53,6 @@ const FirstRoute = () => {
estimatedItemSize={4}
extraData={[filter]}
data={libraryFilterList}
keyExtractor={item => `filter_${item.filter}`}
renderItem={({ item }) => (
<Checkbox
label={item.label}
@@ -86,7 +85,6 @@ const SecondRoute = () => {
data={librarySortOrderList}
extraData={[sortOrder]}
estimatedItemSize={5}
keyExtractor={(item, index) => `sort_${index}_${item.ASC}`}
renderItem={({ item }) => (
<SortItem
label={item.label}
@@ -163,7 +161,6 @@ const ThirdRoute = () => {
estimatedItemSize={4}
data={displayModesList}
extraData={[displayMode]}
keyExtractor={item => `display_mode_${item.value}`}
renderItem={({ item }) => (
<RadioButton
label={item.label}
@@ -177,12 +174,6 @@ const ThirdRoute = () => {
);
};
const bottomSheetSceneMap = SceneMap({
first: FirstRoute,
second: SecondRoute,
third: ThirdRoute,
});
const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
bottomSheetRef,
style,
@@ -191,40 +182,24 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
const layout = useWindowDimensions();
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 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 [index, setIndex] = useState(0);
@@ -237,6 +212,12 @@ 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>
@@ -265,7 +246,7 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
commonOptions={commonOptions}
navigationState={{ index, routes }}
renderTabBar={renderTabBar}
renderScene={bottomSheetSceneMap}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
style={styles.tabView}
@@ -1,9 +1,10 @@
import React, { useCallback, useMemo } from 'react';
import React, { useState } 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';
@@ -12,13 +13,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;
}
@@ -26,110 +27,93 @@ interface Props {
export const LibraryView: React.FC<Props> = ({
categoryId,
categoryName,
selectedNovelIds,
setSelectedNovelIds,
pickAndImport,
navigation,
novels,
}) => {
const theme = useTheme();
const { selectedIdsSet, hasSelection, toggleSelection } =
useSelectionContext();
const renderItem = ({ item }: { item: NovelInfo }) => {
const imageRequestInit = getPlugin(item.pluginId)?.imageRequestInit;
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
return (
<NovelCover
item={item}
theme={theme}
isSelected={selectedIdsSet.has(item.id)}
hasSelection={hasSelection}
onSelect={toggleSelection}
onNavigate={onNavigate}
imageRequestInit={imageRequestInitMap.get(item.pluginId)}
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}
/>
),
[
theme,
selectedIdsSet,
hasSelection,
toggleSelection,
onNavigate,
imageRequestInitMap,
],
);
);
};
const onRefresh = useCallback(() => {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = () => {
// local category
if (categoryId === 2) {
return;
}
setRefreshing(true);
ServiceManager.manager.addTask({
name: 'UPDATE_LIBRARY',
data: { categoryId, categoryName },
data: {
categoryId,
categoryName,
},
});
}, [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],
);
setRefreshing(false);
};
return (
<View style={styles.flex}>
<NovelList
data={novels}
extraData={selectedIdsSet}
extraData={[selectedNovelIds]}
renderItem={renderItem as NovelListRenderItem}
ListEmptyComponent={listEmptyComponent}
refreshControl={refreshControl}
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}
/>
}
/>
</View>
);
@@ -1,52 +0,0 @@
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;
+18 -34
View File
@@ -9,10 +9,7 @@ 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,
QueuedBackgroundTask,
} from '@services/ServiceManager';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import { useMMKVObject } from 'react-native-mmkv';
// type Library = Category & { novels: LibraryNovelInfo[] };
@@ -78,18 +75,12 @@ 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) =>
libraryLookup.has(`${pluginId}::${novelPath}`),
[libraryLookup],
library?.some(
novel => novel.pluginId === pluginId && novel.path === novelPath,
),
[library],
);
const switchNovelToLibrary = useCallback(
@@ -98,8 +89,8 @@ export const useLibrary = (): UseLibraryReturnType => {
// Important to get correct chapters count
// Count is set by sql trigger
await refreshCategories();
const novels = await getLibraryNovelsFromDb(
refreshCategories();
const novels = getLibraryNovelsFromDb(
sortOrder,
filter,
searchText,
@@ -115,24 +106,17 @@ export const useLibrary = (): UseLibraryReturnType => {
getLibrary();
});
const [taskQueue] = useMMKVObject<
Array<BackgroundTask | QueuedBackgroundTask>
>(ServiceManager.manager.STORE_KEY);
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
);
const restoreTasksCount = useMemo(
() =>
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?.filter(
t =>
t.name === 'LOCAL_RESTORE' ||
t.name === 'DRIVE_RESTORE' ||
t.name === 'SELF_HOST_RESTORE',
).length || 0,
[taskQueue],
);
const prevRestoreTasksCountRef = useRef(restoreTasksCount);
@@ -142,7 +126,7 @@ export const useLibrary = (): UseLibraryReturnType => {
getLibrary();
}
prevRestoreTasksCountRef.current = restoreTasksCount;
}, [getLibrary, restoreTasksCount]);
}, [restoreTasksCount]);
return {
library,
@@ -162,7 +146,7 @@ export const useLibraryNovels = () => {
const [library, setLibrary] = useState<NovelInfo[]>([]);
const getLibrary = async () => {
const novels = await getLibraryNovelsFromDb();
const novels = 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.app"
onPress={() => Linking.openURL('https://lnreader.app')}
description="https://lnreader.github.io"
onPress={() => Linking.openURL('https://lnreader.github.io')}
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.plugins')}
description="https://github.com/lnreader/lnreader-plugins"
title={getString('aboutScreen.sources')}
description="https://github.com/LNReader/lnreader-sources"
onPress={() =>
Linking.openURL('https://github.com/lnreader/lnreader-plugins')
Linking.openURL('https://github.com/LNReader/lnreader-sources')
}
theme={theme}
/>
+2
View File
@@ -86,6 +86,7 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
<Switch
value={downloadedOnlyMode}
onValueChange={enableDownloadedOnlyMode}
size={24}
/>
</Pressable>
<Pressable
@@ -119,6 +120,7 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
<Switch
value={incognitoMode}
onValueChange={enableIncognitoMode}
size={24}
/>
</Pressable>
<List.Divider theme={theme} />
+2 -1
View File
@@ -4,6 +4,7 @@ import {
FAB,
ProgressBar,
Appbar as MaterialAppbar,
Menu,
overlay,
} from 'react-native-paper';
@@ -11,7 +12,7 @@ import { useTheme } from '@hooks/persisted';
import { showToast } from '../../utils/showToast';
import { getString } from '@strings/translations';
import { Appbar, EmptyView, Menu, SafeAreaView } from '@components';
import { Appbar, EmptyView, SafeAreaView } from '@components';
import { TaskQueueScreenProps } from '@navigators/types';
import ServiceManager, { QueuedBackgroundTask } from '@services/ServiceManager';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+37 -68
View File
@@ -40,7 +40,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
fetching,
batchInformation,
getNextChapterBatch,
loadUpToBatch,
setNovel,
bookmarkChapters,
markChaptersRead,
@@ -63,6 +62,15 @@ 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) {
@@ -97,19 +105,17 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
},
[chapters, downloadChapters, novel],
);
const deleteChs = useCallback(() => {
deleteChapters(chapters.filter(c => c.isDownloaded));
}, [chapters, deleteChapters]);
const shareNovel = useCallback(() => {
const shareNovel = () => {
if (!novel) {
return;
}
Share.share({
message: resolveUrl(novel.pluginId, novel.path, true),
});
}, [novel]);
};
const [jumpToChapterModal, showJumpToChapterModal] = useState(false);
const {
@@ -211,7 +217,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
selected,
]);
const setCustomNovelCover = useCallback(async () => {
const setCustomNovelCover = async () => {
if (!novel) {
return;
}
@@ -222,58 +228,12 @@ 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={containerStyle}>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<Portal>
{selected.length === 0 ? (
<NovelAppbar
@@ -299,16 +259,18 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Appbar.Action
icon="close"
iconColor={theme.onBackground}
onPress={clearSelection}
onPress={() => setSelected([])}
/>
<Appbar.Content
title={`${selected.length}`}
titleStyle={titleStyle}
titleStyle={{ color: theme.onSurface }}
/>
<Appbar.Action
icon="select-all"
iconColor={theme.onBackground}
onPress={selectAll}
onPress={() => {
setSelected(chapters);
}}
/>
</Animated.View>
)}
@@ -322,7 +284,11 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
getNextChapterBatch={stableGetNextBatch}
getNextChapterBatch={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
/>
</Suspense>
</SafeAreaView>
@@ -332,31 +298,34 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Snackbar
visible={deleteDownloadsSnackbar.value}
onDismiss={deleteDownloadsSnackbar.setFalse}
action={snackbarAction}
theme={snackbarTheme}
action={{
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}}
theme={{ colors: { primary: theme.primary } }}
style={styles.snackbar}
>
<Text style={snackbarTextStyle}>
<Text style={{ color: theme.onSurface }}>
{getString('novelScreen.deleteMessage')}
</Text>
</Snackbar>
</Portal>
<Portal>
{novel ? (
{novel && (
<>
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={hideJumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
chapters={chapters}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
loadUpToBatch={loadUpToBatch}
totalChapters={batchInformation.totalChapters}
chapters={chapters}
/>
<EditInfoModal
modalVisible={editInfoModal}
hideModal={hideEditInfoModal}
hideModal={() => showEditInfoModal(false)}
novel={novel}
setNovel={setNovel}
theme={theme}
@@ -370,7 +339,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
downloadChapters={downloadChapters}
/>
</>
) : null}
)}
</Portal>
</View>
</Portal.Host>
@@ -1,72 +1,92 @@
import React, { useMemo } from 'react';
import { ThemeColors } from '@theme/types';
import React, { useEffect, useRef } from 'react';
import { MD3ThemeType } from '@theme/types';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { overlay } from 'react-native-paper';
import { Menu, overlay } from 'react-native-paper';
import { getString } from '@strings/translations';
import { isChapterDownloaded } from '@database/queries/ChapterQueries';
import { useBoolean } from '@hooks/index';
import { IconButtonV2, Menu } from '@components';
import { IconButtonV2 } 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: ThemeColors;
theme: MD3ThemeType;
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 menuContentStyle = useMemo(
() => ({ backgroundColor: overlay(2, theme.surface) }),
[theme.surface],
);
const menuTitleStyle = useMemo(
() => ({ color: theme.onSurface }),
[theme.onSurface],
);
const isFirstRender = useRef(true);
if (isDownloading) {
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) {
return <ChapterDownloadingButton theme={theme} />;
}
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} />;
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);
}}
/>
);
};
interface theme {
theme: ThemeColors;
theme: MD3ThemeType;
}
type buttonPropType = theme & {
onPress: () => void;
@@ -97,7 +117,7 @@ export const DownloadChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: theme.rippleColor }}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DownloadIcon theme={theme} />
</Pressable>
@@ -120,7 +140,7 @@ export const DeleteChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: theme.rippleColor }}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DeleteIcon theme={theme} />
</Pressable>
+85 -112
View File
@@ -1,5 +1,7 @@
import React, { memo, useCallback, useMemo, ReactNode } from 'react';
/* eslint-disable react-native/no-inline-styles */
import React, { memo, ReactNode } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import color from 'color';
import {
ChapterBookmarkButton,
DownloadButton,
@@ -10,113 +12,88 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i
import { getString } from '@strings/translations';
interface ChapterItemProps {
chapter: ChapterInfo;
isDownloading?: boolean;
isBookmarked?: boolean;
isSelected?: boolean;
isLocal: boolean;
isUpdateCard?: boolean;
chapter: ChapterInfo;
theme: ThemeColors;
showChapterTitles: boolean;
novelName: string;
left?: ReactNode;
onDeleteChapter: (chapter: ChapterInfo) => void;
onDownloadChapter: (chapter: ChapterInfo) => void;
onSelectPress: (chapter: ChapterInfo) => void;
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;
novelName: string;
}
const ChapterItem: React.FC<ChapterItemProps> = ({
chapter,
isDownloading,
isBookmarked,
isSelected,
isLocal,
isUpdateCard,
chapter,
theme,
showChapterTitles,
novelName,
left,
onDeleteChapter,
onDownloadChapter,
downloadChapter,
deleteChapter,
isSelected,
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={selectedStyle}
onPress={handlePress}
onLongPress={handleLongPress}
android_ripple={ripple}
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 }}
>
<View style={styles.row}>
{left}
{isBookmarked ? <ChapterBookmarkButton theme={theme} /> : null}
<View style={styles.flex1}>
<View style={{ flex: 1 }}>
{isUpdateCard ? (
<Text
style={[
styles.updateCardName,
{ color: unread ? theme.onSurface : theme.outline },
]}
style={{
fontSize: 14,
color: unread ? theme.onSurface : theme.outline,
}}
numberOfLines={1}
>
{novelName}
</Text>
) : null}
<View style={styles.titleRow}>
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
{unread ? (
<MaterialCommunityIcons
name="circle"
@@ -127,11 +104,15 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
<Text
style={[
isUpdateCard ? styles.textSmall : styles.textNormal,
{ color: titleColor },
styles.flex1,
]}
style={{
fontSize: isUpdateCard ? 12 : 14,
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurface,
flex: 1,
}}
numberOfLines={1}
ellipsizeMode="tail"
>
@@ -142,11 +123,23 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
})}
</Text>
</View>
<View style={styles.metaRow}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}
>
{releaseTime && !isUpdateCard ? (
<Text
style={[
{ color: releaseColor, marginTop: 4 },
{
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurfaceVariant,
marginTop: 4,
},
styles.text,
]}
numberOfLines={1}
@@ -156,14 +149,12 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
<Text
style={[
styles.text,
{
color: theme.outline,
marginStart: chapter.releaseTime ? 5 : 0,
marginTop: 4,
},
]}
style={{
color: theme.outline,
marginLeft: chapter.releaseTime ? 5 : 0,
fontSize: 12,
marginTop: 4,
}}
numberOfLines={1}
>
{chapter.releaseTime ? '• ' : null}
@@ -177,9 +168,11 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
<DownloadButton
isDownloading={isDownloading}
isDownloaded={chapter.isDownloaded}
chapterId={chapter.id}
theme={theme}
deleteChapter={handleDelete}
downloadChapter={handleDownload}
setChapterDownloaded={setChapterDownloaded}
deleteChapter={deleteChapter}
downloadChapter={downloadChapter}
/>
) : null}
</Pressable>
@@ -198,13 +191,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 8,
},
flex1: {
flex: 1,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
},
row: {
alignItems: 'center',
flex: 1,
@@ -213,20 +199,7 @@ const styles = StyleSheet.create({
text: {
fontSize: 12,
},
textNormal: {
fontSize: 14,
},
textSmall: {
fontSize: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
unreadIcon: {
marginEnd: 4,
},
updateCardName: {
fontSize: 14,
marginRight: 4,
},
});
@@ -137,7 +137,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
epub = new EpubBuilder(
{
title: novel.name,
fileName: novel.name.replace(/[\\/:*?"<>|\s]/g, '') || 'novel',
fileName: novel.name.replace(/\s/g, ''),
language: 'en',
cover: novel.cover,
description: novel.summary,
@@ -157,37 +157,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
const chapterFilePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`;
if (NativeFile.exists(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, '');
}
}
const chapterContent = NativeFile.readFile(chapterFilePath);
await epub.addChapter({
title:
@@ -1,4 +1,4 @@
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { useState } from 'react';
import {
FlatList,
StyleSheet,
@@ -43,31 +43,27 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => (
<View style={styles.novelInfoContainer}>{children}</View>
);
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>;
}
const CoverImage = ({
children,
source,
theme,
hideBackdrop,
}: CoverImageProps) => {
if (hideBackdrop) {
return <View>{children}</View>;
} else {
return (
<ImageBackground source={source} style={styles.coverImage}>
<View style={overlayStyle}>
<View
style={[
{ backgroundColor: color(theme.background).alpha(0.7).string() },
styles.flex1,
]}
>
{source.uri ? (
<LinearGradient
colors={gradientColors}
locations={GRADIENT_LOCATIONS}
colors={['rgba(0,0,0,0)', theme.background]}
locations={[0, 1]}
style={styles.linearGradient}
>
{children}
@@ -78,10 +74,8 @@ const CoverImage = memo(
</View>
</ImageBackground>
);
},
);
const GRADIENT_LOCATIONS = [0, 1] as const;
}
};
const NovelThumbnail = ({
source,
@@ -176,7 +170,7 @@ const FollowButton = ({
<View style={styles.followButtonContainer}>
<Pressable
android_ripple={{
color: theme.rippleColor,
color: color(theme.primary).alpha(0.12).string(),
borderless: false,
}}
onPress={onPress}
@@ -240,28 +234,26 @@ const TrackerButton = ({
</View>
);
const genreKeyExtractor = (_item: string, index: number) => 'genre' + index;
const NovelGenres = ({
theme,
genres,
}: {
theme: ThemeColors;
genres: string;
}) => {
const data = genres.split(/,\s*/);
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}
/>
);
},
);
return (
<FlatList
contentContainerStyle={styles.genreContainer}
horizontal
data={data}
keyExtractor={(item, index) => 'genre' + index}
renderItem={({ item }) => <Chip label={item} theme={theme} />}
showsHorizontalScrollIndicator={false}
/>
);
};
export {
NovelInfoContainer,
@@ -325,6 +317,7 @@ const styles = StyleSheet.create({
},
novelInfo: {
fontSize: 14,
marginBottom: 4,
},
novelInfoContainer: {
flexDirection: 'row',
@@ -1,5 +1,6 @@
import React, { memo, useCallback, useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import color from 'color';
import * as Clipboard from 'expo-clipboard';
@@ -38,24 +39,12 @@ 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;
@@ -84,155 +73,11 @@ 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,
@@ -256,64 +101,20 @@ const NovelInfoHeader = ({
[novel.pluginId],
);
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],
);
const showNotAvailable = async () => {
showToast('Not available while loading');
};
return (
<>
<CoverImage
source={coverSource}
source={{ uri: novel.cover }}
theme={theme}
hideBackdrop={hideBackdrop}
>
<NovelInfoContainer>
<NovelThumbnail
source={coverSource}
source={{ uri: novel.cover }}
theme={theme}
setCustomNovelCover={
isLoading ? showNotAvailable : setCustomNovelCover
@@ -321,74 +122,88 @@ const NovelInfoHeader = ({
saveNovelCover={isLoading ? showNotAvailable : saveNovelCover}
/>
<View style={styles.novelDetails}>
<Row style={styles.infoRow}>
<Row>
<NovelTitle
theme={theme}
onPress={handleTitlePress}
onLongPress={handleTitleLongPress}
onPress={() =>
navigation.replace('GlobalSearchScreen', {
searchText: novel.name,
})
}
onLongPress={() => {
Clipboard.setStringAsync(novel.name).then(() =>
showToast(
getString('common.copiedToClipboard', {
name: novel.name,
}),
),
);
}}
>
{novel.name}
</NovelTitle>
</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>
</>
)}
{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>
</View>
</NovelInfoContainer>
</CoverImage>
<>
{isLoading && novel.id === 'NO_ID' ? (
<ButtonGroupSkeleton theme={theme} />
) : (
<NovelScreenButtonGroup
novel={novel}
handleFollowNovel={handleFollowNovel}
handleTrackerSheet={handleTrackerSheet}
theme={theme}
/>
)}
<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.genres || !novel.summary) ? (
<NovelMetaSkeleton />
) : (
@@ -405,7 +220,7 @@ const NovelInfoHeader = ({
)}
<ReadButton
navigateToChapter={navigateToChapter}
firstUnreadChapter={firstUnreadChapter}
chapters={chapters}
lastRead={lastRead}
/>
{isLoading && (!novel.genres || !novel.summary) ? (
@@ -414,23 +229,23 @@ const NovelInfoHeader = ({
<View style={styles.bottomsheetContainer}>
<Pressable
style={styles.bottomsheet}
onPress={handleOpenBottomSheet}
android_ripple={ripple}
onPress={() => novelBottomSheetRef.current?.present()}
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
}}
>
<View style={styles.flex}>
{fetching && totalChapters === undefined ? (
<ChapterCountSkeleton theme={theme} />
) : (
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{`${totalChapters} ${getString('novelScreen.chapters')}`}
</Text>
)}
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{!fetching || totalChapters !== undefined
? `${totalChapters} ${getString('novelScreen.chapters')}`
: getString('common.loading')}
</Text>
</View>
<IconButton
icon="filter-variant"
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
size={24}
onPress={handleOpenBottomSheet}
onPress={() => novelBottomSheetRef.current?.present()}
/>
</Pressable>
</View>
@@ -453,19 +268,6 @@ 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,
@@ -479,36 +281,4 @@ 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 {
firstUnreadChapter?: ChapterInfo;
chapters: ChapterInfo[];
lastRead?: ChapterInfo;
navigateToChapter: (chapter: ChapterInfo) => void;
}
const ReadButton = ({
firstUnreadChapter,
chapters,
lastRead,
navigateToChapter,
}: ReadButtonProps) => {
const { useFabForContinueReading = false } = useAppSettings();
const targetChapter = lastRead ?? firstUnreadChapter;
const navigateToTargetChapter = () => {
if (targetChapter) {
navigateToChapter(targetChapter);
const navigateToLastReadChapter = () => {
if (lastRead) {
navigateToChapter(lastRead);
} else if (chapters.length) {
navigateToChapter(chapters[0]);
}
};
if (!useFabForContinueReading) {
return targetChapter ? (
return chapters.length > 0 || lastRead ? (
<Animated.View entering={ZoomIn.duration(150)}>
<Button
title={
lastRead
? `${getString('novelScreen.continueReading')} ${lastRead.name}`
: getString('novelScreen.startReadingChapters', {
name: targetChapter.name,
name: chapters[0].name,
})
}
style={styles.margin}
onPress={navigateToTargetChapter}
onPress={navigateToLastReadChapter}
mode="contained"
/>
</Animated.View>
@@ -12,10 +12,6 @@ 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,
@@ -25,26 +21,22 @@ 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: loadedChapters,
chapters,
navigation,
novel,
chapterListRef,
loadUpToBatch,
totalChapters,
}: JumpToChapterModalProps) => {
const minNumber = 1;
const maxNumber = totalChapters ?? -1;
const minNumber = Math.min(...chapters.map(c => c.chapterNumber || -1));
const maxNumber = Math.max(...chapters.map(c => c.chapterNumber || -1));
const theme = useTheme();
const [mode, setMode] = useState(false);
const [openChapter, setOpenChapter] = useState(false);
@@ -73,29 +65,22 @@ const JumpToChapterModal = ({
});
};
const scrollToChapter = async (chap: ChapterInfo) => {
const scrollToChapter = (chap: ChapterInfo) => {
onDismiss();
const loadedIndex = loadedChapters.findIndex(c => c.id === chap.id);
if (loadedIndex >= 0) {
chapterListRef.current?.scrollToIndex({
animated: true,
index: loadedIndex,
viewPosition: 0.5,
});
return;
}
chapterListRef.current?.scrollToItem({
animated: true,
item: chap,
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 scrollToIndex = (index: number) => {
onDismiss();
chapterListRef.current?.scrollToIndex({
animated: true,
index: index,
viewPosition: 0.5,
});
};
const executeFunction = (item: ChapterInfo) => {
@@ -128,48 +113,44 @@ const JumpToChapterModal = ({
);
};
const onSubmit = async () => {
const onSubmit = () => {
if (!mode) {
// Number search
const num = Number(text);
if (num && num >= minNumber && num <= maxNumber) {
const chapters = await getNovelChaptersByNumber(novel!.id, num);
if (chapters.length > 0) {
const chapter = chapters[0];
if (openChapter) {
if (openChapter) {
const chapter = chapters.find(c => c.chapterNumber === num);
if (chapter) {
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 {
// Text search
const chapters = await getNovelChaptersByName(
novel!.id,
text.toLowerCase(),
const searchedChapters = chapters.filter(chap =>
chap.name.toLowerCase().includes(text?.toLowerCase()),
);
if (!chapters.length) {
if (!searchedChapters.length) {
setError(
getString('novelScreen.jumpToChapterModal.error.validChapterName'),
);
return;
}
if (chapters.length === 1) {
if (searchedChapters.length === 1) {
if (openChapter) {
return navigateToChapter(chapters[0]);
} else {
return scrollToChapter(chapters[0]);
return navigateToChapter(searchedChapters[0]);
}
return scrollToChapter(searchedChapters[0]);
}
return setResult(chapters);
return setResult(searchedChapters);
}
};
@@ -227,12 +208,14 @@ 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 ? (
@@ -243,7 +226,6 @@ const JumpToChapterModal = ({
data={result}
extraData={openChapter}
renderItem={renderItem}
keyExtractor={item => `chapter_${item.id}`}
contentContainerStyle={styles.listContentCtn}
/>
</View>
+35 -66
View File
@@ -1,7 +1,6 @@
import React, { memo, useCallback, useMemo, useState } from 'react';
import { getString } from '@strings/translations';
import { Appbar } from 'react-native-paper';
import { Menu as DefaultMenu } from '@components';
import { Appbar, Menu as DefaultMenu } from 'react-native-paper';
import { ThemeColors } from '@theme/types';
import Animated, {
FadeIn,
@@ -17,9 +16,6 @@ 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,
@@ -34,32 +30,20 @@ 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}
contentStyle={contentStyle}
anchorPosition="bottom"
contentStyle={{ backgroundColor: theme.surface2 }}
>
{items.map((item, index) => (
<DefaultMenu.Item
key={index + item.label}
title={item.label}
style={itemStyle}
titleStyle={titleStyle}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
onDismiss();
item.onPress();
@@ -112,11 +96,6 @@ const NovelAppbar = ({
const [downloadMenu, showDownloadMenu] = useState(false);
const [extraMenu, showExtraMenu] = useState(false);
const appbarTheme = useMemo(
() => ({ colors: theme }),
[theme],
);
const AppbarAction = useCallback(
(props: {
icon: MaterialDesignIconName;
@@ -124,19 +103,20 @@ const NovelAppbar = ({
style?: StyleProp<ViewStyle>;
size?: number;
}) => {
const AA = Animated.createAnimatedComponent(Appbar.Action);
return (
<AnimatedAppbarAction
<AA
entering={FadeIn.duration(250)}
// delay to prevent flickering on rerenders
exiting={FadeOut.delay(50).duration(250)}
theme={appbarTheme}
theme={{ colors: theme }}
size={24}
{...props}
/>
);
},
[appbarTheme],
[theme],
);
const downloadMenuItems = useMemo(() => {
return [
{
@@ -170,34 +150,8 @@ 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
@@ -205,7 +159,7 @@ const NovelAppbar = ({
exiting={SlideOutUp.duration(250)}
style={headerOpacityStyle}
>
<Appbar.Header theme={headerTheme}>
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
<Appbar.BackAction onPress={goBack} />
<View style={styles.row}>
@@ -213,16 +167,18 @@ const NovelAppbar = ({
<AppbarAction icon="share-variant" onPress={shareNovel} />
<AppbarAction
icon="text-box-search-outline"
onPress={openJumpToChapter}
onPress={() => {
showJumpToChapterModal(true);
}}
/>
{!isLocal ? (
{!isLocal && (
<Menu
theme={theme}
visible={downloadMenu}
onDismiss={closeDlMenu}
anchor={
<Appbar.Action
theme={appbarTheme}
theme={{ colors: theme }}
icon="download-outline"
onPress={openDlMenu}
size={26}
@@ -230,20 +186,33 @@ const NovelAppbar = ({
}
items={downloadMenuItems}
/>
) : null}
)}
<Menu
visible={extraMenu}
onDismiss={closeExtraMenu}
onDismiss={() => showExtraMenu(false)}
anchor={
<Appbar.Action
theme={appbarTheme}
theme={{ colors: theme }}
icon="dots-vertical"
onPress={openExtraMenu}
onPress={() => showExtraMenu(true)}
size={24}
/>
}
theme={theme}
items={extraMenuItems}
items={[
{
label: getString('novelScreen.edit.info'),
onPress: () => {
showEditInfoModal(true);
},
},
{
label: getString('novelScreen.edit.cover'),
onPress: () => {
setCustomNovelCover();
},
},
]}
/>
</View>
</Appbar.Header>
@@ -255,7 +224,7 @@ const styles = StyleSheet.create({
row: {
flexDirection: 'row',
position: 'absolute',
end: 0,
right: 0,
},
});
@@ -63,7 +63,6 @@ export default function NovelDrawer({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `novel_page_${index}_${item}`}
estimatedItemSize={60}
/>
</View>
+201 -300
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import ChapterItem from './ChapterItem';
import NovelInfoHeader from './Info/NovelInfoHeader';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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 chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id;
const ListEmptyComponent = () => <ChapterListSkeleton />;
const NovelScreenList = ({
headerOpacity,
@@ -66,7 +66,6 @@ const NovelScreenList = ({
chapters,
deleteChapter,
fetching,
firstUnreadChapter,
getNovel,
lastRead,
loading,
@@ -75,11 +74,11 @@ const NovelScreenList = ({
setNovel,
sortAndFilterChapters,
setShowChapterTitles,
updateChapter,
novel: fetchedNovel,
batchInformation,
pageIndex,
openPage,
updateChapter,
} = useNovelContext();
const { pluginId } = routeBaseNovel;
@@ -109,25 +108,7 @@ const NovelScreenList = ({
const theme = useTheme();
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
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 { downloadQueue, downloadChapter } = useDownload();
const [isFabExtended, setIsFabExtended] = useState(true);
const [showScrollToTop, setShowScrollToTop] = useState(false);
@@ -138,116 +119,20 @@ const NovelScreenList = ({
const deleteDownloadsSnackbar = useBoolean();
// Derive selectedIds Set for O(1) lookups
const selectedIds = useMemo(
() => new Set(selected.map(s => s.id)),
[selected],
);
const isSelectionMode = selected.length > 0;
const onPageScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
const onPageScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
const currentScrollPosition = Math.floor(y) ?? 0;
if (useFabForContinueReading && lastRead) {
setIsFabExtended(currentScrollPosition <= 0);
}
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
const currentScrollPosition = Math.floor(y) ?? 0;
if (useFabForContinueReading && lastRead) {
setIsFabExtended(currentScrollPosition <= 0);
}
const screenHeight = Dimensions.get('window').height;
setShowScrollToTop(currentScrollPosition > screenHeight / 2);
};
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 () => {
const onRefresh = async () => {
if (novel.id !== 'NO_ID') {
setUpdating(true);
updateNovel(pluginId, novel.path, novel.id, {
@@ -263,42 +148,101 @@ const NovelScreenList = ({
.catch(error => showToast('Failed updating: ' + error.message))
.finally(() => setUpdating(false));
}
}, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]);
};
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));
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 isSelected = (id: number) => {
return selected.some(obj => obj.id === id);
};
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]);
}
},
[novel, pluginId, downloadNewChapters, getNovel],
);
}
};
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 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;
}
const scrollToTop = useCallback(() => {
/**
* 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 = () => {
listRef.current?.scrollToOffset({ offset: 0, animated: true });
}, [listRef]);
};
const setCustomNovelCover = useCallback(async () => {
const setCustomNovelCover = async () => {
if (!novel || novel.id === 'NO_ID') {
return;
}
@@ -309,9 +253,9 @@ const NovelScreenList = ({
cover: newCover,
});
}
}, [novel, setNovel]);
};
const saveNovelCover = useCallback(async () => {
const saveNovelCover = async () => {
if (!novel) {
showToast(getString('novelScreen.coverNotSaved'));
return;
@@ -339,6 +283,7 @@ 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(
@@ -362,124 +307,10 @@ 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
@@ -487,40 +318,91 @@ const NovelScreenList = ({
estimatedItemSize={64}
data={chapters}
recycleItems
ListEmptyComponent={listEmptyComponent}
renderItem={({ item }) => {
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 }) => {
if (novel.id === 'NO_ID') {
return null;
}
return (
<ChapterItem
chapter={item}
isDownloading={downloadingChapterIds.has(item.id)}
isDownloading={downloadQueue.some(
c => c.task.data.chapterId === item.id,
)}
isBookmarked={!!item.bookmark}
isLocal={novel.isLocal}
theme={theme}
chapter={item}
showChapterTitles={showChapterTitles}
isSelected={selectedIds.has(item.id)}
novelName={novel.name}
onDeleteChapter={handleDeleteChapter}
onDownloadChapter={handleDownloadChapter}
deleteChapter={() => deleteChapter(item)}
downloadChapter={() => downloadChapter(novel, item)}
isSelected={isSelected(item.id)}
onSelectPress={onSelectPress}
onSelectLongPress={onSelectLongPress}
navigateToChapter={navigateToChapter}
novelName={novel.name}
setChapterDownloaded={(value: boolean) =>
updateChapter?.(index, { isDownloaded: value })
}
/>
);
}}
keyExtractor={chapterKeyExtractor}
extraData={downloadingChapterIds}
keyExtractor={item => 'c' + item.id}
contentContainerStyle={styles.contentContainer}
refreshControl={refreshControlElement}
refreshControl={refreshControl()}
onEndReached={getNextChapterBatch}
onEndReachedThreshold={6}
onScroll={onPageScroll}
scrollEventThrottle={16}
drawDistance={1000}
ListHeaderComponent={listHeader}
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}
/>
}
/>
{novel.id !== 'NO_ID' ? (
{novel.id !== 'NO_ID' && (
<>
<NovelBottomSheet
bottomSheetRef={novelBottomSheetRef}
@@ -541,9 +423,12 @@ const NovelScreenList = ({
openPage={openPage}
/>
) : null}
{showScrollToTop ? (
{showScrollToTop && (
<AnimatedFAB
style={scrollToTopFabStyle}
style={[
styles.scrollToTopFab,
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
]}
color={theme.primary}
icon="arrow-up"
label=""
@@ -551,24 +436,40 @@ const NovelScreenList = ({
onPress={scrollToTop}
visible={showScrollToTop}
/>
) : null}
{useFabForContinueReading && (lastRead || firstUnreadChapter) ? (
)}
{useFabForContinueReading && (lastRead || chapters[0]) ? (
<AnimatedFAB
style={continueFabStyle}
style={[
styles.fab,
{ backgroundColor: theme.primary, marginBottom: bottomInset },
]}
extended={isFabExtended && !loading}
color={theme.onPrimary}
uppercase={false}
label={continueFabLabel}
label={
lastRead
? getString('common.resume')
: getString('novelScreen.startReadingChapters', {
name: '',
}).trim()
}
icon="play"
onPress={onFabPress}
onPress={() => {
navigation.navigate('ReaderStack', {
screen: 'Chapter',
params: {
novel: novel,
chapter: lastRead ?? chapters[0],
},
});
}}
/>
) : null}
</>
) : null}
)}
</>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
contentContainer: { paddingBottom: 100 },
@@ -34,10 +34,12 @@ export default function PageNavigationBottomSheet({
<View
style={[
styles.pageItemContainer,
isSelected && {
backgroundColor: theme.isDark
? color(theme.primary).alpha(0.2).string()
: color(theme.primaryContainer).alpha(0.5).string(),
{
backgroundColor: isSelected
? theme.isDark
? color(theme.primary).alpha(0.2).string()
: color(theme.primaryContainer).alpha(0.5).string()
: 'transparent',
},
]}
>
@@ -81,8 +83,8 @@ export default function PageNavigationBottomSheet({
styles.contentContainer,
{
backgroundColor: overlay(2, theme.surface),
marginStart: left,
marginEnd: right,
marginLeft: left,
marginRight: right,
paddingBottom: insets.bottom,
},
]}
@@ -93,7 +95,6 @@ export default function PageNavigationBottomSheet({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `page_${index}_${item}`}
estimatedItemSize={56}
contentContainerStyle={styles.listContent}
/>
@@ -7,79 +7,72 @@ import { Row } from '@components/Common';
import { borderColor } from '@theme/colors';
interface PagePaginationControlProps {
pages: string[];
currentPageIndex: number;
onPageChange: (pageIndex: number) => void;
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
onOpenDrawer: () => void;
theme: ThemeColors;
}
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
pages,
currentPageIndex,
currentPage,
totalPages,
onPageChange,
onOpenDrawer,
theme,
}) => {
const totalPages = pages.length;
const pageIndices = useMemo(() => {
const indices: (number | 'ellipsis')[] = [];
const pageNumbers = useMemo(() => {
const pages: (number | 'ellipsis')[] = [];
if (totalPages <= 3) {
for (let i = 0; i < totalPages; i++) {
indices.push(i);
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page if not current
if (currentPageIndex !== 0) {
indices.push(0);
if (currentPage !== 1) {
pages.push(1);
}
// Show page before current (with ellipsis if there's a gap)
const leftPageIndex = currentPageIndex - 1;
if (leftPageIndex > 0) {
if (leftPageIndex > 1) {
indices.push('ellipsis');
const leftPage = currentPage - 1;
if (leftPage > 1) {
if (leftPage > 2) {
pages.push('ellipsis');
}
pages.push(leftPage);
}
// Always show current page
indices.push(currentPageIndex);
pages.push(currentPage);
// 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 (currentPage < totalPages - 1) {
pages.push('ellipsis');
}
// Always show last page if not current
if (currentPageIndex !== totalPages - 1) {
indices.push(totalPages - 1);
if (totalPages !== currentPage) {
pages.push(totalPages);
}
}
return indices;
}, [currentPageIndex, totalPages]);
return pages;
}, [currentPage, totalPages]);
const canGoPrevious = currentPageIndex > 0;
const canGoNext = currentPageIndex < totalPages - 1;
const canGoPrevious = currentPage > 1;
const canGoNext = currentPage < totalPages;
const handlePrevious = () => {
if (canGoPrevious) {
onPageChange(currentPageIndex - 1);
onPageChange(currentPage - 1);
}
};
const handleNext = () => {
if (canGoNext) {
onPageChange(currentPageIndex + 1);
onPageChange(currentPage + 1);
}
};
const handlePagePress = (pageIndex: number) => {
if (pageIndex !== currentPageIndex) {
onPageChange(pageIndex);
const handlePagePress = (page: number) => {
if (page !== currentPage) {
onPageChange(page);
}
};
@@ -108,8 +101,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
</Pressable>
<Row style={styles.pageNumbersRow}>
{pageIndices.map((pageIndex, index) => {
if (pageIndex === 'ellipsis') {
{pageNumbers.map((page, index) => {
if (page === 'ellipsis') {
return (
<Pressable
key={`ellipsis-${index}`}
@@ -131,19 +124,18 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
);
}
const isActive = pageIndex === currentPageIndex;
const pageName = pages[pageIndex];
const isActive = page === currentPage;
return (
<Pressable
key={`page-${pageIndex}`}
key={page}
style={[
styles.button,
{
borderColor: isActive ? 'transparent' : borderColor,
backgroundColor: isActive ? theme.primary : theme.surface,
},
isActive ? styles.activeButton : styles.inactiveButton,
]}
onPress={() => handlePagePress(pageIndex)}
onPress={() => handlePagePress(page)}
android_ripple={{
color: isActive
? color(theme.onPrimary).alpha(0.2).string()
@@ -155,12 +147,11 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
styles.pageText,
{
color: isActive ? theme.onPrimary : theme.onSurface,
fontWeight: isActive ? '600' : '400',
},
isActive ? styles.activePageText : styles.inactivePageText,
]}
numberOfLines={1}
>
{pageName}
{page}
</Text>
</Pressable>
);
@@ -203,12 +194,13 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
},
container: {
flex: 1,
alignItems: 'center',
flexDirection: 'row',
gap: 8,
justifyContent: 'space-between',
paddingHorizontal: 16,
marginBottom: 16,
paddingTop: 12,
},
disabledButton: {
opacity: 0.5,
@@ -228,28 +220,12 @@ 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 { Dimensions, FlatList, StyleSheet, Text, View } from 'react-native';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { Divider, Portal } from 'react-native-paper';
import { NavigationProp, useNavigation } from '@react-navigation/native';
@@ -61,7 +61,6 @@ const SetCategoryModal: React.FC<SetCategoryModalProps> = ({
</Text>
<FlatList
data={categories}
style={styles.categoryList}
renderItem={({ item }) => (
<Checkbox
status={
@@ -128,9 +127,6 @@ 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,7 +6,6 @@ import { RadioButton, RadioButtonGroup } from '@components/RadioButton';
import { useTheme } from '@hooks/persisted';
import {
getAniListScoreFormatting,
getKitsuScoreFormatting,
getMyAnimeListScoreLabel,
} from './constants';
import {
@@ -142,41 +141,6 @@ 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,7 +5,6 @@ import { Button, DialogTitle, Modal } from '@components';
import { getString } from '@strings/translations';
import {
AniListScoreSelector,
KitsuScoreSelector,
MangaUpdatesScoreSelector,
MyAnimeListScoreSelector,
} from './ScoreSelectors';
@@ -47,13 +46,6 @@ 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, theme.rippleColor, theme.primary, theme.onSurface],
[selectedNovel, handleSelectNovel],
);
return (
@@ -45,14 +45,11 @@ const TrackSheet: React.FC<TrackSheetProps> = ({ bottomSheetRef, novel }) => {
bottomSheetRef.current?.close();
}, [bottomSheetRef]);
const handleSetSearchTrackDialog = useCallback(
(tracker: TrackerMetadata) => {
closeBottomSheet();
setActiveTracker(tracker);
setTrackSearchDialog(true);
},
[closeBottomSheet],
);
const handleSetSearchTrackDialog = useCallback((tracker: TrackerMetadata) => {
closeBottomSheet();
setActiveTracker(tracker);
setTrackSearchDialog(true);
}, []);
const handleSetStatusDialog = useCallback(
(tracker: TrackerMetadata) => {
@@ -3,10 +3,7 @@ import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { IconButton } from 'react-native-paper';
import { useTheme } from '@hooks/persisted';
import {
getAniListScoreFormatting,
getKitsuScoreFormatting,
} from './constants';
import { getAniListScoreFormatting } from './constants';
import { AddTrackingCardProps, TrackedItemCardProps } from './types';
export const AddTrackingCard: React.FC<AddTrackingCardProps> = ({
@@ -63,16 +60,12 @@ 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,7 +7,6 @@ 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> = {
@@ -92,25 +91,3 @@ 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={[{ paddingStart: left, paddingEnd: right }, styles.container]}
style={[{ paddingLeft: left, paddingRight: right }, styles.container]}
>
{keepScreenOn ? <KeepScreenAwake /> : null}
{loading ? (
@@ -16,7 +16,6 @@ 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;
@@ -30,15 +29,7 @@ type ButtonsProperties = {
const ChapterDrawer = () => {
const { chapter, getChapter, setLoading } = useChapterContext();
const {
chapters,
novelSettings,
pages,
fetching,
batchInformation,
getNextChapterBatch,
setPageIndex,
} = useNovelContext();
const { chapters, novelSettings, pages, setPageIndex } = useNovelContext();
const theme = useTheme();
const insets = useSafeAreaInsets();
const { defaultChapterSort } = useAppSettings();
@@ -98,9 +89,9 @@ const ChapterDrawer = () => {
const newBtnLayout = Object.create(defaultButtonLayout);
if (viewableItems.length === 0) return;
const visible = viewableItems
.map(v => v.index)
.includes((scrollToIndex.current ?? 0) + 2);
const cKey = (scrollToIndex.current ?? 0) + 2;
const vKey = parseInt(viewableItems[0].key, 10);
const visible = vKey <= cKey && cKey <= vKey + viewableItems.length - 1;
if (!visible && scrollToIndex.current !== undefined) {
if (
@@ -119,7 +110,12 @@ const ChapterDrawer = () => {
};
}
}
if (cKey <= 2 && vKey <= 4) {
newBtnLayout.up = {
text: curChapter,
index: scrollToIndex.current,
};
}
setButtonProperties(newBtnLayout);
},
[defaultButtonLayout, listAscending],
@@ -140,10 +136,7 @@ const ChapterDrawer = () => {
useEffect(() => {
const next = calculateScrollToIndex();
if (next !== undefined) {
if (
scrollToIndex.current === undefined ||
next !== scrollToIndex.current
) {
if (scrollToIndex.current === undefined) {
scroll(next);
}
scrollToIndex.current = next;
@@ -161,14 +154,12 @@ const ChapterDrawer = () => {
recycleItems
viewabilityConfig={{
minimumViewTime: 100,
itemVisiblePercentThreshold: 90,
viewAreaCoveragePercentThreshold: 95,
}}
onViewableItemsChanged={checkViewableItems}
data={chapters}
extraData={[chapter, scrollToIndex.current]}
keyExtractor={item =>
`chapter_${item.id}_${item.position ?? 'no_pos'}`
}
keyExtractor={item => (item.position ?? item.id).toString()}
renderItem={val =>
renderListChapter({
item: val.item,
@@ -183,12 +174,6 @@ const ChapterDrawer = () => {
}
estimatedItemSize={60}
initialScrollIndex={scrollToIndex.current}
onEndReached={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
onEndReachedThreshold={6}
/>
)}
<View style={styles.footer}>
@@ -235,7 +220,8 @@ const createStylesheet = (theme: ThemeColors, insets: EdgeInsets) => {
drawerElementContainer: {
borderRadius: 50,
margin: 4,
marginHorizontal: 16,
marginLeft: 16,
marginRight: 16,
minHeight: 48,
overflow: 'hidden',
},
@@ -128,7 +128,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
bookmark: {
marginEnd: 4,
marginRight: 4,
},
container: {
flex: 1,

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