Readd commits from before force push
Fixing mistake
This commit is contained in:
@@ -27,10 +27,10 @@ Notifications.setNotificationHandler({
|
||||
};
|
||||
},
|
||||
});
|
||||
createTables();
|
||||
LottieSplashScreen.hide();
|
||||
|
||||
const App = () => {
|
||||
createTables();
|
||||
LottieSplashScreen.hide();
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.flex}>
|
||||
<AppErrorBoundary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
-30
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 ¶ms)
|
||||
{
|
||||
// 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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1597
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user