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';