diff --git a/package.json b/package.json
index 9979290e..da720e11 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8aae7497..bd4fd2b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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):
diff --git a/src/components/ErrorScreenV2/ErrorScreenV2.tsx b/src/components/ErrorScreenV2/ErrorScreenV2.tsx
index d971e211..445218ce 100644
--- a/src/components/ErrorScreenV2/ErrorScreenV2.tsx
+++ b/src/components/ErrorScreenV2/ErrorScreenV2.tsx
@@ -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;
diff --git a/src/components/ListView.tsx b/src/components/ListView.tsx
index 48234979..feee0bb2 100644
--- a/src/components/ListView.tsx
+++ b/src/components/ListView.tsx
@@ -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}
>
-
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,
+ headers: imageRequestInit?.headers || {
+ 'User-Agent': getUserAgent(),
+ },
+ }),
+ [imageRequestInit],
+ );
if (item.completeRow) {
if (!addSkeletonLoading) {
@@ -195,12 +172,14 @@ function NovelCover<
) : null}
{inActivity ? : null}
- {
+ iconSize?: number;
+ requestInit?: ImageRequestInit;
+ style?: StyleProp;
+ 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();
+ const showPlaceholder =
+ isMissingNovelCover(normalizedUri) || failedUri === normalizedUri;
+ const requiresReactNativeImage =
+ Boolean(requestInit?.body) ||
+ Boolean(requestInit?.method && requestInit.method.toUpperCase() !== 'GET');
+
+ if (showPlaceholder) {
+ return (
+
+
+
+ );
+ }
+
+ if (requiresReactNativeImage) {
+ const source: ImageURISource = {
+ body: requestInit?.body,
+ headers: requestInit?.headers,
+ method: requestInit?.method,
+ uri: normalizedUri,
+ };
+
+ return (
+ {
+ setFailedUri(normalizedUri);
+ onError?.({ error: event.nativeEvent.error });
+ }}
+ resizeMode={toReactNativeResizeMode(contentFit)}
+ source={source}
+ style={[{ backgroundColor: theme.surfaceVariant }, style]}
+ testID={testID}
+ />
+ );
+ }
+
+ return (
+ {
+ 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);
diff --git a/src/components/NovelCoverLayoutContext.tsx b/src/components/NovelCoverLayoutContext.tsx
new file mode 100644
index 00000000..af901b01
--- /dev/null
+++ b/src/components/NovelCoverLayoutContext.tsx
@@ -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(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 }>) => (
+
+ {children}
+
+);
+
+export const useNovelCoverLayout = () => {
+ const layout = useContext(NovelCoverLayoutContext);
+
+ if (!layout) {
+ throw new Error(
+ 'NovelCover must be rendered inside NovelCoverLayoutProvider',
+ );
+ }
+
+ return layout;
+};
diff --git a/src/components/NovelList.tsx b/src/components/NovelList.tsx
index 678f602d..5ebec62e 100644
--- a/src/components/NovelList.tsx
+++ b/src/components/NovelList.tsx
@@ -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;
-type listDataItem =
- | (NovelInfo | NovelItem) & {
- completeRow?: number;
- };
+export type NovelListDataItem = (NovelInfo | NovelItem) & {
+ completeRow?: number;
+};
-interface NovelListProps extends FlatListProps {
+interface NovelListProps
+ extends Omit, '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 = 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 = 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 (
-
+
+
+
);
};
diff --git a/src/components/__tests__/NovelCoverImage.test.tsx b/src/components/__tests__/NovelCoverImage.test.tsx
new file mode 100644
index 00000000..058e1ba2
--- /dev/null
+++ b/src/components/__tests__/NovelCoverImage.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByTestId('novel-cover').props.source).toEqual({
+ body: 'token=secret',
+ headers: { Referer: 'https://example.com' },
+ method: 'POST',
+ uri: 'https://example.com/cover',
+ });
+ });
+});
diff --git a/src/components/__tests__/NovelList.test.ts b/src/components/__tests__/NovelList.test.ts
new file mode 100644
index 00000000..ae1c9e4d
--- /dev/null
+++ b/src/components/__tests__/NovelList.test.ts
@@ -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',
+ ]);
+ });
+});
diff --git a/src/components/index.ts b/src/components/index.ts
index 91f7291b..b3f938ae 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -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';
diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts
index f022d4a9..a95107c3 100644
--- a/src/database/queries/ChapterQueries.ts
+++ b/src/database/queries/ChapterQueries.ts
@@ -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 => {
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 => {
return dbManager
.select({
@@ -800,6 +802,9 @@ export const getDetailedUpdatesFromDb = async (
onlyDownloadableChapters
? eq(chapterSchema.isDownloaded, true)
: isNotNull(chapterSchema.updatedTime),
+ updateDate
+ ? eq(sql`DATE(${chapterSchema.updatedTime})`, updateDate)
+ : undefined,
),
)
.orderBy(desc(chapterSchema.updatedTime))
diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts
index 29dd478d..89a1164d 100644
--- a/src/database/queries/__tests__/ChapterQueries.test.ts
+++ b/src/database/queries/__tests__/ChapterQueries.test.ts
@@ -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', () => {
diff --git a/src/database/types/index.ts b/src/database/types/index.ts
index 1e6bf073..cb871a5e 100644
--- a/src/database/types/index.ts
+++ b/src/database/types/index.ts
@@ -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;
}[];
}
diff --git a/src/hooks/persisted/useUpdates.ts b/src/hooks/persisted/useUpdates.ts
index 44b8653b..fae6a9c5 100644
--- a/src/hooks/persisted/useUpdates.ts
+++ b/src/hooks/persisted/useUpdates.ts
@@ -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([]);
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],
);
};
diff --git a/src/plugins/helpers/constants.ts b/src/plugins/helpers/constants.ts
index 2057fe2d..7fe828f9 100644
--- a/src/plugins/helpers/constants.ts
+++ b/src/plugins/helpers/constants.ts
@@ -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';
diff --git a/src/plugins/types/index.ts b/src/plugins/types/index.ts
index ed3f36dd..3c8c3c7d 100644
--- a/src/plugins/types/index.ts
+++ b/src/plugins/types/index.ts
@@ -107,7 +107,12 @@ export interface PluginItem {
}
export interface ImageRequestInit {
- [x: string]: string | Record | Headers | FormData | undefined;
+ [x: string]:
+ | string
+ | Record
+ | Headers
+ | FormData
+ | undefined;
method?: string;
headers: Record;
body?: string;
diff --git a/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx b/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx
index 9f60e525..6995d06f 100644
--- a/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx
+++ b/src/screens/GlobalSearchScreen/components/GlobalSearchResultsList.tsx
@@ -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 = ({
searchResults,
ListEmptyComponent,
}) => {
+ const coverLayout = useNovelCoverLayoutValue(true);
const keyExtractor = useCallback(
(item: GlobalSearchResult) => item.plugin.id,
[],
);
return (
-
- keyExtractor={keyExtractor}
- data={searchResults}
- contentContainerStyle={styles.resultList}
- renderItem={({ item }) => }
- ListEmptyComponent={ListEmptyComponent}
- />
+
+
+ keyExtractor={keyExtractor}
+ data={searchResults}
+ contentContainerStyle={styles.resultList}
+ renderItem={({ item }) => }
+ ListEmptyComponent={ListEmptyComponent}
+ />
+
);
};
@@ -45,7 +53,8 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
item,
}) => {
const theme = useTheme();
- const navigation = useNavigation>();
+ const navigation =
+ useNavigation>();
const [inActivity, setInActivity] = useState>({});
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={
{getString('sourceScreen.noResultsFound')}
diff --git a/src/screens/StatsScreen/StatsScreen.tsx b/src/screens/StatsScreen/StatsScreen.tsx
index 1d53f744..8a151575 100644
--- a/src/screens/StatsScreen/StatsScreen.tsx
+++ b/src/screens/StatsScreen/StatsScreen.tsx
@@ -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({});
- const [error, setError] = useState();
+ const [error, setError] = useState();
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(
+ (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
-
diff --git a/src/screens/browse/discover/DiscoverNovelCard/index.tsx b/src/screens/browse/discover/DiscoverNovelCard/index.tsx
index 47de5b68..ecb6e1c9 100644
--- a/src/screens/browse/discover/DiscoverNovelCard/index.tsx
+++ b/src/screens/browse/discover/DiscoverNovelCard/index.tsx
@@ -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 = ({ novel, onPress, theme }) => {
onPress={onPress}
android_ripple={{ color: theme.rippleColor }}
>
-
+
-
void;
@@ -36,10 +32,12 @@ const HistoryCard: React.FC = ({
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 = ({
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 = ({
})
}
>
-
@@ -110,7 +110,6 @@ const styles = StyleSheet.create({
paddingVertical: 8,
},
cover: {
- backgroundColor: coverPlaceholderColor,
borderRadius: 4,
height: 80,
width: 56,
diff --git a/src/screens/more/DownloadsScreen.tsx b/src/screens/more/DownloadsScreen.tsx
index a314f7b3..02d08d46 100644
--- a/src/screens/more/DownloadsScreen.tsx
+++ b/src/screens/more/DownloadsScreen.tsx
@@ -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;
+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([]);
- 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 ? (
-
- ) : null,
- [loading],
- );
+ }, []);
useEffect(() => {
- getChapters().finally(() => setLoading(false));
- }, []);
+ const timer = setTimeout(
+ () => void getChapters().finally(() => setLoading(false)),
+ 0,
+ );
+
+ return () => clearTimeout(timer);
+ }, [getChapters]);
return (
@@ -107,15 +101,14 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
) : (
'downloadGroup' + index}
+ data={groupChaptersByNovel(chapters)}
+ keyExtractor={item => `downloadGroup-${item[0]?.novelId}`}
renderItem={({ item }) => {
return (
- {
+ {
deleteChapter(
chapter.pluginId,
chapter.novelId,
@@ -128,7 +121,12 @@ const Downloads = ({ navigation }: DownloadsScreenProps) => {
/>
);
}}
- ListEmptyComponent={}
+ ListEmptyComponent={
+
+ }
/>
)}
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 (
+
+ );
+};
+
+export default memo(DownloadedNovelChapterGroup);
diff --git a/src/screens/novel/components/ChapterItem.tsx b/src/screens/novel/components/ChapterItem.tsx
index ca6cc6e5..258bbebc 100644
--- a/src/screens/novel/components/ChapterItem.tsx
+++ b/src/screens/novel/components/ChapterItem.tsx
@@ -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 = ({
isBookmarked,
isSelected,
isLocal,
- isUpdateCard,
+ variant = 'default',
theme,
showChapterTitles,
novelName,
@@ -45,6 +45,7 @@ const ChapterItem: React.FC = ({
}) => {
const { id, name, unread, releaseTime, bookmark, chapterNumber, progress } =
chapter;
+ const isGrouped = variant === 'grouped';
isBookmarked ??= bookmark ?? false;
@@ -117,10 +118,10 @@ const ChapterItem: React.FC = ({
{left}
{isBookmarked ? : null}
- {isUpdateCard ? (
+ {isGrouped ? (
= ({
= ({
- {parsedTime && !isUpdateCard ? (
+ {parsedTime && !isGrouped ? (
= ({
{parsedTime}
) : null}
- {chapter.scanlator && !isUpdateCard ? (
+ {chapter.scanlator && !isGrouped ? (
= ({
{chapter.scanlator}
) : null}
- {!isUpdateCard && progress && progress > 0 && chapter.unread ? (
+ {!isGrouped && progress && progress > 0 && chapter.unread ? (
Promise;
saveNovelCover: () => Promise;
@@ -45,6 +37,7 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => (
const CoverImage = memo(
({ children, source, theme, hideBackdrop }: CoverImageProps) => {
+ const [failedUri, setFailedUri] = useState();
const overlayBg = useMemo(
() => color(theme.background).alpha(0.7).string(),
[theme.background],
@@ -61,21 +54,44 @@ const CoverImage = memo(
if (hideBackdrop) {
return {children};
}
- return (
-
-
- {source.uri ? (
-
- {children}
-
- ) : (
- children
- )}
+
+ const showPlaceholder =
+ isMissingNovelCover(source.uri) || failedUri === source.uri;
+ const content = (
+
+ {!showPlaceholder ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+ );
+
+ if (showPlaceholder) {
+ return (
+
+ {content}
+ );
+ }
+
+ return (
+ setFailedUri(source.uri)}
+ source={source}
+ style={styles.coverImage}
+ >
+ {content}
);
},
@@ -98,7 +114,12 @@ const NovelThumbnail = ({
style={styles.novelThumbnailContainer}
>
{!expanded ? (
-
+
) : (
setExpanded(false)}
>
-
+
)}
@@ -334,7 +361,6 @@ const styles = StyleSheet.create({
paddingTop: 90,
},
novelThumbnail: {
- backgroundColor: coverPlaceholderColor,
borderRadius: 6,
height: 150,
width: 100,
diff --git a/src/screens/novel/components/NovelChapterGroup.tsx b/src/screens/novel/components/NovelChapterGroup.tsx
new file mode 100644
index 00000000..777aead5
--- /dev/null
+++ b/src/screens/novel/components/NovelChapterGroup.tsx
@@ -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 = ({
+ chapterCount,
+ chapterCountLabel,
+ chapters,
+ isLoading = false,
+ novel,
+ onDeleteChapter,
+ onExpand,
+}) => {
+ const { navigate } = useNavigation>();
+ 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(
+ () => (
+
+
+
+ ),
+ [navigateToNovel, novel.cover, styles.alignSelf, styles.cover, theme],
+ );
+
+ const coverElement = useMemo(
+ () => {renderCover()},
+ [renderCover, styles.novelCover],
+ );
+
+ const renderChapter = useCallback(
+ (chapter: GroupedNovelChapter) => (
+
+ ),
+ [
+ coverElement,
+ downloadingChapterIds,
+ handleDeleteChapter,
+ handleDownloadChapter,
+ navigateToChapter,
+ novel.name,
+ theme,
+ ],
+ );
+
+ if (chapterCount > 1) {
+ return (
+
+
+ {isLoading ? (
+
+ ) : null}
+ {chapters.map(chapter => (
+
+ {renderChapter(chapter)}
+
+ ))}
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ 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 },
+ });
+}
diff --git a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
index 6e9378a1..7aa9373c 100644
--- a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
+++ b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
@@ -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 = ({
novelName,
}) => {
const theme = useTheme();
- const [loading, setLoading] = useState(true);
+ const [loading, setLoading] = useState(false);
const [searchResults, setSearchResults] = useState([]);
- const [searchText, setSearchText] = useState(novelName);
+ const [searchTextOverride, setSearchTextOverride] = useState();
const [selectedNovel, setSelectedNovel] = useState();
+ const latestRequestId = useRef(0);
+ const searchTimer = useRef | 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 = ({
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 = ({
style={styles.checkIcon}
/>
)}
-
= ({
);
},
- [
- selectedNovel,
- handleSelectNovel,
- theme.rippleColor,
- theme.primary,
- theme.onSurface,
- ],
+ [selectedNovel, handleSelectNovel, theme],
);
return (
-
+
{tracker.name}
= ({
/>
-
- {loading ? (
-
- ) : (
- searchResults.map(renderSearchResultCard)
- )}
-
+ item.id.toString()}
+ ListEmptyComponent={
+ loading ? (
+
+ ) : null
+ }
+ renderItem={({ item }) => renderSearchResultCard(item)}
+ style={styles.resultsList}
+ />
-
+
@@ -193,7 +277,7 @@ const styles = StyleSheet.create({
padding: 8,
paddingLeft: 0,
},
- scrollView: {
+ resultsList: {
flexGrow: 1,
marginVertical: 8,
maxHeight: 500,
diff --git a/src/screens/novel/components/Tracker/__tests__/TrackSearchDialog.test.tsx b/src/screens/novel/components/Tracker/__tests__/TrackSearchDialog.test.tsx
new file mode 100644
index 00000000..ea28783b
--- /dev/null
+++ b/src/screens/novel/components/Tracker/__tests__/TrackSearchDialog.test.tsx
@@ -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('react');
+ const { Text, View } =
+ jest.requireActual('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('react');
+ const { Pressable, TextInput: NativeTextInput } =
+ jest.requireActual('react-native');
+ const TextInput = (props: Record) =>
+ 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('react');
+ const { View } =
+ jest.requireActual('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 = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(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(
+ ,
+ );
+
+ 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();
+ const secondSearch = deferred();
+ mockHandleSearch
+ .mockReturnValueOnce(firstSearch.promise)
+ .mockReturnValueOnce(secondSearch.promise);
+
+ render(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/src/screens/updates/UpdatesScreen.tsx b/src/screens/updates/UpdatesScreen.tsx
index c752c045..f0a54f87 100644
--- a/src/screens/updates/UpdatesScreen.tsx
+++ b/src/screens/updates/UpdatesScreen.tsx
@@ -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) => {
) : (
@@ -83,51 +107,29 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
{dayjs(date).calendar()}
)}
- 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 }) => (
- }>
- {
- deleteChapter(
- chapter.pluginId,
- chapter.novelId,
- chapter.id,
- ).then(() => {
- showToast(
- getString('common.deleted', {
- name: chapter.name,
- }),
- );
- getUpdates();
- });
- }}
- chapterListInfo={item}
- descriptionText={getString('updatesScreen.updatesLower')}
- />
-
+ {
+ deleteChapter(
+ chapter.pluginId,
+ chapter.novelId,
+ chapter.id,
+ ).then(() => {
+ showToast(
+ getString('common.deleted', {
+ name: chapter.name,
+ }),
+ );
+ getUpdates();
+ });
+ }}
+ overview={item}
+ chapterCountLabel={getString('updatesScreen.updatesLower')}
+ />
)}
ListEmptyComponent={
void;
-} & (
- | { chapterList: Update[] | DownloadedChapter[]; chapterListInfo?: undefined }
- | {
- chapterListInfo: UpdateOverview;
- chapterList?: undefined;
- }
-);
-
-const UpdateNovelCard: React.FC = ({
- onlyDownloadedChapters = false,
- chapterList: chapterListRaw,
- chapterListInfo: chapterListInfoRaw,
- descriptionText,
- deleteChapter,
-}) => {
- const { navigate } = useNavigation>();
- 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 (
-
-
-
- );
- }, [
- chapterListInfo.novelCover,
- navigateToNovel,
- styles.alignSelf,
- styles.cover,
- ]);
-
- const coverElement = useMemo(
- () => (
-
-
-
- ),
- [Cover, styles.novelCover],
- );
-
- if (chapterListInfo.updatesPerDay > 1) {
- return (
-
- {chapterList.length > 0 ? (
- 'update' + it.id}
- extraData={[chapterList, isLoading]}
- style={styles.chapterList}
- renderItem={({ item }) => {
- return (
-
- );
- }}
- scrollEnabled={false}
- />
- ) : (
- <>>
- )}
-
- );
- } else if (chapterListInfo.updatesPerDay > 0 && chapterList[0]) {
- return (
-
- );
- }
- 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 },
- });
-}
diff --git a/src/screens/updates/components/UpdateNovelChapterGroup.tsx b/src/screens/updates/components/UpdateNovelChapterGroup.tsx
new file mode 100644
index 00000000..b05f6f1b
--- /dev/null
+++ b/src/screens/updates/components/UpdateNovelChapterGroup.tsx
@@ -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 = ({
+ chapterCountLabel,
+ onDeleteChapter,
+ overview,
+}) => {
+ const [chapters, setChapters] = useState([]);
+ 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 | undefined;
+
+ if (overview.updatesPerDay === 1) {
+ loadTimer = setTimeout(() => void loadChapters(), 0);
+ }
+
+ return () => {
+ if (loadTimer) {
+ clearTimeout(loadTimer);
+ }
+ latestRequestId.current += 1;
+ };
+ }, [loadChapters, overview.updatesPerDay]);
+
+ return (
+
+ );
+};
+
+export default memo(UpdateNovelChapterGroup);
diff --git a/src/screens/updates/components/__tests__/UpdateNovelChapterGroup.test.tsx b/src/screens/updates/components/__tests__/UpdateNovelChapterGroup.test.tsx
new file mode 100644
index 00000000..30a58569
--- /dev/null
+++ b/src/screens/updates/components/__tests__/UpdateNovelChapterGroup.test.tsx
@@ -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('react');
+ const { Pressable } =
+ jest.requireActual('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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(mockFetchDetailedUpdates).not.toHaveBeenCalled();
+
+ await act(async () => {
+ jest.runOnlyPendingTimers();
+ await Promise.resolve();
+ });
+
+ expect(mockFetchDetailedUpdates).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 59a636e3..9f393204 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -1,5 +1,3 @@
-export const coverPlaceholderColor = '#8888881F';
-
export const filterColor: (isDark: boolean) => string = isDark =>
isDark ? '#FFC107' : '#FFC107';