refactor: Optimize Novel Screen Performance and Fix Download State Tracking

- Optimize Novel screen components with useCallback, useMemo, and memo to prevent unnecessary re-renders
- Replace download queue array iteration with downloadingChapterIds Set for O(1) lookups
- Add loading shimmer to chapter count display (replaces "Loading..." text)
- Fix state mutation bug in useNovel updateChapter (now creates new array)
- Refactor ChapterItem API (onDeleteChapter, onDownloadChapter props for better stability)
- Track download completion via useEffect to update chapter isDownloaded state
- Fix pagination control logic and improve spacing
- Remove unnecessary local state in ChapterDownloadButtons

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Rajarshee Chatterjee
2026-02-07 22:48:14 +05:30
parent 2f45db659b
commit 4c7684a3a7
12 changed files with 808 additions and 527 deletions
+3 -1
View File
@@ -89,4 +89,6 @@ flake.lock
# pnpm
.pnpm-store
.cursor/
.cursor/
.agents/
.claude/
+6
View File
@@ -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,
+3 -2
View File
@@ -130,8 +130,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
(index: number, update: Partial<ChapterInfo>) => {
if (novel) {
_setChapters(chs => {
chs[index] = { ...chs[index], ...update };
return chs;
const next = [...chs];
next[index] = { ...next[index], ...update };
return next;
});
}
},
+64 -36
View File
@@ -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 (
<Portal.Host>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={containerStyle}>
<Portal>
{selected.length === 0 ? (
<NovelAppbar
@@ -260,18 +299,16 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Appbar.Action
icon="close"
iconColor={theme.onBackground}
onPress={() => setSelected([])}
onPress={clearSelection}
/>
<Appbar.Content
title={`${selected.length}`}
titleStyle={{ color: theme.onSurface }}
titleStyle={titleStyle}
/>
<Appbar.Action
icon="select-all"
iconColor={theme.onBackground}
onPress={() => {
setSelected(chapters);
}}
onPress={selectAll}
/>
</Animated.View>
)}
@@ -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}
/>
</Suspense>
</SafeAreaView>
@@ -299,26 +332,21 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<Snackbar
visible={deleteDownloadsSnackbar.value}
onDismiss={deleteDownloadsSnackbar.setFalse}
action={{
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}}
theme={{ colors: { primary: theme.primary } }}
action={snackbarAction}
theme={snackbarTheme}
style={styles.snackbar}
>
<Text style={{ color: theme.onSurface }}>
<Text style={snackbarTextStyle}>
{getString('novelScreen.deleteMessage')}
</Text>
</Snackbar>
</Portal>
<Portal>
{novel && (
{novel ? (
<>
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
hideModal={hideJumpToChapterModal}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
@@ -328,7 +356,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
/>
<EditInfoModal
modalVisible={editInfoModal}
hideModal={() => showEditInfoModal(false)}
hideModal={hideEditInfoModal}
novel={novel}
setNovel={setNovel}
theme={theme}
@@ -342,7 +370,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
downloadChapters={downloadChapters}
/>
</>
)}
) : null}
</Portal>
</View>
</Portal.Host>
@@ -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<DownloadButtonProps> = ({
chapterId,
isDownloaded,
isDownloading,
theme,
deleteChapter,
downloadChapter,
setChapterDownloaded,
}) => {
const [downloaded, setDownloaded] = React.useState<boolean | undefined>(
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 <ChapterDownloadingButton theme={theme} />;
}
return downloaded ? (
<Menu
visible={deleteChapterMenuVisible}
onDismiss={hideDeleteChapterMenu}
anchor={
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
}
contentStyle={{ backgroundColor: overlay(2, theme.surface) }}
>
<Menu.Item
onPress={() => {
deleteChapter();
hideDeleteChapterMenu();
setDownloaded(false);
}}
title={getString('common.delete')}
titleStyle={{ color: theme.onSurface }}
/>
</Menu>
) : (
<DownloadChapterButton
theme={theme}
onPress={() => {
downloadChapter();
setDownloaded(undefined);
}}
/>
);
if (isDownloaded) {
return (
<Menu
visible={deleteChapterMenuVisible}
onDismiss={hideDeleteChapterMenu}
anchor={
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
}
contentStyle={menuContentStyle}
>
<Menu.Item
onPress={() => {
deleteChapter();
hideDeleteChapterMenu();
}}
title={getString('common.delete')}
titleStyle={menuTitleStyle}
/>
</Menu>
);
}
return <DownloadChapterButton theme={theme} onPress={downloadChapter} />;
};
interface theme {
theme: MD3ThemeType;
theme: ThemeColors;
}
type buttonPropType = theme & {
onPress: () => void;
@@ -118,7 +97,7 @@ export const DownloadChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
android_ripple={{ color: theme.rippleColor }}
>
<DownloadIcon theme={theme} />
</Pressable>
@@ -141,7 +120,7 @@ export const DeleteChapterButton: React.FC<buttonPropType> = ({
<Pressable
style={styles.pressable}
onPress={onPress}
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
android_ripple={{ color: theme.rippleColor }}
>
<DeleteIcon theme={theme} />
</Pressable>
+112 -85
View File
@@ -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<ChapterItemProps> = ({
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 (
<View key={'chapterItem' + id}>
<Pressable
style={[
styles.chapterCardContainer,
isSelected && {
backgroundColor: color(theme.primary).alpha(0.12).string(),
},
]}
onPress={() => {
if (onSelectPress) {
onSelectPress(chapter);
} else {
navigateToChapter(chapter);
}
}}
onLongPress={() => onSelectLongPress?.(chapter)}
android_ripple={{ color: theme.rippleColor }}
style={selectedStyle}
onPress={handlePress}
onLongPress={handleLongPress}
android_ripple={ripple}
>
<View style={styles.row}>
{left}
{isBookmarked ? <ChapterBookmarkButton theme={theme} /> : null}
<View style={{ flex: 1 }}>
<View style={styles.flex1}>
{isUpdateCard ? (
<Text
style={{
fontSize: 14,
color: unread ? theme.onSurface : theme.outline,
}}
style={[
styles.updateCardName,
{ color: unread ? theme.onSurface : theme.outline },
]}
numberOfLines={1}
>
{novelName}
</Text>
) : null}
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<View style={styles.titleRow}>
{unread ? (
<MaterialCommunityIcons
name="circle"
@@ -104,42 +127,26 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
<Text
style={{
fontSize: isUpdateCard ? 12 : 14,
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurface,
flex: 1,
}}
style={[
isUpdateCard ? styles.textSmall : styles.textNormal,
{ color: titleColor },
styles.flex1,
]}
numberOfLines={1}
ellipsizeMode="tail"
>
{showChapterTitles
? name
: getString('novelScreen.chapterChapnum', {
num: chapterNumber,
})}
num: chapterNumber,
})}
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}
>
<View style={styles.metaRow}>
{releaseTime && !isUpdateCard ? (
<Text
style={[
{
color: !unread
? theme.outline
: bookmark
? theme.primary
: theme.onSurfaceVariant,
marginTop: 4,
},
{ color: releaseColor, marginTop: 4 },
styles.text,
]}
numberOfLines={1}
@@ -149,12 +156,14 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
) : null}
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
<Text
style={{
color: theme.outline,
marginStart: chapter.releaseTime ? 5 : 0,
fontSize: 12,
marginTop: 4,
}}
style={[
styles.text,
{
color: theme.outline,
marginStart: chapter.releaseTime ? 5 : 0,
marginTop: 4,
},
]}
numberOfLines={1}
>
{chapter.releaseTime ? '• ' : null}
@@ -168,11 +177,9 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
<DownloadButton
isDownloading={isDownloading}
isDownloaded={chapter.isDownloaded}
chapterId={chapter.id}
theme={theme}
setChapterDownloaded={setChapterDownloaded}
deleteChapter={deleteChapter}
downloadChapter={downloadChapter}
deleteChapter={handleDelete}
downloadChapter={handleDownload}
/>
) : null}
</Pressable>
@@ -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,
},
});
@@ -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 }) => (
<View style={styles.novelInfoContainer}>{children}</View>
);
const CoverImage = ({
children,
source,
theme,
hideBackdrop,
}: CoverImageProps) => {
if (hideBackdrop) {
return <View>{children}</View>;
} 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 <View>{children}</View>;
}
return (
<ImageBackground source={source} style={styles.coverImage}>
<View
style={[
{ backgroundColor: color(theme.background).alpha(0.7).string() },
styles.flex1,
]}
>
<View style={overlayStyle}>
{source.uri ? (
<LinearGradient
colors={['rgba(0,0,0,0)', theme.background]}
locations={[0, 1]}
colors={gradientColors}
locations={GRADIENT_LOCATIONS}
style={styles.linearGradient}
>
{children}
@@ -74,8 +78,10 @@ const CoverImage = ({
</View>
</ImageBackground>
);
}
};
},
);
const GRADIENT_LOCATIONS = [0, 1] as const;
const NovelThumbnail = ({
source,
@@ -170,7 +176,7 @@ const FollowButton = ({
<View style={styles.followButtonContainer}>
<Pressable
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
color: theme.rippleColor,
borderless: false,
}}
onPress={onPress}
@@ -234,26 +240,28 @@ const TrackerButton = ({
</View>
);
const NovelGenres = ({
theme,
genres,
}: {
theme: ThemeColors;
genres: string;
}) => {
const data = genres.split(/,\s*/);
const genreKeyExtractor = (_item: string, index: number) => 'genre' + index;
return (
<FlatList
contentContainerStyle={styles.genreContainer}
horizontal
data={data}
keyExtractor={(item, index) => 'genre' + index}
renderItem={({ item }) => <Chip label={item} theme={theme} />}
showsHorizontalScrollIndicator={false}
/>
);
};
const NovelGenres = memo(
({ theme, genres }: { theme: ThemeColors; genres: string }) => {
const data = useMemo(() => genres.split(/,\s*/), [genres]);
const renderGenre = useCallback(
({ item }: { item: string }) => <Chip label={item} theme={theme} />,
[theme],
);
return (
<FlatList
contentContainerStyle={styles.genreContainer}
horizontal
data={data}
keyExtractor={genreKeyExtractor}
renderItem={renderGenre}
showsHorizontalScrollIndicator={false}
/>
);
},
);
export {
NovelInfoContainer,
@@ -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 (
<View
style={[
styles.chapterCountSkeleton,
{ backgroundColor: backgroundColor },
]}
/>
);
}
const LG = Animated.createAnimatedComponent(LinearGradient);
return (
<View
style={[styles.chapterCountSkeleton, { backgroundColor: backgroundColor }]}
>
<LG
start={[0, 0]}
end={[1, 0]}
locations={[0, 0.3, 0.7, 1]}
style={[animatedProps, styles.chapterCountGradient]}
colors={['transparent', highlightColor, highlightColor, 'transparent']}
/>
</View>
);
};
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 (
<>
<CoverImage
source={{ uri: novel.cover }}
source={coverSource}
theme={theme}
hideBackdrop={hideBackdrop}
>
<NovelInfoContainer>
<NovelThumbnail
source={{ uri: novel.cover }}
source={coverSource}
theme={theme}
setCustomNovelCover={
isLoading ? showNotAvailable : setCustomNovelCover
@@ -125,20 +228,8 @@ const NovelInfoHeader = ({
<Row>
<NovelTitle
theme={theme}
onPress={() =>
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}
</NovelTitle>
@@ -188,20 +279,8 @@ const NovelInfoHeader = ({
<>
<NovelScreenButtonGroup
novel={novel}
handleFollowNovel={
isLoading
? showNotAvailable
: () => {
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 = ({
<View style={styles.bottomsheetContainer}>
<Pressable
style={styles.bottomsheet}
onPress={() => novelBottomSheetRef.current?.present()}
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
}}
onPress={handleOpenBottomSheet}
android_ripple={ripple}
>
<View style={styles.flex}>
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{!fetching || totalChapters !== undefined
? `${totalChapters} ${getString('novelScreen.chapters')}`
: getString('common.loading')}
</Text>
{fetching && totalChapters === undefined ? (
<ChapterCountSkeleton theme={theme} />
) : (
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{`${totalChapters} ${getString('novelScreen.chapters')}`}
</Text>
)}
</View>
<IconButton
icon="filter-variant"
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
size={24}
onPress={() => novelBottomSheetRef.current?.present()}
onPress={handleOpenBottomSheet}
/>
</Pressable>
</View>
@@ -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,
+63 -33
View File
@@ -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 (
<DefaultMenu
visible={visible}
onDismiss={onDismiss}
anchor={anchor}
// anchorPosition="bottom"
contentStyle={{ backgroundColor: theme.surface2 }}
contentStyle={contentStyle}
>
{items.map((item, index) => (
<DefaultMenu.Item
key={index + item.label}
title={item.label}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
style={itemStyle}
titleStyle={titleStyle}
onPress={() => {
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<ViewStyle>;
size?: number;
}) => {
const AA = Animated.createAnimatedComponent(Appbar.Action);
return (
<AA
<AnimatedAppbarAction
entering={FadeIn.duration(250)}
// delay to prevent flickering on rerenders
exiting={FadeOut.delay(50).duration(250)}
theme={{ colors: theme }}
theme={appbarTheme}
size={24}
{...props}
/>
);
},
[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 (
<Animated.View
@@ -160,7 +205,7 @@ const NovelAppbar = ({
exiting={SlideOutUp.duration(250)}
style={headerOpacityStyle}
>
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
<Appbar.Header theme={headerTheme}>
<Appbar.BackAction onPress={goBack} />
<View style={styles.row}>
@@ -168,18 +213,16 @@ const NovelAppbar = ({
<AppbarAction icon="share-variant" onPress={shareNovel} />
<AppbarAction
icon="text-box-search-outline"
onPress={() => {
showJumpToChapterModal(true);
}}
onPress={openJumpToChapter}
/>
{!isLocal && (
{!isLocal ? (
<Menu
theme={theme}
visible={downloadMenu}
onDismiss={closeDlMenu}
anchor={
<Appbar.Action
theme={{ colors: theme }}
theme={appbarTheme}
icon="download-outline"
onPress={openDlMenu}
size={26}
@@ -187,33 +230,20 @@ const NovelAppbar = ({
}
items={downloadMenuItems}
/>
)}
) : null}
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
onDismiss={closeExtraMenu}
anchor={
<Appbar.Action
theme={{ colors: theme }}
theme={appbarTheme}
icon="dots-vertical"
onPress={() => 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}
/>
</View>
</Appbar.Header>
+280 -187
View File
@@ -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 = () => <ChapterListSkeleton />;
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<NativeScrollEvent>) => {
@@ -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 = () => (
<RefreshControl
progressViewOffset={topInset + 32}
onRefresh={onRefresh}
refreshing={updating}
colors={[theme.primary]}
progressBackgroundColor={theme.onPrimary}
/>
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(
() => (
<RefreshControl
progressViewOffset={topInset + 32}
onRefresh={onRefresh}
refreshing={updating}
colors={[theme.primary]}
progressBackgroundColor={theme.onPrimary}
/>
),
[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 (
<View>
<PagePaginationControl
pages={pages}
currentPageIndex={pageIndex}
onPageChange={openPage}
onOpenDrawer={openPageNavDrawer}
theme={theme}
/>
</View>
);
}, [hasMultiplePages, pages, pageIndex, openPage, openPageNavDrawer, theme]);
const listEmptyComponent = useMemo(
() => (fetching ? <ChapterListSkeleton /> : null),
[fetching],
);
const listHeader = useMemo(
() => (
<>
<NovelInfoHeader
chapters={chapters}
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
fetching={fetching}
filter={filter}
isLoading={loading}
lastRead={lastRead}
navigateToChapter={navigateToChapter}
navigation={navigation}
novel={novel}
novelBottomSheetRef={novelBottomSheetRef}
onRefreshPage={onRefreshPage}
page={pages.length > 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 (
<>
<LegendList
@@ -326,86 +484,40 @@ const NovelScreenList = ({
estimatedItemSize={64}
data={chapters}
recycleItems
extraData={extraData}
// ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={
!fetching ? (
hasMultiplePages ? (
<View>
<PagePaginationControl
pages={pages}
currentPageIndex={pageIndex}
onPageChange={openPage}
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
theme={theme}
/>
</View>
) : undefined
) : (
ListEmptyComponent
)
}
renderItem={({ item, index }) => {
ListEmptyComponent={listEmptyComponent}
renderItem={({ item }) => {
if (novel.id === 'NO_ID') {
return null;
}
return (
<ChapterItem
isDownloading={downloadQueue.some(
c => 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={
<NovelInfoHeader
chapters={chapters}
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
fetching={fetching}
filter={filter}
isLoading={loading}
lastRead={lastRead}
navigateToChapter={navigateToChapter}
navigation={navigation}
novel={novel}
novelBottomSheetRef={novelBottomSheetRef}
onRefreshPage={onRefreshPage}
page={pages.length > 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' ? (
<>
<NovelBottomSheet
bottomSheetRef={novelBottomSheetRef}
@@ -426,12 +538,9 @@ const NovelScreenList = ({
openPage={openPage}
/>
) : null}
{showScrollToTop && (
{showScrollToTop ? (
<AnimatedFAB
style={[
styles.scrollToTopFab,
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
]}
style={scrollToTopFabStyle}
color={theme.primary}
icon="arrow-up"
label=""
@@ -439,40 +548,24 @@ const NovelScreenList = ({
onPress={scrollToTop}
visible={showScrollToTop}
/>
)}
) : null}
{useFabForContinueReading && (lastRead || chapters[0]) ? (
<AnimatedFAB
style={[
styles.fab,
{ backgroundColor: theme.primary, marginBottom: bottomInset },
]}
style={continueFabStyle}
extended={isFabExtended && !loading}
color={theme.onPrimary}
uppercase={false}
label={
lastRead
? getString('common.resume')
: getString('novelScreen.startReadingChapters', {
name: '',
}).trim()
}
label={continueFabLabel}
icon="play"
onPress={() => {
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 },
@@ -31,24 +31,29 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
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',
@@ -37,7 +37,7 @@ const UpdateNovelCard: React.FC<UpdateCardProps> = ({
deleteChapter,
}) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
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<UpdateCardProps> = ({
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<UpdateCardProps> = ({
styles.cover,
]);
const coverElement = useMemo(
() => (
<View style={styles.novelCover}>
<Cover />
</View>
),
[Cover, styles.novelCover],
);
if (chapterListInfo.updatesPerDay > 1) {
return (
<List.Accordion
@@ -151,22 +171,16 @@ const UpdateNovelCard: React.FC<UpdateCardProps> = ({
return (
<ChapterItem
isLocal={false}
isDownloading={downloadQueue.some(c => {
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={
<View style={styles.novelCover}>
<Cover />
</View>
}
onDownloadChapter={handleDownloadChapter}
onDeleteChapter={handleDeleteChapter}
onSelectPress={navigateToChapter}
left={coverElement}
/>
);
}}
@@ -181,22 +195,16 @@ const UpdateNovelCard: React.FC<UpdateCardProps> = ({
return (
<ChapterItem
isLocal={false}
isDownloading={downloadQueue.some(
c => 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={
<View style={styles.novelCover}>
<Cover />
</View>
}
onDownloadChapter={handleDownloadChapter}
onDeleteChapter={handleDeleteChapter}
onSelectPress={navigateToChapter}
left={coverElement}
/>
);
}