Readd commits from before force push

Fixing mistake
This commit is contained in:
Patrick Loser
2025-06-06 18:51:32 +02:00
committed by GitHub
34 changed files with 17528 additions and 974 deletions
+2 -2
View File
@@ -27,10 +27,10 @@ Notifications.setNotificationHandler({
};
},
});
createTables();
LottieSplashScreen.hide();
const App = () => {
createTables();
LottieSplashScreen.hide();
return (
<GestureHandlerRootView style={styles.flex}>
<AppErrorBoundary>
+5
View File
@@ -116,6 +116,11 @@ android {
kotlinOptions {
jvmTarget = '17'
}
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt"
}
}
}
dependencies {
@@ -12,7 +12,6 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.rajarsheechatterjee.NativeEpubUtil.NativeEpubUtilPackage
import com.rajarsheechatterjee.NativeFile.NativePackage
import com.rajarsheechatterjee.NativeVolumeButtonListener.NativeVolumeButtonListenerPackage
import com.rajarsheechatterjee.NativeZipArchive.NativeZipArchivePackage
@@ -23,7 +22,6 @@ class MainApplication : Application(), ReactApplication {
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(NativeEpubUtilPackage())
add(NativePackage())
add(NativeVolumeButtonListenerPackage())
add(NativeZipArchivePackage())
@@ -1,3 +0,0 @@
package com.rajarsheechatterjee.NativeEpubUtil
class ChapterEntry (val name: String, val href: String)
@@ -1,192 +0,0 @@
package com.rajarsheechatterjee.NativeEpubUtil
import android.util.Xml
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.lnreader.spec.NativeEpubUtilSpec
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.FileInputStream
class NativeEpubUtil(context: ReactApplicationContext) : NativeEpubUtilSpec(context) {
private fun initParse(file: File): XmlPullParser {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(FileInputStream(file), null)
parser.nextTag()
return parser
}
private fun readText(parser: XmlPullParser): String {
var result = ""
if (parser.next() == XmlPullParser.TEXT) {
result = parser.text
parser.nextTag()
}
return result
}
private fun cleanUrl(url: String): String {
return url.replaceFirst("#[^.]+?$".toRegex(), "")
}
private fun getContentMetaFilePath(file: File): String {
val parser = initParse(file)
while (parser.next() != XmlPullParser.END_TAG) {
val tag = parser.name
if (tag != null && tag == "rootfile") {
return parser.getAttributeValue(null, "full-path")
}
}
return "OEBPS/content.opf" // default
}
private fun mergeChapters(
entryList: List<ChapterEntry>,
fileList: List<String>,
contentDir: String
): ReadableArray {
val foundIndexes = entryList.mapIndexed { index, entry ->
val foundIndex = fileList.indexOf(entry.href)
if (foundIndex < index) -1
else foundIndex
}
val linkedIndex = foundIndexes.mapIndexed { arrayIndex, foundIndex ->
if (foundIndex == -1) Pair(-1, -1)
else {
var nextIndex = -1
for (j in arrayIndex + 1 until foundIndexes.size) {
if (foundIndexes[j] != -1) {
nextIndex = foundIndexes[j]
break
}
}
if (nextIndex == -1) nextIndex = fileList.size
Pair(foundIndex, nextIndex)
}
}
val chapters: WritableArray = WritableNativeArray()
linkedIndex.forEachIndexed { arrayIndex, (firstIndex, lastIndex) ->
val chapter = WritableNativeMap()
val entryPath = "${contentDir}/${entryList[arrayIndex].href}"
chapter.putString("name", entryList[arrayIndex].name)
if (firstIndex == -1 || firstIndex == lastIndex) {
chapter.putString("path", entryPath)
} else {
val mergedChapterPath = "${entryPath}-copy-$arrayIndex.html"
chapter.putString("path", mergedChapterPath)
val entryFile = File(entryPath)
val mergedFile = File(mergedChapterPath)
mergedFile.createNewFile()
mergedFile.appendBytes(entryFile.readBytes())
for (i in firstIndex + 1 until lastIndex) {
val file = File("${contentDir}/${fileList[i]}")
mergedFile.appendBytes(file.readBytes())
}
}
chapters.pushMap(chapter)
}
return chapters
}
private fun getNovelMetadata(file: File, contentDir: String): WritableMap {
val novel: WritableMap = WritableNativeMap()
val parser = initParse(file)
val refMap = HashMap<String, String>()
val entryList = mutableListOf<ChapterEntry>()
val fileList = mutableListOf<String>()
val tocFile = File(contentDir, "toc.ncx")
if (tocFile.exists()) {
val tocParser = initParse(tocFile)
var label = ""
while (tocParser.next() != XmlPullParser.END_DOCUMENT) {
if (tocParser.name == "navMap") break
}
while (tocParser.next() != XmlPullParser.END_DOCUMENT) {
val tag = tocParser.name
if (tag == "navMap") break
if (tag != null) {
if (tag == "text") {
label = readText(tocParser)
} else if (tag == "content") {
val href = cleanUrl(tocParser.getAttributeValue(null, "src"))
if (label.isNotBlank() && File("${contentDir}/$href").exists()) {
entryList.add(ChapterEntry(name = label, href = href))
}
label = ""
}
}
}
} else {
throw Exception("Table of content (toc.ncx) does not exist!")
}
var cover: String? = null
while (parser.next() != XmlPullParser.END_DOCUMENT) {
val tag = parser.name
if (tag != null) {
when (tag) {
"item" -> {
val id = parser.getAttributeValue(null, "id")
val href = parser.getAttributeValue(null, "href")
if (id != null) {
refMap[id] = href
}
}
"itemref" -> {
val idRef = parser.getAttributeValue(null, "idref")
val href = refMap[idRef]
if (href != null && File("$contentDir/$href").exists()) {
fileList.add(href)
}
}
"title" -> novel.putString("name", readText(parser))
"creator" -> novel.putString("author", readText(parser))
"contributor" -> novel.putString("artist", readText(parser))
"description" -> novel.putString("summary", readText(parser))
"meta" -> {
val metaName = parser.getAttributeValue(null, "name")
if (metaName != null && metaName == "cover") {
cover = parser.getAttributeValue(null, "content")
}
}
}
parser.next()
}
}
val chapters = mergeChapters(entryList, fileList, contentDir)
novel.putArray("chapters", chapters)
if (cover != null) {
val coverPath = contentDir + "/" + refMap[cover]
novel.putString("cover", coverPath)
} else {
// try scanning Images dir if exists
val imageDir = File("$contentDir/Images")
if (imageDir.exists() && imageDir.isDirectory) {
imageDir.listFiles()?.forEach { img ->
if (img.isFile && img.name.lowercase().contains("cover|illus".toRegex())) {
cover = img.path
}
}
}
if (cover != null) {
novel.putString("cover", cover)
}
}
return novel
}
override fun parseNovelAndChapters(epubDirPath: String?): WritableMap? {
val containerFile = File(epubDirPath, "META-INF/container.xml")
val contentMetaFile = File(epubDirPath, getContentMetaFilePath(containerFile))
val contentDir = contentMetaFile.parent
val novel = contentDir?.let { getNovelMetadata(contentMetaFile, it) }
return novel
}
}
@@ -1,30 +0,0 @@
package com.rajarsheechatterjee.NativeEpubUtil
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.lnreader.spec.NativeEpubUtilSpec
class NativeEpubUtilPackage : BaseReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == NativeEpubUtilSpec.NAME) {
NativeEpubUtil(reactContext)
} else {
null
}
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
NativeEpubUtilSpec.NAME to ReactModuleInfo(
NativeEpubUtilSpec.NAME,
NativeEpubUtilSpec.NAME,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = true
)
)
}
}
+17
View File
@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.13)
# Define the library name here.
project(appmodules)
# This file includes all the necessary to let you build your React Native application
include(${REACT_ANDROID_DIR}/cmake-utils/ReactNative-application.cmake)
# Define where the additional source code lives. We need to crawl back the jni, main, src, app, android folders
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
../../../../../shared/NativeEpub.cpp
../../../../../shared/Epub.cpp
../../../../../shared/pugixml.cpp
)
# Define where CMake can find the additional header files. We need to crawl back the jni, main, src, app, android folders
target_include_directories(${CMAKE_PROJECT_NAME} PUBLIC ../../../../../shared)
+138
View File
@@ -0,0 +1,138 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
// This C++ file is part of the default configuration used by apps and is placed
// inside react-native to encapsulate it from user space (so you won't need to
// touch C++/Cmake code at all on Android).
//
// If you wish to customize it (because you want to manually link a C++ library
// or pass a custom compilation flag) you can:
//
// 1. Copy this CMake file inside the `android/app/src/main/jni` folder of your
// project
// 2. Copy the OnLoad.cpp (in this same folder) file inside the same folder as
// above.
// 3. Extend your `android/app/build.gradle` as follows
//
// android {
// // Other config here...
// externalNativeBuild {
// cmake {
// path "src/main/jni/CMakeLists.txt"
// }
// }
// }
#include <DefaultComponentsRegistry.h>
#include <DefaultTurboModuleManagerDelegate.h>
#include <autolinking.h>
#include <fbjni/fbjni.h>
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <rncore.h>
#include <NativeEpub.hpp>
#ifdef REACT_NATIVE_APP_CODEGEN_HEADER
#include REACT_NATIVE_APP_CODEGEN_HEADER
#endif
#ifdef REACT_NATIVE_APP_COMPONENT_DESCRIPTORS_HEADER
#include REACT_NATIVE_APP_COMPONENT_DESCRIPTORS_HEADER
#endif
namespace facebook::react
{
void registerComponents(
std::shared_ptr<const ComponentDescriptorProviderRegistry> registry)
{
// Custom Fabric Components go here. You can register custom
// components coming from your App or from 3rd party libraries here.
//
// providerRegistry->add(concreteComponentDescriptorProvider<
// MyComponentDescriptor>());
// We link app local components if available
#ifdef REACT_NATIVE_APP_COMPONENT_REGISTRATION
REACT_NATIVE_APP_COMPONENT_REGISTRATION(registry);
#endif
// And we fallback to the components autolinked
autolinking_registerProviders(registry);
}
std::shared_ptr<TurboModule> cxxModuleProvider(
const std::string &name,
const std::shared_ptr<CallInvoker> &jsInvoker)
{
// Here you can provide your CXX Turbo Modules coming from
// either your application or from external libraries. The approach to follow
// is similar to the following (for a module called `NativeCxxModuleExample`):
//
// if (name == NativeCxxModuleExample::kModuleName) {
// return std::make_shared<NativeCxxModuleExample>(jsInvoker);
// }
if (name == NativeEpub::kModuleName)
{
return std::make_shared<NativeEpub>(jsInvoker);
}
// And we fallback to the CXX module providers autolinked
return autolinking_cxxModuleProvider(name, jsInvoker);
}
std::shared_ptr<TurboModule> javaModuleProvider(
const std::string &name,
const JavaTurboModule::InitParams &params)
{
// Here you can provide your own module provider for TurboModules coming from
// either your application or from external libraries. The approach to follow
// is similar to the following (for a library called `samplelibrary`):
//
// auto module = samplelibrary_ModuleProvider(name, params);
// if (module != nullptr) {
// return module;
// }
// return rncore_ModuleProvider(name, params);
// We link app local modules if available
#ifdef REACT_NATIVE_APP_MODULE_PROVIDER
auto module = REACT_NATIVE_APP_MODULE_PROVIDER(name, params);
if (module != nullptr)
{
return module;
}
#endif
// We first try to look up core modules
if (auto module = rncore_ModuleProvider(name, params))
{
return module;
}
// And we fallback to the module providers autolinked
if (auto module = autolinking_ModuleProvider(name, params))
{
return module;
}
return nullptr;
}
} // namespace facebook::react
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *)
{
return facebook::jni::initialize(vm, []
{
facebook::react::DefaultTurboModuleManagerDelegate::cxxModuleProvider =
&facebook::react::cxxModuleProvider;
facebook::react::DefaultTurboModuleManagerDelegate::javaModuleProvider =
&facebook::react::javaModuleProvider;
facebook::react::DefaultComponentsRegistry::
registerComponentDescriptorsFromEntryPoint =
&facebook::react::registerComponents; });
}
+303
View File
@@ -0,0 +1,303 @@
#include <string>
#include <pugixml.hpp>
#include <iostream>
#include <unordered_map>
#include <vector>
#include <Epub.hpp>
#include <sstream>
std::string join(const std::string &folder_path, const std::string &child_path)
{
std::string full_path = folder_path;
if (!full_path.empty() && full_path.back() != '/')
{
full_path += '/';
}
std::stringstream ss(child_path);
std::string segment;
while (std::getline(ss, segment, '/'))
{
if (segment == "..")
{
// Go up one level
if (!full_path.empty())
{
size_t lastSlash = full_path.rfind('/', full_path.length() - 2);
if (lastSlash != std::string::npos)
{
full_path.resize(lastSlash + 1);
}
else
{
full_path = "";
}
}
}
else if (segment != "." && !segment.empty())
{
full_path += segment + '/';
}
}
if (full_path.length() > 1 && full_path.back() == '/')
{
full_path.pop_back();
}
return full_path;
}
std::string getParentPath(const std::string &path)
{
if (path.empty())
return "";
size_t pos = path.find_last_of("/\\");
if (pos == std::string::npos)
{
return "";
}
return path.substr(0, pos);
}
std::string find_toc_href(const pugi::xml_document &opf_doc)
{
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
{
std::string media_type = item.attribute("media-type").value();
std::string id = item.attribute("id").value();
if (media_type == "application/x-dtbncx+xml" || id == "ncx" || id == "nav")
{
return item.attribute("href").value();
}
}
return "";
}
void parse_navele_recursive(const pugi::xml_node &parent,
std::unordered_map<std::string, std::string> &href_to_label, std::string &nav_folder)
{
for (pugi::xml_node li : parent.children("li"))
{
pugi::xml_node a = li.child("a");
if (a)
{
std::string href = a.attribute("href").as_string();
std::string label = a.text().as_string();
size_t sharp = href.find('#');
if (sharp != std::string::npos)
href = href.substr(0, sharp);
if (!href.empty() && !label.empty())
href_to_label[join(nav_folder, href)] = label;
}
if (pugi::xml_node sublist = li.child("ol"))
{
parse_navele_recursive(sublist, href_to_label, nav_folder);
}
}
}
void parse_nav_xhtml(const std::string &nav_path,
std::unordered_map<std::string, std::string> &path_to_label)
{
std::string nav_folder = getParentPath(nav_path);
pugi::xml_document nav_doc;
if (!nav_doc.load_file(nav_path.c_str()))
return;
for (pugi::xpath_node nav : nav_doc.select_nodes("//nav"))
{
pugi::xml_node node = nav.node();
std::string nav_type = node.attribute("epub:type").as_string();
pugi::xml_node ol = node.child("ol");
if (ol)
{
parse_navele_recursive(ol, path_to_label, nav_folder);
}
}
}
void parse_navpoint_recursive(const pugi::xml_node &navPoint,
std::unordered_map<std::string, std::string> &result, std::string &ncx_folder)
{
for (pugi::xml_node point : navPoint.children("navPoint"))
{
std::string label;
pugi::xml_node labelNode = point.child("navLabel").child("text");
if (labelNode)
label = labelNode.text().as_string();
std::string src;
pugi::xml_node contentNode = point.child("content");
if (contentNode)
src = contentNode.attribute("src").as_string();
if (!label.empty() && !src.empty())
{
size_t sharp = src.find('#');
if (sharp != std::string::npos)
src = src.substr(0, sharp);
result[join(ncx_folder, src)] = label;
}
parse_navpoint_recursive(point, result, ncx_folder);
}
}
void parse_toc_ncx(const std::string &ncx_path, std::unordered_map<std::string, std::string> &href_to_label)
{
std::string ncx_folder = getParentPath(ncx_path);
pugi::xml_document doc;
if (!doc.load_file(ncx_path.c_str()))
return;
pugi::xml_node navMap = doc.child("ncx").child("navMap");
if (!navMap)
return;
parse_navpoint_recursive(navMap, href_to_label, ncx_folder);
}
void parse_opf_from_folder(const std::string &base_dir,
const std::string &opf_rel_path,
EpubMetadata &meta_out)
{
std::string opf_path = join(base_dir, opf_rel_path);
std::string opf_dir = getParentPath(opf_path);
pugi::xml_document opf_doc;
if (!opf_doc.load_file(opf_path.c_str()))
return;
std::string version;
pugi::xml_node package = opf_doc.child("package");
if (package)
{
version = package.attribute("version").as_string();
}
std::unordered_map<std::string, std::string> path_to_label;
std::string toc_href = find_toc_href(opf_doc);
if (toc_href.find("ncx") != std::string::npos)
{
std::string ncx_path = join(opf_dir, toc_href);
parse_toc_ncx(ncx_path, path_to_label);
}
else
{
std::string nav_path = join(opf_dir, toc_href);
parse_nav_xhtml(nav_path, path_to_label);
}
auto metadata = opf_doc.child("package").child("metadata");
meta_out.name = metadata.child("dc:title").text().as_string();
meta_out.author = metadata.child("dc:creator").text().as_string();
meta_out.artist = metadata.child("dc:contributor").text().as_string();
meta_out.summary = metadata.child("dc:description").text().as_string();
std::unordered_map<std::string, std::string> id_to_href;
std::string cover_id;
for (pugi::xml_node meta : metadata.children("meta"))
{
if (std::string(meta.attribute("name").value()) == "cover")
{
cover_id = meta.attribute("content").value();
break;
}
}
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
{
std::string id = item.attribute("id").value();
std::string href = item.attribute("href").value();
std::string media_type = item.attribute("media-type").value();
id_to_href[id] = href;
if (media_type == "text/css")
{
meta_out.cssPaths.push_back(join(opf_dir, href));
}
else if (media_type == "image/jpeg" || media_type == "image/png" || media_type == "image/jpg")
{
meta_out.imagePaths.push_back(join(opf_dir, href));
}
}
if (!cover_id.empty() && id_to_href.count(cover_id))
{
meta_out.cover = join(opf_dir, id_to_href[cover_id]);
}
auto spine = opf_doc.child("package").child("spine");
std::string prev_name = "";
int part = 2;
for (pugi::xml_node itemref : spine.children("itemref"))
{
std::string idref = itemref.attribute("idref").value();
if (id_to_href.count(idref))
{
std::string chapter_href = id_to_href[idref];
Chapter chapter;
chapter.path = join(opf_dir, chapter_href);
if (path_to_label.count(chapter.path))
{
chapter.name = path_to_label[chapter.path];
}
if (chapter.name.empty())
{
if (prev_name.empty())
{
int start_pos = chapter_href.find_last_of("/");
if (start_pos == std::string::npos)
{
start_pos = 0;
}
else
{
start_pos++;
}
chapter.name = chapter_href.substr(start_pos, chapter_href.find_last_of(".") - start_pos);
}
else
{
chapter.name = prev_name + " (" + std::to_string(part) + ")";
part += 1;
}
}
else
{
prev_name = chapter.name;
part = 2;
}
meta_out.chapters.push_back(chapter);
}
}
}
EpubMetadata parseEpub(const std::string epub_path)
{
std::string container_path = join(epub_path, "META-INF/container.xml");
pugi::xml_document container_doc;
if (!container_doc.load_file(container_path.c_str()))
throw std::runtime_error("Failed to load container.xml");
std::string opf_path = container_doc.child("container")
.child("rootfiles")
.child("rootfile")
.attribute("full-path")
.value();
EpubMetadata metadata;
parse_opf_from_folder(epub_path, opf_path, metadata);
return metadata;
}
+28
View File
@@ -0,0 +1,28 @@
#include <string>
#include <vector>
#ifndef Epub_H
#define Epub_H
struct Chapter
{
std::string name;
std::string path;
};
struct EpubMetadata
{
std::string name;
std::string path;
std::string cover;
std::string summary;
std::string author;
std::string artist;
std::vector<Chapter> chapters;
std::vector<std::string> cssPaths;
std::vector<std::string> imagePaths;
};
EpubMetadata parseEpub(const std::string epub_path);
#endif
+45
View File
@@ -0,0 +1,45 @@
#include <NativeEpub.hpp>
namespace facebook::react
{
NativeEpub::NativeEpub(std::shared_ptr<CallInvoker> jsInvoker)
: NativeEpubCxxSpec(std::move(jsInvoker)) {}
jsi::Object NativeEpub::parseNovelAndChapters(jsi::Runtime &rt, jsi::String epubDirPath)
{
jsi::Object novel(rt);
EpubMetadata metadata = parseEpub(epubDirPath.utf8(rt).c_str());
novel.setProperty(rt, "name", metadata.name);
novel.setProperty(rt, "author", metadata.author);
novel.setProperty(rt, "artist", metadata.artist);
novel.setProperty(rt, "summary", metadata.summary);
novel.setProperty(rt, "cover", metadata.cover);
jsi::Array chapters(rt, metadata.chapters.size());
for (int i = 0; i < metadata.chapters.size(); i++)
{
jsi::Object chapter(rt);
chapter.setProperty(rt, "name", metadata.chapters[i].name);
chapter.setProperty(rt, "path", metadata.chapters[i].path);
chapters.setValueAtIndex(rt, i, chapter);
}
novel.setProperty(rt, "chapters", chapters);
jsi::Array cssPaths(rt, metadata.cssPaths.size());
for (int i = 0; i < metadata.cssPaths.size(); i++)
{
cssPaths.setValueAtIndex(rt, i, metadata.cssPaths[i]);
}
novel.setProperty(rt, "cssPaths", cssPaths);
jsi::Array imagePaths(rt, metadata.imagePaths.size());
for (int i = 0; i < metadata.imagePaths.size(); i++)
{
imagePaths.setValueAtIndex(rt, i, metadata.imagePaths[i]);
}
novel.setProperty(rt, "imagePaths", imagePaths);
return novel;
}
} // namespace facebook::react
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <LNReaderSpecJSI.h>
#include <string>
#include <Epub.hpp>
namespace facebook::react
{
class NativeEpub : public NativeEpubCxxSpec<NativeEpub>
{
public:
NativeEpub(std::shared_ptr<CallInvoker> jsInvoker);
jsi::Object parseNovelAndChapters(jsi::Runtime &rt, jsi::String epubDirPath);
};
} // namespace facebook::react
+80
View File
@@ -0,0 +1,80 @@
/**
* pugixml parser - version 1.15
* --------------------------------------------------------
* Copyright (C) 2006-2025, by Arseny Kapoulkine (arseny.kapoulkine@gmail.com)
* Report bugs and download new versions at https://pugixml.org/
*
* This library is distributed under the MIT License. See notice at the end
* of this file.
*
* This work is based on the pugxml parser, which is:
* Copyright (C) 2003, by Kristen Wegner (kristen@tima.net)
*/
#ifndef HEADER_PUGICONFIG_HPP
#define HEADER_PUGICONFIG_HPP
// Uncomment this to enable wchar_t mode
// #define PUGIXML_WCHAR_MODE
// Uncomment this to enable compact mode
// #define PUGIXML_COMPACT
// Uncomment this to disable XPath
// #define PUGIXML_NO_XPATH
// Uncomment this to disable STL
// #define PUGIXML_NO_STL
// Uncomment this to disable exceptions
// #define PUGIXML_NO_EXCEPTIONS
// Set this to control attributes for public classes/functions, i.e.:
// #define PUGIXML_API __declspec(dllexport) // to export all public symbols from DLL
// #define PUGIXML_CLASS __declspec(dllimport) // to import all classes from DLL
// #define PUGIXML_FUNCTION __fastcall // to set calling conventions to all public functions to fastcall
// In absence of PUGIXML_CLASS/PUGIXML_FUNCTION definitions PUGIXML_API is used instead
// Tune these constants to adjust memory-related behavior
// #define PUGIXML_MEMORY_PAGE_SIZE 32768
// #define PUGIXML_MEMORY_OUTPUT_STACK 10240
// #define PUGIXML_MEMORY_XPATH_PAGE_SIZE 4096
// Tune this constant to adjust max nesting for XPath queries
// #define PUGIXML_XPATH_DEPTH_LIMIT 1024
// Uncomment this to switch to header-only version
// #define PUGIXML_HEADER_ONLY
// Uncomment this to enable long long support (usually enabled automatically)
// #define PUGIXML_HAS_LONG_LONG
// Uncomment this to enable support for std::string_view (usually enabled automatically)
// #define PUGIXML_HAS_STRING_VIEW
#endif
/**
* Copyright (c) 2006-2025 Arseny Kapoulkine
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
+14246
View File
File diff suppressed because it is too large Load Diff
+1597
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { TurboModule, TurboModuleRegistry } from 'react-native';
interface EpubChapter {
name: string;
path: string;
}
interface EpubNovel {
name: string;
cover: string | null;
summary: string | null;
author: string | null;
artist: string | null;
chapters: EpubChapter[];
cssPaths: string[];
imagePaths: string[];
}
export interface Spec extends TurboModule {
parseNovelAndChapters: (epubDirPath: string) => EpubNovel;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeEpub');
-39
View File
@@ -1,39 +0,0 @@
import { TurboModule, TurboModuleRegistry } from 'react-native';
interface ChapterItem {
name: string;
path: string;
chapterNumber: number | null;
releaseTime: string | null;
page: string | null;
}
enum NovelStatus {
Unknown = 'Unknown',
Ongoing = 'Ongoing',
Completed = 'Completed',
Licensed = 'Licensed',
PublishingFinished = 'Publishing Finished',
Cancelled = 'Cancelled',
OnHiatus = 'On Hiatus',
}
// current codegen can not resolve imported types
interface SourceNovel {
name: string;
path: string;
cover: string | null;
genres: string | null;
summary: string | null;
author: string | null;
artist: string | null;
status: NovelStatus | null;
chapters: ChapterItem[];
totalPages: number | null;
}
export interface Spec extends TurboModule {
parseNovelAndChapters: (epubDirPath: string) => SourceNovel | null;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeEpubUtil');
+9 -7
View File
@@ -5,19 +5,21 @@ import {
} from 'react-native-paper';
import { useTheme } from '@hooks/persisted';
import { ThemeProp } from 'react-native-paper/lib/typescript/types';
interface ButtonProps extends Partial<PaperButtonProps> {
title?: string;
}
const Button: React.FC<ButtonProps> = props => {
const theme = useTheme();
return (
<PaperButton {...props} theme={{ colors: theme }}>
{props.title || props.children}
</PaperButton>
const t = useTheme();
const theme: ThemeProp = React.useMemo(() => ({ colors: t }), [t]);
const Children = React.useMemo(
() => props.title || props.children,
[props.children, props.title],
);
return <PaperButton {...props} theme={theme} children={Children} />;
};
export default Button;
export default React.memo(Button);
+31
View File
@@ -0,0 +1,31 @@
import { useUpdates } from '@hooks/persisted';
import React, { createContext, useContext, useMemo } from 'react';
type UpdateContextType = ReturnType<typeof useUpdates>;
const defaultValue = {} as UpdateContextType;
const UpdateContext = createContext<UpdateContextType>(defaultValue);
export function UpdateContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const useUpdateParams = useUpdates();
const contextValue = useMemo(
() => ({
...useUpdateParams,
}),
[useUpdateParams],
);
return (
<UpdateContext.Provider value={contextValue}>
{children}
</UpdateContext.Provider>
);
}
export const useUpdateContext = (): UpdateContextType => {
return useContext(UpdateContext);
};
+6 -3
View File
@@ -1,5 +1,5 @@
import { useIsFocused } from '@react-navigation/native';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
const useSearch = (defaultSearchText?: string, clearSearchOnUnfocus = true) => {
const isFocused = useIsFocused();
@@ -14,9 +14,12 @@ const useSearch = (defaultSearchText?: string, clearSearchOnUnfocus = true) => {
clearSearchbar();
}
}
}, [isFocused, clearSearchbar, clearSearchOnUnfocus]);
}, [clearSearchbar, clearSearchOnUnfocus, isFocused]);
return { searchText, setSearchText, clearSearchbar };
return useMemo(
() => ({ searchText, setSearchText, clearSearchbar }),
[searchText, setSearchText, clearSearchbar],
);
};
export default useSearch;
+59 -36
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
getDetailedUpdatesFromDb,
getUpdatedOverviewFromDb,
@@ -8,6 +8,7 @@ import { Update, UpdateOverview } from '@database/types';
import { useMMKVBoolean, useMMKVString } from 'react-native-mmkv';
import dayjs from 'dayjs';
import { parseChapterNumber } from '@utils/parseChapterNumber';
import { useFocusEffect } from '@react-navigation/native';
export const SHOW_LAST_UPDATE_TIME = 'SHOW_LAST_UPDATE_TIME';
export const LAST_UPDATE_TIME = 'LAST_UPDATE_TIME';
@@ -33,33 +34,33 @@ export const useUpdates = () => {
useLastUpdate();
const [error, setError] = useState('');
const getDetailedUpdates = async (
novelId: number,
onlyDownloadedChapters: boolean = false,
) => {
setIsLoading(true);
const getDetailedUpdates = useCallback(
async (novelId: number, onlyDownloadedChapters: boolean = false) => {
setIsLoading(true);
let result: Update[] = await getDetailedUpdatesFromDb(
novelId,
onlyDownloadedChapters,
);
result = result.map(update => {
const parsedTime = dayjs(update.releaseTime);
return {
...update,
releaseTime: parsedTime.isValid()
? parsedTime.format('LL')
: update.releaseTime,
chapterNumber: update.chapterNumber
? update.chapterNumber
: parseChapterNumber(update.novelName, update.name),
};
});
setIsLoading(false);
return result;
};
let result: Update[] = await getDetailedUpdatesFromDb(
novelId,
onlyDownloadedChapters,
);
result = result.map(update => {
const parsedTime = dayjs(update.releaseTime);
return {
...update,
releaseTime: parsedTime.isValid()
? parsedTime.format('LL')
: update.releaseTime,
chapterNumber: update.chapterNumber
? update.chapterNumber
: parseChapterNumber(update.novelName, update.name),
};
});
setIsLoading(false);
return result;
},
[],
);
const getUpdates = () => {
const getUpdates = useCallback(async () => {
setIsLoading(true);
getUpdatedOverviewFromDb()
.then(res => {
@@ -75,15 +76,37 @@ export const useUpdates = () => {
})
.catch((err: Error) => setError(err.message))
.finally(() => setIsLoading(false));
};
}, [lastUpdateTime, setLastUpdateTime]);
return {
isLoading,
updatesOverview,
getUpdates,
getDetailedUpdates,
lastUpdateTime,
showLastUpdateTime,
error,
};
useFocusEffect(
useCallback(() => {
setIsLoading(true);
//? Push updates to the end of the stack to avoid lag
setTimeout(async () => {
await getUpdates();
setIsLoading(false);
}, 0);
}, [getUpdates]),
);
return useMemo(
() => ({
isLoading,
updatesOverview,
getUpdates,
getDetailedUpdates,
lastUpdateTime,
showLastUpdateTime,
error,
}),
[
isLoading,
updatesOverview,
getUpdates,
getDetailedUpdates,
lastUpdateTime,
showLastUpdateTime,
error,
],
);
};
+21 -18
View File
@@ -38,6 +38,7 @@ import OnboardingScreen from '@screens/onboarding/OnboardingScreen';
import ServiceManager from '@services/ServiceManager';
import ReaderStack from './ReaderStack';
import { LibraryContextProvider } from '@components/Context/LibraryContext';
import { UpdateContextProvider } from '@components/Context/UpdateContext';
const Stack = createNativeStackNavigator<RootStackParamList>();
const MainNavigator = () => {
@@ -105,24 +106,26 @@ const MainNavigator = () => {
}}
>
<LibraryContextProvider>
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="BottomNavigator" component={BottomNavigator} />
<Stack.Screen name="ReaderStack" component={ReaderStack} />
<Stack.Screen name="MoreStack" component={MoreStack} />
<Stack.Screen name="SourceScreen" component={BrowseSourceScreen} />
<Stack.Screen name="BrowseMal" component={MalTopNovels} />
<Stack.Screen name="BrowseAL" component={AniListTopNovels} />
<Stack.Screen name="BrowseSettings" component={BrowseSettings} />
<Stack.Screen
name="GlobalSearchScreen"
component={GlobalSearchScreen}
/>
<Stack.Screen name="Migration" component={Migration} />
<Stack.Screen name="SourceNovels" component={SourceNovels} />
<Stack.Screen name="MigrateNovel" component={MigrateNovel} />
<Stack.Screen name="WebviewScreen" component={WebviewScreen} />
</Stack.Navigator>
<UpdateContextProvider>
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="BottomNavigator" component={BottomNavigator} />
<Stack.Screen name="ReaderStack" component={ReaderStack} />
<Stack.Screen name="MoreStack" component={MoreStack} />
<Stack.Screen name="SourceScreen" component={BrowseSourceScreen} />
<Stack.Screen name="BrowseMal" component={MalTopNovels} />
<Stack.Screen name="BrowseAL" component={AniListTopNovels} />
<Stack.Screen name="BrowseSettings" component={BrowseSettings} />
<Stack.Screen
name="GlobalSearchScreen"
component={GlobalSearchScreen}
/>
<Stack.Screen name="Migration" component={Migration} />
<Stack.Screen name="SourceNovels" component={SourceNovels} />
<Stack.Screen name="MigrateNovel" component={MigrateNovel} />
<Stack.Screen name="WebviewScreen" component={WebviewScreen} />
</Stack.Navigator>
</UpdateContextProvider>
</LibraryContextProvider>
</NavigationContainer>
);
+2 -1
View File
@@ -8,7 +8,8 @@ import { getString } from '@strings/translations';
import { EmptyView, SafeAreaView, SearchbarV2 } from '@components';
import { BrowseScreenProps } from '@navigators/types';
import { AvailableTab, InstalledTab } from './components/BrowseTabs';
import { AvailableTab } from './components/AvailableTab';
import { InstalledTab } from './components/InstalledTab';
const routes = [
{ key: 'installedRoute', title: getString('browseScreen.installed') },
@@ -0,0 +1,264 @@
import React, { useCallback, useMemo, useState, memo } from 'react';
import { View, Text, StyleSheet, RefreshControl } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { usePlugins } from '@hooks/persisted';
import { PluginItem } from '@plugins/types';
import { coverPlaceholderColor } from '@theme/colors';
import { ThemeColors } from '@theme/types';
import { getString } from '@strings/translations';
import { MoreStackScreenProps } from '@navigators/types';
import { EmptyView, IconButtonV2 } from '@components';
import { showToast } from '@utils/showToast';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { getLocaleLanguageName } from '@utils/constants/languages';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
interface AvailableTabProps {
searchText: string;
theme: ThemeColors;
}
interface AvailablePluginCardProps {
plugin: PluginItem & { header: boolean };
theme: ThemeColors;
installPlugin: (plugin: PluginItem) => Promise<void>;
}
const AvailablePluginCard = ({
plugin,
theme,
installPlugin,
}: AvailablePluginCardProps) => {
const ratio = useSharedValue(1);
const imageStyles = useAnimatedStyle(() => ({
height: ratio.value * 40,
}));
const viewStyles = useAnimatedStyle(() => ({
height: ratio.value * 64,
paddingVertical: ratio.value * 12,
}));
const textStyles = useAnimatedStyle(() => ({
lineHeight: ratio.value * 20,
}));
return (
<View>
{plugin.header ? (
<Text style={[styles.listHeader, { color: theme.onSurfaceVariant }]}>
{getLocaleLanguageName(plugin.lang)}
</Text>
) : null}
<Animated.View
style={[
styles.container,
{ backgroundColor: theme.surface },
viewStyles,
]}
>
<Animated.View style={styles.row}>
<Animated.Image
source={{ uri: plugin.iconUrl }}
style={[
styles.icon,
imageStyles,
{ backgroundColor: theme.surface },
]}
/>
<Animated.View style={styles.details}>
<Animated.Text
numberOfLines={1}
style={[
{
color: theme.onSurface,
},
styles.name,
textStyles,
]}
>
{plugin.name}
</Animated.Text>
<Animated.Text
numberOfLines={1}
style={[
{ color: theme.onSurfaceVariant },
styles.addition,
textStyles,
]}
>
{`${getLocaleLanguageName(plugin.lang)} - ${plugin.version}`}
</Animated.Text>
</Animated.View>
</Animated.View>
<IconButtonV2
name="download-outline"
color={theme.primary}
onPress={() => {
ratio.value = withTiming(0, { duration: 500 });
installPlugin(plugin)
.then(() => {
showToast(
getString('browseScreen.installedPlugin', {
name: plugin.name,
}),
);
})
.catch((error: Error) => {
showToast(error.message);
ratio.value = 1;
});
}}
size={22}
theme={theme}
/>
</Animated.View>
</View>
);
};
export const AvailableTab = memo(({ searchText, theme }: AvailableTabProps) => {
const navigation = useNavigation<MoreStackScreenProps['navigation']>();
const [refreshing, setRefreshing] = useState(false);
const { filteredAvailablePlugins, refreshPlugins, installPlugin } =
usePlugins();
const searchedPlugins = useMemo(() => {
let res = filteredAvailablePlugins;
if (searchText) {
const lowerCaseSearchText = searchText.toLocaleLowerCase();
res = filteredAvailablePlugins.filter(
plg =>
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
plg.id.includes(lowerCaseSearchText),
);
}
return res
.sort((a, b) => a.lang.localeCompare(b.lang))
.map((plg, i) => {
return {
...plg,
header: i === 0 ? true : plg.lang !== res[i - 1].lang,
};
});
}, [searchText, filteredAvailablePlugins]);
const renderItem: ListRenderItem<PluginItem & { header: boolean }> =
useCallback(
({ item }) => {
return (
<AvailablePluginCard
plugin={item}
theme={theme}
installPlugin={installPlugin}
/>
);
},
[theme, installPlugin],
);
return (
<FlashList
estimatedItemSize={64}
data={searchedPlugins}
extraData={theme}
renderItem={renderItem}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
keyExtractor={item => item.id + '_available'}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
refreshPlugins()
.finally(() => setRefreshing(false))
.catch(e => {
showToast(e);
});
}}
colors={[theme.onPrimary]}
progressBackgroundColor={theme.primary}
/>
}
ListEmptyComponent={
!filteredAvailablePlugins.length ? (
<View style={styles.margintTop100}>
<EmptyView
icon="(・Д・。"
description=" No repositories yet. Add your first plugin repository to get
started."
actions={[
{
iconName: 'cog-outline',
title: 'Add Repository',
onPress: () =>
navigation.navigate('MoreStack', {
screen: 'SettingsStack',
params: {
screen: 'RespositorySettings',
},
}),
},
]}
theme={theme}
/>
</View>
) : (
<View style={styles.margintTop100}>
<EmptyView
icon="(・Д・。"
description="No plugins available for this search term"
theme={theme}
/>
</View>
)
}
/>
);
});
const styles = StyleSheet.create({
margintTop100: { marginTop: 100 },
addition: {
fontSize: 12,
lineHeight: 20,
},
buttonGroup: {
alignItems: 'center',
flexDirection: 'row',
paddingHorizontal: 8,
},
container: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
details: {
marginLeft: 16,
},
icon: {
backgroundColor: coverPlaceholderColor,
borderRadius: 4,
height: 40,
width: 40,
},
listHeader: {
fontSize: 14,
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 8,
},
name: {
fontWeight: 'bold',
lineHeight: 20,
},
row: {
flexDirection: 'row',
},
center: { alignItems: 'center' },
flex: { flex: 1 },
});
@@ -1,532 +0,0 @@
import React, { useCallback, useMemo, useState, memo } from 'react';
import {
Pressable,
Image,
View,
Text,
StyleSheet,
RefreshControl,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useBrowseSettings, usePlugins } from '@hooks/persisted';
import { PluginItem } from '@plugins/types';
import { coverPlaceholderColor } from '@theme/colors';
import { ThemeColors } from '@theme/types';
import { getString } from '@strings/translations';
import { BrowseScreenProps, MoreStackScreenProps } from '@navigators/types';
import { Button, EmptyView, IconButtonV2 } from '@components';
import TrackerCard from '../discover/TrackerCard';
import { showToast } from '@utils/showToast';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Portal } from 'react-native-paper';
import SourceSettingsModal from './Modals/SourceSettings';
import { useBoolean } from '@hooks';
import { getPlugin } from '@plugins/pluginManager';
import { getLocaleLanguageName } from '@utils/constants/languages';
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
interface AvailableTabProps {
searchText: string;
theme: ThemeColors;
}
interface InstalledTabProps {
navigation: BrowseScreenProps['navigation'];
theme: ThemeColors;
searchText: string;
}
export const InstalledTab = memo(
({ navigation, theme, searchText }: InstalledTabProps) => {
const {
filteredInstalledPlugins,
lastUsedPlugin,
setLastUsedPlugin,
uninstallPlugin,
updatePlugin,
} = usePlugins();
const { showMyAnimeList, showAniList } = useBrowseSettings();
const settingsModal = useBoolean();
const [selectedPluginId, setSelectedPluginId] = useState<string>('');
const pluginSettings = selectedPluginId
? getPlugin(selectedPluginId)?.pluginSettings
: undefined;
const navigateToSource = useCallback(
(plugin: PluginItem, showLatestNovels?: boolean) => {
navigation.navigate('SourceScreen', {
pluginId: plugin.id,
pluginName: plugin.name,
site: plugin.site,
showLatestNovels,
});
setLastUsedPlugin(plugin);
},
[navigation, setLastUsedPlugin],
);
const searchedPlugins = useMemo(() => {
const sortedInstalledPlugins = filteredInstalledPlugins.sort(
(plgFirst, plgSecond) => plgFirst.name.localeCompare(plgSecond.name),
);
if (searchText) {
const lowerCaseSearchText = searchText.toLocaleLowerCase();
return sortedInstalledPlugins.filter(
plg =>
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
plg.id.includes(lowerCaseSearchText),
);
} else {
return sortedInstalledPlugins;
}
}, [searchText, filteredInstalledPlugins]);
const renderItem: ListRenderItem<PluginItem> = useCallback(
({ item }) => {
return (
<Swipeable
dragOffsetFromLeftEdge={30}
dragOffsetFromRightEdge={30}
renderLeftActions={(_progress, _dragX, ref) => {
return (
<View
style={[
styles.buttonGroup,
{ backgroundColor: theme.inverseSurface },
]}
>
<IconButtonV2
name="earth"
size={22}
color={theme.inverseOnSurface}
onPress={() => {
ref.close();
navigation.navigate('WebviewScreen', {
name: item.name,
url: item.site,
pluginId: item.id,
});
}}
theme={theme}
/>
</View>
);
}}
renderRightActions={(_progress, _dragX, ref) => (
<View
style={[styles.buttonGroup, { backgroundColor: theme.error }]}
>
<IconButtonV2
name="delete"
size={22}
color={theme.onError}
onPress={() => {
ref.close();
uninstallPlugin(item).then(() =>
showToast(
getString('browseScreen.uninstalledPlugin', {
name: item.name,
}),
),
);
}}
theme={theme}
/>
</View>
)}
>
<Pressable
style={[styles.container, { backgroundColor: theme.surface }]}
android_ripple={{ color: theme.rippleColor }}
onPress={() => navigateToSource(item)}
>
<View style={[styles.center, styles.row]}>
<Image
source={{ uri: item.iconUrl }}
style={[styles.icon, { backgroundColor: theme.surface }]}
/>
<View style={styles.details}>
<Text
numberOfLines={1}
style={[{ color: theme.onSurface }, styles.name]}
>
{item.name}
</Text>
<Text
numberOfLines={1}
style={[{ color: theme.onSurfaceVariant }, styles.addition]}
>
{`${item.lang} - ${item.version}`}
</Text>
</View>
</View>
<View style={styles.flex} />
{item.hasSettings ? (
<IconButtonV2
name="cog-outline"
size={22}
color={theme.primary}
onPress={() => {
setSelectedPluginId(item.id);
settingsModal.setTrue();
}}
theme={theme}
/>
) : null}
{item.hasUpdate || __DEV__ ? (
<IconButtonV2
name="download-outline"
size={22}
color={theme.primary}
onPress={() => {
updatePlugin(item)
.then(version =>
showToast(
getString('browseScreen.updatedTo', { version }),
),
)
.catch((error: Error) => showToast(error.message));
}}
theme={theme}
/>
) : null}
<Button
title={getString('browseScreen.latest')}
textColor={theme.primary}
onPress={() => navigateToSource(item, true)}
/>
</Pressable>
</Swipeable>
);
},
[
theme,
navigation,
uninstallPlugin,
navigateToSource,
settingsModal,
updatePlugin,
],
);
return (
<FlashList
estimatedItemSize={64}
data={searchedPlugins}
extraData={theme}
renderItem={renderItem}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
keyExtractor={item => item.id + '_installed'}
ListHeaderComponent={
<>
{showMyAnimeList || showAniList ? (
<>
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.discover')}
</Text>
{showAniList ? (
<TrackerCard
theme={theme}
icon={require('../../../../assets/anilist.png')}
trackerName="Anilist"
onPress={() => navigation.navigate('BrowseAL')}
/>
) : null}
{showMyAnimeList ? (
<TrackerCard
theme={theme}
icon={require('../../../../assets/mal.png')}
trackerName="MyAnimeList"
onPress={() => navigation.navigate('BrowseMal')}
/>
) : null}
</>
) : null}
{lastUsedPlugin ? (
<>
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.lastUsed')}
</Text>
{renderItem({
item: lastUsedPlugin,
index: 0,
target: 'Cell',
extraData: [theme],
})}
</>
) : null}
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.installedPlugins')}
</Text>
<Portal>
<SourceSettingsModal
visible={settingsModal.value}
onDismiss={settingsModal.setFalse}
title={getString('browseScreen.settings.title')}
description={getString('browseScreen.settings.description')}
pluginId={selectedPluginId}
pluginSettings={pluginSettings}
/>
</Portal>
</>
}
/>
);
},
);
interface AvailablePluginCardProps {
plugin: PluginItem & { header: boolean };
theme: ThemeColors;
installPlugin: (plugin: PluginItem) => Promise<void>;
}
const AvailablePluginCard = ({
plugin,
theme,
installPlugin,
}: AvailablePluginCardProps) => {
const ratio = useSharedValue(1);
const imageStyles = useAnimatedStyle(() => ({
height: ratio.value * 40,
}));
const viewStyles = useAnimatedStyle(() => ({
height: ratio.value * 64,
paddingVertical: ratio.value * 12,
}));
const textStyles = useAnimatedStyle(() => ({
lineHeight: ratio.value * 20,
}));
return (
<View>
{plugin.header ? (
<Text style={[styles.listHeader, { color: theme.onSurfaceVariant }]}>
{getLocaleLanguageName(plugin.lang)}
</Text>
) : null}
<Animated.View
style={[
styles.container,
{ backgroundColor: theme.surface },
viewStyles,
]}
>
<Animated.View style={styles.row}>
<Animated.Image
source={{ uri: plugin.iconUrl }}
style={[
styles.icon,
imageStyles,
{ backgroundColor: theme.surface },
]}
/>
<Animated.View style={styles.details}>
<Animated.Text
numberOfLines={1}
style={[
{
color: theme.onSurface,
},
styles.name,
textStyles,
]}
>
{plugin.name}
</Animated.Text>
<Animated.Text
numberOfLines={1}
style={[
{ color: theme.onSurfaceVariant },
styles.addition,
textStyles,
]}
>
{`${getLocaleLanguageName(plugin.lang)} - ${plugin.version}`}
</Animated.Text>
</Animated.View>
</Animated.View>
<IconButtonV2
name="download-outline"
color={theme.primary}
onPress={() => {
ratio.value = withTiming(0, { duration: 500 });
installPlugin(plugin)
.then(() => {
showToast(
getString('browseScreen.installedPlugin', {
name: plugin.name,
}),
);
})
.catch((error: Error) => {
showToast(error.message);
ratio.value = 1;
});
}}
size={22}
theme={theme}
/>
</Animated.View>
</View>
);
};
export const AvailableTab = memo(({ searchText, theme }: AvailableTabProps) => {
const navigation = useNavigation<MoreStackScreenProps['navigation']>();
const [refreshing, setRefreshing] = useState(false);
const { filteredAvailablePlugins, refreshPlugins, installPlugin } =
usePlugins();
const searchedPlugins = useMemo(() => {
let res = filteredAvailablePlugins;
if (searchText) {
const lowerCaseSearchText = searchText.toLocaleLowerCase();
res = filteredAvailablePlugins.filter(
plg =>
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
plg.id.includes(lowerCaseSearchText),
);
}
return res
.sort((a, b) => a.lang.localeCompare(b.lang))
.map((plg, i) => {
return {
...plg,
header: i === 0 ? true : plg.lang !== res[i - 1].lang,
};
});
}, [searchText, filteredAvailablePlugins]);
const renderItem: ListRenderItem<PluginItem & { header: boolean }> =
useCallback(
({ item }) => {
return (
<AvailablePluginCard
plugin={item}
theme={theme}
installPlugin={installPlugin}
/>
);
},
[theme, installPlugin],
);
return (
<FlashList
estimatedItemSize={64}
data={searchedPlugins}
extraData={theme}
renderItem={renderItem}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
keyExtractor={item => item.id + '_available'}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
refreshPlugins()
.finally(() => setRefreshing(false))
.catch(e => {
showToast(e);
});
}}
colors={[theme.onPrimary]}
progressBackgroundColor={theme.primary}
/>
}
ListEmptyComponent={
!filteredAvailablePlugins.length ? (
<View style={styles.margintTop100}>
<EmptyView
icon="(・Д・。"
description=" No repositories yet. Add your first plugin repository to get
started."
actions={[
{
iconName: 'cog-outline',
title: 'Add Repository',
onPress: () =>
navigation.navigate('MoreStack', {
screen: 'SettingsStack',
params: {
screen: 'RespositorySettings',
},
}),
},
]}
theme={theme}
/>
</View>
) : (
<View style={styles.margintTop100}>
<EmptyView
icon="(・Д・。"
description="No plugins available for this search term"
theme={theme}
/>
</View>
)
}
/>
);
});
const styles = StyleSheet.create({
margintTop100: { marginTop: 100 },
addition: {
fontSize: 12,
lineHeight: 20,
},
buttonGroup: {
alignItems: 'center',
flexDirection: 'row',
paddingHorizontal: 8,
},
container: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
details: {
marginLeft: 16,
},
icon: {
backgroundColor: coverPlaceholderColor,
borderRadius: 4,
height: 40,
width: 40,
},
listHeader: {
fontSize: 14,
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 8,
},
name: {
fontWeight: 'bold',
lineHeight: 20,
},
row: {
flexDirection: 'row',
},
center: { alignItems: 'center' },
flex: { flex: 1 },
});
@@ -0,0 +1,503 @@
import React, { useCallback, useMemo, useState, memo, useEffect } from 'react';
import {
Pressable,
Image,
View,
Text,
StyleSheet,
InteractionManager,
ViewStyle,
StyleProp,
TextStyle,
} from 'react-native';
import { useBrowseSettings, usePlugins } from '@hooks/persisted';
import { PluginItem } from '@plugins/types';
import { coverPlaceholderColor } from '@theme/colors';
import { ThemeColors } from '@theme/types';
import { getString } from '@strings/translations';
import { BrowseScreenProps } from '@navigators/types';
import { Button, IconButtonV2 } from '@components';
import TrackerCard from '../discover/TrackerCard';
import { showToast } from '@utils/showToast';
import { Portal, Text as PaperText } from 'react-native-paper';
import SourceSettingsModal from './Modals/SourceSettings';
import { useBoolean, UseBooleanReturnType } from '@hooks';
import { getPlugin } from '@plugins/pluginManager';
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
interface InstalledTabProps {
navigation: BrowseScreenProps['navigation'];
theme: ThemeColors;
searchText: string;
}
const Item = memo(
({
item,
theme,
navigation,
settingsModal,
navigateToSource,
setSelectedPluginId,
}: {
item: PluginItem;
theme: ThemeColors;
navigation: BrowseScreenProps['navigation'];
settingsModal: UseBooleanReturnType;
navigateToSource: (plugin: PluginItem, showLatestNovels?: boolean) => void;
setSelectedPluginId: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { uninstallPlugin, updatePlugin } = usePlugins();
// Memoized styles
const leftActionStyle = useMemo(
() => [styles.buttonGroup, { backgroundColor: theme.inverseSurface }],
[theme.inverseSurface],
);
const rightActionStyle = useMemo(
() => [styles.buttonGroup, { backgroundColor: theme.error }],
[theme.error],
);
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: theme.surface }],
[theme.surface],
);
const iconStyle = useMemo(
() => [styles.icon, { backgroundColor: theme.surface }],
[theme.surface],
);
const nameStyle = useMemo(
() => [{ color: theme.onSurface }, styles.name],
[theme.onSurface],
);
const additionStyle = useMemo(
() => [{ color: theme.onSurfaceVariant }, styles.addition],
[theme.onSurfaceVariant],
);
// Memoized handlers
const handleWebviewPress = useCallback(
(ref: any) => {
ref.close();
navigation.navigate('WebviewScreen', {
name: item.name,
url: item.site,
pluginId: item.id,
});
},
[navigation, item],
);
const handleDeletePress = useCallback(
(ref: any) => {
ref.close();
uninstallPlugin(item).then(() =>
showToast(
getString('browseScreen.uninstalledPlugin', {
name: item.name,
}),
),
);
},
[uninstallPlugin, item],
);
const handleSettingsPress = useCallback(() => {
setSelectedPluginId(item.id);
settingsModal.setTrue();
}, [setSelectedPluginId, item.id, settingsModal]);
const handleUpdatePress = useCallback(() => {
updatePlugin(item)
.then(version =>
showToast(getString('browseScreen.updatedTo', { version })),
)
.catch((error: Error) => showToast(error.message));
}, [updatePlugin, item]);
const handleLatestPress = useCallback(() => {
navigateToSource(item, true);
}, [navigateToSource, item]);
const handlePress = useCallback(() => {
navigateToSource(item);
}, [navigateToSource, item]);
// Memoized render actions
const renderLeftActions = useCallback(
(_progress: any, _dragX: any, ref: any) => (
<View style={leftActionStyle}>
<IconButtonV2
name="earth"
size={22}
color={theme.inverseOnSurface}
onPress={() => handleWebviewPress(ref)}
theme={theme}
/>
</View>
),
[leftActionStyle, theme, handleWebviewPress],
);
const renderRightActions = useCallback(
(_progress: any, _dragX: any, ref: any) => (
<View style={rightActionStyle}>
<IconButtonV2
name="delete"
size={22}
color={theme.onError}
onPress={() => handleDeletePress(ref)}
theme={theme}
/>
</View>
),
[rightActionStyle, theme, handleDeletePress],
);
return (
<Swipeable
dragOffsetFromLeftEdge={30}
dragOffsetFromRightEdge={30}
renderLeftActions={renderLeftActions}
renderRightActions={renderRightActions}
>
<Pressable
style={containerStyle}
android_ripple={{ color: theme.rippleColor }}
onPress={handlePress}
>
<View style={[styles.center, styles.row]}>
<Image source={{ uri: item.iconUrl }} style={iconStyle} />
<View style={styles.details}>
<Text numberOfLines={1} style={nameStyle}>
{item.name}
</Text>
<Text numberOfLines={1} style={additionStyle}>
{`${item.lang} - ${item.version}`}
</Text>
</View>
</View>
<View style={styles.flex} />
{item.hasSettings ? (
<IconButtonV2
name="cog-outline"
size={22}
color={theme.primary}
onPress={handleSettingsPress}
theme={theme}
/>
) : null}
{item.hasUpdate || __DEV__ ? (
<IconButtonV2
name="download-outline"
size={22}
color={theme.primary}
onPress={handleUpdatePress}
theme={theme}
/>
) : null}
<Button
title={getString('browseScreen.latest')}
textColor={theme.primary}
onPress={handleLatestPress}
/>
</Pressable>
</Swipeable>
);
},
);
const LatestButton = memo(({ textColor }: { textColor: string }) => {
const viewStyle: StyleProp<ViewStyle> = useMemo(
() => ({
alignItems: 'center',
justifyContent: 'center',
minWidth: 64,
paddingBottom: 1,
}),
[],
);
const textStyle: StyleProp<TextStyle> = useMemo(
() => ({
color: textColor,
}),
[textColor],
);
return (
<View style={viewStyle}>
<PaperText variant="labelLarge" style={[styles.buttonGroup, textStyle]}>
{getString('browseScreen.latest')}
</PaperText>
</View>
);
});
const SkeletonItem = memo(
({ item, theme }: { item: PluginItem; theme: ThemeColors }) => {
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: theme.surface }],
[theme.surface],
);
const iconStyle = useMemo(
() => [styles.icon, { backgroundColor: theme.surface }],
[theme.surface],
);
const nameStyle = useMemo(
() => [{ color: theme.onSurface }, styles.name],
[theme.onSurface],
);
const additionStyle = useMemo(
() => [{ color: theme.onSurfaceVariant }, styles.addition],
[theme.onSurfaceVariant],
);
const CogButton = useCallback(
() => (
<IconButtonV2
name="cog-outline"
size={22}
color={theme.primary}
theme={theme}
/>
),
[theme],
);
const DownloadButton = useCallback(
() => (
<IconButtonV2
name="download-outline"
size={22}
color={theme.primary}
theme={theme}
/>
),
[theme],
);
return (
<Pressable
style={containerStyle}
android_ripple={{ color: theme.rippleColor }}
// onPress={handlePress}
>
<View style={[styles.center, styles.row]}>
<Image source={{ uri: item.iconUrl }} style={iconStyle} />
<View style={styles.details}>
<Text numberOfLines={1} style={nameStyle}>
{item.name}
</Text>
<Text numberOfLines={1} style={additionStyle}>
{`${item.lang} - ${item.version}`}
</Text>
</View>
</View>
<View style={styles.flex} />
{item.hasSettings ? <CogButton /> : null}
{item.hasUpdate || __DEV__ ? <DownloadButton /> : null}
<LatestButton textColor={theme.primary} />
</Pressable>
);
},
);
const DeferredItem = ({
...props
}: {
item: PluginItem;
theme: ThemeColors;
navigation: BrowseScreenProps['navigation'];
settingsModal: UseBooleanReturnType;
navigateToSource: (plugin: PluginItem, showLatestNovels?: boolean) => void;
setSelectedPluginId: React.Dispatch<React.SetStateAction<string>>;
}) => {
const [showReal, setShowReal] = useState(false);
useEffect(() => {
// Use InteractionManager to wait until interactions/animations are done
const task = InteractionManager.runAfterInteractions(() =>
setShowReal(true),
);
return () => task.cancel();
}, []);
return showReal ? (
<Item {...props} />
) : (
<SkeletonItem item={props.item} theme={props.theme} />
);
};
export const InstalledTab = memo(
({ navigation, theme, searchText }: InstalledTabProps) => {
const { filteredInstalledPlugins, lastUsedPlugin, setLastUsedPlugin } =
usePlugins();
const { showMyAnimeList, showAniList } = useBrowseSettings();
const settingsModal = useBoolean();
const [selectedPluginId, setSelectedPluginId] = useState<string>('');
const pluginSettings = selectedPluginId
? getPlugin(selectedPluginId)?.pluginSettings
: undefined;
const navigateToSource = useCallback(
(plugin: PluginItem, showLatestNovels?: boolean) => {
navigation.navigate('SourceScreen', {
pluginId: plugin.id,
pluginName: plugin.name,
site: plugin.site,
showLatestNovels,
});
setLastUsedPlugin(plugin);
},
[navigation, setLastUsedPlugin],
);
const searchedPlugins = useMemo(() => {
const sortedInstalledPlugins = filteredInstalledPlugins.sort(
(plgFirst, plgSecond) => plgFirst.name.localeCompare(plgSecond.name),
);
if (searchText) {
const lowerCaseSearchText = searchText.toLocaleLowerCase();
return sortedInstalledPlugins.filter(
plg =>
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
plg.id.includes(lowerCaseSearchText),
);
} else {
return sortedInstalledPlugins;
}
}, [searchText, filteredInstalledPlugins]);
const renderItem: ListRenderItem<PluginItem> = useCallback(
({ item }) => {
return (
<DeferredItem
item={item}
theme={theme}
navigation={navigation}
settingsModal={settingsModal}
navigateToSource={navigateToSource}
setSelectedPluginId={setSelectedPluginId}
/>
);
},
[theme, navigation, navigateToSource, settingsModal],
);
return (
<FlashList
estimatedItemSize={64}
data={searchedPlugins}
renderItem={renderItem}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
keyExtractor={item => item.id + '_installed'}
drawDistance={100}
ListHeaderComponent={
<>
{showMyAnimeList || showAniList ? (
<>
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.discover')}
</Text>
{showAniList ? (
<TrackerCard
theme={theme}
icon={require('../../../../assets/anilist.png')}
trackerName="Anilist"
onPress={() => navigation.navigate('BrowseAL')}
/>
) : null}
{showMyAnimeList ? (
<TrackerCard
theme={theme}
icon={require('../../../../assets/mal.png')}
trackerName="MyAnimeList"
onPress={() => navigation.navigate('BrowseMal')}
/>
) : null}
</>
) : null}
{lastUsedPlugin ? (
<>
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.lastUsed')}
</Text>
{renderItem({
item: lastUsedPlugin,
index: 0,
target: 'Cell',
extraData: [theme],
})}
</>
) : null}
<Text
style={[styles.listHeader, { color: theme.onSurfaceVariant }]}
>
{getString('browseScreen.installedPlugins')}
</Text>
<Portal>
<SourceSettingsModal
visible={settingsModal.value}
onDismiss={settingsModal.setFalse}
title={getString('browseScreen.settings.title')}
description={getString('browseScreen.settings.description')}
pluginId={selectedPluginId}
pluginSettings={pluginSettings}
/>
</Portal>
</>
}
/>
);
},
);
const styles = StyleSheet.create({
margintTop100: { marginTop: 100 },
addition: {
fontSize: 12,
lineHeight: 20,
},
buttonGroup: {
alignItems: 'center',
flexDirection: 'row',
paddingHorizontal: 8,
},
container: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
details: {
marginLeft: 16,
},
icon: {
backgroundColor: coverPlaceholderColor,
borderRadius: 4,
height: 40,
width: 40,
},
listHeader: {
fontSize: 14,
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 8,
},
name: {
fontWeight: 'bold',
lineHeight: 20,
},
row: {
flexDirection: 'row',
},
center: { alignItems: 'center' },
flex: { flex: 1 },
});
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { TextInput } from 'react-native-paper';
import { Button, Modal } from '@components/index';
import { Button, Modal, SwitchItem } from '@components/index';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { Storage } from '@plugins/helpers/storage';
@@ -9,6 +9,7 @@ import { Storage } from '@plugins/helpers/storage';
interface PluginSetting {
value: string;
label: string;
type?: 'Switch';
}
interface PluginSettings {
@@ -34,7 +35,9 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
}) => {
const theme = useTheme();
const [formValues, setFormValues] = useState<{ [key: string]: string }>({});
const [formValues, setFormValues] = useState<
Record<string, string | boolean>
>({});
useEffect(() => {
if (pluginSettings) {
@@ -63,7 +66,7 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
}
}, [pluginSettings, pluginId]);
const handleChange = (key: string, value: string) => {
const handleChange = (key: string, value: string | boolean) => {
setFormValues(prevValues => ({
...prevValues,
[key]: value,
@@ -97,22 +100,33 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
{title}
</Text>
<Text style={{ color: theme.onSurfaceVariant }}>{description}</Text>
{Object.entries(pluginSettings).map(([key, setting]) => (
<TextInput
key={key}
mode="outlined"
label={setting.label}
value={formValues[key] || ''}
onChangeText={value => handleChange(key, value)}
placeholder={`Enter ${setting.label}`}
placeholderTextColor={theme.onSurfaceDisabled}
underlineColor={theme.outline}
style={[{ color: theme.onSurface }, styles.textInput]}
theme={{ colors: { ...theme } }}
/>
))}
{Object.entries(pluginSettings).map(([key, setting]) => {
if (setting?.type === 'Switch') {
return (
<SwitchItem
key={key}
value={!!formValues[key]}
label={setting.label}
onPress={() => handleChange(key, !formValues[key])}
theme={theme}
/>
);
}
return (
<TextInput
key={key}
mode="outlined"
label={setting.label}
value={(formValues[key] ?? '') as string}
onChangeText={value => handleChange(key, value)}
placeholder={`Enter ${setting.label}`}
placeholderTextColor={theme.onSurfaceDisabled}
underlineColor={theme.outline}
style={[{ color: theme.onSurface }, styles.textInput]}
theme={{ colors: { ...theme } }}
/>
);
})}
<View style={styles.customCSSButtons}>
<Button
onPress={handleSave}
+10 -10
View File
@@ -38,7 +38,6 @@ import {
} from '@database/queries/ChapterQueries';
import { removeNovelsFromLibrary } from '@database/queries/NovelQueries';
import SetCategoryModal from '@screens/novel/components/SetCategoriesModal';
import { debounce } from 'lodash-es';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
import { Row } from '@components/Common';
@@ -72,10 +71,10 @@ type TabViewLabelProps = {
};
const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
const { searchText, setSearchText, clearSearchbar } = useSearch();
const theme = useTheme();
const styles = createStyles(theme);
const { left: leftInset, right: rightInset } = useSafeAreaInsets();
const { searchText, setSearchText, clearSearchbar } = useSearch();
const {
library,
categories,
@@ -84,18 +83,13 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
settings: { showNumberOfNovels, downloadedOnlyMode, incognitoMode },
} = useLibraryContext();
const { importNovel } = useImport();
const { useLibraryFAB = false } = useAppSettings();
const { isLoading: isHistoryLoading, history, error } = useHistory();
const { importNovel } = useImport();
const layout = useWindowDimensions();
const onChangeText = debounce((text: string) => {
setSearchText(text);
}, 100);
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
const [index, setIndex] = useState(0);
@@ -214,7 +208,13 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
};
}) => {
const ids = route.novelIds;
const novels = library.filter(l => ids.includes(l.id));
const unfilteredNovels = library.filter(l => ids.includes(l.id));
const novels = unfilteredNovels.filter(
n =>
n.name.toLowerCase().includes(searchText.toLowerCase()) ||
(n.author?.toLowerCase().includes(searchText.toLowerCase()) ?? false),
);
return isLoading ? (
<SourceScreenSkeletonLoading theme={theme} />
@@ -312,7 +312,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
setSelectedNovelIds([]);
}
}}
onChangeText={onChangeText}
onChangeText={setSearchText}
leftIcon={selectedNovelIds.length ? 'close' : 'magnify'}
rightIcons={
selectedNovelIds.length
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { MD3ThemeType } from '@theme/types';
import { ActivityIndicator, Pressable, StyleSheet } from 'react-native';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { Menu, overlay } from 'react-native-paper';
import { getString } from '@strings/translations';
@@ -92,11 +92,13 @@ type buttonPropType = theme & {
onPress: () => void;
};
export const ChapterDownloadingButton: React.FC<theme> = ({ theme }) => (
<ActivityIndicator
color={theme.outline}
size={25}
style={styles.activityIndicator}
/>
<View style={styles.container}>
<ActivityIndicator
color={theme.outline}
size={25}
style={styles.activityIndicator}
/>
</View>
);
const DownloadIcon: React.FC<theme> = ({ theme }) => (
@@ -111,13 +113,15 @@ export const DownloadChapterButton: React.FC<buttonPropType> = ({
theme,
onPress,
}) => (
<Pressable
style={styles.container}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DownloadIcon theme={theme} />
</Pressable>
<View style={styles.container}>
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DownloadIcon theme={theme} />
</Pressable>
</View>
);
const DeleteIcon: React.FC<theme> = ({ theme }) => (
@@ -132,13 +136,15 @@ export const DeleteChapterButton: React.FC<buttonPropType> = ({
theme,
onPress,
}) => (
<Pressable
style={styles.container}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DeleteIcon theme={theme} />
</Pressable>
<View style={styles.container}>
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
>
<DeleteIcon theme={theme} />
</Pressable>
</View>
);
export const ChapterBookmarkButton: React.FC<theme> = ({ theme }) => (
@@ -155,8 +161,17 @@ const styles = StyleSheet.create({
activityIndicator: { margin: 3.5, padding: 5 },
container: {
borderRadius: 50,
width: 40,
height: 40,
overflow: 'hidden',
padding: 8,
alignItems: 'center',
justifyContent: 'center',
},
pressable: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
iconButton: { margin: 2 },
iconButtonLeft: { marginLeft: 2 },
+7 -1
View File
@@ -111,8 +111,10 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
: bookmark
? theme.primary
: theme.onSurface,
flex: 1,
}}
numberOfLines={1}
ellipsizeMode="tail"
>
{showChapterTitles
? name
@@ -189,7 +191,11 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 8,
},
row: { alignItems: 'center', flex: 1, flexDirection: 'row' },
row: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
},
text: {
fontSize: 12,
},
@@ -29,7 +29,7 @@ type ButtonsProperties = {
};
const ChapterDrawer = () => {
const { chapter, setChapter, setLoading } = useChapterContext();
const { chapter, getChapter, setLoading } = useChapterContext();
const { chapters, novelSettings, pages, setPageIndex } = useNovelContext();
const theme = useTheme();
const insets = useSafeAreaInsets();
@@ -167,7 +167,7 @@ const ChapterDrawer = () => {
chapterId: chapter.id,
onPress: () => {
setLoading(true);
setChapter(val.item);
getChapter(val.item);
},
})
}
+2 -1
View File
@@ -120,7 +120,6 @@ export default function useChapter(
const chap = navChapter ?? chapter;
const cachedText = chapterTextCache.get(chap.id);
const text = cachedText ?? loadChapterText(chap.id, chap.path);
const [nextChap, prevChap, awaitedText] = await Promise.all([
getNextChapter(chap.novelId, chap.position!, chap.page),
getPrevChapter(chap.novelId, chap.position!, chap.page),
@@ -292,6 +291,7 @@ export default function useChapter(
refetch,
setChapter,
setLoading,
getChapter,
}),
[
hidden,
@@ -308,6 +308,7 @@ export default function useChapter(
refetch,
setChapter,
setLoading,
getChapter,
],
);
}
+5 -21
View File
@@ -1,4 +1,4 @@
import React, { memo, Suspense, useCallback, useEffect, useState } from 'react';
import React, { memo, Suspense, useEffect } from 'react';
import dayjs from 'dayjs';
import { RefreshControl, SectionList, StyleSheet, Text } from 'react-native';
@@ -10,45 +10,31 @@ import {
} from '@components';
import { useSearch } from '@hooks';
import { useUpdates, useTheme } from '@hooks/persisted';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import UpdatesSkeletonLoading from './components/UpdatesSkeletonLoading';
import UpdateNovelCard from './components/UpdateNovelCard';
import { useFocusEffect } from '@react-navigation/native';
import { deleteChapter } from '@database/queries/ChapterQueries';
import { showToast } from '@utils/showToast';
import ServiceManager from '@services/ServiceManager';
import { UpdateScreenProps } from '@navigators/types';
import { UpdateOverview } from '@database/types';
import { ChapterListSkeleton } from '@components/Skeleton/Skeleton';
const Skeleton = () => <ChapterListSkeleton img />;
import { useUpdateContext } from '@components/Context/UpdateContext';
const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
const theme = useTheme();
const {
isLoading: isLoadingUpdates,
updatesOverview,
getUpdates,
lastUpdateTime,
showLastUpdateTime,
error,
} = useUpdates();
} = useUpdateContext();
const { searchText, setSearchText, clearSearchbar } = useSearch();
const [isLoading, setIsLoading] = useState(true);
const onChangeText = (text: string) => {
setSearchText(text);
};
useFocusEffect(
useCallback(() => {
//? Push updates to the end of the stack to avoid lag
setTimeout(() => {
getUpdates();
setIsLoading(isLoadingUpdates);
}, 0);
}, [getUpdates, isLoadingUpdates]),
);
useEffect(
() =>
@@ -81,9 +67,7 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
},
]}
/>
{isLoading && updatesOverview.length === 0 ? (
<Skeleton />
) : error ? (
{error ? (
<ErrorScreenV2 error={error} />
) : (
<SectionList
+37 -34
View File
@@ -10,7 +10,15 @@ import { db } from '@database/db';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import NativeFile from '@specs/NativeFile';
import NativeZipArchive from '@specs/NativeZipArchive';
import NativeEpubUtil from '@specs/NativeEpubUtil';
import NativeEpub from '@specs/NativeEpub';
const decodePath = (path: string) => {
try {
return decodeURI(path);
} catch {
return path;
}
};
const insertLocalNovel = async (
name: string,
@@ -34,8 +42,12 @@ const insertLocalNovel = async (
NativeFile.mkdir(novelDir);
const newCoverPath =
'file://' + novelDir + '/' + cover?.split(/[/\\]/).pop();
if (cover && NativeFile.exists(cover)) {
NativeFile.moveFile(cover, newCoverPath);
if (cover) {
const decodedPath = decodePath(cover);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(decodedPath, newCoverPath);
}
}
await updateNovelInfo({
id: insertedNovel.lastInsertRowId,
@@ -61,7 +73,7 @@ const insertLocalChapter = async (
name: string,
path: string,
releaseTime: string,
): Promise<string[]> => {
) => {
const insertedChapter = await db.runAsync(
'INSERT INTO Chapter(novelId, name, path, releaseTime, position) VALUES(?, ?, ?, ?, ?)',
novelId,
@@ -72,25 +84,15 @@ const insertLocalChapter = async (
);
if (insertedChapter.lastInsertRowId && insertedChapter.lastInsertRowId >= 0) {
let chapterText: string = '';
try {
path = decodeURI(path);
} catch {
// nothing to do
}
chapterText = NativeFile.readFile(path);
chapterText = NativeFile.readFile(decodePath(path));
if (!chapterText) {
return [];
}
const staticPaths: string[] = [];
const novelDir = NOVEL_STORAGE + '/local/' + novelId;
const epubContentDir = path.replace(/[^\\/]+$/, '');
chapterText = chapterText.replace(
/(href|src)=(["'])(.*?)\2/g,
(_, $1, __, $3: string) => {
if ($3) {
staticPaths.push(epubContentDir + '/' + $3);
}
return `${$1}="file://${novelDir}/${$3.split(/[\\/]/)?.pop()}"`;
return `${$1}="file://${novelDir}/${$3.split(/[\\/]/).pop()}"`;
},
);
NativeFile.mkdir(novelDir + '/' + insertedChapter.lastInsertRowId);
@@ -98,7 +100,7 @@ const insertLocalChapter = async (
novelDir + '/' + insertedChapter.lastInsertRowId + '/index.html',
chapterText,
);
return staticPaths;
return;
}
throw new Error(getString('advancedSettingsScreen.chapterInsertFailed'));
};
@@ -131,11 +133,8 @@ export const importEpub = async (
}
NativeFile.mkdir(epubDirPath);
await NativeZipArchive.unzip(epubFilePath, epubDirPath);
const novel = NativeEpubUtil.parseNovelAndChapters(epubDirPath);
if (novel === null) {
throw new Error(getString('advancedSettingsScreen.novelInsertFailed'));
}
if (!novel || !novel.name) {
const novel = NativeEpub.parseNovelAndChapters(epubDirPath);
if (!novel.name) {
novel.name = filename.replace('.epub', '') || 'Untitled';
}
const novelId = await insertLocalNovel(
@@ -147,7 +146,6 @@ export const importEpub = async (
novel.summary || '',
);
const now = dayjs().toISOString();
const filePathSet = new Set<string>();
if (novel.chapters) {
for (let i = 0; i < novel.chapters?.length; i++) {
const chapter = novel.chapters[i];
@@ -160,14 +158,7 @@ export const importEpub = async (
progressText: chapter.name,
}));
const filePaths = await insertLocalChapter(
novelId,
i,
chapter.name,
chapter.path,
now,
);
filePaths.forEach(filePath => filePathSet.add(filePath));
await insertLocalChapter(novelId, i, chapter.name, chapter.path, now);
setMeta(meta => ({
...meta,
@@ -182,10 +173,22 @@ export const importEpub = async (
progressText: getString('advancedSettingsScreen.importStaticFiles'),
}));
for (const filePath of filePathSet) {
if (NativeFile.exists(filePath)) {
for (const filePath of novel.imagePaths) {
const decodedPath = decodePath(filePath);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(
filePath,
decodedPath,
novelDir + '/' + filePath.split(/[\\/]/).pop(),
);
}
}
for (const filePath of novel.cssPaths) {
const decodedPath = decodePath(filePath);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(
decodedPath,
novelDir + '/' + filePath.split(/[\\/]/).pop(),
);
}