perf: Optimize Novel Covers, Lists, and Chapter Groups

This commit is contained in:
Rajarshee Chatterjee
2026-07-24 11:15:35 +05:30
parent dfd620aa29
commit a16ec87631
34 changed files with 1685 additions and 677 deletions
+1
View File
@@ -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",
+21
View File
@@ -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;
+6 -8
View File
@@ -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,
+23 -44
View File
@@ -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,
+160
View File
@@ -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;
};
+75 -68
View File
@@ -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',
]);
});
});
+1
View File
@@ -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';
+12 -7
View File
@@ -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', () => {
+5 -3
View File
@@ -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;
}[];
}
+49 -58
View File
@@ -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
View File
@@ -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';
+6 -1
View File
@@ -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')}
+53 -28
View File
@@ -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,
+37 -39
View File
@@ -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);
+10 -9
View File
@@ -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();
});
});
+52 -50
View File
@@ -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);
});
});
-2
View File
@@ -1,5 +1,3 @@
export const coverPlaceholderColor = '#8888881F';
export const filterColor: (isDark: boolean) => string = isDark =>
isDark ? '#FFC107' : '#FFC107';