diff --git a/.gitignore b/.gitignore index a0b7908c..424905ee 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,6 @@ flake.lock # pnpm .pnpm-store -.cursor/ \ No newline at end of file +.cursor/ +.agents/ +.claude/ \ No newline at end of file diff --git a/src/hooks/persisted/useDownload.ts b/src/hooks/persisted/useDownload.ts index e4622b3a..4902719c 100644 --- a/src/hooks/persisted/useDownload.ts +++ b/src/hooks/persisted/useDownload.ts @@ -20,6 +20,11 @@ export default function useDownload() { [queue], ) as { task: DownloadChapterTask; meta: BackgroundTaskMetadata }[]; + const downloadingChapterIds = useMemo( + () => new Set(downloadQueue.map(c => c.task.data.chapterId)), + [downloadQueue], + ); + const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) => ServiceManager.manager.addTask({ name: 'DOWNLOAD_CHAPTER', @@ -49,6 +54,7 @@ export default function useDownload() { return { downloadQueue, + downloadingChapterIds, resumeDowndload, downloadChapter, downloadChapters, diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index cdd2eaee..4665dccd 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -130,8 +130,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { (index: number, update: Partial) => { if (novel) { _setChapters(chs => { - chs[index] = { ...chs[index], ...update }; - return chs; + const next = [...chs]; + next[index] = { ...next[index], ...update }; + return next; }); } }, diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index baf9b832..73b10a6f 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -63,15 +63,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { const headerOpacity = useSharedValue(0); - // TODO: fix this - // useEffect(() => { - // if (chapters.length !== 0 && !fetching) { - // refreshChapters(); - // } - // }, [chapters.length, downloadQueue.length, fetching, refreshChapters]); - - // useFocusEffect(refreshChapters); - const downloadChs = useCallback( async (amount: number | 'all' | 'unread') => { if (!novel) { @@ -106,17 +97,19 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { }, [chapters, downloadChapters, novel], ); + const deleteChs = useCallback(() => { deleteChapters(chapters.filter(c => c.isDownloaded)); }, [chapters, deleteChapters]); - const shareNovel = () => { + + const shareNovel = useCallback(() => { if (!novel) { return; } Share.share({ message: resolveUrl(novel.pluginId, novel.path, true), }); - }; + }, [novel]); const [jumpToChapterModal, showJumpToChapterModal] = useState(false); const { @@ -218,7 +211,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { selected, ]); - const setCustomNovelCover = async () => { + const setCustomNovelCover = useCallback(async () => { if (!novel) { return; } @@ -229,12 +222,58 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { cover: newCover, }); } - }; + }, [novel, setNovel]); + + const stableGetNextBatch = useMemo( + () => + batchInformation.batch < batchInformation.total && !fetching + ? getNextChapterBatch + : noop, + [batchInformation.batch, batchInformation.total, fetching, getNextChapterBatch], + ); + + const hideJumpToChapterModal = useCallback( + () => showJumpToChapterModal(false), + [], + ); + const hideEditInfoModal = useCallback( + () => showEditInfoModal(false), + [], + ); + const clearSelection = useCallback(() => setSelected([]), []); + const selectAll = useCallback(() => setSelected(chapters), [chapters]); + + const snackbarTheme = useMemo( + () => ({ colors: { primary: theme.primary } }), + [theme.primary], + ); + const snackbarTextStyle = useMemo( + () => ({ color: theme.onSurface }), + [theme.onSurface], + ); + const titleStyle = useMemo( + () => ({ color: theme.onSurface }), + [theme.onSurface], + ); + const snackbarAction = useMemo( + () => ({ + label: getString('common.delete'), + onPress: () => { + deleteChapters(chapters.filter(c => c.isDownloaded)); + }, + }), + [chapters, deleteChapters], + ); + const styles = useMemo(() => createStyles(theme), [theme]); + const containerStyle = useMemo( + () => [styles.container, { backgroundColor: theme.background }], + [styles.container, theme.background], + ); return ( - + {selected.length === 0 ? ( { setSelected([])} + onPress={clearSelection} /> { - setSelected(chapters); - }} + onPress={selectAll} /> )} @@ -285,11 +322,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { routeBaseNovel={route.params} selected={selected} setSelected={setSelected} - getNextChapterBatch={ - batchInformation.batch < batchInformation.total && !fetching - ? getNextChapterBatch - : noop - } + getNextChapterBatch={stableGetNextBatch} /> @@ -299,26 +332,21 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { { - deleteChapters(chapters.filter(c => c.isDownloaded)); - }, - }} - theme={{ colors: { primary: theme.primary } }} + action={snackbarAction} + theme={snackbarTheme} style={styles.snackbar} > - + {getString('novelScreen.deleteMessage')} - {novel && ( + {novel ? ( <> showJumpToChapterModal(false)} + hideModal={hideJumpToChapterModal} novel={novel} chapterListRef={chapterListRef} navigation={navigation} @@ -328,7 +356,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { /> showEditInfoModal(false)} + hideModal={hideEditInfoModal} novel={novel} setNovel={setNovel} theme={theme} @@ -342,7 +370,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { downloadChapters={downloadChapters} /> - )} + ) : null} diff --git a/src/screens/novel/components/Chapter/ChapterDownloadButtons.tsx b/src/screens/novel/components/Chapter/ChapterDownloadButtons.tsx index 710f2705..7b2afbe0 100644 --- a/src/screens/novel/components/Chapter/ChapterDownloadButtons.tsx +++ b/src/screens/novel/components/Chapter/ChapterDownloadButtons.tsx @@ -1,93 +1,72 @@ -import React, { useEffect, useRef } from 'react'; -import { MD3ThemeType } from '@theme/types'; +import React, { useMemo } from 'react'; +import { ThemeColors } from '@theme/types'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { overlay } from 'react-native-paper'; import { getString } from '@strings/translations'; -import { isChapterDownloaded } from '@database/queries/ChapterQueries'; import { useBoolean } from '@hooks/index'; import { IconButtonV2, Menu } from '@components'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; -import Color from 'color'; interface DownloadButtonProps { - chapterId: number; isDownloaded: boolean; isDownloading?: boolean; - theme: MD3ThemeType; + theme: ThemeColors; deleteChapter: () => void; downloadChapter: () => void; - setChapterDownloaded?: (value: boolean) => void; } export const DownloadButton: React.FC = ({ - chapterId, isDownloaded, isDownloading, theme, deleteChapter, downloadChapter, - setChapterDownloaded, }) => { - const [downloaded, setDownloaded] = React.useState( - isDownloaded, - ); - const { value: deleteChapterMenuVisible, setTrue: showDeleteChapterMenu, setFalse: hideDeleteChapterMenu, } = useBoolean(); - const isFirstRender = useRef(true); + const menuContentStyle = useMemo( + () => ({ backgroundColor: overlay(2, theme.surface) }), + [theme.surface], + ); + const menuTitleStyle = useMemo( + () => ({ color: theme.onSurface }), + [theme.onSurface], + ); - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; // Skip the first render as it leads to 'Maximum update depth exceeded.' error - } - if (!isDownloading) { - isChapterDownloaded(chapterId).then(isDownloadedValue => { - setDownloaded(isDownloadedValue); - setChapterDownloaded?.(isDownloadedValue); - }); - } - }, [chapterId, isDownloading, setChapterDownloaded]); - if (isDownloading || downloaded === undefined) { + if (isDownloading) { return ; } - return downloaded ? ( - - } - contentStyle={{ backgroundColor: overlay(2, theme.surface) }} - > - { - deleteChapter(); - hideDeleteChapterMenu(); - setDownloaded(false); - }} - title={getString('common.delete')} - titleStyle={{ color: theme.onSurface }} - /> - - ) : ( - { - downloadChapter(); - setDownloaded(undefined); - }} - /> - ); + if (isDownloaded) { + return ( + + } + contentStyle={menuContentStyle} + > + { + deleteChapter(); + hideDeleteChapterMenu(); + }} + title={getString('common.delete')} + titleStyle={menuTitleStyle} + /> + + ); + } + return ; }; interface theme { - theme: MD3ThemeType; + theme: ThemeColors; } type buttonPropType = theme & { onPress: () => void; @@ -118,7 +97,7 @@ export const DownloadChapterButton: React.FC = ({ @@ -141,7 +120,7 @@ export const DeleteChapterButton: React.FC = ({ diff --git a/src/screens/novel/components/ChapterItem.tsx b/src/screens/novel/components/ChapterItem.tsx index 422ce597..b9a6b068 100644 --- a/src/screens/novel/components/ChapterItem.tsx +++ b/src/screens/novel/components/ChapterItem.tsx @@ -1,7 +1,5 @@ -/* eslint-disable react-native/no-inline-styles */ -import React, { memo, ReactNode } from 'react'; +import React, { memo, useCallback, useMemo, ReactNode } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import color from 'color'; import { ChapterBookmarkButton, DownloadButton, @@ -12,88 +10,113 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i import { getString } from '@strings/translations'; interface ChapterItemProps { + chapter: ChapterInfo; isDownloading?: boolean; isBookmarked?: boolean; - chapter: ChapterInfo; - theme: ThemeColors; - showChapterTitles: boolean; isSelected?: boolean; - downloadChapter: () => void; - deleteChapter: () => void; - onSelectPress?: (chapter: ChapterInfo) => void; - onSelectLongPress?: (chapter: ChapterInfo) => void; - navigateToChapter: (chapter: ChapterInfo) => void; - setChapterDownloaded?: (value: boolean) => void; - left?: ReactNode; isLocal: boolean; isUpdateCard?: boolean; + theme: ThemeColors; + showChapterTitles: boolean; novelName: string; + left?: ReactNode; + onDeleteChapter: (chapter: ChapterInfo) => void; + onDownloadChapter: (chapter: ChapterInfo) => void; + onSelectPress: (chapter: ChapterInfo) => void; + onSelectLongPress?: (chapter: ChapterInfo) => void; } const ChapterItem: React.FC = ({ + chapter, isDownloading, isBookmarked, - chapter, + isSelected, + isLocal, + isUpdateCard, theme, showChapterTitles, - downloadChapter, - deleteChapter, - isSelected, + novelName, + left, + onDeleteChapter, + onDownloadChapter, onSelectPress, onSelectLongPress, - navigateToChapter, - setChapterDownloaded, - isLocal, - left, - isUpdateCard, - novelName, }) => { const { id, name, unread, releaseTime, bookmark, chapterNumber, progress } = chapter; isBookmarked ??= bookmark; + const handlePress = useCallback( + () => onSelectPress(chapter), + [onSelectPress, chapter], + ); + const handleLongPress = useCallback( + () => onSelectLongPress?.(chapter), + [onSelectLongPress, chapter], + ); + const handleDelete = useCallback( + () => onDeleteChapter(chapter), + [onDeleteChapter, chapter], + ); + const handleDownload = useCallback( + () => onDownloadChapter(chapter), + [onDownloadChapter, chapter], + ); + + const selectedStyle = useMemo( + () => + isSelected + ? [styles.chapterCardContainer, { backgroundColor: theme.rippleColor }] + : styles.chapterCardContainer, + [isSelected, theme.rippleColor], + ); + + const titleColor = useMemo( + () => + !unread ? theme.outline : bookmark ? theme.primary : theme.onSurface, + [unread, bookmark, theme.outline, theme.primary, theme.onSurface], + ); + + const releaseColor = useMemo( + () => + !unread + ? theme.outline + : bookmark + ? theme.primary + : theme.onSurfaceVariant, + [unread, bookmark, theme.outline, theme.primary, theme.onSurfaceVariant], + ); + + const ripple = useMemo( + () => ({ color: theme.rippleColor }), + [theme.rippleColor], + ); + return ( { - if (onSelectPress) { - onSelectPress(chapter); - } else { - navigateToChapter(chapter); - } - }} - onLongPress={() => onSelectLongPress?.(chapter)} - android_ripple={{ color: theme.rippleColor }} + style={selectedStyle} + onPress={handlePress} + onLongPress={handleLongPress} + android_ripple={ripple} > {left} {isBookmarked ? : null} - + {isUpdateCard ? ( {novelName} ) : null} - + {unread ? ( = ({ ) : null} {showChapterTitles ? name : getString('novelScreen.chapterChapnum', { - num: chapterNumber, - })} + num: chapterNumber, + })} - + {releaseTime && !isUpdateCard ? ( = ({ ) : null} {!isUpdateCard && progress && progress > 0 && chapter.unread ? ( {chapter.releaseTime ? '• ' : null} @@ -168,11 +177,9 @@ const ChapterItem: React.FC = ({ ) : null} @@ -191,6 +198,13 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 8, }, + flex1: { + flex: 1, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + }, row: { alignItems: 'center', flex: 1, @@ -199,7 +213,20 @@ const styles = StyleSheet.create({ text: { fontSize: 12, }, + textNormal: { + fontSize: 14, + }, + textSmall: { + fontSize: 12, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + }, unreadIcon: { marginEnd: 4, }, + updateCardName: { + fontSize: 14, + }, }); diff --git a/src/screens/novel/components/Info/NovelInfoComponents.tsx b/src/screens/novel/components/Info/NovelInfoComponents.tsx index 9e01a95b..04fd002e 100644 --- a/src/screens/novel/components/Info/NovelInfoComponents.tsx +++ b/src/screens/novel/components/Info/NovelInfoComponents.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { FlatList, StyleSheet, @@ -43,27 +43,31 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => ( {children} ); -const CoverImage = ({ - children, - source, - theme, - hideBackdrop, -}: CoverImageProps) => { - if (hideBackdrop) { - return {children}; - } else { +const CoverImage = memo( + ({ children, source, theme, hideBackdrop }: CoverImageProps) => { + const overlayBg = useMemo( + () => color(theme.background).alpha(0.7).string(), + [theme.background], + ); + const overlayStyle = useMemo( + () => [{ backgroundColor: overlayBg }, styles.flex1], + [overlayBg], + ); + const gradientColors = useMemo( + () => ['rgba(0,0,0,0)', theme.background] as const, + [theme.background], + ); + + if (hideBackdrop) { + return {children}; + } return ( - + {source.uri ? ( {children} @@ -74,8 +78,10 @@ const CoverImage = ({ ); - } -}; + }, +); + +const GRADIENT_LOCATIONS = [0, 1] as const; const NovelThumbnail = ({ source, @@ -170,7 +176,7 @@ const FollowButton = ({ ); -const NovelGenres = ({ - theme, - genres, -}: { - theme: ThemeColors; - genres: string; -}) => { - const data = genres.split(/,\s*/); +const genreKeyExtractor = (_item: string, index: number) => 'genre' + index; - return ( - 'genre' + index} - renderItem={({ item }) => } - showsHorizontalScrollIndicator={false} - /> - ); -}; +const NovelGenres = memo( + ({ theme, genres }: { theme: ThemeColors; genres: string }) => { + const data = useMemo(() => genres.split(/,\s*/), [genres]); + const renderGenre = useCallback( + ({ item }: { item: string }) => , + [theme], + ); + + return ( + + ); + }, +); export { NovelInfoContainer, diff --git a/src/screens/novel/components/Info/NovelInfoHeader.tsx b/src/screens/novel/components/Info/NovelInfoHeader.tsx index 27f3f5ea..7fd22a30 100644 --- a/src/screens/novel/components/Info/NovelInfoHeader.tsx +++ b/src/screens/novel/components/Info/NovelInfoHeader.tsx @@ -1,6 +1,5 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import color from 'color'; import * as Clipboard from 'expo-clipboard'; @@ -39,6 +38,15 @@ import { VerticalBarSkeleton, } from '@components/Skeleton/Skeleton'; import { useNovelContext } from '@screens/novel/NovelContext'; +import Animated, { + useAnimatedProps, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import useLoadingColors from '@components/Skeleton/useLoadingColors'; interface NovelInfoHeaderProps { chapters: ChapterInfo[]; @@ -73,6 +81,57 @@ const getStatusIcon = (status?: string) => { return 'help'; }; +const ChapterCountSkeleton = ({ theme }: { theme: ThemeColors }) => { + const sv = useSharedValue(0); + const { disableLoadingAnimations } = useAppSettings(); + const [highlightColor, backgroundColor] = useLoadingColors(theme); + + const animatedProps = useAnimatedProps(() => { + return { + left: (sv.value + '%') as `${number}%`, + }; + }); + + React.useEffect(() => { + if (disableLoadingAnimations) return; + sv.value = withRepeat( + withSequence(0, withTiming(160, { duration: 1000 })), + -1, + ); + }, [disableLoadingAnimations, sv]); + + if (disableLoadingAnimations) { + return ( + + ); + } + + const LG = Animated.createAnimatedComponent(LinearGradient); + + return ( + + + + ); +}; + +const showNotAvailable = async () => { + showToast('Not available while loading'); +}; + const NovelInfoHeader = ({ chapters, deleteDownloadsSnackbar, @@ -101,20 +160,64 @@ const NovelInfoHeader = ({ [novel.pluginId], ); - const showNotAvailable = async () => { - showToast('Not available while loading'); - }; + const coverSource = useMemo(() => ({ uri: novel.cover }), [novel.cover]); + + const handleTitlePress = useCallback( + () => + navigation.replace('GlobalSearchScreen', { + searchText: novel.name, + }), + [navigation, novel.name], + ); + + const handleTitleLongPress = useCallback(() => { + Clipboard.setStringAsync(novel.name).then(() => + showToast(getString('common.copiedToClipboard', { name: novel.name })), + ); + }, [novel.name]); + + const handleFollowNovel = useCallback(() => { + if (isLoading) { + showNotAvailable(); + return; + } + followNovel(); + if (novel.inLibrary && chapters.some(chapter => chapter.isDownloaded)) { + deleteDownloadsSnackbar.setTrue(); + } + }, [ + isLoading, + followNovel, + novel.inLibrary, + chapters, + deleteDownloadsSnackbar, + ]); + + const handleTrackerSheet = useCallback( + () => trackerSheetRef.current?.present(), + [trackerSheetRef], + ); + + const handleOpenBottomSheet = useCallback( + () => novelBottomSheetRef.current?.present(), + [novelBottomSheetRef], + ); + + const ripple = useMemo( + () => ({ color: theme.rippleColor }), + [theme.rippleColor], + ); return ( <> - navigation.replace('GlobalSearchScreen', { - searchText: novel.name, - }) - } - onLongPress={() => { - Clipboard.setStringAsync(novel.name).then(() => - showToast( - getString('common.copiedToClipboard', { - name: novel.name, - }), - ), - ); - }} + onPress={handleTitlePress} + onLongPress={handleTitleLongPress} > {novel.name} @@ -188,20 +279,8 @@ const NovelInfoHeader = ({ <> { - followNovel(); - if ( - novel.inLibrary && - chapters.some(chapter => chapter.isDownloaded) - ) { - deleteDownloadsSnackbar.setTrue(); - } - } - } - handleTrackerSheet={() => trackerSheetRef.current?.present()} + handleFollowNovel={handleFollowNovel} + handleTrackerSheet={handleTrackerSheet} theme={theme} /> {isLoading && (!novel.genres || !novel.summary) ? ( @@ -229,23 +308,23 @@ const NovelInfoHeader = ({ novelBottomSheetRef.current?.present()} - android_ripple={{ - color: color(theme.primary).alpha(0.12).string(), - }} + onPress={handleOpenBottomSheet} + android_ripple={ripple} > - - {!fetching || totalChapters !== undefined - ? `${totalChapters} ${getString('novelScreen.chapters')}` - : getString('common.loading')} - + {fetching && totalChapters === undefined ? ( + + ) : ( + + {`${totalChapters} ${getString('novelScreen.chapters')}`} + + )} novelBottomSheetRef.current?.present()} + onPress={handleOpenBottomSheet} /> @@ -268,6 +347,19 @@ const styles = StyleSheet.create({ bottomsheetContainer: { gap: 12, }, + chapterCountGradient: { + height: 20, + position: 'absolute', + transform: [{ translateX: '-100%' }], + width: '60%', + }, + chapterCountSkeleton: { + borderRadius: 4, + height: 14, + marginHorizontal: 16, + overflow: 'hidden', + width: 120, + }, chapters: { fontSize: 14, paddingHorizontal: 16, diff --git a/src/screens/novel/components/NovelAppbar.tsx b/src/screens/novel/components/NovelAppbar.tsx index ce7ec45d..67767aad 100644 --- a/src/screens/novel/components/NovelAppbar.tsx +++ b/src/screens/novel/components/NovelAppbar.tsx @@ -17,6 +17,9 @@ import { NovelInfo } from '@database/types'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { MaterialDesignIconName } from '@type/icon'; +const AnimatedAppbarAction = + Animated.createAnimatedComponent(Appbar.Action); + const Menu = React.memo( ({ visible, @@ -31,20 +34,32 @@ const Menu = React.memo( theme: ThemeColors; items: { label: string; onPress: () => void }[]; }) => { + const contentStyle = useMemo( + () => ({ backgroundColor: theme.surface2 }), + [theme.surface2], + ); + const itemStyle = useMemo( + () => ({ backgroundColor: theme.surface2 }), + [theme.surface2], + ); + const titleStyle = useMemo( + () => ({ color: theme.onSurface }), + [theme.onSurface], + ); + return ( {items.map((item, index) => ( { onDismiss(); item.onPress(); @@ -97,6 +112,11 @@ const NovelAppbar = ({ const [downloadMenu, showDownloadMenu] = useState(false); const [extraMenu, showExtraMenu] = useState(false); + const appbarTheme = useMemo( + () => ({ colors: theme }), + [theme], + ); + const AppbarAction = useCallback( (props: { icon: MaterialDesignIconName; @@ -104,20 +124,19 @@ const NovelAppbar = ({ style?: StyleProp; size?: number; }) => { - const AA = Animated.createAnimatedComponent(Appbar.Action); return ( - ); }, - [theme], + [appbarTheme], ); + const downloadMenuItems = useMemo(() => { return [ { @@ -151,8 +170,34 @@ const NovelAppbar = ({ ]; }, [deleteChapters, downloadChapters, downloadCustomChapterModal]); + const extraMenuItems = useMemo( + () => [ + { + label: getString('novelScreen.edit.info'), + onPress: () => showEditInfoModal(true), + }, + { + label: getString('novelScreen.edit.cover'), + onPress: () => setCustomNovelCover(), + }, + ], + [showEditInfoModal, setCustomNovelCover], + ); + const openDlMenu = useCallback(() => showDownloadMenu(true), []); const closeDlMenu = useCallback(() => showDownloadMenu(false), []); + const openExtraMenu = useCallback(() => showExtraMenu(true), []); + const closeExtraMenu = useCallback(() => showExtraMenu(false), []); + + const openJumpToChapter = useCallback( + () => showJumpToChapterModal(true), + [showJumpToChapterModal], + ); + + const headerTheme = useMemo( + () => ({ colors: { ...theme, surface: 'transparent' } }), + [theme], + ); return ( - + @@ -168,18 +213,16 @@ const NovelAppbar = ({ { - showJumpToChapterModal(true); - }} + onPress={openJumpToChapter} /> - {!isLocal && ( + {!isLocal ? ( - )} + ) : null} showExtraMenu(false)} + onDismiss={closeExtraMenu} anchor={ showExtraMenu(true)} + onPress={openExtraMenu} size={24} /> } theme={theme} - items={[ - { - label: getString('novelScreen.edit.info'), - onPress: () => { - showEditInfoModal(true); - }, - }, - { - label: getString('novelScreen.edit.cover'), - onPress: () => { - setCustomNovelCover(); - }, - }, - ]} + items={extraMenuItems} /> diff --git a/src/screens/novel/components/NovelScreenList.tsx b/src/screens/novel/components/NovelScreenList.tsx index 25b2e181..11930fb5 100644 --- a/src/screens/novel/components/NovelScreenList.tsx +++ b/src/screens/novel/components/NovelScreenList.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import ChapterItem from './ChapterItem'; import NovelInfoHeader from './Info/NovelInfoHeader'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { pickCustomNovelCover } from '@database/queries/NovelQueries'; import { ChapterInfo, NovelInfo } from '@database/types'; import { useBoolean } from '@hooks/index'; @@ -51,7 +51,7 @@ type NovelScreenListProps = { }; }; -const ListEmptyComponent = () => ; +const chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id; const NovelScreenList = ({ headerOpacity, @@ -74,11 +74,11 @@ const NovelScreenList = ({ setNovel, sortAndFilterChapters, setShowChapterTitles, - updateChapter, novel: fetchedNovel, batchInformation, pageIndex, openPage, + updateChapter, } = useNovelContext(); const { pluginId } = routeBaseNovel; @@ -108,7 +108,25 @@ const NovelScreenList = ({ const theme = useTheme(); const { top: topInset, bottom: bottomInset } = useSafeAreaInsets(); - const { downloadQueue, downloadChapter } = useDownload(); + const { downloadingChapterIds, downloadChapter } = useDownload(); + + // Mark chapters as downloaded when their download completes + const prevDownloadingRef = useRef(downloadingChapterIds); + useEffect(() => { + const prev = prevDownloadingRef.current; + prevDownloadingRef.current = downloadingChapterIds; + if (prev === downloadingChapterIds) { + return; + } + for (const id of prev) { + if (!downloadingChapterIds.has(id)) { + const index = chapters.findIndex(c => c.id === id); + if (index !== -1) { + updateChapter(index, { isDownloaded: true }); + } + } + } + }, [downloadingChapterIds, chapters, updateChapter]); const [isFabExtended, setIsFabExtended] = useState(true); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -119,10 +137,12 @@ const NovelScreenList = ({ const deleteDownloadsSnackbar = useBoolean(); - const extraData = useMemo( - () => [chapters.length, selected.length, novel.id, loading, downloadQueue.length], - [chapters.length, selected.length, novel.id, loading, downloadQueue.length], + // Derive selectedIds Set for O(1) lookups + const selectedIds = useMemo( + () => new Set(selected.map(s => s.id)), + [selected], ); + const isSelectionMode = selected.length > 0; const onPageScroll = useCallback( (event: NativeSyntheticEvent) => { @@ -140,7 +160,93 @@ const NovelScreenList = ({ [headerOpacity, useFabForContinueReading, lastRead], ); - const onRefresh = async () => { + // --- Stable callbacks --- + + const navigateToChapter = useCallback( + (chapter: ChapterInfo) => { + navigation.navigate('ReaderStack', { + screen: 'Chapter', + params: { novel, chapter }, + }); + }, + [navigation, novel], + ); + + const onSelectPress = useCallback( + (chapter: ChapterInfo) => { + if (!isSelectionMode) { + navigateToChapter(chapter); + } else { + setSelected(sel => + sel.some(it => it.id === chapter.id) + ? sel.filter(it => it.id !== chapter.id) + : [...sel, chapter], + ); + } + }, + [isSelectionMode, navigateToChapter, setSelected], + ); + + const onSelectLongPress = useCallback( + (chapter: ChapterInfo) => { + setSelected(sel => { + if (sel.length === 0) { + if (!disableHapticFeedback) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + return [...sel, chapter]; + } + if (sel.length === chapters.length) { + return sel; + } + + const lastSelectedChapter = sel[sel.length - 1]; + if (lastSelectedChapter.id === chapter.id) { + return sel; + } + + if (lastSelectedChapter.id > chapter.id) { + return [ + ...sel, + chapter, + ...chapters.filter( + (chap: ChapterInfo) => + (chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) === + false, + ), + ]; + } + return [ + ...sel, + chapter, + ...chapters.filter( + (chap: ChapterInfo) => + (chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) === + false, + ), + ]; + }); + }, + [chapters, disableHapticFeedback], + ); + + const handleDeleteChapter = useCallback( + (chapter: ChapterInfo) => { + deleteChapter(chapter); + }, + [deleteChapter], + ); + + const handleDownloadChapter = useCallback( + (chapter: ChapterInfo) => { + if (novel && novel.id !== 'NO_ID') { + downloadChapter(novel, chapter); + } + }, + [novel, downloadChapter], + ); + + const onRefresh = useCallback(async () => { if (novel.id !== 'NO_ID') { setUpdating(true); updateNovel(pluginId, novel.path, novel.id, { @@ -156,101 +262,42 @@ const NovelScreenList = ({ .catch(error => showToast('Failed updating: ' + error.message)) .finally(() => setUpdating(false)); } - }; + }, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]); - const onRefreshPage = async (page: string) => { - if (novel.id !== 'NO_ID') { - setUpdating(true); - updateNovelPage(pluginId, novel.path, novel.id, page, { - downloadNewChapters, - }) - .then(() => getNovel()) - .then(() => showToast(`Updated page: ${page}`)) - .catch(e => showToast('Failed updating: ' + e.message)) - .finally(() => setUpdating(false)); - } - }; - - const refreshControl = () => ( - + const onRefreshPage = useCallback( + async (page: string) => { + if (novel.id !== 'NO_ID') { + setUpdating(true); + updateNovelPage(pluginId, novel.path, novel.id, page, { + downloadNewChapters, + }) + .then(() => getNovel()) + .then(() => showToast(`Updated page: ${page}`)) + .catch(e => showToast('Failed updating: ' + e.message)) + .finally(() => setUpdating(false)); + } + }, + [novel, pluginId, downloadNewChapters, getNovel], ); - const isSelected = (id: number) => { - return selected.some(obj => obj.id === id); - }; + const refreshControlElement = useMemo( + () => ( + + ), + [onRefresh, updating, topInset, theme.primary, theme.onPrimary], + ); - const onSelectPress = (chapter: ChapterInfo) => { - if (selected.length === 0) { - navigateToChapter(chapter); - } else { - if (isSelected(chapter.id)) { - setSelected(sel => sel.filter(it => it.id !== chapter.id)); - } else { - setSelected(sel => [...sel, chapter]); - } - } - }; - - const onSelectLongPress = (chapter: ChapterInfo) => { - if (selected.length === 0) { - if (!disableHapticFeedback) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } - setSelected(sel => [...sel, chapter]); - } else { - if (selected.length === chapters.length) { - return; - } - - /** - * Select custom range - */ - const lastSelectedChapter = selected[selected.length - 1]; - - if (lastSelectedChapter.id !== chapter.id) { - if (lastSelectedChapter.id > chapter.id) { - setSelected(sel => [ - ...sel, - chapter, - ...chapters.filter( - (chap: ChapterInfo) => - (chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) === - false, - ), - ]); - } else { - setSelected(sel => [ - ...sel, - chapter, - ...chapters.filter( - (chap: ChapterInfo) => - (chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) === - false, - ), - ]); - } - } - } - }; - - const navigateToChapter = (chapter: ChapterInfo) => { - navigation.navigate('ReaderStack', { - screen: 'Chapter', - params: { novel, chapter }, - }); - }; - - const scrollToTop = () => { + const scrollToTop = useCallback(() => { listRef.current?.scrollToOffset({ offset: 0, animated: true }); - }; + }, [listRef]); - const setCustomNovelCover = async () => { + const setCustomNovelCover = useCallback(async () => { if (!novel || novel.id === 'NO_ID') { return; } @@ -261,9 +308,9 @@ const NovelScreenList = ({ cover: newCover, }); } - }; + }, [novel, setNovel]); - const saveNovelCover = async () => { + const saveNovelCover = useCallback(async () => { if (!novel) { showToast(getString('novelScreen.coverNotSaved')); return; @@ -291,7 +338,6 @@ const NovelScreenList = ({ ? imageExtension : 'png'; - // sanitize novel name as app crashes while copying file with ':' character const novelName = novel.name.replace(/[^a-zA-Z0-9]/g, '_'); const fileName = `${novelName}_${novel.id}.${imageExtension}`; const coverDestUri = await StorageAccessFramework.createFileAsync( @@ -315,10 +361,122 @@ const NovelScreenList = ({ FileManager.unlink(tempCoverUri); } } - }; + }, [novel]); + + const onFabPress = useCallback(() => { + const chapter = lastRead ?? chapters[0]; + if (chapter) { + navigation.navigate('ReaderStack', { + screen: 'Chapter', + params: { novel, chapter }, + }); + } + }, [lastRead, chapters, novel, navigation]); const hasMultiplePages = pages.length > 1 || (novel?.totalPages ?? 0) > 1; + const openPageNavDrawer = useCallback( + () => pageNavigationSheetRef.current?.present(), + [], + ); + + // --- Memoized list components --- + + const paginationControl = useMemo(() => { + if (!hasMultiplePages) { + return null; + } + return ( + + + + ); + }, [hasMultiplePages, pages, pageIndex, openPage, openPageNavDrawer, theme]); + + const listEmptyComponent = useMemo( + () => (fetching ? : null), + [fetching], + ); + + const listHeader = useMemo( + () => ( + <> + 1 ? pages[pageIndex] : undefined} + pageIndex={pageIndex} + pages={pages} + pageNavigationSheetRef={pageNavigationSheetRef} + setCustomNovelCover={setCustomNovelCover} + saveNovelCover={saveNovelCover} + theme={theme} + totalChapters={batchInformation.totalChapters} + trackerSheetRef={trackerSheetRef} + /> + {paginationControl} + + ), + [ + chapters, + deleteDownloadsSnackbar, + fetching, + filter, + loading, + lastRead, + navigateToChapter, + navigation, + novel, + onRefreshPage, + pages, + pageIndex, + setCustomNovelCover, + saveNovelCover, + theme, + batchInformation.totalChapters, + paginationControl, + ], + ); + + const scrollToTopFabStyle = useMemo( + () => [ + styles.scrollToTopFab, + { backgroundColor: theme.surface2, marginBottom: bottomInset }, + ], + [theme.surface2, bottomInset], + ); + + const continueFabStyle = useMemo( + () => [ + styles.fab, + { backgroundColor: theme.primary, marginBottom: bottomInset }, + ], + [theme.primary, bottomInset], + ); + + const continueFabLabel = useMemo( + () => + lastRead + ? getString('common.resume') + : getString('novelScreen.startReadingChapters', { name: '' }).trim(), + [lastRead], + ); + return ( <> - pageNavigationSheetRef.current?.present()} - theme={theme} - /> - - ) : undefined - ) : ( - ListEmptyComponent - ) - } - renderItem={({ item, index }) => { + ListEmptyComponent={listEmptyComponent} + renderItem={({ item }) => { if (novel.id === 'NO_ID') { return null; } return ( c.task.data.chapterId === item.id, - )} + chapter={item} + isDownloading={downloadingChapterIds.has(item.id)} isBookmarked={!!item.bookmark} isLocal={novel.isLocal} theme={theme} - chapter={item} showChapterTitles={showChapterTitles} - deleteChapter={() => deleteChapter(item)} - downloadChapter={() => downloadChapter(novel, item)} - isSelected={isSelected(item.id)} + isSelected={selectedIds.has(item.id)} + novelName={novel.name} + onDeleteChapter={handleDeleteChapter} + onDownloadChapter={handleDownloadChapter} onSelectPress={onSelectPress} onSelectLongPress={onSelectLongPress} - navigateToChapter={navigateToChapter} - novelName={novel.name} - setChapterDownloaded={(value: boolean) => - updateChapter?.(index, { isDownloaded: value }) - } /> ); }} - keyExtractor={item => 'c' + item.id} + keyExtractor={chapterKeyExtractor} + extraData={downloadingChapterIds} contentContainerStyle={styles.contentContainer} - refreshControl={refreshControl()} + refreshControl={refreshControlElement} onEndReached={getNextChapterBatch} onEndReachedThreshold={6} onScroll={onPageScroll} scrollEventThrottle={16} drawDistance={1000} - ListHeaderComponent={ - 1 ? pages[pageIndex] : undefined} - pageIndex={pageIndex} - pages={pages} - pageNavigationSheetRef={pageNavigationSheetRef} - setCustomNovelCover={setCustomNovelCover} - saveNovelCover={saveNovelCover} - theme={theme} - totalChapters={batchInformation.totalChapters} - trackerSheetRef={trackerSheetRef} - /> - } + ListHeaderComponent={listHeader} /> - {novel.id !== 'NO_ID' && ( + {novel.id !== 'NO_ID' ? ( <> ) : null} - {showScrollToTop && ( + {showScrollToTop ? ( - )} + ) : null} {useFabForContinueReading && (lastRead || chapters[0]) ? ( { - navigation.navigate('ReaderStack', { - screen: 'Chapter', - params: { - novel: novel, - chapter: lastRead ?? chapters[0], - }, - }); - }} + onPress={onFabPress} /> ) : null} - )} + ) : null} ); }; + const styles = StyleSheet.create({ container: { flex: 1 }, contentContainer: { paddingBottom: 100 }, diff --git a/src/screens/novel/components/PagePaginationControl.tsx b/src/screens/novel/components/PagePaginationControl.tsx index 61a90f4e..1748efc6 100644 --- a/src/screens/novel/components/PagePaginationControl.tsx +++ b/src/screens/novel/components/PagePaginationControl.tsx @@ -31,24 +31,29 @@ const PagePaginationControl: React.FC = ({ indices.push(i); } } else { + // Always show first page if not current if (currentPageIndex !== 0) { indices.push(0); } + // Show page before current (with ellipsis if there's a gap) const leftPageIndex = currentPageIndex - 1; if (leftPageIndex > 0) { if (leftPageIndex > 1) { indices.push('ellipsis'); } - indices.push(leftPageIndex); } + // Always show current page indices.push(currentPageIndex); - if (currentPageIndex < totalPages - 1) { + // Show ellipsis after current only if there's a gap to last page + const rightPageIndex = currentPageIndex + 1; + if (rightPageIndex < totalPages - 1) { indices.push('ellipsis'); } + // Always show last page if not current if (currentPageIndex !== totalPages - 1) { indices.push(totalPages - 1); } @@ -198,13 +203,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, }, container: { - flex: 1, alignItems: 'center', flexDirection: 'row', gap: 8, justifyContent: 'space-between', paddingHorizontal: 16, - paddingTop: 12, + marginBottom: 16, }, disabledButton: { opacity: 0.5, @@ -224,13 +228,16 @@ const styles = StyleSheet.create({ }, pageNumbersRow: { alignItems: 'center', + justifyContent: 'space-evenly', + flex: 1, flexDirection: 'row', + flexShrink: 1, gap: 8, }, pageText: { fontSize: 15, letterSpacing: 0.15, - maxWidth: 100, + maxWidth: 80, }, activeButton: { borderColor: 'transparent', diff --git a/src/screens/updates/components/UpdateNovelCard.tsx b/src/screens/updates/components/UpdateNovelCard.tsx index d84a6021..583007fe 100644 --- a/src/screens/updates/components/UpdateNovelCard.tsx +++ b/src/screens/updates/components/UpdateNovelCard.tsx @@ -37,7 +37,7 @@ const UpdateNovelCard: React.FC = ({ deleteChapter, }) => { const { navigate } = useNavigation>(); - const { downloadChapter, downloadQueue } = useDownload(); + const { downloadChapter, downloadingChapterIds } = useDownload(); const { getDetailedUpdates, isLoading } = useUpdates(); const [chapterList, setChapterList] = useState< Update[] | DownloadedChapter[] @@ -66,18 +66,29 @@ const UpdateNovelCard: React.FC = ({ updateList(); }, [updateList]); - const handleDownloadChapter = (chapter: Update | DownloadedChapter) => { - if (chapterListInfo.updatesPerDay) { - downloadChapter( - { - id: chapter?.novelId, - pluginId: chapter.pluginId, - name: chapter.novelName, - } as NovelInfo, - chapter, - ); - } - }; + 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) => { @@ -129,6 +140,15 @@ const UpdateNovelCard: React.FC = ({ styles.cover, ]); + const coverElement = useMemo( + () => ( + + + + ), + [Cover, styles.novelCover], + ); + if (chapterListInfo.updatesPerDay > 1) { return ( = ({ return ( { - return c.task.data.chapterId === item.id; - })} + isDownloading={downloadingChapterIds.has(item.id)} isUpdateCard novelName={chapterListInfo.novelName} chapter={item} theme={theme} showChapterTitles={false} - downloadChapter={() => handleDownloadChapter(item)} - deleteChapter={() => deleteChapter(item)} - navigateToChapter={navigateToChapter} - left={ - - - - } + onDownloadChapter={handleDownloadChapter} + onDeleteChapter={handleDeleteChapter} + onSelectPress={navigateToChapter} + left={coverElement} /> ); }} @@ -181,22 +195,16 @@ const UpdateNovelCard: React.FC = ({ return ( c.task.data.chapterId === chapterList[0]?.id, - )} + isDownloading={downloadingChapterIds.has(chapterList[0]?.id)} isUpdateCard novelName={chapterListInfo.novelName} chapter={chapterList[0]} theme={theme} showChapterTitles={false} - downloadChapter={() => handleDownloadChapter(chapterList[0])} - deleteChapter={() => deleteChapter(chapterList[0])} - navigateToChapter={navigateToChapter} - left={ - - - - } + onDownloadChapter={handleDownloadChapter} + onDeleteChapter={handleDeleteChapter} + onSelectPress={navigateToChapter} + left={coverElement} /> ); }