perf: Optimize Novel Covers, Lists, and Chapter Groups
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
"expo-document-picker": "~57.0.1",
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-haptics": "~57.0.1",
|
||||
"expo-image": "~57.0.1",
|
||||
"expo-keep-awake": "~57.0.1",
|
||||
"expo-linear-gradient": "~57.0.1",
|
||||
"expo-linking": "~57.0.3",
|
||||
|
||||
Generated
+21
@@ -101,6 +101,9 @@ importers:
|
||||
expo-haptics:
|
||||
specifier: ~57.0.1
|
||||
version: 57.0.1(expo@57.0.7)
|
||||
expo-image:
|
||||
specifier: ~57.0.1
|
||||
version: 57.0.1(expo@57.0.7)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
expo-keep-awake:
|
||||
specifier: ~57.0.1
|
||||
version: 57.0.1(expo@57.0.7)(react@19.2.3)
|
||||
@@ -3335,6 +3338,17 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-image@57.0.1:
|
||||
resolution: {integrity: sha512-EP0lisd2bUqtErry4weRcMW9bLMxtKsht/MLLK3/3do5u4ZMiJbWkY5zfYV+WYmeGab7x9G0sjbeFeEagYGjMw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-web: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-web:
|
||||
optional: true
|
||||
|
||||
expo-json-utils@57.0.1:
|
||||
resolution: {integrity: sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==}
|
||||
|
||||
@@ -9630,6 +9644,13 @@ snapshots:
|
||||
dependencies:
|
||||
expo: 57.0.7(7682188daf453a5cba4239dd25208922)
|
||||
|
||||
expo-image@57.0.1(expo@57.0.7)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
|
||||
dependencies:
|
||||
expo: 57.0.7(7682188daf453a5cba4239dd25208922)
|
||||
react: 19.2.3
|
||||
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0)))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
|
||||
sf-symbols-typescript: 2.2.0
|
||||
|
||||
expo-json-utils@57.0.1: {}
|
||||
|
||||
expo-keep-awake@57.0.1(expo@57.0.7)(react@19.2.3):
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getErrorMessage } from '@utils/error';
|
||||
import { MaterialDesignIconName } from '@type/icon';
|
||||
|
||||
interface ErrorScreenProps {
|
||||
error: any;
|
||||
error: unknown;
|
||||
actions?: {
|
||||
iconName: MaterialDesignIconName;
|
||||
title: string;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, Pressable, Image } from 'react-native';
|
||||
|
||||
import { coverPlaceholderColor } from '../theme/colors';
|
||||
import { StyleSheet, View, Text, Pressable } from 'react-native';
|
||||
|
||||
import color from 'color';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import NovelCoverImage from './NovelCoverImage';
|
||||
|
||||
interface ListViewProps {
|
||||
item: NovelItem | NovelInfo;
|
||||
@@ -42,10 +41,10 @@ const ListView = ({
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: item.cover ?? undefined,
|
||||
}}
|
||||
<NovelCoverImage
|
||||
uri={item.cover}
|
||||
theme={theme}
|
||||
iconSize={20}
|
||||
style={[styles.extensionIcon, fadedImage]}
|
||||
/>
|
||||
<Text
|
||||
@@ -72,7 +71,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
extensionIcon: {
|
||||
backgroundColor: coverPlaceholderColor,
|
||||
borderRadius: 4,
|
||||
height: 40,
|
||||
width: 40,
|
||||
|
||||
@@ -3,26 +3,22 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
Pressable,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import ListView from './ListView';
|
||||
|
||||
import { useDeviceOrientation } from '@hooks';
|
||||
import { coverPlaceholderColor } from '../theme/colors';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
import { DBNovelInfo, NovelInfo } from '@database/types';
|
||||
import { NovelItem, ImageRequestInit } from '@plugins/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
import { getString } from '@strings/translations';
|
||||
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import NovelCoverImage from './NovelCoverImage';
|
||||
import { useNovelCoverLayout } from './NovelCoverLayoutContext';
|
||||
|
||||
interface UnreadBadgeProps {
|
||||
showDownloadBadges: boolean;
|
||||
@@ -92,44 +88,25 @@ function NovelCover<
|
||||
const selectionActive =
|
||||
hasSelection ?? (selectedNovelIds != null && selectedNovelIds.length > 0);
|
||||
const {
|
||||
displayMode = DisplayModes.Comfortable,
|
||||
showDownloadBadges = true,
|
||||
showUnreadBadges = true,
|
||||
novelsPerRow = 3,
|
||||
} = useLibrarySettings();
|
||||
|
||||
const window = useWindowDimensions();
|
||||
|
||||
const orientation = useDeviceOrientation();
|
||||
|
||||
const numColumns = useMemo(
|
||||
() => (orientation === 'landscape' ? 6 : novelsPerRow),
|
||||
[orientation, novelsPerRow],
|
||||
);
|
||||
|
||||
const coverHeight = useMemo(() => {
|
||||
if (globalSearch) {
|
||||
return ((window.width / 3 - 16) * 4) / 3;
|
||||
}
|
||||
return (window.width / numColumns) * (4 / 3);
|
||||
}, [globalSearch, window.width, numColumns]);
|
||||
|
||||
const coverWidth = useMemo(() => {
|
||||
if (globalSearch) {
|
||||
return window.width / 3 - 16;
|
||||
}
|
||||
return undefined;
|
||||
}, [globalSearch, window.width]);
|
||||
coverHeight,
|
||||
coverWidth,
|
||||
displayMode,
|
||||
numColumns,
|
||||
showDownloadBadges,
|
||||
showUnreadBadges,
|
||||
} = useNovelCoverLayout();
|
||||
|
||||
const selectNovel = () => onLongPress(item);
|
||||
|
||||
const uri = item.cover || defaultCover;
|
||||
const requestInit = imageRequestInit || ({} as ImageRequestInit);
|
||||
if (!requestInit.headers) {
|
||||
requestInit.headers = {
|
||||
'User-Agent': getUserAgent(),
|
||||
};
|
||||
}
|
||||
const requestInit = useMemo<ImageRequestInit>(
|
||||
() => ({
|
||||
...imageRequestInit,
|
||||
headers: imageRequestInit?.headers || {
|
||||
'User-Agent': getUserAgent(),
|
||||
},
|
||||
}),
|
||||
[imageRequestInit],
|
||||
);
|
||||
|
||||
if (item.completeRow) {
|
||||
if (!addSkeletonLoading) {
|
||||
@@ -195,12 +172,14 @@ function NovelCover<
|
||||
) : null}
|
||||
{inActivity ? <InActivityBadge theme={theme} /> : null}
|
||||
</View>
|
||||
<Image
|
||||
source={{ uri, ...requestInit }}
|
||||
<NovelCoverImage
|
||||
uri={item.cover}
|
||||
requestInit={requestInit}
|
||||
theme={theme}
|
||||
iconSize={36}
|
||||
style={[
|
||||
{
|
||||
height: coverHeight,
|
||||
backgroundColor: coverPlaceholderColor,
|
||||
},
|
||||
styles.standardBorderRadius,
|
||||
libraryStatus && styles.opacityPoint5,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { memo, useState } from 'react';
|
||||
import {
|
||||
Image as ReactNativeImage,
|
||||
type ImageStyle,
|
||||
type ImageURISource,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Image, type ImageContentFit, type ImageProps } from 'expo-image';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import type { ImageRequestInit } from '@plugins/types';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
|
||||
interface NovelCoverImageProps
|
||||
extends Pick<
|
||||
ImageProps,
|
||||
| 'accessibilityLabel'
|
||||
| 'accessible'
|
||||
| 'cachePolicy'
|
||||
| 'contentFit'
|
||||
| 'onError'
|
||||
| 'onLayout'
|
||||
| 'priority'
|
||||
| 'recyclingKey'
|
||||
| 'testID'
|
||||
| 'transition'
|
||||
> {
|
||||
iconSize?: number;
|
||||
requestInit?: ImageRequestInit;
|
||||
style?: StyleProp<ImageStyle>;
|
||||
theme: ThemeColors;
|
||||
uri?: string | null;
|
||||
}
|
||||
|
||||
export const isMissingNovelCover = (uri?: string | null) => {
|
||||
const normalizedUri = uri?.trim();
|
||||
return !normalizedUri || normalizedUri === defaultCover;
|
||||
};
|
||||
|
||||
const NovelCoverImage = ({
|
||||
iconSize = 32,
|
||||
onError,
|
||||
requestInit,
|
||||
style,
|
||||
theme,
|
||||
uri,
|
||||
accessibilityLabel,
|
||||
accessible,
|
||||
cachePolicy = 'memory-disk',
|
||||
contentFit = 'cover',
|
||||
onLayout,
|
||||
priority,
|
||||
recyclingKey,
|
||||
testID,
|
||||
transition = 150,
|
||||
}: NovelCoverImageProps) => {
|
||||
const normalizedUri = uri?.trim();
|
||||
const [failedUri, setFailedUri] = useState<string>();
|
||||
const showPlaceholder =
|
||||
isMissingNovelCover(normalizedUri) || failedUri === normalizedUri;
|
||||
const requiresReactNativeImage =
|
||||
Boolean(requestInit?.body) ||
|
||||
Boolean(requestInit?.method && requestInit.method.toUpperCase() !== 'GET');
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<View
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
accessible={accessible}
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
styles.placeholder,
|
||||
{ backgroundColor: theme.surfaceVariant },
|
||||
style,
|
||||
]}
|
||||
testID={testID}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
color={theme.onSurfaceVariant}
|
||||
name="book-open-page-variant-outline"
|
||||
size={iconSize}
|
||||
style={styles.icon}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (requiresReactNativeImage) {
|
||||
const source: ImageURISource = {
|
||||
body: requestInit?.body,
|
||||
headers: requestInit?.headers,
|
||||
method: requestInit?.method,
|
||||
uri: normalizedUri,
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactNativeImage
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessible={accessible}
|
||||
onLayout={onLayout}
|
||||
onError={event => {
|
||||
setFailedUri(normalizedUri);
|
||||
onError?.({ error: event.nativeEvent.error });
|
||||
}}
|
||||
resizeMode={toReactNativeResizeMode(contentFit)}
|
||||
source={source}
|
||||
style={[{ backgroundColor: theme.surfaceVariant }, style]}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessible={accessible}
|
||||
cachePolicy={cachePolicy}
|
||||
contentFit={contentFit}
|
||||
onLayout={onLayout}
|
||||
onError={event => {
|
||||
setFailedUri(normalizedUri);
|
||||
onError?.(event);
|
||||
}}
|
||||
priority={priority}
|
||||
recyclingKey={recyclingKey ?? normalizedUri}
|
||||
source={{ headers: requestInit?.headers, uri: normalizedUri }}
|
||||
style={[{ backgroundColor: theme.surfaceVariant }, style]}
|
||||
testID={testID}
|
||||
transition={transition}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const toReactNativeResizeMode = (
|
||||
contentFit: ImageContentFit,
|
||||
): 'center' | 'contain' | 'cover' | 'stretch' =>
|
||||
contentFit === 'fill'
|
||||
? 'stretch'
|
||||
: contentFit === 'none'
|
||||
? 'center'
|
||||
: contentFit === 'scale-down'
|
||||
? 'contain'
|
||||
: contentFit;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
icon: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
placeholder: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(NovelCoverImage);
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
import { useDeviceOrientation } from '@hooks';
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
|
||||
export interface NovelCoverLayout {
|
||||
coverHeight: number;
|
||||
coverWidth?: number;
|
||||
displayMode: DisplayModes;
|
||||
numColumns: number;
|
||||
showDownloadBadges: boolean;
|
||||
showUnreadBadges: boolean;
|
||||
}
|
||||
|
||||
const NovelCoverLayoutContext = createContext<NovelCoverLayout | null>(null);
|
||||
|
||||
export const useNovelCoverLayoutValue = (
|
||||
globalSearch = false,
|
||||
): NovelCoverLayout => {
|
||||
const {
|
||||
displayMode = DisplayModes.Comfortable,
|
||||
novelsPerRow = 3,
|
||||
showDownloadBadges = true,
|
||||
showUnreadBadges = true,
|
||||
} = useLibrarySettings();
|
||||
const { width } = useWindowDimensions();
|
||||
const orientation = useDeviceOrientation();
|
||||
|
||||
return useMemo(() => {
|
||||
const numColumns = globalSearch
|
||||
? 3
|
||||
: orientation === 'landscape'
|
||||
? 6
|
||||
: displayMode === DisplayModes.List
|
||||
? 1
|
||||
: novelsPerRow;
|
||||
const coverWidth = globalSearch ? width / 3 - 16 : undefined;
|
||||
const coverHeight = (coverWidth ?? width / numColumns) * (4 / 3);
|
||||
|
||||
return {
|
||||
coverHeight,
|
||||
coverWidth,
|
||||
displayMode,
|
||||
numColumns,
|
||||
showDownloadBadges,
|
||||
showUnreadBadges,
|
||||
};
|
||||
}, [
|
||||
displayMode,
|
||||
globalSearch,
|
||||
novelsPerRow,
|
||||
orientation,
|
||||
showDownloadBadges,
|
||||
showUnreadBadges,
|
||||
width,
|
||||
]);
|
||||
};
|
||||
|
||||
export const NovelCoverLayoutProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: NovelCoverLayout }>) => (
|
||||
<NovelCoverLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</NovelCoverLayoutContext.Provider>
|
||||
);
|
||||
|
||||
export const useNovelCoverLayout = () => {
|
||||
const layout = useContext(NovelCoverLayoutContext);
|
||||
|
||||
if (!layout) {
|
||||
throw new Error(
|
||||
'NovelCover must be rendered inside NovelCoverLayoutProvider',
|
||||
);
|
||||
}
|
||||
|
||||
return layout;
|
||||
};
|
||||
@@ -1,86 +1,93 @@
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import {
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
FlatListProps,
|
||||
ListRenderItem,
|
||||
} from 'react-native';
|
||||
FlashList,
|
||||
type FlashListProps,
|
||||
type ListRenderItem,
|
||||
} from '@shopify/flash-list';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import { NovelInfo } from '../database/types';
|
||||
import { useDeviceOrientation } from '@hooks';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
import {
|
||||
NovelCoverLayoutProvider,
|
||||
useNovelCoverLayoutValue,
|
||||
} from './NovelCoverLayoutContext';
|
||||
|
||||
export type NovelListRenderItem = ListRenderItem<NovelInfo | NovelItem>;
|
||||
|
||||
type listDataItem =
|
||||
| (NovelInfo | NovelItem) & {
|
||||
completeRow?: number;
|
||||
};
|
||||
export type NovelListDataItem = (NovelInfo | NovelItem) & {
|
||||
completeRow?: number;
|
||||
};
|
||||
|
||||
interface NovelListProps extends FlatListProps<NovelInfo | NovelItem> {
|
||||
interface NovelListProps
|
||||
extends Omit<FlashListProps<NovelInfo | NovelItem>, 'data'> {
|
||||
inSource?: boolean;
|
||||
data: listDataItem[];
|
||||
data: NovelListDataItem[];
|
||||
}
|
||||
|
||||
const novelListKeyExtractor = (item: NovelInfo | NovelItem, index: number) =>
|
||||
index + '_' + item.path;
|
||||
export const novelListKeyExtractor = (item: NovelInfo | NovelItem) =>
|
||||
'pluginId' in item ? `${item.pluginId}:${item.path}` : item.path;
|
||||
|
||||
const NovelList: React.FC<NovelListProps> = props => {
|
||||
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
|
||||
useLibrarySettings();
|
||||
const orientation = useDeviceOrientation();
|
||||
|
||||
const isListView = displayMode === DisplayModes.List;
|
||||
|
||||
const numColumns = useMemo(() => {
|
||||
if (isListView) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (orientation === 'landscape') {
|
||||
return 6;
|
||||
} else {
|
||||
return novelsPerRow;
|
||||
}
|
||||
}, [isListView, orientation, novelsPerRow]);
|
||||
|
||||
let extendedNovelList: listDataItem[] = props?.data;
|
||||
if (props.data?.length && props.inSource) {
|
||||
const remainder = numColumns - (props.data?.length % numColumns);
|
||||
const extension: listDataItem[] = [];
|
||||
if (remainder !== 0 && remainder !== numColumns) {
|
||||
for (let i = 0; i < remainder; i++) {
|
||||
extension.push({
|
||||
cover: '',
|
||||
name: '',
|
||||
path: 'loading-' + remainder,
|
||||
completeRow: 1,
|
||||
} as listDataItem);
|
||||
}
|
||||
}
|
||||
extension.push({
|
||||
cover: '',
|
||||
name: '',
|
||||
path: 'loading-' + remainder,
|
||||
completeRow: 2,
|
||||
} as listDataItem);
|
||||
|
||||
extendedNovelList = [...props.data, ...extension];
|
||||
export const extendNovelList = (
|
||||
data: NovelListDataItem[],
|
||||
inSource: boolean | undefined,
|
||||
numColumns: number,
|
||||
) => {
|
||||
if (!data.length || !inSource) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const remainder = numColumns - (data.length % numColumns);
|
||||
const extension: NovelListDataItem[] = [];
|
||||
|
||||
if (remainder !== 0 && remainder !== numColumns) {
|
||||
for (let index = 0; index < remainder; index += 1) {
|
||||
extension.push({
|
||||
id: undefined,
|
||||
cover: '',
|
||||
name: '',
|
||||
path: `__loading-filler-${index}`,
|
||||
completeRow: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension.push({
|
||||
id: undefined,
|
||||
cover: '',
|
||||
name: '',
|
||||
path: '__loading-row',
|
||||
completeRow: 2,
|
||||
});
|
||||
|
||||
return [...data, ...extension];
|
||||
};
|
||||
|
||||
const NovelList: React.FC<NovelListProps> = props => {
|
||||
const layout = useNovelCoverLayoutValue();
|
||||
const { displayMode, numColumns } = layout;
|
||||
const isListView = displayMode === DisplayModes.List;
|
||||
const { data, inSource, ...listProps } = props;
|
||||
|
||||
const extendedNovelList = useMemo(
|
||||
() => extendNovelList(data, inSource, numColumns),
|
||||
[data, inSource, numColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={[
|
||||
!isListView && styles.listView,
|
||||
styles.flatListCont,
|
||||
]}
|
||||
numColumns={numColumns}
|
||||
key={numColumns}
|
||||
keyExtractor={novelListKeyExtractor}
|
||||
{...props}
|
||||
data={extendedNovelList}
|
||||
/>
|
||||
<NovelCoverLayoutProvider value={layout}>
|
||||
<FlashList
|
||||
contentContainerStyle={[
|
||||
!isListView && styles.listView,
|
||||
styles.flatListCont,
|
||||
]}
|
||||
numColumns={numColumns}
|
||||
key={numColumns}
|
||||
keyExtractor={novelListKeyExtractor}
|
||||
{...listProps}
|
||||
data={extendedNovelList}
|
||||
/>
|
||||
</NovelCoverLayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import NovelCoverImage, { isMissingNovelCover } from '../NovelCoverImage';
|
||||
|
||||
jest.mock('@react-native-vector-icons/material-design-icons', () => 'Icon');
|
||||
|
||||
const theme = {
|
||||
onSurfaceVariant: '#404040',
|
||||
surfaceVariant: '#d0d0d0',
|
||||
} as ThemeColors;
|
||||
|
||||
describe('NovelCoverImage', () => {
|
||||
it('identifies empty and legacy fallback covers as missing', () => {
|
||||
expect(isMissingNovelCover(undefined)).toBe(true);
|
||||
expect(isMissingNovelCover('')).toBe(true);
|
||||
expect(isMissingNovelCover(defaultCover)).toBe(true);
|
||||
expect(isMissingNovelCover('https://example.com/cover.webp')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the themed placeholder when a cover is missing', () => {
|
||||
render(
|
||||
<NovelCoverImage testID="novel-cover" theme={theme} uri={undefined} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('novel-cover')).toHaveStyle({
|
||||
backgroundColor: theme.surfaceVariant,
|
||||
});
|
||||
expect(screen.getByTestId('novel-cover').props.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replaces a cover with the placeholder when loading fails', () => {
|
||||
render(
|
||||
<NovelCoverImage
|
||||
testID="novel-cover"
|
||||
theme={theme}
|
||||
uri="https://example.com/broken.webp"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('novel-cover').props.source).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
uri: 'https://example.com/broken.webp',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
fireEvent(screen.getByTestId('novel-cover'), 'error', {
|
||||
nativeEvent: { error: 'Failed to load image' },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('novel-cover')).toHaveStyle({
|
||||
backgroundColor: theme.surfaceVariant,
|
||||
});
|
||||
expect(screen.getByTestId('novel-cover').props.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves request bodies through the React Native compatibility path', () => {
|
||||
render(
|
||||
<NovelCoverImage
|
||||
requestInit={{
|
||||
body: 'token=secret',
|
||||
headers: { Referer: 'https://example.com' },
|
||||
method: 'POST',
|
||||
}}
|
||||
testID="novel-cover"
|
||||
theme={theme}
|
||||
uri="https://example.com/cover"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('novel-cover').props.source).toEqual({
|
||||
body: 'token=secret',
|
||||
headers: { Referer: 'https://example.com' },
|
||||
method: 'POST',
|
||||
uri: 'https://example.com/cover',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { NovelInfo } from '@database/types';
|
||||
import type { NovelItem } from '@plugins/types';
|
||||
import {
|
||||
extendNovelList,
|
||||
novelListKeyExtractor,
|
||||
type NovelListDataItem,
|
||||
} from '../NovelList';
|
||||
|
||||
jest.mock('@shopify/flash-list', () => ({
|
||||
FlashList: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@hooks', () => ({
|
||||
useDeviceOrientation: () => 'portrait',
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useLibrarySettings: () => ({
|
||||
displayMode: 'comfortable',
|
||||
novelsPerRow: 3,
|
||||
showDownloadBadges: true,
|
||||
showUnreadBadges: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('NovelList helpers', () => {
|
||||
it('uses source and path instead of the array index for persisted novels', () => {
|
||||
const novel = {
|
||||
id: 1,
|
||||
name: 'Novel',
|
||||
path: '/novel',
|
||||
pluginId: 'english/source',
|
||||
} as NovelInfo;
|
||||
|
||||
expect(novelListKeyExtractor(novel)).toBe('english/source:/novel');
|
||||
});
|
||||
|
||||
it('uses the path as the stable key for source results', () => {
|
||||
const novel: NovelItem = {
|
||||
id: undefined,
|
||||
name: 'Novel',
|
||||
path: '/novel',
|
||||
};
|
||||
|
||||
expect(novelListKeyExtractor(novel)).toBe('/novel');
|
||||
});
|
||||
|
||||
it('adds uniquely keyed filler cells without mutating the source data', () => {
|
||||
const novels: NovelListDataItem[] = [
|
||||
{ id: undefined, name: 'One', path: '/one' },
|
||||
{ id: undefined, name: 'Two', path: '/two' },
|
||||
];
|
||||
|
||||
const extended = extendNovelList(novels, true, 4);
|
||||
|
||||
expect(novels).toHaveLength(2);
|
||||
expect(extended.map(item => item.path)).toEqual([
|
||||
'/one',
|
||||
'/two',
|
||||
'__loading-filler-0',
|
||||
'__loading-filler-1',
|
||||
'__loading-row',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export { default as SwitchItem } from './Switch/SwitchItem';
|
||||
export { default as List } from './List/List';
|
||||
export { default as ColorPreferenceItem } from './ColorPreferenceItem/ColorPreferenceItem';
|
||||
export { default as LoadingMoreIndicator } from './LoadingMoreIndicator/LoadingMoreIndicator';
|
||||
export { default as NovelCoverImage } from './NovelCoverImage';
|
||||
export { Checkbox } from './Checkbox/Checkbox';
|
||||
export { RadioButton } from './RadioButton/RadioButton';
|
||||
export { Dialog } from './Dialog';
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getString } from '@strings/translations';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { dbManager } from '@database/db';
|
||||
import { chapterSchema, novelSchema } from '@database/schema';
|
||||
import NativeFile from '@modules/native-file'
|
||||
import NativeFile from '@modules/native-file';
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import { chapterFilterToSQL, chapterOrderToSQL } from '@database/utils/parser';
|
||||
import { castInt } from '@database/manager/manager';
|
||||
@@ -235,8 +235,11 @@ export const increaseTimeSpent = async (
|
||||
timeSpent: number,
|
||||
): Promise<void> => {
|
||||
await dbManager.write(async tx => {
|
||||
await tx.update(chapterSchema)
|
||||
.set({ timeSpent: sql`COALESCE(${chapterSchema.timeSpent}, 0) + ${timeSpent}` })
|
||||
await tx
|
||||
.update(chapterSchema)
|
||||
.set({
|
||||
timeSpent: sql`COALESCE(${chapterSchema.timeSpent}, 0) + ${timeSpent}`,
|
||||
})
|
||||
.where(eq(chapterSchema.id, chapterId))
|
||||
.run();
|
||||
});
|
||||
@@ -747,10 +750,7 @@ export const getNovelDownloadedChapters = async (
|
||||
eq(chapterSchema.isDownloaded, true),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(castInt(chapterSchema.page)),
|
||||
asc(chapterSchema.position),
|
||||
)
|
||||
.orderBy(asc(castInt(chapterSchema.page)), asc(chapterSchema.position))
|
||||
.$dynamic();
|
||||
|
||||
if (startPosition !== undefined && endPosition !== undefined) {
|
||||
@@ -764,6 +764,7 @@ export const getUpdatedOverviewFromDb = async () =>
|
||||
dbManager
|
||||
.select({
|
||||
novelId: novelSchema.id,
|
||||
pluginId: novelSchema.pluginId,
|
||||
novelName: novelSchema.name,
|
||||
novelCover: novelSchema.cover,
|
||||
novelPath: novelSchema.path,
|
||||
@@ -782,6 +783,7 @@ export const getUpdatedOverviewFromDb = async () =>
|
||||
export const getDetailedUpdatesFromDb = async (
|
||||
novelId: number,
|
||||
onlyDownloadableChapters?: boolean,
|
||||
updateDate?: string,
|
||||
): Promise<Update[]> => {
|
||||
return dbManager
|
||||
.select({
|
||||
@@ -800,6 +802,9 @@ export const getDetailedUpdatesFromDb = async (
|
||||
onlyDownloadableChapters
|
||||
? eq(chapterSchema.isDownloaded, true)
|
||||
: isNotNull(chapterSchema.updatedTime),
|
||||
updateDate
|
||||
? eq(sql<string>`DATE(${chapterSchema.updatedTime})`, updateDate)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chapterSchema.updatedTime))
|
||||
|
||||
@@ -1267,6 +1267,26 @@ describe('ChapterQueries', () => {
|
||||
|
||||
expect(result.every(c => c.isDownloaded === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter updates by their overview date', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
updatedTime: '2024-01-01 10:00:00',
|
||||
});
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
updatedTime: '2024-01-02 10:00:00',
|
||||
});
|
||||
|
||||
const result = await getDetailedUpdatesFromDb(
|
||||
novelId,
|
||||
false,
|
||||
'2024-01-01',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.updatedTime).toBe('2024-01-01 10:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChapterDownloaded', () => {
|
||||
|
||||
@@ -68,7 +68,9 @@ export interface Update extends ChapterInfo {
|
||||
|
||||
export interface UpdateOverview {
|
||||
novelId: number;
|
||||
pluginId: string;
|
||||
novelName: string;
|
||||
novelPath: string;
|
||||
updateDate: string;
|
||||
updatesPerDay: number;
|
||||
novelCover: string | null;
|
||||
@@ -107,9 +109,9 @@ export interface LibraryStats {
|
||||
timeSpent: number;
|
||||
}[];
|
||||
topCategoriesByTimeSpent?: {
|
||||
id: number;
|
||||
name: string;
|
||||
timeSpent: number;
|
||||
id: number;
|
||||
name: string;
|
||||
timeSpent: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getUpdatedOverviewFromDb,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
|
||||
import { Update, UpdateOverview } from '@database/types';
|
||||
import { UpdateOverview } from '@database/types';
|
||||
import { useMMKVBoolean, useMMKVString } from 'react-native-mmkv';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
@@ -13,6 +13,31 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
export const SHOW_LAST_UPDATE_TIME = 'SHOW_LAST_UPDATE_TIME';
|
||||
export const LAST_UPDATE_TIME = 'LAST_UPDATE_TIME';
|
||||
|
||||
export const fetchDetailedUpdates = async (
|
||||
novelId: number,
|
||||
onlyDownloadedChapters = false,
|
||||
updateDate?: string,
|
||||
) => {
|
||||
const result = await getDetailedUpdatesFromDb(
|
||||
novelId,
|
||||
onlyDownloadedChapters,
|
||||
updateDate,
|
||||
);
|
||||
|
||||
return 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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const useLastUpdate = () => {
|
||||
const [showLastUpdateTime = true, setShowLastUpdateTime] = useMMKVBoolean(
|
||||
SHOW_LAST_UPDATE_TIME,
|
||||
@@ -27,86 +52,52 @@ export const useLastUpdate = () => {
|
||||
};
|
||||
|
||||
export const useUpdates = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [updatesOverview, setUpdatesOverview] = useState<UpdateOverview[]>([]);
|
||||
|
||||
const { lastUpdateTime, showLastUpdateTime, setLastUpdateTime } =
|
||||
useLastUpdate();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getUpdates = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
getUpdatedOverviewFromDb()
|
||||
.then(res => {
|
||||
setUpdatesOverview(res);
|
||||
if (res.length) {
|
||||
if (
|
||||
!lastUpdateTime ||
|
||||
dayjs(lastUpdateTime).isBefore(dayjs(res[0].updateDate))
|
||||
) {
|
||||
setLastUpdateTime(res[0].updateDate);
|
||||
}
|
||||
try {
|
||||
const result = await getUpdatedOverviewFromDb();
|
||||
setUpdatesOverview(result);
|
||||
setError('');
|
||||
|
||||
if (result.length) {
|
||||
if (
|
||||
!lastUpdateTime ||
|
||||
dayjs(lastUpdateTime).isBefore(dayjs(result[0].updateDate))
|
||||
) {
|
||||
setLastUpdateTime(result[0].updateDate);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
} catch (updateError) {
|
||||
setError(
|
||||
updateError instanceof Error
|
||||
? updateError.message
|
||||
: String(updateError),
|
||||
);
|
||||
}
|
||||
}, [lastUpdateTime, setLastUpdateTime]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setIsLoading(true);
|
||||
//? Push updates to the end of the stack to avoid lag
|
||||
setTimeout(async () => {
|
||||
await getUpdates();
|
||||
setIsLoading(false);
|
||||
}, 0);
|
||||
const timer = setTimeout(() => void getUpdates(), 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [getUpdates]),
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isLoading,
|
||||
updatesOverview,
|
||||
getUpdates,
|
||||
getDetailedUpdates,
|
||||
lastUpdateTime,
|
||||
showLastUpdateTime,
|
||||
error,
|
||||
}),
|
||||
[
|
||||
isLoading,
|
||||
updatesOverview,
|
||||
getUpdates,
|
||||
getDetailedUpdates,
|
||||
lastUpdateTime,
|
||||
showLastUpdateTime,
|
||||
error,
|
||||
],
|
||||
[updatesOverview, getUpdates, lastUpdateTime, showLastUpdateTime, error],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/** @deprecated Retained for third-party plugin compatibility. */
|
||||
export const defaultCover =
|
||||
'https://github.com/lnreader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
|
||||
|
||||
@@ -107,7 +107,12 @@ export interface PluginItem {
|
||||
}
|
||||
|
||||
export interface ImageRequestInit {
|
||||
[x: string]: string | Record<string, string> | Headers | FormData | undefined;
|
||||
[x: string]:
|
||||
| string
|
||||
| Record<string, string>
|
||||
| Headers
|
||||
| FormData
|
||||
| undefined;
|
||||
method?: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import color from 'color';
|
||||
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
|
||||
@@ -15,6 +15,11 @@ import GlobalSearchSkeletonLoading from '@screens/browse/loadingAnimation/Global
|
||||
import { interpolateColor } from 'react-native-reanimated';
|
||||
import { useLibraryContext } from '@components/Context/LibraryContext';
|
||||
import NovelCover from '@components/NovelCover';
|
||||
import { RootStackParamList } from '@navigators/types';
|
||||
import {
|
||||
NovelCoverLayoutProvider,
|
||||
useNovelCoverLayoutValue,
|
||||
} from '@components/NovelCoverLayoutContext';
|
||||
|
||||
interface GlobalSearchResultsListProps {
|
||||
searchResults: GlobalSearchResult[];
|
||||
@@ -25,19 +30,22 @@ const GlobalSearchResultsList: React.FC<GlobalSearchResultsListProps> = ({
|
||||
searchResults,
|
||||
ListEmptyComponent,
|
||||
}) => {
|
||||
const coverLayout = useNovelCoverLayoutValue(true);
|
||||
const keyExtractor = useCallback(
|
||||
(item: GlobalSearchResult) => item.plugin.id,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList<GlobalSearchResult>
|
||||
keyExtractor={keyExtractor}
|
||||
data={searchResults}
|
||||
contentContainerStyle={styles.resultList}
|
||||
renderItem={({ item }) => <GlobalSearchSourceResults item={item} />}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
/>
|
||||
<NovelCoverLayoutProvider value={coverLayout}>
|
||||
<FlatList<GlobalSearchResult>
|
||||
keyExtractor={keyExtractor}
|
||||
data={searchResults}
|
||||
contentContainerStyle={styles.resultList}
|
||||
renderItem={({ item }) => <GlobalSearchSourceResults item={item} />}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
/>
|
||||
</NovelCoverLayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +53,8 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
|
||||
item,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<StackNavigationProp<any>>();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [inActivity, setInActivity] = useState<Record<string, boolean>>({});
|
||||
const { novelInLibrary, switchNovelToLibrary } = useLibraryContext();
|
||||
const imageRequestInit = getPlugin(item.plugin.id)?.imageRequestInit;
|
||||
@@ -111,7 +120,7 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
|
||||
contentContainerStyle={styles.novelsContainer}
|
||||
keyExtractor={novelItem => item.plugin.id + '_' + novelItem.path}
|
||||
data={item.novels}
|
||||
extraData={inActivity.length}
|
||||
extraData={inActivity}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.listEmpty, { color: noResultsColor }]}>
|
||||
{getString('sourceScreen.noResultsFound')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View, Image } from 'react-native';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Appbar,
|
||||
ErrorScreenV2,
|
||||
LoadingScreenV2,
|
||||
NovelCoverImage,
|
||||
SafeAreaView,
|
||||
} from '@components';
|
||||
|
||||
@@ -69,34 +70,55 @@ const StatsScreen = () => {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [stats, setStats] = useState<LibraryStats>({});
|
||||
const [error, setError] = useState<any>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const [showingNovels, setShowingNovels] = useState(true);
|
||||
|
||||
const getStats = async () => {
|
||||
try {
|
||||
const res = await Promise.all([
|
||||
getLibraryStatsFromDb(),
|
||||
getChaptersTotalCountFromDb(),
|
||||
getChaptersReadCountFromDb(),
|
||||
getChaptersUnreadCountFromDb(),
|
||||
getChaptersDownloadedCountFromDb(),
|
||||
getNovelGenresFromDb(),
|
||||
getNovelStatusFromDb(),
|
||||
getTopNovelsByTimeSpentFromDb(),
|
||||
getTopCategoriesByTimeSpentFromDb(),
|
||||
getTotalTimeSpentFromDb(),
|
||||
]);
|
||||
setStats(Object.assign(...res));
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getStats();
|
||||
let cancelled = false;
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await Promise.all([
|
||||
getLibraryStatsFromDb(),
|
||||
getChaptersTotalCountFromDb(),
|
||||
getChaptersReadCountFromDb(),
|
||||
getChaptersUnreadCountFromDb(),
|
||||
getChaptersDownloadedCountFromDb(),
|
||||
getNovelGenresFromDb(),
|
||||
getNovelStatusFromDb(),
|
||||
getTopNovelsByTimeSpentFromDb(),
|
||||
getTopCategoriesByTimeSpentFromDb(),
|
||||
getTotalTimeSpentFromDb(),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setStats(
|
||||
res.reduce<LibraryStats>(
|
||||
(combinedStats, currentStats) => ({
|
||||
...combinedStats,
|
||||
...currentStats,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadStats();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const Header = (
|
||||
@@ -207,10 +229,13 @@ const StatsScreen = () => {
|
||||
const headers = plugin?.imageRequestInit?.headers || { 'User-Agent': getUserAgent() };
|
||||
const requestInit = {...plugin?.imageRequestInit, headers };
|
||||
return <View key={novel.id} style={styles.timeSpentRow}>
|
||||
<Image
|
||||
source={{ uri: novel.cover || undefined, ...requestInit }}
|
||||
<NovelCoverImage
|
||||
uri={novel.cover}
|
||||
requestInit={requestInit}
|
||||
theme={theme}
|
||||
iconSize={22}
|
||||
style={styles.timeSpentNovelCover}
|
||||
resizeMode='cover'
|
||||
contentFit='cover'
|
||||
/>
|
||||
<View>
|
||||
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View, Image } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { NovelCoverImage } from '@components';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
|
||||
interface Props {
|
||||
@@ -22,7 +23,12 @@ const DiscoverNovelCard: React.FC<Props> = ({ novel, onPress, theme }) => {
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<Image source={{ uri: novel.novelCover }} style={styles.cover} />
|
||||
<NovelCoverImage
|
||||
uri={novel.novelCover}
|
||||
theme={theme}
|
||||
iconSize={32}
|
||||
style={styles.cover}
|
||||
/>
|
||||
<View style={styles.infoContainer}>
|
||||
<Text
|
||||
style={[styles.title, { color: theme.onSurface }]}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, Image, Pressable } from 'react-native';
|
||||
import { coverPlaceholderColor } from '@theme/colors';
|
||||
import { StyleSheet, View, Text, Pressable } from 'react-native';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import { NovelCoverImage } from '@components';
|
||||
|
||||
interface GlobalSearchNovelCoverProps {
|
||||
novel: NovelItem;
|
||||
@@ -33,10 +32,11 @@ const GlobalSearchNovelCover = ({
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<Image
|
||||
source={{ uri }}
|
||||
<NovelCoverImage
|
||||
uri={uri}
|
||||
theme={theme}
|
||||
iconSize={34}
|
||||
style={[styles.novelCover, { opacity }]}
|
||||
progressiveRenderingEnabled={true}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
@@ -58,7 +58,6 @@ const styles = StyleSheet.create({
|
||||
overflow: 'hidden',
|
||||
},
|
||||
novelCover: {
|
||||
backgroundColor: coverPlaceholderColor,
|
||||
borderRadius: 4,
|
||||
height: 150,
|
||||
width: 115,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { IconButtonV2 } from '@components';
|
||||
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import { IconButtonV2, NovelCoverImage } from '@components';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
import { History, NovelInfo } from '@database/types';
|
||||
import { History } from '@database/types';
|
||||
import { HistoryScreenProps } from '@navigators/types';
|
||||
|
||||
import { coverPlaceholderColor } from '@theme/colors';
|
||||
|
||||
interface HistoryCardProps {
|
||||
history: History;
|
||||
handleRemoveFromHistory: (chapterId: number) => void;
|
||||
@@ -36,10 +32,12 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
screen: 'Chapter',
|
||||
params: {
|
||||
novel: {
|
||||
id: history.novelId,
|
||||
path: history.novelPath,
|
||||
name: history.novelName,
|
||||
pluginId: history.pluginId,
|
||||
} as NovelInfo,
|
||||
cover: history.novelCover,
|
||||
},
|
||||
chapter: history,
|
||||
},
|
||||
})
|
||||
@@ -51,7 +49,7 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: {
|
||||
name: history.name,
|
||||
name: history.novelName,
|
||||
path: history.novelPath,
|
||||
cover: history.novelCover,
|
||||
pluginId: history.pluginId,
|
||||
@@ -59,8 +57,10 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: history.novelCover || defaultCover }}
|
||||
<NovelCoverImage
|
||||
uri={history.novelCover}
|
||||
theme={theme}
|
||||
iconSize={24}
|
||||
style={styles.cover}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -110,7 +110,6 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
},
|
||||
cover: {
|
||||
backgroundColor: coverPlaceholderColor,
|
||||
borderRadius: 4,
|
||||
height: 80,
|
||||
width: 56,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTheme } from '@hooks/persisted';
|
||||
|
||||
import RemoveDownloadsDialog from './components/RemoveDownloadsDialog';
|
||||
import UpdatesSkeletonLoading from '@screens/updates/components/UpdatesSkeletonLoading';
|
||||
import UpdateNovelCard from '@screens/updates/components/UpdateNovelCard';
|
||||
import DownloadedNovelChapterGroup from './components/DownloadedNovelChapterGroup';
|
||||
import { getString } from '@strings/translations';
|
||||
import { DownloadsScreenProps } from '@navigators/types';
|
||||
import { DownloadedChapter } from '@database/types';
|
||||
@@ -25,25 +25,25 @@ import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
|
||||
type DownloadGroup = Record<number, DownloadedChapter[]>;
|
||||
|
||||
const groupChaptersByNovel = (
|
||||
chapters: DownloadedChapter[],
|
||||
): DownloadedChapter[][] => {
|
||||
const novelGroups = chapters.reduce((groups, chapter) => {
|
||||
if (!groups[chapter.novelId]) {
|
||||
groups[chapter.novelId] = [];
|
||||
}
|
||||
|
||||
groups[chapter.novelId].push(chapter);
|
||||
return groups;
|
||||
}, {} as DownloadGroup);
|
||||
|
||||
return Object.values(novelGroups);
|
||||
};
|
||||
|
||||
const Downloads = ({ navigation }: DownloadsScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chapters, setChapters] = useState<DownloadedChapter[]>([]);
|
||||
const groupUpdatesByDate = (
|
||||
localChapters: DownloadedChapter[],
|
||||
): DownloadedChapter[][] => {
|
||||
const dateGroups = localChapters.reduce((groups, item) => {
|
||||
const novelId = item.novelId;
|
||||
if (!groups[novelId]) {
|
||||
groups[novelId] = [];
|
||||
}
|
||||
|
||||
groups[novelId].push(item);
|
||||
|
||||
return groups;
|
||||
}, {} as DownloadGroup);
|
||||
return Object.values(dateGroups);
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm Clear downloads Dialog
|
||||
@@ -52,7 +52,7 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
|
||||
const showDialog = () => setVisible(true);
|
||||
const hideDialog = () => setVisible(false);
|
||||
|
||||
const getChapters = async () => {
|
||||
const getChapters = useCallback(async () => {
|
||||
const res = await getDownloadedChapters();
|
||||
setChapters(
|
||||
res.map(download => {
|
||||
@@ -68,22 +68,16 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const ListEmptyComponent = useCallback(
|
||||
() =>
|
||||
!loading ? (
|
||||
<EmptyView
|
||||
icon="(˘・_・˘)"
|
||||
description={getString('downloadScreen.noDownloads')}
|
||||
/>
|
||||
) : null,
|
||||
[loading],
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getChapters().finally(() => setLoading(false));
|
||||
}, []);
|
||||
const timer = setTimeout(
|
||||
() => void getChapters().finally(() => setLoading(false)),
|
||||
0,
|
||||
);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [getChapters]);
|
||||
|
||||
return (
|
||||
<SafeAreaView excludeTop>
|
||||
@@ -107,15 +101,14 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
|
||||
) : (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.flatList}
|
||||
data={groupUpdatesByDate(chapters)}
|
||||
keyExtractor={(item, index) => 'downloadGroup' + index}
|
||||
data={groupChaptersByNovel(chapters)}
|
||||
keyExtractor={item => `downloadGroup-${item[0]?.novelId}`}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<UpdateNovelCard
|
||||
onlyDownloadedChapters
|
||||
chapterList={item}
|
||||
descriptionText={getString('downloadScreen.downloadsLower')}
|
||||
deleteChapter={chapter => {
|
||||
<DownloadedNovelChapterGroup
|
||||
chapters={item}
|
||||
chapterCountLabel={getString('downloadScreen.downloadsLower')}
|
||||
onDeleteChapter={chapter => {
|
||||
deleteChapter(
|
||||
chapter.pluginId,
|
||||
chapter.novelId,
|
||||
@@ -128,7 +121,12 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
|
||||
/>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={<ListEmptyComponent />}
|
||||
ListEmptyComponent={
|
||||
<EmptyView
|
||||
icon="(˘・_・˘)"
|
||||
description={getString('downloadScreen.noDownloads')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RemoveDownloadsDialog
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { DownloadedChapter } from '@database/types';
|
||||
import NovelChapterGroup, {
|
||||
GroupedNovelChapter,
|
||||
} from '@screens/novel/components/NovelChapterGroup';
|
||||
|
||||
interface DownloadedNovelChapterGroupProps {
|
||||
chapterCountLabel: string;
|
||||
chapters: DownloadedChapter[];
|
||||
onDeleteChapter: (chapter: GroupedNovelChapter) => void;
|
||||
}
|
||||
|
||||
const DownloadedNovelChapterGroup: React.FC<
|
||||
DownloadedNovelChapterGroupProps
|
||||
> = ({ chapterCountLabel, chapters, onDeleteChapter }) => {
|
||||
const firstChapter = chapters[0];
|
||||
const novel = useMemo(
|
||||
() =>
|
||||
firstChapter
|
||||
? {
|
||||
id: firstChapter.novelId,
|
||||
pluginId: firstChapter.pluginId,
|
||||
name: firstChapter.novelName,
|
||||
path: firstChapter.novelPath,
|
||||
cover: firstChapter.novelCover,
|
||||
}
|
||||
: null,
|
||||
[firstChapter],
|
||||
);
|
||||
|
||||
if (!novel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NovelChapterGroup
|
||||
chapterCount={chapters.length}
|
||||
chapterCountLabel={chapterCountLabel}
|
||||
chapters={chapters}
|
||||
novel={novel}
|
||||
onDeleteChapter={onDeleteChapter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DownloadedNovelChapterGroup);
|
||||
@@ -16,7 +16,7 @@ interface ChapterItemProps {
|
||||
isBookmarked?: boolean;
|
||||
isSelected?: boolean;
|
||||
isLocal: boolean;
|
||||
isUpdateCard?: boolean;
|
||||
variant?: 'default' | 'grouped';
|
||||
theme: ThemeColors;
|
||||
showChapterTitles: boolean;
|
||||
novelName: string;
|
||||
@@ -33,7 +33,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
isBookmarked,
|
||||
isSelected,
|
||||
isLocal,
|
||||
isUpdateCard,
|
||||
variant = 'default',
|
||||
theme,
|
||||
showChapterTitles,
|
||||
novelName,
|
||||
@@ -45,6 +45,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
}) => {
|
||||
const { id, name, unread, releaseTime, bookmark, chapterNumber, progress } =
|
||||
chapter;
|
||||
const isGrouped = variant === 'grouped';
|
||||
|
||||
isBookmarked ??= bookmark ?? false;
|
||||
|
||||
@@ -117,10 +118,10 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
{left}
|
||||
{isBookmarked ? <ChapterBookmarkButton theme={theme} /> : null}
|
||||
<View style={styles.flex1}>
|
||||
{isUpdateCard ? (
|
||||
{isGrouped ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.updateCardName,
|
||||
styles.groupedChapterNovelName,
|
||||
{ color: unread ? theme.onSurface : theme.outline },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
@@ -140,7 +141,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
|
||||
<Text
|
||||
style={[
|
||||
isUpdateCard ? styles.textSmall : styles.textNormal,
|
||||
isGrouped ? styles.textSmall : styles.textNormal,
|
||||
{ color: titleColor },
|
||||
styles.flex1,
|
||||
]}
|
||||
@@ -155,7 +156,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
{parsedTime && !isUpdateCard ? (
|
||||
{parsedTime && !isGrouped ? (
|
||||
<Text
|
||||
style={[{ color: releaseColor }, styles.mt4, styles.text]}
|
||||
numberOfLines={1}
|
||||
@@ -163,7 +164,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
{parsedTime}
|
||||
</Text>
|
||||
) : null}
|
||||
{chapter.scanlator && !isUpdateCard ? (
|
||||
{chapter.scanlator && !isGrouped ? (
|
||||
<Text
|
||||
style={[
|
||||
{ color: releaseColor },
|
||||
@@ -177,7 +178,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
{chapter.scanlator}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
|
||||
{!isGrouped && progress && progress > 0 && chapter.unread ? (
|
||||
<Text
|
||||
style={[styles.text, styles.mt4, releaseTimeStyle]}
|
||||
numberOfLines={1}
|
||||
@@ -242,7 +243,7 @@ const styles = StyleSheet.create({
|
||||
unreadIcon: {
|
||||
marginEnd: 4,
|
||||
},
|
||||
updateCardName: {
|
||||
groupedChapterNovelName: {
|
||||
fontSize: 14,
|
||||
},
|
||||
mt4: {
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
Pressable,
|
||||
ImageBackground,
|
||||
Image,
|
||||
ImageURISource,
|
||||
} from 'react-native';
|
||||
import { FlatList, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { ImageBackground, type ImageSource } from 'expo-image';
|
||||
import color from 'color';
|
||||
import { IconButton, Portal } from 'react-native-paper';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Chip } from '../../../../components';
|
||||
import { coverPlaceholderColor } from '../../../../theme/colors';
|
||||
import { Chip, NovelCoverImage } from '../../../../components';
|
||||
import { isMissingNovelCover } from '../../../../components/NovelCoverImage';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface CoverImageProps {
|
||||
children: React.ReactNode;
|
||||
source: ImageURISource;
|
||||
source: ImageSource;
|
||||
theme: ThemeColors;
|
||||
hideBackdrop: boolean;
|
||||
}
|
||||
|
||||
interface NovelThumbnailProps {
|
||||
source: ImageURISource;
|
||||
source: ImageSource;
|
||||
theme: ThemeColors;
|
||||
setCustomNovelCover: () => Promise<void>;
|
||||
saveNovelCover: () => Promise<void>;
|
||||
@@ -45,6 +37,7 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
const CoverImage = memo(
|
||||
({ children, source, theme, hideBackdrop }: CoverImageProps) => {
|
||||
const [failedUri, setFailedUri] = useState<string>();
|
||||
const overlayBg = useMemo(
|
||||
() => color(theme.background).alpha(0.7).string(),
|
||||
[theme.background],
|
||||
@@ -61,21 +54,44 @@ const CoverImage = memo(
|
||||
if (hideBackdrop) {
|
||||
return <View>{children}</View>;
|
||||
}
|
||||
return (
|
||||
<ImageBackground source={source} style={styles.coverImage}>
|
||||
<View style={overlayStyle}>
|
||||
{source.uri ? (
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
locations={GRADIENT_LOCATIONS}
|
||||
style={styles.linearGradient}
|
||||
>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
const showPlaceholder =
|
||||
isMissingNovelCover(source.uri) || failedUri === source.uri;
|
||||
const content = (
|
||||
<View style={overlayStyle}>
|
||||
{!showPlaceholder ? (
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
locations={GRADIENT_LOCATIONS}
|
||||
style={styles.linearGradient}
|
||||
>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.coverImage, { backgroundColor: theme.background }]}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageBackground
|
||||
cachePolicy="memory-disk"
|
||||
contentFit="cover"
|
||||
onError={() => setFailedUri(source.uri)}
|
||||
source={source}
|
||||
style={styles.coverImage}
|
||||
>
|
||||
{content}
|
||||
</ImageBackground>
|
||||
);
|
||||
},
|
||||
@@ -98,7 +114,12 @@ const NovelThumbnail = ({
|
||||
style={styles.novelThumbnailContainer}
|
||||
>
|
||||
{!expanded ? (
|
||||
<Image source={source} style={styles.novelThumbnail} />
|
||||
<NovelCoverImage
|
||||
uri={source.uri}
|
||||
theme={theme}
|
||||
iconSize={36}
|
||||
style={styles.novelThumbnail}
|
||||
/>
|
||||
) : (
|
||||
<Portal>
|
||||
<IconButton
|
||||
@@ -125,7 +146,13 @@ const NovelThumbnail = ({
|
||||
style={[styles.expandedOverlay]}
|
||||
onPress={() => setExpanded(false)}
|
||||
>
|
||||
<Image source={source} resizeMode="contain" style={styles.flex1} />
|
||||
<NovelCoverImage
|
||||
uri={source.uri}
|
||||
theme={theme}
|
||||
iconSize={64}
|
||||
contentFit="contain"
|
||||
style={styles.flex1}
|
||||
/>
|
||||
</Pressable>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -334,7 +361,6 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 90,
|
||||
},
|
||||
novelThumbnail: {
|
||||
backgroundColor: coverPlaceholderColor,
|
||||
borderRadius: 6,
|
||||
height: 150,
|
||||
width: 100,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { ActivityIndicator, List } from 'react-native-paper';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
|
||||
import { NovelCoverImage } from '@components';
|
||||
import {
|
||||
ChapterInfo,
|
||||
DownloadedChapter,
|
||||
NovelInfo,
|
||||
Update,
|
||||
} from '@database/types';
|
||||
import { useDownload, useTheme } from '@hooks/persisted';
|
||||
import { RootStackParamList } from '@navigators/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import ChapterItem from './ChapterItem';
|
||||
|
||||
export type GroupedNovelChapter = DownloadedChapter | Update;
|
||||
|
||||
interface NovelChapterGroupProps {
|
||||
chapterCount: number;
|
||||
chapterCountLabel: string;
|
||||
chapters: GroupedNovelChapter[];
|
||||
isLoading?: boolean;
|
||||
novel: NovelInfo;
|
||||
onDeleteChapter: (chapter: GroupedNovelChapter) => void;
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
const NovelChapterGroup: React.FC<NovelChapterGroupProps> = ({
|
||||
chapterCount,
|
||||
chapterCountLabel,
|
||||
chapters,
|
||||
isLoading = false,
|
||||
novel,
|
||||
onDeleteChapter,
|
||||
onExpand,
|
||||
}) => {
|
||||
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { downloadChapter, downloadingChapterIds } = useDownload();
|
||||
const theme = useTheme();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const handleAccordionPress = useCallback(() => {
|
||||
const nextExpanded = !isExpanded;
|
||||
setIsExpanded(nextExpanded);
|
||||
|
||||
if (nextExpanded) {
|
||||
onExpand?.();
|
||||
}
|
||||
}, [isExpanded, onExpand]);
|
||||
|
||||
const handleDownloadChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
if (isGroupedNovelChapter(chapter)) {
|
||||
downloadChapter(novel, chapter);
|
||||
}
|
||||
},
|
||||
[downloadChapter, novel],
|
||||
);
|
||||
|
||||
const handleDeleteChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
if (isGroupedNovelChapter(chapter)) {
|
||||
onDeleteChapter(chapter);
|
||||
}
|
||||
},
|
||||
[onDeleteChapter],
|
||||
);
|
||||
|
||||
const navigateToChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
if (!isGroupedNovelChapter(chapter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: { novel, chapter },
|
||||
});
|
||||
},
|
||||
[navigate, novel],
|
||||
);
|
||||
|
||||
const navigateToNovel = useCallback(() => {
|
||||
navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: {
|
||||
pluginId: novel.pluginId,
|
||||
path: novel.path,
|
||||
cover: novel.cover,
|
||||
name: novel.name,
|
||||
},
|
||||
});
|
||||
}, [navigate, novel]);
|
||||
|
||||
const renderCover = useCallback(
|
||||
() => (
|
||||
<Pressable onPress={navigateToNovel} style={styles.alignSelf}>
|
||||
<NovelCoverImage
|
||||
uri={novel.cover}
|
||||
theme={theme}
|
||||
iconSize={20}
|
||||
style={styles.cover}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
[navigateToNovel, novel.cover, styles.alignSelf, styles.cover, theme],
|
||||
);
|
||||
|
||||
const coverElement = useMemo(
|
||||
() => <View style={styles.novelCover}>{renderCover()}</View>,
|
||||
[renderCover, styles.novelCover],
|
||||
);
|
||||
|
||||
const renderChapter = useCallback(
|
||||
(chapter: GroupedNovelChapter) => (
|
||||
<ChapterItem
|
||||
isLocal={false}
|
||||
isDownloading={downloadingChapterIds.has(chapter.id)}
|
||||
variant="grouped"
|
||||
novelName={novel.name}
|
||||
chapter={chapter}
|
||||
theme={theme}
|
||||
showChapterTitles={false}
|
||||
onDownloadChapter={handleDownloadChapter}
|
||||
onDeleteChapter={handleDeleteChapter}
|
||||
onSelectPress={navigateToChapter}
|
||||
left={coverElement}
|
||||
/>
|
||||
),
|
||||
[
|
||||
coverElement,
|
||||
downloadingChapterIds,
|
||||
handleDeleteChapter,
|
||||
handleDownloadChapter,
|
||||
navigateToChapter,
|
||||
novel.name,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
|
||||
if (chapterCount > 1) {
|
||||
return (
|
||||
<List.Accordion
|
||||
title={novel.name}
|
||||
titleStyle={styles.title}
|
||||
left={renderCover}
|
||||
descriptionStyle={styles.description}
|
||||
theme={{ colors: theme }}
|
||||
style={[styles.container, styles.padding]}
|
||||
description={`${chapterCount} ${chapterCountLabel}`}
|
||||
expanded={isExpanded}
|
||||
onPress={handleAccordionPress}
|
||||
>
|
||||
<View style={styles.chapterList}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator
|
||||
color={theme.primary}
|
||||
style={styles.loadingIndicator}
|
||||
/>
|
||||
) : null}
|
||||
{chapters.map(chapter => (
|
||||
<React.Fragment key={`chapter-${chapter.id}`}>
|
||||
{renderChapter(chapter)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</List.Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
color={theme.primary}
|
||||
style={styles.loadingIndicator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return chapters[0] ? renderChapter(chapters[0]) : null;
|
||||
};
|
||||
|
||||
export default memo(NovelChapterGroup);
|
||||
|
||||
const isGroupedNovelChapter = (
|
||||
chapter: ChapterInfo,
|
||||
): chapter is GroupedNovelChapter =>
|
||||
'pluginId' in chapter &&
|
||||
'novelName' in chapter &&
|
||||
'novelPath' in chapter &&
|
||||
'novelCover' in chapter;
|
||||
|
||||
function createStyles(theme: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
alignSelf: { alignSelf: 'center' },
|
||||
chapterList: {
|
||||
marginStart: -40,
|
||||
},
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cover: {
|
||||
borderRadius: 4,
|
||||
height: 40,
|
||||
width: 40,
|
||||
},
|
||||
description: { fontSize: 12 },
|
||||
loadingIndicator: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
novelCover: {
|
||||
marginEnd: 16,
|
||||
},
|
||||
padding: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
title: { color: theme.onSurface, fontSize: 14 },
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, StyleSheet, Text } from 'react-native';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text } from 'react-native';
|
||||
import { TextInput, TouchableRipple } from 'react-native-paper';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
|
||||
import { Dialog } from '@components';
|
||||
import { Dialog, NovelCoverImage } from '@components';
|
||||
import { getTracker, useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import { SearchResult } from '@services/Trackers';
|
||||
@@ -20,43 +20,124 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
novelName,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchText, setSearchText] = useState(novelName);
|
||||
const [searchTextOverride, setSearchTextOverride] = useState<string>();
|
||||
const [selectedNovel, setSelectedNovel] = useState<SearchResult>();
|
||||
const latestRequestId = useRef(0);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchText = searchTextOverride ?? novelName;
|
||||
|
||||
const getSearchResults = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const trackerObj = getTracker(tracker.name);
|
||||
const results = await trackerObj.handleSearch(searchText, tracker.auth);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`Failed to fetch search results from ${tracker.name}: ${getErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const getSearchResults = useCallback(
|
||||
async (query: string) => {
|
||||
const normalizedQuery = query.trim();
|
||||
const requestId = ++latestRequestId.current;
|
||||
|
||||
if (!normalizedQuery) {
|
||||
setLoading(false);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const trackerObj = getTracker(tracker.name);
|
||||
const results = await trackerObj.handleSearch(
|
||||
normalizedQuery,
|
||||
tracker.auth,
|
||||
);
|
||||
|
||||
if (requestId === latestRequestId.current) {
|
||||
setSearchResults(results);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === latestRequestId.current) {
|
||||
showToast(
|
||||
`Failed to fetch search results from ${
|
||||
tracker.name
|
||||
}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
setSearchResults([]);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === latestRequestId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tracker.auth, tracker.name],
|
||||
);
|
||||
|
||||
const cancelScheduledSearch = useCallback(() => {
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = null;
|
||||
}
|
||||
}, [searchText, tracker.auth, tracker.name]);
|
||||
}, []);
|
||||
|
||||
const scheduleSearch = useCallback(
|
||||
(query: string) => {
|
||||
cancelScheduledSearch();
|
||||
latestRequestId.current += 1;
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void getSearchResults(query);
|
||||
}, 350);
|
||||
},
|
||||
[cancelScheduledSearch, getSearchResults],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelScheduledSearch();
|
||||
latestRequestId.current += 1;
|
||||
},
|
||||
[cancelScheduledSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
getSearchResults();
|
||||
if (!visible) {
|
||||
cancelScheduledSearch();
|
||||
latestRequestId.current += 1;
|
||||
return;
|
||||
}
|
||||
}, [getSearchResults, visible]);
|
||||
|
||||
scheduleSearch(novelName);
|
||||
}, [cancelScheduledSearch, novelName, scheduleSearch, visible]);
|
||||
|
||||
const handleSearchTextChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchTextOverride(value);
|
||||
setSelectedNovel(undefined);
|
||||
scheduleSearch(value);
|
||||
},
|
||||
[scheduleSearch],
|
||||
);
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
cancelScheduledSearch();
|
||||
void getSearchResults(searchText);
|
||||
}, [cancelScheduledSearch, getSearchResults, searchText]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
cancelScheduledSearch();
|
||||
latestRequestId.current += 1;
|
||||
setSearchTextOverride('');
|
||||
setSearchResults([]);
|
||||
setLoading(false);
|
||||
}, [cancelScheduledSearch]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
cancelScheduledSearch();
|
||||
latestRequestId.current += 1;
|
||||
setSearchTextOverride(undefined);
|
||||
setSelectedNovel(undefined);
|
||||
onDismiss();
|
||||
}, [cancelScheduledSearch, onDismiss]);
|
||||
|
||||
const handleSelectNovel = useCallback((item: SearchResult) => {
|
||||
setSelectedNovel(item);
|
||||
}, []);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchText('');
|
||||
}, []);
|
||||
|
||||
const handleRemoveSelection = useCallback(() => {
|
||||
setSelectedNovel(undefined);
|
||||
}, []);
|
||||
@@ -65,8 +146,8 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
if (selectedNovel) {
|
||||
onTrackNovel(tracker, selectedNovel);
|
||||
}
|
||||
onDismiss();
|
||||
}, [selectedNovel, onTrackNovel, tracker, onDismiss]);
|
||||
handleDismiss();
|
||||
}, [selectedNovel, onTrackNovel, tracker, handleDismiss]);
|
||||
|
||||
const renderSearchResultCard = useCallback(
|
||||
(item: SearchResult) => {
|
||||
@@ -94,8 +175,10 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
style={styles.checkIcon}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
source={{ uri: item.coverImage }}
|
||||
<NovelCoverImage
|
||||
uri={item.coverImage}
|
||||
theme={theme}
|
||||
iconSize={28}
|
||||
style={styles.coverImage}
|
||||
/>
|
||||
<Text
|
||||
@@ -108,23 +191,17 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
</TouchableRipple>
|
||||
);
|
||||
},
|
||||
[
|
||||
selectedNovel,
|
||||
handleSelectNovel,
|
||||
theme.rippleColor,
|
||||
theme.primary,
|
||||
theme.onSurface,
|
||||
],
|
||||
[selectedNovel, handleSelectNovel, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root visible={visible} onDismiss={onDismiss}>
|
||||
<Dialog.Root visible={visible} onDismiss={handleDismiss}>
|
||||
<Dialog.Title>{tracker.name}</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<TextInput
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmitEditing={getSearchResults}
|
||||
onChangeText={handleSearchTextChange}
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
textColor={theme.onSurface}
|
||||
theme={{
|
||||
colors: {
|
||||
@@ -144,24 +221,31 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
/>
|
||||
</Dialog.Content>
|
||||
<Dialog.ScrollArea>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
color={theme.primary}
|
||||
size={45}
|
||||
style={styles.loader}
|
||||
/>
|
||||
) : (
|
||||
searchResults.map(renderSearchResultCard)
|
||||
)}
|
||||
</ScrollView>
|
||||
<FlashList
|
||||
data={loading ? [] : searchResults}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<ActivityIndicator
|
||||
color={theme.primary}
|
||||
size={45}
|
||||
style={styles.loader}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => renderSearchResultCard(item)}
|
||||
style={styles.resultsList}
|
||||
/>
|
||||
</Dialog.ScrollArea>
|
||||
<Dialog.Actions>
|
||||
<Dialog.Action
|
||||
title={getString('common.remove')}
|
||||
onPress={handleRemoveSelection}
|
||||
/>
|
||||
<Dialog.Action title={getString('common.cancel')} onPress={onDismiss} />
|
||||
<Dialog.Action
|
||||
title={getString('common.cancel')}
|
||||
onPress={handleDismiss}
|
||||
/>
|
||||
<Dialog.Action title="OK" onPress={handleConfirm} />
|
||||
</Dialog.Actions>
|
||||
</Dialog.Root>
|
||||
@@ -193,7 +277,7 @@ const styles = StyleSheet.create({
|
||||
padding: 8,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
scrollView: {
|
||||
resultsList: {
|
||||
flexGrow: 1,
|
||||
marginVertical: 8,
|
||||
maxHeight: 500,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
|
||||
import TrackSearchDialog from '../TrackSearchDialog';
|
||||
import type { SearchResult } from '@services/Trackers';
|
||||
import type { TrackerMetadata } from '@hooks/persisted/useTracker';
|
||||
|
||||
const mockHandleSearch = jest.fn();
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
getTracker: () => ({ handleSearch: mockHandleSearch }),
|
||||
useTheme: () => ({
|
||||
onSurface: '#111111',
|
||||
onSurfaceVariant: '#444444',
|
||||
outline: '#777777',
|
||||
primary: '#006666',
|
||||
rippleColor: '#dddddd',
|
||||
surface: '#ffffff',
|
||||
surfaceVariant: '#eeeeee',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@components', () => {
|
||||
const ReactModule = jest.requireActual<typeof import('react')>('react');
|
||||
const { Text, View } =
|
||||
jest.requireActual<typeof import('react-native')>('react-native');
|
||||
const Section = ({ children }: { children: React.ReactNode }) =>
|
||||
ReactModule.createElement(View, null, children);
|
||||
|
||||
return {
|
||||
Dialog: {
|
||||
Action: ({ onPress, title }: { onPress: () => void; title: string }) =>
|
||||
ReactModule.createElement(Text, { onPress }, title),
|
||||
Actions: Section,
|
||||
Content: Section,
|
||||
Root: Section,
|
||||
ScrollArea: Section,
|
||||
Title: Section,
|
||||
},
|
||||
NovelCoverImage: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-paper', () => {
|
||||
const ReactModule = jest.requireActual<typeof import('react')>('react');
|
||||
const { Pressable, TextInput: NativeTextInput } =
|
||||
jest.requireActual<typeof import('react-native')>('react-native');
|
||||
const TextInput = (props: Record<string, unknown>) =>
|
||||
ReactModule.createElement(NativeTextInput, {
|
||||
...props,
|
||||
testID: 'tracker-search-input',
|
||||
});
|
||||
TextInput.Icon = () => null;
|
||||
|
||||
return {
|
||||
TextInput,
|
||||
TouchableRipple: ({
|
||||
children,
|
||||
onPress,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onPress: () => void;
|
||||
}) => ReactModule.createElement(Pressable, { onPress }, children),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@shopify/flash-list', () => {
|
||||
const ReactModule = jest.requireActual<typeof import('react')>('react');
|
||||
const { View } =
|
||||
jest.requireActual<typeof import('react-native')>('react-native');
|
||||
|
||||
return {
|
||||
FlashList: ({
|
||||
data,
|
||||
ListEmptyComponent,
|
||||
renderItem,
|
||||
}: {
|
||||
data: SearchResult[];
|
||||
ListEmptyComponent?: React.ReactNode;
|
||||
renderItem: ({ item }: { item: SearchResult }) => React.ReactElement;
|
||||
}) =>
|
||||
ReactModule.createElement(
|
||||
View,
|
||||
null,
|
||||
data.length
|
||||
? data.map(item =>
|
||||
ReactModule.createElement(
|
||||
ReactModule.Fragment,
|
||||
{ key: item.id },
|
||||
renderItem({ item }),
|
||||
),
|
||||
)
|
||||
: ListEmptyComponent,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@react-native-vector-icons/material-design-icons', () => 'Icon');
|
||||
|
||||
const tracker = {
|
||||
auth: { accessToken: 'token' },
|
||||
name: 'AniList',
|
||||
} as TrackerMetadata;
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(promiseResolve => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('TrackSearchDialog', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockHandleSearch.mockReset();
|
||||
mockHandleSearch.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces search text changes and searches only the latest value', async () => {
|
||||
render(
|
||||
<TrackSearchDialog
|
||||
novelName="Initial title"
|
||||
onDismiss={jest.fn()}
|
||||
onTrackNovel={jest.fn()}
|
||||
tracker={tracker}
|
||||
visible
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.changeText(
|
||||
screen.getByTestId('tracker-search-input'),
|
||||
'First value',
|
||||
);
|
||||
act(() => jest.advanceTimersByTime(200));
|
||||
fireEvent.changeText(
|
||||
screen.getByTestId('tracker-search-input'),
|
||||
'Latest value',
|
||||
);
|
||||
act(() => jest.advanceTimersByTime(349));
|
||||
|
||||
expect(mockHandleSearch).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockHandleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSearch).toHaveBeenCalledWith('Latest value', tracker.auth);
|
||||
});
|
||||
|
||||
it('ignores a stale response that finishes after a newer search', async () => {
|
||||
const firstSearch = deferred<SearchResult[]>();
|
||||
const secondSearch = deferred<SearchResult[]>();
|
||||
mockHandleSearch
|
||||
.mockReturnValueOnce(firstSearch.promise)
|
||||
.mockReturnValueOnce(secondSearch.promise);
|
||||
|
||||
render(
|
||||
<TrackSearchDialog
|
||||
novelName="Initial title"
|
||||
onDismiss={jest.fn()}
|
||||
onTrackNovel={jest.fn()}
|
||||
tracker={tracker}
|
||||
visible
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => jest.advanceTimersByTime(350));
|
||||
fireEvent.changeText(
|
||||
screen.getByTestId('tracker-search-input'),
|
||||
'New title',
|
||||
);
|
||||
act(() => jest.advanceTimersByTime(350));
|
||||
|
||||
await act(async () => {
|
||||
secondSearch.resolve([{ coverImage: '', id: 2, title: 'Newer result' }]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Newer result')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
firstSearch.resolve([{ coverImage: '', id: 1, title: 'Stale result' }]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Stale result')).toBeNull();
|
||||
expect(screen.getByText('Newer result')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, Suspense, useEffect } from 'react';
|
||||
import React, { memo, useEffect, useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { RefreshControl, SectionList, StyleSheet, Text } from 'react-native';
|
||||
|
||||
@@ -13,8 +13,7 @@ import { useSearch } from '@hooks';
|
||||
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 UpdateNovelChapterGroup from './components/UpdateNovelChapterGroup';
|
||||
import { deleteChapter } from '@database/queries/ChapterQueries';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { backgroundTasks } from '@services/backgroundTasks';
|
||||
@@ -35,6 +34,33 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
const onChangeText = (text: string) => {
|
||||
setSearchText(text);
|
||||
};
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
updatesOverview
|
||||
.filter(update =>
|
||||
searchText
|
||||
? update.novelName.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
.reduce(
|
||||
(
|
||||
groups: { data: UpdateOverview[]; date: string }[],
|
||||
update: UpdateOverview,
|
||||
) => {
|
||||
if (
|
||||
groups.length === 0 ||
|
||||
groups[groups.length - 1]?.date !== update.updateDate
|
||||
) {
|
||||
groups.push({ data: [update], date: update.updateDate });
|
||||
return groups;
|
||||
}
|
||||
groups[groups.length - 1]?.data.push(update);
|
||||
return groups;
|
||||
},
|
||||
[],
|
||||
),
|
||||
[searchText, updatesOverview],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -62,8 +88,7 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
rightIcons={[
|
||||
{
|
||||
iconName: 'reload',
|
||||
onPress: () =>
|
||||
backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' }),
|
||||
onPress: () => backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -71,7 +96,6 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
<ErrorScreenV2 error={error} />
|
||||
) : (
|
||||
<SectionList
|
||||
extraData={[updatesOverview.length]}
|
||||
ListHeaderComponent={
|
||||
showLastUpdateTime && lastUpdateTime ? (
|
||||
<LastUpdateTime lastUpdateTime={lastUpdateTime} theme={theme} />
|
||||
@@ -83,51 +107,29 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
{dayjs(date).calendar()}
|
||||
</Text>
|
||||
)}
|
||||
sections={updatesOverview
|
||||
.filter(v =>
|
||||
searchText
|
||||
? v.novelName.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
.reduce(
|
||||
(
|
||||
acc: { data: UpdateOverview[]; date: string }[],
|
||||
cur: UpdateOverview,
|
||||
) => {
|
||||
if (
|
||||
acc.length === 0 ||
|
||||
acc[acc.length - 1]?.date !== cur.updateDate
|
||||
) {
|
||||
acc.push({ data: [cur], date: cur.updateDate });
|
||||
return acc;
|
||||
}
|
||||
acc[acc.length - 1]?.data.push(cur);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
)}
|
||||
keyExtractor={item => 'updatedGroup' + item.novelId}
|
||||
sections={sections}
|
||||
keyExtractor={item =>
|
||||
`updatedGroup-${item.novelId}-${item.updateDate}-${item.updatesPerDay}`
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Suspense fallback={<UpdatesSkeletonLoading theme={theme} />}>
|
||||
<UpdateNovelCard
|
||||
deleteChapter={chapter => {
|
||||
deleteChapter(
|
||||
chapter.pluginId,
|
||||
chapter.novelId,
|
||||
chapter.id,
|
||||
).then(() => {
|
||||
showToast(
|
||||
getString('common.deleted', {
|
||||
name: chapter.name,
|
||||
}),
|
||||
);
|
||||
getUpdates();
|
||||
});
|
||||
}}
|
||||
chapterListInfo={item}
|
||||
descriptionText={getString('updatesScreen.updatesLower')}
|
||||
/>
|
||||
</Suspense>
|
||||
<UpdateNovelChapterGroup
|
||||
onDeleteChapter={chapter => {
|
||||
deleteChapter(
|
||||
chapter.pluginId,
|
||||
chapter.novelId,
|
||||
chapter.id,
|
||||
).then(() => {
|
||||
showToast(
|
||||
getString('common.deleted', {
|
||||
name: chapter.name,
|
||||
}),
|
||||
);
|
||||
getUpdates();
|
||||
});
|
||||
}}
|
||||
overview={item}
|
||||
chapterCountLabel={getString('updatesScreen.updatesLower')}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<EmptyView
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { Pressable, StyleSheet, View, Image } from 'react-native';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ChapterInfo,
|
||||
DownloadedChapter,
|
||||
NovelInfo,
|
||||
Update,
|
||||
UpdateOverview,
|
||||
} from '@database/types';
|
||||
import { List } from 'react-native-paper';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import ChapterItem from '@screens/novel/components/ChapterItem';
|
||||
import { useDownload, useTheme, useUpdates } from '@hooks/persisted';
|
||||
import { RootStackParamList } from '@navigators/types';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
|
||||
type UpdateCardProps = {
|
||||
onlyDownloadedChapters?: boolean;
|
||||
descriptionText: string;
|
||||
deleteChapter: (chapter: Update | DownloadedChapter) => void;
|
||||
} & (
|
||||
| { chapterList: Update[] | DownloadedChapter[]; chapterListInfo?: undefined }
|
||||
| {
|
||||
chapterListInfo: UpdateOverview;
|
||||
chapterList?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
const UpdateNovelCard: React.FC<UpdateCardProps> = ({
|
||||
onlyDownloadedChapters = false,
|
||||
chapterList: chapterListRaw,
|
||||
chapterListInfo: chapterListInfoRaw,
|
||||
descriptionText,
|
||||
deleteChapter,
|
||||
}) => {
|
||||
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { downloadChapter, downloadingChapterIds } = useDownload();
|
||||
const { getDetailedUpdates, isLoading } = useUpdates();
|
||||
const [chapterList, setChapterList] = useState<
|
||||
Update[] | DownloadedChapter[]
|
||||
>(chapterListRaw ?? []);
|
||||
|
||||
const chapterListInfo = chapterListInfoRaw ?? {
|
||||
novelId: chapterList![0]?.novelId,
|
||||
novelName: chapterList![0]?.novelName,
|
||||
updateDate: chapterList![0]?.updatedTime ?? '',
|
||||
updatesPerDay: chapterList?.length,
|
||||
novelCover: chapterList![0]?.novelCover ?? '',
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const updateList = useCallback(async () => {
|
||||
getDetailedUpdates(chapterListInfo.novelId, onlyDownloadedChapters).then(
|
||||
res => {
|
||||
if (res.length) {
|
||||
setChapterList(res);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [chapterListInfo.novelId, getDetailedUpdates, onlyDownloadedChapters]);
|
||||
useEffect(() => {
|
||||
updateList();
|
||||
}, [updateList]);
|
||||
|
||||
const handleDownloadChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
const update = chapter as Update | DownloadedChapter;
|
||||
if (chapterListInfo.updatesPerDay) {
|
||||
downloadChapter(
|
||||
{
|
||||
id: update?.novelId,
|
||||
pluginId: update.pluginId,
|
||||
name: update.novelName,
|
||||
} as NovelInfo,
|
||||
chapter,
|
||||
);
|
||||
}
|
||||
},
|
||||
[chapterListInfo.updatesPerDay, downloadChapter],
|
||||
);
|
||||
|
||||
const handleDeleteChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
deleteChapter(chapter as Update | DownloadedChapter);
|
||||
},
|
||||
[deleteChapter],
|
||||
);
|
||||
|
||||
const navigateToChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
const { novelPath, pluginId, novelName } = chapter as
|
||||
| Update
|
||||
| DownloadedChapter;
|
||||
navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: {
|
||||
novel: {
|
||||
path: novelPath,
|
||||
pluginId: pluginId,
|
||||
name: novelName,
|
||||
} as NovelInfo,
|
||||
chapter: chapter,
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const navigateToNovel = useCallback(() => {
|
||||
if (chapterListInfo.updatesPerDay) {
|
||||
navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: {
|
||||
pluginId: chapterList[0].pluginId,
|
||||
path: chapterList[0].novelPath,
|
||||
cover: chapterList[0].novelCover ?? undefined,
|
||||
name: chapterList[0].novelName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [chapterList, chapterListInfo.updatesPerDay, navigate]);
|
||||
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const Cover = useCallback(() => {
|
||||
const uri = chapterListInfo.novelCover || defaultCover;
|
||||
return (
|
||||
<Pressable onPress={navigateToNovel} style={styles.alignSelf}>
|
||||
<Image source={{ uri }} style={styles.cover} />
|
||||
</Pressable>
|
||||
);
|
||||
}, [
|
||||
chapterListInfo.novelCover,
|
||||
navigateToNovel,
|
||||
styles.alignSelf,
|
||||
styles.cover,
|
||||
]);
|
||||
|
||||
const coverElement = useMemo(
|
||||
() => (
|
||||
<View style={styles.novelCover}>
|
||||
<Cover />
|
||||
</View>
|
||||
),
|
||||
[Cover, styles.novelCover],
|
||||
);
|
||||
|
||||
if (chapterListInfo.updatesPerDay > 1) {
|
||||
return (
|
||||
<List.Accordion
|
||||
title={chapterListInfo.novelName}
|
||||
titleStyle={styles.title}
|
||||
left={Cover}
|
||||
descriptionStyle={styles.description}
|
||||
theme={{ colors: theme }}
|
||||
style={[styles.container, styles.padding]}
|
||||
description={`${chapterListInfo.updatesPerDay} ${descriptionText}`}
|
||||
onPress={updateList}
|
||||
>
|
||||
{chapterList.length > 0 ? (
|
||||
<FlatList
|
||||
data={chapterList}
|
||||
keyExtractor={it => 'update' + it.id}
|
||||
extraData={[chapterList, isLoading]}
|
||||
style={styles.chapterList}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<ChapterItem
|
||||
isLocal={false}
|
||||
isDownloading={downloadingChapterIds.has(item.id)}
|
||||
isUpdateCard
|
||||
novelName={chapterListInfo.novelName}
|
||||
chapter={item}
|
||||
theme={theme}
|
||||
showChapterTitles={false}
|
||||
onDownloadChapter={handleDownloadChapter}
|
||||
onDeleteChapter={handleDeleteChapter}
|
||||
onSelectPress={navigateToChapter}
|
||||
left={coverElement}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</List.Accordion>
|
||||
);
|
||||
} else if (chapterListInfo.updatesPerDay > 0 && chapterList[0]) {
|
||||
return (
|
||||
<ChapterItem
|
||||
isLocal={false}
|
||||
isDownloading={downloadingChapterIds.has(chapterList[0]?.id)}
|
||||
isUpdateCard
|
||||
novelName={chapterListInfo.novelName}
|
||||
chapter={chapterList[0]}
|
||||
theme={theme}
|
||||
showChapterTitles={false}
|
||||
onDownloadChapter={handleDownloadChapter}
|
||||
onDeleteChapter={handleDeleteChapter}
|
||||
onSelectPress={navigateToChapter}
|
||||
left={coverElement}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default memo(UpdateNovelCard);
|
||||
|
||||
function createStyles(theme: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
alignSelf: { alignSelf: 'center' },
|
||||
chapterList: {
|
||||
marginStart: -40,
|
||||
},
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cover: {
|
||||
borderRadius: 4,
|
||||
height: 40,
|
||||
width: 40,
|
||||
},
|
||||
description: { fontSize: 12 },
|
||||
novelCover: {
|
||||
marginEnd: 16,
|
||||
},
|
||||
padding: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
title: { color: theme.onSurface, fontSize: 14 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Update, UpdateOverview } from '@database/types';
|
||||
import { fetchDetailedUpdates } from '@hooks/persisted/useUpdates';
|
||||
import NovelChapterGroup, {
|
||||
GroupedNovelChapter,
|
||||
} from '@screens/novel/components/NovelChapterGroup';
|
||||
import { getErrorMessage } from '@utils/error';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
interface UpdateNovelChapterGroupProps {
|
||||
chapterCountLabel: string;
|
||||
onDeleteChapter: (chapter: GroupedNovelChapter) => void;
|
||||
overview: UpdateOverview;
|
||||
}
|
||||
|
||||
const UpdateNovelChapterGroup: React.FC<UpdateNovelChapterGroupProps> = ({
|
||||
chapterCountLabel,
|
||||
onDeleteChapter,
|
||||
overview,
|
||||
}) => {
|
||||
const [chapters, setChapters] = useState<Update[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loadStatus = useRef<'idle' | 'loading' | 'loaded'>('idle');
|
||||
const latestRequestId = useRef(0);
|
||||
|
||||
const novel = useMemo(
|
||||
() => ({
|
||||
id: overview.novelId,
|
||||
pluginId: overview.pluginId,
|
||||
name: overview.novelName,
|
||||
path: overview.novelPath,
|
||||
cover: overview.novelCover,
|
||||
}),
|
||||
[overview],
|
||||
);
|
||||
|
||||
const loadChapters = useCallback(async () => {
|
||||
if (loadStatus.current !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
loadStatus.current = 'loading';
|
||||
setIsLoading(true);
|
||||
const requestId = ++latestRequestId.current;
|
||||
|
||||
try {
|
||||
const result = await fetchDetailedUpdates(
|
||||
overview.novelId,
|
||||
false,
|
||||
overview.updateDate,
|
||||
);
|
||||
|
||||
if (requestId === latestRequestId.current) {
|
||||
setChapters(result);
|
||||
loadStatus.current = 'loaded';
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === latestRequestId.current) {
|
||||
loadStatus.current = 'idle';
|
||||
showToast(getErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
if (requestId === latestRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [overview.novelId, overview.updateDate]);
|
||||
|
||||
useEffect(() => {
|
||||
let loadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (overview.updatesPerDay === 1) {
|
||||
loadTimer = setTimeout(() => void loadChapters(), 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loadTimer) {
|
||||
clearTimeout(loadTimer);
|
||||
}
|
||||
latestRequestId.current += 1;
|
||||
};
|
||||
}, [loadChapters, overview.updatesPerDay]);
|
||||
|
||||
return (
|
||||
<NovelChapterGroup
|
||||
chapterCount={overview.updatesPerDay}
|
||||
chapterCountLabel={chapterCountLabel}
|
||||
chapters={chapters}
|
||||
isLoading={isLoading}
|
||||
novel={novel}
|
||||
onDeleteChapter={onDeleteChapter}
|
||||
onExpand={loadChapters}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(UpdateNovelChapterGroup);
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react-native';
|
||||
|
||||
import { UpdateOverview } from '@database/types';
|
||||
import UpdateNovelChapterGroup from '../UpdateNovelChapterGroup';
|
||||
|
||||
const mockFetchDetailedUpdates = jest.fn();
|
||||
const mockNovelChapterGroup = jest.fn();
|
||||
|
||||
jest.mock('@hooks/persisted/useUpdates', () => ({
|
||||
fetchDetailedUpdates: (...args: unknown[]) =>
|
||||
mockFetchDetailedUpdates(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@screens/novel/components/NovelChapterGroup', () => {
|
||||
const ReactModule = jest.requireActual<typeof import('react')>('react');
|
||||
const { Pressable } =
|
||||
jest.requireActual<typeof import('react-native')>('react-native');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: (props: { onExpand?: () => void }) => {
|
||||
mockNovelChapterGroup(props);
|
||||
return ReactModule.createElement(Pressable, {
|
||||
onPress: props.onExpand,
|
||||
testID: 'novel-chapter-group',
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@utils/showToast', () => ({
|
||||
showToast: jest.fn(),
|
||||
}));
|
||||
|
||||
const overview: UpdateOverview = {
|
||||
novelId: 42,
|
||||
pluginId: 'source-id',
|
||||
novelName: 'Example Novel',
|
||||
novelPath: '/example-novel',
|
||||
novelCover: null,
|
||||
updateDate: '2026-07-24',
|
||||
updatesPerDay: 2,
|
||||
};
|
||||
|
||||
describe('UpdateNovelChapterGroup', () => {
|
||||
beforeEach(() => {
|
||||
mockFetchDetailedUpdates.mockReset();
|
||||
mockNovelChapterGroup.mockReset();
|
||||
mockFetchDetailedUpdates.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads a collapsed group only on its first expansion', async () => {
|
||||
render(
|
||||
<UpdateNovelChapterGroup
|
||||
chapterCountLabel="updates"
|
||||
onDeleteChapter={jest.fn()}
|
||||
overview={overview}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockFetchDetailedUpdates).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.press(screen.getByTestId('novel-chapter-group'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchDetailedUpdates).toHaveBeenCalledWith(
|
||||
overview.novelId,
|
||||
false,
|
||||
overview.updateDate,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.press(screen.getByTestId('novel-chapter-group'));
|
||||
|
||||
expect(mockFetchDetailedUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads a single-chapter group immediately because it has no accordion', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
render(
|
||||
<UpdateNovelChapterGroup
|
||||
chapterCountLabel="update"
|
||||
onDeleteChapter={jest.fn()}
|
||||
overview={{ ...overview, updatesPerDay: 1 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockFetchDetailedUpdates).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchDetailedUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
export const coverPlaceholderColor = '#8888881F';
|
||||
|
||||
export const filterColor: (isDark: boolean) => string = isDark =>
|
||||
isDark ? '#FFC107' : '#FFC107';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user