From b7f90000648cbe41e5d617ad9863135c96b81ac2 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:53:56 +0700 Subject: [PATCH 1/7] first --- .../app/src/main/assets/css/toolWrapper.css | 6 +- src/components/Common.tsx | 19 +- src/components/Common/ToggleButton.tsx | 7 +- .../IconButtonV2/AnimatedIconButton.tsx | 90 +++++ .../Modal/KeyboardAvoidingModal.tsx | 168 ++++++++++ src/components/Switch/SwitchItem.tsx | 10 +- src/components/TextInput/index.tsx | 71 ++++ src/components/index.ts | 2 + src/hooks/common/useKeyboardHeight.ts | 31 ++ .../novel/components/EditInfoModal.tsx | 314 ++++++++---------- 10 files changed, 538 insertions(+), 180 deletions(-) create mode 100644 src/components/IconButtonV2/AnimatedIconButton.tsx create mode 100644 src/components/Modal/KeyboardAvoidingModal.tsx create mode 100644 src/components/TextInput/index.tsx create mode 100644 src/hooks/common/useKeyboardHeight.ts diff --git a/android/app/src/main/assets/css/toolWrapper.css b/android/app/src/main/assets/css/toolWrapper.css index 580a8ece70..7097ff5d82 100644 --- a/android/app/src/main/assets/css/toolWrapper.css +++ b/android/app/src/main/assets/css/toolWrapper.css @@ -12,7 +12,7 @@ right: unset; left: 50%; transform: translateX(-50%); - bottom: 120px; + bottom: calc(68px + var(--bottom-inset)); display: flex; flex-direction: row-reverse; align-items: center; @@ -20,7 +20,7 @@ @media only screen and (min-width: 500px) { #ToolWrapper.horizontal { - bottom: 80px; + bottom: calc(28px + var(--bottom-inset)); } } @@ -32,7 +32,7 @@ #ToolWrapper.hidden.horizontal { opacity: 0; - bottom: 80px; + bottom: calc(28px + var(--bottom-inset)); right: unset; } diff --git a/src/components/Common.tsx b/src/components/Common.tsx index 2c9e7b9f83..f5bb2cb008 100644 --- a/src/components/Common.tsx +++ b/src/components/Common.tsx @@ -4,10 +4,27 @@ import { StyleSheet, View } from 'react-native'; const Row = ({ children, style = {}, + horizontalSpacing, + verticalSpacing, }: { children?: React.ReactNode; style?: any; -}) => {children}; + horizontalSpacing?: number | `${number}%`; + verticalSpacing?: number | `${number}%`; +}) => ( + + {children} + +); export { Row }; diff --git a/src/components/Common/ToggleButton.tsx b/src/components/Common/ToggleButton.tsx index d00aff98e7..781a9ccd6a 100644 --- a/src/components/Common/ToggleButton.tsx +++ b/src/components/Common/ToggleButton.tsx @@ -11,7 +11,9 @@ import { ThemeColors } from '../../theme/types'; const getToggleButtonPressableStyle = ( selected: boolean, theme: ThemeColors, + disabled?: boolean, ) => ({ + opacity: disabled ? 0.6 : 1, backgroundColor: selected ? Color(theme.primary).alpha(0.12).string() : 'transparent', @@ -29,6 +31,7 @@ interface ToggleButtonProps { theme: ThemeColors; color?: string; onPress: () => void; + disabled?: boolean; } export const ToggleButton: React.FC = ({ @@ -37,15 +40,17 @@ export const ToggleButton: React.FC = ({ theme, color, onPress, + disabled, }) => ( void; + theme: ThemeColors; + style?: ViewStyle; + rotation?: SharedValue; + scale?: SharedValue; +}; + +const AnimatedIconButton: React.FC = ({ + name, + color, + size = 24, + padding = 8, + onPress, + disabled, + theme, + style, + rotation, + scale: _scale, +}) => { + const IconStyle = useAnimatedStyle(() => { + const rotate = rotation + ? withTiming(rotation.value + 'deg', { duration: 250 }) + : '0deg'; + const scale = _scale ? withTiming(_scale.value, { duration: 250 }) : 1; + return { + textAlign: 'center', + transform: [ + { + rotate, + }, + { + scale, + }, + ], + }; + }); + return ( + + + + + + ); +}; +export default React.memo(AnimatedIconButton); + +const styles = StyleSheet.create({ + container: { + borderRadius: 50, + overflow: 'hidden', + }, + pressable: { + padding: 8, + }, +}); diff --git a/src/components/Modal/KeyboardAvoidingModal.tsx b/src/components/Modal/KeyboardAvoidingModal.tsx new file mode 100644 index 0000000000..e47e0a2e6b --- /dev/null +++ b/src/components/Modal/KeyboardAvoidingModal.tsx @@ -0,0 +1,168 @@ +import Button from '@components/Button/Button'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import { ThemeColors } from '@theme/types'; +import React from 'react'; +import { + Keyboard, + ScrollView, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; +import { Modal, ModalProps, overlay, Portal } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + withClamp, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const MODAL_MARGIN = 24; +const BORDER_RADIUS = 28; + +const getModalTitleColor = (theme: ThemeColors) => ({ + color: theme.onSurface, +}); + +export type DefaultModalProps = { + title: string; + onSave: () => boolean; + onDismiss: () => void; + onCancel?: () => void; + onReset?: () => void; +} & Omit; + +const KeyboardAvoidingModal: React.FC = ({ + visible, + onDismiss: _onDismiss, + onSave, + onCancel, + onReset, + title, + children, + ...props +}) => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const keyboard = useAnimatedKeyboard(); + + const onDismiss = () => { + Keyboard.dismiss(); + _onDismiss?.(); + }; + + const dismiss = (cb?: () => void | boolean) => { + if (cb?.() === false) return; + onDismiss(); + }; + + const default_availableHeight = windowHeight - insets.top; + const animatedContainerStyle = useAnimatedStyle(() => { + const kb = keyboard.height.value; + + const availableHeight = + default_availableHeight - Math.max(insets.bottom, kb); + return { + maxHeight: withClamp( + { min: 200 }, + withTiming(availableHeight, { duration: 0 }), + ), + marginBottom: kb, + }; + }, [insets.bottom, default_availableHeight]); + + return ( + + + + + {title} + + + + + {children} + + + + + {onReset ? ( + + ) : null} + + + + + + + + + + ); +}; + +export default KeyboardAvoidingModal; + +const styles = StyleSheet.create({ + modalWrapper: { + justifyContent: 'center', + paddingHorizontal: MODAL_MARGIN, + }, + modalContainer: { + borderRadius: BORDER_RADIUS, + shadowColor: 'transparent', + }, + modalTitle: { + fontSize: 24, + lineHeight: 24, + padding: 24, + }, + body: { + flexShrink: 1, + minHeight: 0, + }, + content: { + paddingBottom: 16, + paddingHorizontal: 24, + }, + buttonRow: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: -8, + marginHorizontal: -8, + padding: 24, + paddingTop: 8, + }, + flex: { + flex: 1, + }, +}); diff --git a/src/components/Switch/SwitchItem.tsx b/src/components/Switch/SwitchItem.tsx index dc4ec93777..9f7b5884e3 100644 --- a/src/components/Switch/SwitchItem.tsx +++ b/src/components/Switch/SwitchItem.tsx @@ -15,7 +15,9 @@ interface SwitchItemProps { value: boolean; label: string; description?: string; + descriptionNumberOfLines?: number; onPress: () => void; + onLongPress?: () => void; theme: ThemeColors; style?: StyleProp; } @@ -23,7 +25,9 @@ interface SwitchItemProps { const SwitchItem: React.FC = ({ label, description, + descriptionNumberOfLines, onPress, + onLongPress, theme, value, style, @@ -32,11 +36,15 @@ const SwitchItem: React.FC = ({ android_ripple={{ color: theme.rippleColor }} style={[styles.container, style]} onPress={onPress} + onLongPress={onLongPress} > {label} {description ? ( - + {description} ) : null} diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx new file mode 100644 index 0000000000..1cfe158ad3 --- /dev/null +++ b/src/components/TextInput/index.tsx @@ -0,0 +1,71 @@ +import { useTheme } from '@hooks/persisted'; +import React, { useState } from 'react'; +import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native'; +import { TextInput as RNTextInput } from 'react-native-gesture-handler'; + +interface TextInputProps extends RNTextInputProps { + error?: boolean; + value?: never; + forceFocused?: boolean; +} + +const TextInput = ({ + onBlur, + onFocus, + error, + forceFocused, + style, + ...props +}: TextInputProps) => { + const theme = useTheme(); + + const [inputFocused, setInputFocused] = useState(false); + + const _onFocus: RNTextInputProps['onFocus'] = e => { + setInputFocused(true); + onFocus?.(e); + }; + const _onBlur: RNTextInputProps['onBlur'] = e => { + setInputFocused(false); + onBlur?.(e); + }; + + const isFocused = forceFocused ?? inputFocused; + const borderWidth = isFocused || error ? 2 : 1; + const margin = isFocused || error ? 0 : 1; + return ( + + ); +}; + +export default TextInput; + +const styles = StyleSheet.create({ + textInput: { + borderRadius: 4, + borderStyle: 'solid', + fontSize: 16, + paddingHorizontal: 16, + paddingVertical: 10, + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index d5a2ad95a4..43624978ba 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,6 +9,7 @@ export { default as ConfirmationDialog } from './ConfirmationDialog/Confirmation export { DialogTitle } from './DialogTitle'; export { default as EmptyView } from './EmptyView/EmptyView'; export { default as ErrorScreenV2 } from './ErrorScreenV2/ErrorScreenV2'; +export { default as AnimatedIconButton } from './IconButtonV2/AnimatedIconButton'; export { default as IconButtonV2 } from './IconButtonV2/IconButtonV2'; export { default as List } from './List/List'; export { default as LoadingMoreIndicator } from './LoadingMoreIndicator/LoadingMoreIndicator'; @@ -23,3 +24,4 @@ export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2'; export { SegmentedControl } from './SegmentedControl'; export { default as SwitchItem } from './Switch/SwitchItem'; export { default as TaskLogDialog } from './TaskLogDialog'; +export { default as TextInput } from './TextInput'; diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts new file mode 100644 index 0000000000..3af2afc9c4 --- /dev/null +++ b/src/hooks/common/useKeyboardHeight.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { Keyboard, KeyboardEvent } from 'react-native'; + +export const useKeyboardHeight = () => { + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + function onKeyboardDidShow(e: KeyboardEvent) { + setKeyboardHeight(e.endCoordinates.height); + } + + function onKeyboardDidHide() { + setKeyboardHeight(0); + } + + const showSubscription = Keyboard.addListener( + 'keyboardDidShow', + onKeyboardDidShow, + ); + const hideSubscription = Keyboard.addListener( + 'keyboardDidHide', + onKeyboardDidHide, + ); + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, []); + + return keyboardHeight; +}; diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx index 83e0c3f5b1..cd15454798 100644 --- a/src/screens/novel/components/EditInfoModal.tsx +++ b/src/screens/novel/components/EditInfoModal.tsx @@ -1,4 +1,4 @@ -import { Button, Modal } from '@components'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; import { updateNovelInfo } from '@database/queries/NovelQueries'; import { NovelInfo } from '@database/types'; import { NovelStatus } from '@plugins/types'; @@ -15,7 +15,6 @@ import { Text, View, } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { Portal, TextInput } from 'react-native-paper'; interface EditInfoModalProps { @@ -27,7 +26,6 @@ interface EditInfoModalProps { } // --- Dynamic style helpers --- -const getModalTitleColor = (theme: ThemeColors) => ({ color: theme.onSurface }); const getStatusLabelColor = (theme: ThemeColors) => ({ color: theme.onSurfaceVariant, }); @@ -40,8 +38,6 @@ const getStatusChipText = (selected: boolean, theme: ThemeColors) => ({ color: selected ? theme.primary : theme.onSurfaceVariant, }); const getGenreListStyle = () => styles.genreList; -const getButtonRowStyle = () => styles.buttonRow; -const getFlex1 = () => styles.flex1; // --- Main Component --- const EditInfoModal = ({ @@ -53,7 +49,6 @@ const EditInfoModal = ({ }: EditInfoModalProps) => { const initialNovelInfo = { ...novel }; const [novelInfo, setNovelInfo] = useState(novel); - const [resetKey, setResetKey] = useState(0); const [newGenre, setNewGenre] = useState(''); const [genreKey, setGenreKey] = useState(0); @@ -72,171 +67,152 @@ const EditInfoModal = ({ return ( - - - - {getString('novelScreen.edit.info')} + { + setNovel(novelInfo); + updateNovelInfo(novelInfo); + hideModal(); + return true; + }} + onCancel={hideModal} + onReset={() => { + setNovelInfo(initialNovelInfo); + updateNovelInfo(initialNovelInfo); + }} + > + + + {getString('novelScreen.edit.status')} - - - {getString('novelScreen.edit.status')} - - - {status.map((item, index) => ( - + {status.map((item, index) => ( + + + setNovelInfo(prev => ({ ...prev, status: item })) + } > - - setNovelInfo(prev => ({ ...prev, status: item })) - } + - - {translateNovelStatus(item)} - - - - ))} - - - - setNovelInfo(prev => ({ ...prev, name: text })) - } - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, author: text })) - } - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, artist: text })) - } - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, summary: text })) - } - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> + {translateNovelStatus(item)} + + + + ))} + + + setNovelInfo(prev => ({ ...prev, name: text }))} + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, author: text })) + } + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, artist: text })) + } + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, summary: text })) + } + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} + /> - setNewGenre(text)} - onSubmitEditing={() => { - const newGenreTrimmed = newGenre.trim(); + setNewGenre(text)} + onSubmitEditing={() => { + const newGenreTrimmed = newGenre.trim(); - if (newGenreTrimmed === '') { - return; - } + if (newGenreTrimmed === '') { + return; + } - setNovelInfo(prevVal => ({ - ...prevVal, - genres: novelInfo.genres - ? `${novelInfo.genres},` + newGenreTrimmed - : newGenreTrimmed, - })); - setNewGenre(''); - setGenreKey(prev => prev + 1); - }} - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> + setNovelInfo(prevVal => ({ + ...prevVal, + genres: novelInfo.genres + ? `${novelInfo.genres},` + newGenreTrimmed + : newGenreTrimmed, + })); + setNewGenre(''); + setGenreKey(prev => prev + 1); + }} + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} + /> - {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( - 'novelTag' + index} - renderItem={({ item }) => ( - removeTag(item)}> - {item} - - )} - showsHorizontalScrollIndicator={false} - /> - ) : null} - - - - - - - - + {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( + 'novelTag' + index} + renderItem={({ item }) => ( + removeTag(item)}> + {item} + + )} + showsHorizontalScrollIndicator={false} + /> + ) : null} + ); }; @@ -286,10 +262,6 @@ const styles = StyleSheet.create({ fontSize: 14, marginBottom: 12, }, - modalTitle: { - fontSize: 24, - marginBottom: 16, - }, statusRow: { marginVertical: 8, flexDirection: 'row', @@ -309,12 +281,6 @@ const styles = StyleSheet.create({ genreList: { marginVertical: 8, }, - buttonRow: { - flexDirection: 'row', - }, - flex1: { - flex: 1, - }, genreChipContainer: { flex: 1, flexDirection: 'row', From 7658dd1f59406b1fa73d9de05b316b063228f43b Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:55:51 +0700 Subject: [PATCH 2/7] refactor(reader): modularize WebViewReader and TTS lifecycle --- src/screens/reader/ReaderScreen.tsx | 7 +- .../Hooks/useReaderMessageHandler.ts | 163 ++++ .../components/Hooks/useReaderSettings.ts | 77 ++ .../reader/components/Hooks/useReadingTime.ts | 77 ++ src/screens/reader/components/Hooks/useTTS.ts | 381 +++++++++ .../reader/components/Hooks/webViewEvents.ts | 46 ++ .../reader/components/WebViewReader.tsx | 725 +++--------------- 7 files changed, 838 insertions(+), 638 deletions(-) create mode 100644 src/screens/reader/components/Hooks/useReaderMessageHandler.ts create mode 100644 src/screens/reader/components/Hooks/useReaderSettings.ts create mode 100644 src/screens/reader/components/Hooks/useReadingTime.ts create mode 100644 src/screens/reader/components/Hooks/useTTS.ts create mode 100644 src/screens/reader/components/Hooks/webViewEvents.ts diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 8b8109fae9..81805a18ab 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -66,7 +66,7 @@ export const ChapterContent = ({ navigation, openDrawer, }: ChapterContentProps) => { - const { left, right } = useSafeAreaInsets(); + const { left, right, bottom } = useSafeAreaInsets(); const { novel, chapter, @@ -85,6 +85,10 @@ export const ChapterContent = ({ const [bookmarked, setBookmarked] = useState( chapter.bookmark ?? false, ); + const nonZeroBottom = useRef(bottom); + if (nonZeroBottom.current !== bottom && bottom !== 0) { + nonZeroBottom.current = bottom; + } useEffect(() => { setBookmarked(chapter.bookmark ?? false); @@ -188,6 +192,7 @@ export const ChapterContent = ({ diff --git a/src/screens/reader/components/Hooks/useReaderMessageHandler.ts b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts new file mode 100644 index 0000000000..5b719ccd78 --- /dev/null +++ b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts @@ -0,0 +1,163 @@ +import * as ScreenOrientation from 'expo-screen-orientation'; +import { useCallback, useRef } from 'react'; + +import type { NativeFindResult } from '../../hooks/useNativeChapterSearch'; +import type useTTS from './useTTS'; +import { parseWebViewEvent } from './webViewEvents'; + +type TTSController = ReturnType; + +type UseReaderMessageHandlerOptions = { + onPress: () => void; + onFindResult: (result: NativeFindResult) => void; + navigateChapter: (direction: 'NEXT' | 'PREV') => void; + saveProgress: (progress: number) => void; + resetAutoScroll: () => void; + refetch: () => void; + tts: TTSController; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export default function useReaderMessageHandler({ + onPress, + onFindResult, + navigateChapter, + saveProgress, + resetAutoScroll, + refetch, + tts, +}: UseReaderMessageHandlerOptions) { + const nextChapterScreenVisible = useRef(false); + const pendingScrollPosition = useRef<'start' | 'end' | null>(null); + const getNextChapterScreenVisible = useCallback( + () => nextChapterScreenVisible.current, + [], + ); + const getPendingScrollPosition = useCallback( + () => pendingScrollPosition.current, + [], + ); + const clearPendingScrollPosition = useCallback(() => { + pendingScrollPosition.current = null; + }, []); + + const handleMessage = useCallback( + (payload: string) => { + const event = parseWebViewEvent(payload); + if (!event) return; + + switch (event.type) { + case 'user-interaction': + resetAutoScroll(); + break; + case 'tts-queue': + tts.handleQueue(event); + break; + case 'hide': + onPress(); + break; + case 'next': + nextChapterScreenVisible.current = true; + if (event.initialScrollPosition) { + pendingScrollPosition.current = event.initialScrollPosition; + } + if (event.autoStartTTS) { + tts.scheduleAutoStart(); + } + navigateChapter('NEXT'); + break; + case 'prev': + if (event.initialScrollPosition) { + pendingScrollPosition.current = event.initialScrollPosition; + } + if (event.autoStartTTS) { + tts.scheduleAutoStart(); + } + navigateChapter('PREV'); + break; + case 'save': + if (typeof event.data === 'number') { + saveProgress(event.data); + } + break; + case 'find-result': + if ( + isRecord(event.data) && + typeof event.data.query === 'string' && + typeof event.data.activeMatchOrdinal === 'number' && + typeof event.data.numberOfMatches === 'number' && + typeof event.data.isDoneCounting === 'boolean' + ) { + onFindResult({ + query: event.data.query, + current: + event.data.numberOfMatches > 0 + ? event.data.activeMatchOrdinal + 1 + : 0, + total: event.data.numberOfMatches, + isDoneCounting: event.data.isDoneCounting, + }); + } + break; + case 'speak': + tts.handleSpeak(event); + break; + case 'pause-speak': + tts.handlePause(); + break; + case 'stop-speak': + tts.handleStop(); + break; + case 'tts-state': + tts.handleState(event); + break; + case 'refetch': + refetch(); + break; + case 'video-fullscreen-enter': + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE, + ).catch(() => {}); + break; + case 'video-fullscreen-exit': + ScreenOrientation.unlockAsync().catch(() => {}); + break; + case 'console': { + const method = ['debug', 'error', 'info', 'log', 'warn'].includes( + event.method ?? '', + ) + ? (event.method as 'debug' | 'error' | 'info' | 'log' | 'warn') + : 'log'; + // eslint-disable-next-line no-console + console[method]('[WebView]', ...(event.args ?? [])); + break; + } + case 'error': + // eslint-disable-next-line no-console + console.error('[WebView Error]', event.msg); + break; + default: + // eslint-disable-next-line no-console + console.warn(`Unknown event: ${event.type}`, event); + } + }, + [ + navigateChapter, + onFindResult, + onPress, + refetch, + resetAutoScroll, + saveProgress, + tts, + ], + ); + + return { + clearPendingScrollPosition, + getNextChapterScreenVisible, + getPendingScrollPosition, + handleMessage, + }; +} diff --git a/src/screens/reader/components/Hooks/useReaderSettings.ts b/src/screens/reader/components/Hooks/useReaderSettings.ts new file mode 100644 index 0000000000..a98d805cf8 --- /dev/null +++ b/src/screens/reader/components/Hooks/useReaderSettings.ts @@ -0,0 +1,77 @@ +import type { + ChapterGeneralSettings, + ChapterReaderSettings, +} from '@hooks/persisted/useSettings'; +import { useEffect, useRef } from 'react'; +import { NativeEventEmitter, NativeModules } from 'react-native'; +import type WebView from 'react-native-webview'; + +const { RNDeviceInfo } = NativeModules; +const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); + +type UseReaderSettingsBridgeOptions = { + webViewRef: React.RefObject; + bottomInset: number; + chapterGeneralSettings: ChapterGeneralSettings; + readerSettings: ChapterReaderSettings; + stopNativePlayback: () => void; +}; + +export const useReaderSettingsBridge = ({ + webViewRef, + bottomInset, + chapterGeneralSettings, + readerSettings, + stopNativePlayback, +}: UseReaderSettingsBridgeOptions) => { + const readerSettingsMountedRef = useRef(false); + const generalSettingsMountedRef = useRef(false); + + useEffect(() => { + if (!readerSettingsMountedRef.current) { + readerSettingsMountedRef.current = true; + return; + } + stopNativePlayback(); + webViewRef.current?.injectJavaScript(` + if (window.reader?.readerSettings) { + reader.readerSettings.val = ${JSON.stringify(readerSettings)}; + if (window.tts && tts.reading) { + const currentElement = tts.currentElement; + tts.stop(); + setTimeout(() => tts.start(currentElement), 100); + } + }`); + }, [readerSettings, stopNativePlayback, webViewRef]); + + useEffect(() => { + if (!generalSettingsMountedRef.current) { + generalSettingsMountedRef.current = true; + return; + } + webViewRef.current?.injectJavaScript(` + if (window.reader?.generalSettings) { + reader.generalSettings.val = ${JSON.stringify(chapterGeneralSettings)}; + document.documentElement.style.setProperty( + '--reader-bottomInset', + '${chapterGeneralSettings.fullScreenMode ? 0 : bottomInset}px' + ); + }`); + }, [bottomInset, chapterGeneralSettings, webViewRef]); + + useEffect(() => { + const batterySubscription = deviceInfoEmitter.addListener( + 'RNDeviceInfo_batteryLevelDidChange', + (level: number) => { + webViewRef.current?.injectJavaScript(` + if (window.reader?.batteryLevel) { + reader.batteryLevel.val = ${level}; + }`); + }, + ); + + return () => { + batterySubscription.remove(); + }; + }, [webViewRef]); +}; diff --git a/src/screens/reader/components/Hooks/useReadingTime.ts b/src/screens/reader/components/Hooks/useReadingTime.ts new file mode 100644 index 0000000000..8ec7ed2541 --- /dev/null +++ b/src/screens/reader/components/Hooks/useReadingTime.ts @@ -0,0 +1,77 @@ +import { addReadDuration } from '@database/queries/ChapterQueries'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +export default function useReadingTime(chapterId: number) { + const isTTSReadingRef = useRef(false); + const readStartTimeRef = useRef(null); + const accumulatedReadTimeRef = useRef(0); + const chapterIdRef = useRef(chapterId); + + const start = useCallback(() => { + if (readStartTimeRef.current === null && !isTTSReadingRef.current) { + readStartTimeRef.current = Date.now(); + } + }, []); + + const pause = useCallback(() => { + if (readStartTimeRef.current !== null) { + accumulatedReadTimeRef.current += Math.floor( + (Date.now() - readStartTimeRef.current) / 1000, + ); + readStartTimeRef.current = null; + } + }, []); + + const save = useCallback( + (id: number) => { + pause(); + const totalSeconds = accumulatedReadTimeRef.current; + if (totalSeconds > 0) { + addReadDuration(id, totalSeconds).catch(() => {}); + accumulatedReadTimeRef.current = 0; + } + }, + [pause], + ); + + const setTTSReading = useCallback( + (isReading: boolean) => { + const wasReading = isTTSReadingRef.current; + isTTSReadingRef.current = isReading; + if (isReading && !wasReading) { + pause(); + } else if (!isReading && wasReading) { + start(); + } + }, + [pause, start], + ); + + const stopTTSForBackground = useCallback(() => { + isTTSReadingRef.current = false; + }, []); + + useEffect(() => { + start(); + return () => save(chapterIdRef.current); + }, [save, start]); + + useEffect(() => { + if (chapterIdRef.current !== chapterId) { + save(chapterIdRef.current); + chapterIdRef.current = chapterId; + start(); + } + }, [chapterId, save, start]); + + return useMemo( + () => ({ + isTTSReadingRef, + start, + pause, + setTTSReading, + stopTTSForBackground, + }), + [pause, setTTSReading, start, stopTTSForBackground], + ); +} diff --git a/src/screens/reader/components/Hooks/useTTS.ts b/src/screens/reader/components/Hooks/useTTS.ts new file mode 100644 index 0000000000..7c1535044b --- /dev/null +++ b/src/screens/reader/components/Hooks/useTTS.ts @@ -0,0 +1,381 @@ +import type { ChapterInfo, NovelInfo } from '@database/types'; +import type { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { showToast } from '@utils/showToast'; +import { + dismissTTSNotification, + showTTSNotification, + ttsMediaEmitter, + updateTTSNotification, + updateTTSPlaybackState, + updateTTSProgress, +} from '@utils/ttsNotification'; +import * as Speech from 'expo-speech'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { AppState, NativeEventEmitter, NativeModules } from 'react-native'; +import type WebView from 'react-native-webview'; + +import type useReadingTime from './useReadingTime'; +import type { WebViewPostEvent } from './webViewEvents'; + +const { TikTokTTS } = NativeModules; +const tiktokTTSEmitter = TikTokTTS ? new NativeEventEmitter(TikTokTTS) : null; +const stopNativeEngines = () => { + Speech.stop(); + TikTokTTS?.stop(); +}; + +type ReadingTimeController = ReturnType; + +type UseTTSOptions = { + webViewRef: React.RefObject; + novel: NovelInfo; + chapter: ChapterInfo; + readerSettingsRef: React.RefObject; + readingTime: ReadingTimeController; +}; + +export default function useTTS({ + webViewRef, + novel, + chapter, + readerSettingsRef, + readingTime, +}: UseTTSOptions) { + const autoStartTTSRef = useRef(false); + const ttsQueueRef = useRef([]); + const ttsQueueIndexRef = useRef(0); + const appStateRef = useRef(AppState.currentState); + const autoStartTimerRef = useRef | null>(null); + const { isTTSReadingRef } = readingTime; + const notificationInfo = useMemo( + () => ({ + novelName: novel.name || 'Unknown', + chapterName: chapter.name, + coverUri: novel.cover || '', + isPlaying: true, + }), + [chapter.name, novel.cover, novel.name], + ); + const clearAutoStartTimer = useCallback(() => { + if (autoStartTimerRef.current) { + clearTimeout(autoStartTimerRef.current); + autoStartTimerRef.current = null; + } + }, []); + const resetAfterPlaybackError = useCallback(() => { + autoStartTTSRef.current = false; + clearAutoStartTimer(); + ttsQueueRef.current = []; + ttsQueueIndexRef.current = 0; + if (appStateRef.current === 'active') { + readingTime.setTTSReading(false); + } else { + readingTime.stopTTSForBackground(); + } + dismissTTSNotification(); + webViewRef.current?.injectJavaScript('tts.stop?.()'); + }, [clearAutoStartTimer, readingTime, webViewRef]); + + useEffect(() => { + const inject = (script: string) => + webViewRef.current?.injectJavaScript(script); + const listeners = [ + ttsMediaEmitter.addListener('TTSPlay', () => + inject('if (window.tts && !tts.reading) { tts.resume(); }'), + ), + ttsMediaEmitter.addListener('TTSPause', () => + inject('if (window.tts && tts.reading) { tts.pause(); }'), + ), + ttsMediaEmitter.addListener('TTSStop', () => + inject('if (window.tts) { tts.stop(); }'), + ), + ttsMediaEmitter.addListener('TTSRewind', () => + inject('if (window.tts && tts.started) { tts.rewind(); }'), + ), + ttsMediaEmitter.addListener('TTSPrev', () => + inject(` + if (window.tts && window.reader && window.reader.prevChapter) { + window.reader.post({ type: 'prev', autoStartTTS: true }); + }`), + ), + ttsMediaEmitter.addListener('TTSNext', () => + inject(` + if (window.tts && window.reader && window.reader.nextChapter) { + window.reader.post({ type: 'next', autoStartTTS: true }); + }`), + ), + ttsMediaEmitter.addListener( + 'TTSSeekTo', + ({ position }: { position: number }) => { + if (Number.isFinite(position)) { + inject( + `if (window.tts && tts.started) { tts.seekTo(${position}); }`, + ); + } + }, + ), + ]; + return () => listeners.forEach(listener => listener.remove()); + }, [webViewRef]); + + useEffect(() => { + if (isTTSReadingRef.current) { + updateTTSNotification(notificationInfo); + } + }, [isTTSReadingRef, notificationInfo]); + + useEffect( + () => () => { + clearAutoStartTimer(); + dismissTTSNotification(); + }, + [clearAutoStartTimer], + ); + + useEffect(() => { + const subscription = AppState.addEventListener('change', nextState => { + appStateRef.current = nextState; + if (nextState === 'active') { + readingTime.start(); + return; + } + + readingTime.pause(); + if (isTTSReadingRef.current) { + stopNativeEngines(); + readingTime.stopTTSForBackground(); + ttsQueueRef.current = []; + ttsQueueIndexRef.current = 0; + dismissTTSNotification(); + webViewRef.current?.injectJavaScript('if (window.tts) { tts.stop(); }'); + } + }); + return () => subscription.remove(); + }, [isTTSReadingRef, readingTime, webViewRef]); + + useEffect(() => { + if (!tiktokTTSEmitter) return; + + const onStart = tiktokTTSEmitter.addListener('TikTokTTS_onStart', () => { + webViewRef.current?.injectJavaScript('tts.setLoading(true)'); + }); + const onDone = tiktokTTSEmitter.addListener('TikTokTTS_onDone', () => { + webViewRef.current?.injectJavaScript('tts.setLoading(false)'); + if (appStateRef.current === 'active') { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + }); + const onError = tiktokTTSEmitter.addListener( + 'TikTokTTS_onError', + (error: { message?: string }) => { + webViewRef.current?.injectJavaScript('tts.setLoading(false)'); + resetAfterPlaybackError(); + // eslint-disable-next-line no-console + console.error('TikTokTTS Error:', error.message); + }, + ); + return () => { + onStart.remove(); + onDone.remove(); + onError.remove(); + }; + }, [resetAfterPlaybackError, webViewRef]); + + const speakText = useCallback( + (text: string) => { + const ttsSettings = readerSettingsRef.current?.tts; + if (ttsSettings?.engine === 'tiktok') { + const voice = ttsSettings.voice?.identifier; + if (!voice) { + showToast('TikTok TTS: No voice selected'); + return; + } + TikTokTTS?.speak( + text, + voice, + ttsSettings.queueSize || 3, + ttsSettings.rate || 1, + ttsSettings.pitch || 1, + ); + return; + } + + Speech.speak(text, { + onDone() { + if (appStateRef.current === 'active') { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + }, + onError: resetAfterPlaybackError, + voice: ttsSettings?.voice?.identifier, + pitch: ttsSettings?.pitch || 1, + rate: ttsSettings?.rate || 1, + }); + }, + [readerSettingsRef, resetAfterPlaybackError, webViewRef], + ); + + const handleQueue = useCallback( + (event: WebViewPostEvent) => { + const payload = event.data as + | { queue?: unknown; startIndex?: unknown } + | undefined; + const queue = Array.isArray(payload?.queue) + ? payload.queue.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) + : []; + ttsQueueRef.current = queue; + ttsQueueIndexRef.current = + typeof payload?.startIndex === 'number' ? payload.startIndex : 0; + + if (readerSettingsRef.current?.tts?.engine === 'tiktok') { + const voice = readerSettingsRef.current.tts.voice?.identifier; + if (voice) { + TikTokTTS?.updateQueue(queue.slice(ttsQueueIndexRef.current), voice); + } + } + }, + [readerSettingsRef], + ); + + const handleSpeak = useCallback( + (event: WebViewPostEvent) => { + if (typeof event.data !== 'string' || !event.data) { + webViewRef.current?.injectJavaScript('tts.next?.()'); + return; + } + + const ttsSettings = readerSettingsRef.current?.tts; + if (ttsSettings?.engine === 'tiktok') { + if (!TikTokTTS) { + showToast('TikTok TTS is unavailable'); + resetAfterPlaybackError(); + return; + } + if (!ttsSettings.voice?.identifier) { + showToast('TikTok TTS: No voice selected'); + resetAfterPlaybackError(); + return; + } + } + + if (typeof event.index === 'number') { + ttsQueueIndexRef.current = event.index; + } + if (!isTTSReadingRef.current) { + readingTime.setTTSReading(true); + showTTSNotification(notificationInfo); + } else { + updateTTSNotification(notificationInfo); + } + if ( + typeof event.index === 'number' && + typeof event.total === 'number' && + event.total > 0 + ) { + updateTTSProgress(event.index, event.total); + } + if (readerSettingsRef.current?.tts?.engine === 'tiktok') { + const voice = readerSettingsRef.current.tts.voice?.identifier; + if (voice) { + TikTokTTS?.updateQueue( + ttsQueueRef.current.slice(ttsQueueIndexRef.current + 1), + voice, + ); + } + } + speakText(event.data); + }, + [ + isTTSReadingRef, + notificationInfo, + readerSettingsRef, + readingTime, + resetAfterPlaybackError, + speakText, + webViewRef, + ], + ); + + const handlePause = useCallback(() => { + Speech.stop(); + TikTokTTS?.pause(); + }, []); + + const handleStop = useCallback(() => { + stopNativeEngines(); + if (!autoStartTTSRef.current) { + clearAutoStartTimer(); + readingTime.setTTSReading(false); + ttsQueueRef.current = []; + ttsQueueIndexRef.current = 0; + dismissTTSNotification(); + } + }, [clearAutoStartTimer, readingTime]); + + const handleState = useCallback( + (event: WebViewPostEvent) => { + if (typeof event.data !== 'object' || event.data === null) return; + const isReading = + (event.data as { isReading?: boolean }).isReading === true; + readingTime.setTTSReading(isReading); + updateTTSPlaybackState(isReading); + }, + [readingTime], + ); + + const stopNativePlayback = useCallback(() => { + stopNativeEngines(); + }, []); + + const scheduleAutoStart = useCallback(() => { + clearAutoStartTimer(); + autoStartTTSRef.current = true; + }, [clearAutoStartTimer]); + + const handleLoadEnd = useCallback(() => { + if (!autoStartTTSRef.current) return; + autoStartTTSRef.current = false; + clearAutoStartTimer(); + autoStartTimerRef.current = setTimeout(() => { + autoStartTimerRef.current = null; + webViewRef.current?.injectJavaScript(` + (function() { + if (window.tts && reader.generalSettings.val.TTSEnable) { + setTimeout(() => { + tts.start(); + const controller = document.getElementById('TTS-Controller'); + if (controller && controller.firstElementChild) { + controller.firstElementChild.innerHTML = pauseIcon; + } + }, 500); + } + })();`); + }, 300); + }, [clearAutoStartTimer, webViewRef]); + + return useMemo( + () => ({ + handleLoadEnd, + handlePause, + handleQueue, + handleSpeak, + handleState, + handleStop, + scheduleAutoStart, + stopNativePlayback, + }), + [ + handleLoadEnd, + handlePause, + handleQueue, + handleSpeak, + handleState, + handleStop, + scheduleAutoStart, + stopNativePlayback, + ], + ); +} diff --git a/src/screens/reader/components/Hooks/webViewEvents.ts b/src/screens/reader/components/Hooks/webViewEvents.ts new file mode 100644 index 0000000000..3b2e700649 --- /dev/null +++ b/src/screens/reader/components/Hooks/webViewEvents.ts @@ -0,0 +1,46 @@ +export type WebViewPostEvent = { + type: string; + data?: unknown; + autoStartTTS?: boolean; + index?: number; + total?: number; + initialScrollPosition?: 'start' | 'end'; + method?: string; + args?: unknown[]; + msg?: string; +}; + +export const parseWebViewEvent = (payload: string): WebViewPostEvent | null => { + try { + const event: unknown = JSON.parse(payload); + if ( + typeof event === 'object' && + event !== null && + 'type' in event && + typeof event.type === 'string' + ) { + const value = event as Record; + return { + type: event.type, + data: value.data, + autoStartTTS: + typeof value.autoStartTTS === 'boolean' + ? value.autoStartTTS + : undefined, + index: typeof value.index === 'number' ? value.index : undefined, + total: typeof value.total === 'number' ? value.total : undefined, + initialScrollPosition: + value.initialScrollPosition === 'start' || + value.initialScrollPosition === 'end' + ? value.initialScrollPosition + : undefined, + method: typeof value.method === 'string' ? value.method : undefined, + args: Array.isArray(value.args) ? value.args : undefined, + msg: typeof value.msg === 'string' ? value.msg : undefined, + }; + } + } catch { + // Ignore messages that are not part of the reader bridge protocol. + } + return null; +}; diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 8f219b6d9f..69e51952eb 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -1,69 +1,36 @@ -import { addReadDuration } from '@database/queries/ChapterQueries'; -import { useTheme } from '@hooks/persisted'; import { - CHAPTER_GENERAL_SETTINGS, - CHAPTER_READER_SETTINGS, + useChapterGeneralSettings, + useChapterReaderSettings, + useTheme, +} from '@hooks/persisted'; +import type { ChapterGeneralSettings, ChapterReaderSettings, - initialChapterGeneralSettings, - initialChapterReaderSettings, } from '@hooks/persisted/useSettings'; import { getUserAgent } from '@hooks/persisted/useUserAgent'; import { getLocalServerUrl } from '@plugins/local/localServerManager'; import { getPlugin } from '@plugins/pluginManager'; import { getString } from '@strings/translations'; -import { getMMKVObject, MMKVStorage } from '@utils/mmkv/mmkv'; -import { showToast } from '@utils/showToast'; import { PLUGIN_STORAGE } from '@utils/Storages'; -import { - dismissTTSNotification, - showTTSNotification, - ttsMediaEmitter, - updateTTSNotification, - updateTTSPlaybackState, - updateTTSProgress, -} from '@utils/ttsNotification'; import * as ScreenOrientation from 'expo-screen-orientation'; -import * as Speech from 'expo-speech'; -import React, { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { AppState, NativeEventEmitter, NativeModules } from 'react-native'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; import { getBatteryLevelSync } from 'react-native-device-info'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { useChapterContext } from '../ChapterContext'; import type { NativeFindResult } from '../hooks/useNativeChapterSearch'; import { generateReaderHtml } from '../utils/htmlGenerator'; - -type WebViewPostEvent = { - type: string; - data?: { [key: string]: unknown }; - autoStartTTS?: boolean; - index?: number; - total?: number; - initialScrollPosition?: 'start' | 'end'; - // console/error log payloads - method?: string; - args?: unknown[]; - msg?: string; -}; +import useReaderMessageHandler from './Hooks/useReaderMessageHandler'; +import { useReaderSettingsBridge } from './Hooks/useReaderSettings'; +import useReadingTime from './Hooks/useReadingTime'; +import useTTS from './Hooks/useTTS'; type WebViewReaderProps = { onPress(): void; onFindResult(result: NativeFindResult): void; + bottomInset: number; }; -const { RNDeviceInfo, TikTokTTS } = NativeModules; -const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); -const tiktokTTSEmitter = new NativeEventEmitter(TikTokTTS); - const assetsUriPrefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'; @@ -71,6 +38,7 @@ const assetsUriPrefix = __DEV__ const WebViewReader: React.FC = ({ onPress, onFindResult, + bottomInset, }) => { const { novel, @@ -85,379 +53,65 @@ const WebViewReader: React.FC = ({ refetch, } = useChapterContext(); const theme = useTheme(); - const { bottom } = useSafeAreaInsets(); - // Use state for settings so they update when MMKV changes - const [readerSettings, setReaderSettings] = useState( - () => - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, - ); - const chapterGeneralSettings = useMemo(() => { - // eslint-disable-next-line no-void - void chapter.id; // fix eslint warning: react-hooks/exhaustive-deps - return ( - getMMKVObject(CHAPTER_GENERAL_SETTINGS) || - initialChapterGeneralSettings - ); - }, [chapter.id]); - const readerBottomInset = chapterGeneralSettings.fullScreenMode ? 0 : bottom; - - // Update readerSettings when chapter changes - useEffect(() => { - setReaderSettings( - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, - ); - }, [chapter.id]); - - // Update battery level when chapter changes to ensure fresh value on navigation - const batteryLevel = useMemo(() => getBatteryLevelSync(), []); - const plugin = getPlugin(novel?.pluginId); - const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`; - const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`; - const nextChapterScreenVisible = useRef(false); - const pendingScrollPositionRef = useRef<'start' | 'end' | null>(null); - const autoStartTTSRef = useRef(false); - const isTTSReadingRef = useRef(false); + const readerSettings = useChapterReaderSettings(); + const chapterGeneralSettings = useChapterGeneralSettings(); const readerSettingsRef = useRef(readerSettings); - const appStateRef = useRef(AppState.currentState); - const ttsQueueRef = useRef([]); - const ttsQueueIndexRef = useRef(0); - - // --- Reading time tracking --- - const readStartTimeRef = useRef(null); - const accumulatedReadTimeRef = useRef(0); - const chapterIdForReadTimeRef = useRef(chapter.id); - - // Start reading timer - const startReadTimer = useCallback(() => { - if (!readStartTimeRef.current && !isTTSReadingRef.current) { - readStartTimeRef.current = Date.now(); - } - }, []); - - // Pause reading timer and accumulate - const pauseReadTimer = useCallback(() => { - if (readStartTimeRef.current) { - const elapsed = Math.floor( - (Date.now() - readStartTimeRef.current) / 1000, - ); - accumulatedReadTimeRef.current += elapsed; - readStartTimeRef.current = null; - } - }, []); - - // Save accumulated reading time to DB and reset - const saveReadTime = useCallback( - (chId: number) => { - pauseReadTimer(); - const totalSeconds = accumulatedReadTimeRef.current; - if (totalSeconds > 0) { - addReadDuration(chId, totalSeconds).catch(() => {}); - accumulatedReadTimeRef.current = 0; - } - }, - [pauseReadTimer], + const chapterGeneralSettingsRef = useRef( + chapterGeneralSettings, ); + const readingTime = useReadingTime(chapter.id); + const tts = useTTS({ + webViewRef, + novel, + chapter, + readerSettingsRef, + readingTime, + }); - // Start timer on mount - useEffect(() => { - startReadTimer(); - return () => { - // Save on unmount (leaving reader) - saveReadTime(chapterIdForReadTimeRef.current); - }; - }, [startReadTimer, saveReadTime]); - - // Track chapter changes — save read time for previous chapter - useEffect(() => { - if (chapterIdForReadTimeRef.current !== chapter.id) { - saveReadTime(chapterIdForReadTimeRef.current); - chapterIdForReadTimeRef.current = chapter.id; - startReadTimer(); - } - }, [chapter.id, saveReadTime, startReadTimer]); + useReaderSettingsBridge({ + webViewRef, + bottomInset, + chapterGeneralSettings, + readerSettings, + stopNativePlayback: tts.stopNativePlayback, + }); useEffect(() => { readerSettingsRef.current = readerSettings; - }, [readerSettings]); - - useEffect(() => { - const playListener = ttsMediaEmitter.addListener('TTSPlay', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && !tts.reading) { tts.resume(); } - `); - }); - const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.reading) { tts.pause(); } - `); - }); - const stopListener = ttsMediaEmitter.addListener('TTSStop', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts) { tts.stop(); } - `); - }); - const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.started) { tts.rewind(); } - `); - }); - const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && window.reader && window.reader.prevChapter) { - window.reader.post({ type: 'prev', autoStartTTS: true }); - } - `); - }); - const nextListener = ttsMediaEmitter.addListener('TTSNext', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && window.reader && window.reader.nextChapter) { - window.reader.post({ type: 'next', autoStartTTS: true }); - } - `); - }); - const seekToListener = ttsMediaEmitter.addListener( - 'TTSSeekTo', - (event: { position: number }) => { - const { position } = event; - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.started) { tts.seekTo(${position}); } - `); - }, - ); - return () => { - playListener.remove(); - pauseListener.remove(); - stopListener.remove(); - rewindListener.remove(); - prevListener.remove(); - nextListener.remove(); - seekToListener.remove(); - }; - }, [webViewRef]); - - useEffect(() => { - if (isTTSReadingRef.current) { - updateTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: isTTSReadingRef.current, - }); - } - }, [novel?.name, novel?.cover, chapter.name]); - - useEffect(() => { - return () => { - dismissTTSNotification(); - ScreenOrientation.unlockAsync(); - }; - }, []); - - useEffect(() => { - const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { - switch (key) { - case CHAPTER_READER_SETTINGS: - // Update local state with new settings - const newSettings = - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings; - setReaderSettings(newSettings); - - // Stop any currently playing speech - Speech.stop(); - TikTokTTS?.stop(); - - // Update WebView settings - webViewRef.current?.injectJavaScript( - ` - reader.readerSettings.val = ${MMKVStorage.getString( - CHAPTER_READER_SETTINGS, - )}; - // Auto-restart TTS if currently reading - if (window.tts && tts.reading) { - const currentElement = tts.currentElement; - const wasReading = tts.reading; - tts.stop(); - if (wasReading) { - setTimeout(() => { - tts.start(currentElement); - }, 100); - } - } - `, - ); - break; - case CHAPTER_GENERAL_SETTINGS: - const newGeneralSettings = - getMMKVObject(CHAPTER_GENERAL_SETTINGS) || - initialChapterGeneralSettings; - webViewRef.current?.injectJavaScript( - `reader.generalSettings.val = ${MMKVStorage.getString( - CHAPTER_GENERAL_SETTINGS, - )}; - document.documentElement.style.setProperty('--reader-bottomInset', '${ - newGeneralSettings.fullScreenMode ? 0 : bottom - }px');`, - ); - break; - } - }); - - const subscription = deviceInfoEmitter.addListener( - 'RNDeviceInfo_batteryLevelDidChange', - (level: number) => { - webViewRef.current?.injectJavaScript( - `reader.batteryLevel.val = ${level}`, - ); - }, - ); - return () => { - subscription.remove(); - mmkvListener.remove(); - }; - }, [bottom, webViewRef]); - - useEffect(() => { - const subscription = AppState.addEventListener('change', nextState => { - appStateRef.current = nextState; - if (nextState === 'active') { - // Resume reading timer (only if not TTS) - if (!isTTSReadingRef.current) { - startReadTimer(); - } - if (isTTSReadingRef.current) { - const index = ttsQueueIndexRef.current; - webViewRef.current?.injectJavaScript(` - if (window.tts && window.tts.allReadableElements) { - const idx = ${index}; - if (idx < tts.allReadableElements.length) { - if (tts.currentElement) { - tts.currentElement.classList.remove('highlight'); - } - tts.elementsRead = idx; - tts.currentElement = tts.allReadableElements[idx]; - tts.prevElement = null; - tts.started = true; - tts.reading = true; - tts.scrollToElement(tts.currentElement); - tts.currentElement.classList.add('highlight'); - } - } - `); - } - } else { - // Pause reading timer on background/inactive - pauseReadTimer(); - - // Stop TTS on background to prevent sync issues - if (isTTSReadingRef.current) { - Speech.stop(); - TikTokTTS?.stop(); - isTTSReadingRef.current = false; - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; - dismissTTSNotification(); - webViewRef.current?.injectJavaScript(` - if (window.tts) { tts.stop(); } - `); - } - } - }); - - return () => subscription.remove(); - }, [webViewRef, startReadTimer, pauseReadTimer]); + chapterGeneralSettingsRef.current = chapterGeneralSettings; + }, [chapterGeneralSettings, readerSettings]); - useEffect(() => { - if (!TikTokTTS) { - return; - } - - const onStart = tiktokTTSEmitter.addListener('TikTokTTS_onStart', () => { - webViewRef.current?.injectJavaScript('tts.setLoading(true)'); - }); - const onDone = tiktokTTSEmitter.addListener('TikTokTTS_onDone', () => { - webViewRef.current?.injectJavaScript('tts.setLoading(false)'); - const isBackground = - appStateRef.current === 'background' || - appStateRef.current === 'inactive'; - - /* - if ( - isBackground && - ttsQueueRef.current.length > 0 && - ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length - ) { - const nextIndex = ttsQueueIndexRef.current + 1; - const nextText = ttsQueueRef.current[nextIndex]; - if (nextText) { - ttsQueueIndexRef.current = nextIndex; - speakText(nextText); - return; - } - } - - if (isBackground) { - isTTSReadingRef.current = false; - dismissTTSNotification(); - webViewRef.current?.injectJavaScript('tts.stop?.()'); - return; - } - */ - - if (isBackground) { - return; - } - - webViewRef.current?.injectJavaScript('tts.next?.()'); - }); - const onError = tiktokTTSEmitter.addListener('TikTokTTS_onError', err => { - webViewRef.current?.injectJavaScript('tts.setLoading(false)'); - // eslint-disable-next-line no-console - console.error('TikTokTTS Error:', err.message); - }); - - return () => { - onStart.remove(); - onDone.remove(); - onError.remove(); - }; - }, [webViewRef]); - - const speakText = (text: string) => { - if (readerSettingsRef.current.tts?.engine === 'tiktok') { - const voice = readerSettingsRef.current.tts?.voice?.identifier; - if (!voice) { - // Voice must be selected for TikTok TTS - showToast('TikTok TTS: No voice selected'); - return; - } - const queueSize = readerSettingsRef.current.tts?.queueSize || 3; - const rate = readerSettingsRef.current.tts?.rate || 1; - const pitch = readerSettingsRef.current.tts?.pitch || 1; - TikTokTTS.speak(text, voice, queueSize, rate, pitch); - return; - } - Speech.speak(text, { - onDone() { - const isBackground = - appStateRef.current === 'background' || - appStateRef.current === 'inactive'; + useEffect( + () => () => { + ScreenOrientation.unlockAsync().catch(() => {}); + }, + [], + ); - if (isBackground) { - return; - } + const batteryLevel = useMemo(() => getBatteryLevelSync(), []); + const plugin = getPlugin(novel.pluginId); + const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`; + const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`; + const readerDir = + plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew' ? 'rtl' : 'ltr'; + const readerBottomInset = chapterGeneralSettingsRef.current.fullScreenMode + ? 0 + : bottomInset; - webViewRef.current?.injectJavaScript('tts.next?.()'); - }, - voice: readerSettingsRef.current.tts?.voice?.identifier, - pitch: readerSettingsRef.current.tts?.pitch || 1, - rate: readerSettingsRef.current.tts?.rate || 1, - }); - }; - const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew'; - const readerDir = isRTL ? 'rtl' : 'ltr'; + const { + clearPendingScrollPosition, + getNextChapterScreenVisible, + getPendingScrollPosition, + handleMessage, + } = useReaderMessageHandler({ + onPress, + onFindResult, + navigateChapter, + saveProgress, + resetAutoScroll, + refetch, + tts, + }); const source = useMemo( () => ({ @@ -474,7 +128,7 @@ const WebViewReader: React.FC = ({ theme, readerDir, readerSettings: readerSettingsRef.current, - chapterGeneralSettings, + chapterGeneralSettings: chapterGeneralSettingsRef.current, novel, chapter, nextChapter, @@ -484,8 +138,8 @@ const WebViewReader: React.FC = ({ readerBottomInset, pluginCustomCSS, pluginCustomJS, - nextChapterScreenVisible: nextChapterScreenVisible.current, - pendingScrollPosition: pendingScrollPositionRef.current, + nextChapterScreenVisible: getNextChapterScreenVisible(), + pendingScrollPosition: getPendingScrollPosition(), getLocalServerUrl, isSettingsPreview: false, strings: { @@ -500,20 +154,23 @@ const WebViewReader: React.FC = ({ }), }), [ - novel, + batteryLevel, chapter, + chapterGeneralSettingsRef, + getNextChapterScreenVisible, + getPendingScrollPosition, html, - theme, - readerDir, - plugin?.site, - plugin?.imageRequestInit, - readerBottomInset, - batteryLevel, - chapterGeneralSettings, nextChapter, - prevChapter, + novel, + plugin?.imageRequestInit, + plugin?.site, pluginCustomCSS, pluginCustomJS, + prevChapter, + readerBottomInset, + readerDir, + readerSettingsRef, + theme, ], ); @@ -521,233 +178,27 @@ const WebViewReader: React.FC = ({ { - // Update battery level when WebView finishes loading const currentBatteryLevel = getBatteryLevelSync(); - webViewRef.current?.injectJavaScript( - `if (window.reader && window.reader.batteryLevel) { + webViewRef.current?.injectJavaScript(` + if (window.reader && window.reader.batteryLevel) { window.reader.batteryLevel.val = ${currentBatteryLevel}; - }`, - ); - - if (pendingScrollPositionRef.current) { - pendingScrollPositionRef.current = null; // Reset after first load - } - - if (autoStartTTSRef.current) { - autoStartTTSRef.current = false; - setTimeout(() => { - webViewRef.current?.injectJavaScript(` - (function() { - if (window.tts && reader.generalSettings.val.TTSEnable) { - setTimeout(() => { - tts.start(); - const controller = document.getElementById('TTS-Controller'); - if (controller && controller.firstElementChild) { - controller.firstElementChild.innerHTML = pauseIcon; - } - }, 500); - } - })(); - `); - }, 300); - } - }} - onMessage={(ev: { nativeEvent: { data: string } }) => { - let event: WebViewPostEvent; - try { - event = JSON.parse(ev.nativeEvent.data); - } catch { - // Non-JSON / unparseable message - return; - } - switch (event.type) { - case 'user-interaction': - resetAutoScroll(); - break; - case 'tts-queue': { - const payload = event.data as - | { queue?: unknown; startIndex?: unknown } - | undefined; - const queue = Array.isArray(payload?.queue) - ? payload?.queue.filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ) - : []; - ttsQueueRef.current = queue; - if (typeof payload?.startIndex === 'number') { - ttsQueueIndexRef.current = payload.startIndex; - } else { - ttsQueueIndexRef.current = 0; - } - if (readerSettingsRef.current.tts?.engine === 'tiktok') { - const voice = readerSettingsRef.current.tts?.voice?.identifier; - if (voice) { - TikTokTTS?.updateQueue( - queue.slice(ttsQueueIndexRef.current), - voice, - ); - } - } - break; - } - case 'hide': - onPress(); - break; - case 'next': - nextChapterScreenVisible.current = true; - if (event.initialScrollPosition) { - pendingScrollPositionRef.current = event.initialScrollPosition; - } - if (event.autoStartTTS) { - autoStartTTSRef.current = true; - } - navigateChapter('NEXT'); - break; - case 'prev': - if (event.initialScrollPosition) { - pendingScrollPositionRef.current = event.initialScrollPosition; - } - if (event.autoStartTTS) { - autoStartTTSRef.current = true; - } - navigateChapter('PREV'); - break; - case 'save': - if (event.data && typeof event.data === 'number') { - saveProgress(event.data); - } - break; - case 'find-result': { - const { data } = event; - if ( - typeof data?.query === 'string' && - typeof data.activeMatchOrdinal === 'number' && - typeof data.numberOfMatches === 'number' && - typeof data.isDoneCounting === 'boolean' - ) { - onFindResult({ - query: data.query, - current: - data.numberOfMatches > 0 ? data.activeMatchOrdinal + 1 : 0, - total: data.numberOfMatches, - isDoneCounting: data.isDoneCounting, - }); - } - break; - } - case 'speak': - if (event.data && typeof event.data === 'string') { - if (typeof event.index === 'number') { - ttsQueueIndexRef.current = event.index; - } - if (!isTTSReadingRef.current) { - isTTSReadingRef.current = true; - pauseReadTimer(); // Stop counting reading time during TTS - showTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: true, - }); - } else { - updateTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: true, - }); - } - if ( - typeof event.index === 'number' && - typeof event.total === 'number' && - event.total > 0 - ) { - updateTTSProgress(event.index, event.total); - } - if (readerSettingsRef.current.tts?.engine === 'tiktok') { - const voice = readerSettingsRef.current.tts?.voice?.identifier; - if (voice) { - TikTokTTS?.updateQueue( - ttsQueueRef.current.slice(ttsQueueIndexRef.current + 1), - voice, - ); - } - } - speakText(event.data); - } else { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - break; - case 'pause-speak': - Speech.stop(); - TikTokTTS?.pause(); - break; - case 'stop-speak': - Speech.stop(); - TikTokTTS?.stop(); - if (!autoStartTTSRef.current) { - isTTSReadingRef.current = false; - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; - dismissTTSNotification(); - startReadTimer(); // Resume reading time tracking - } - break; - case 'tts-state': - if (event.data && typeof event.data === 'object') { - const data = event.data as { isReading?: boolean }; - const isReading = data.isReading === true; - const wasReading = isTTSReadingRef.current; - isTTSReadingRef.current = isReading; - updateTTSPlaybackState(isReading); - // Toggle reading timer based on TTS state - if (isReading && !wasReading) { - pauseReadTimer(); - } else if (!isReading && wasReading) { - startReadTimer(); - } - } - break; - case 'refetch': - refetch(); - break; - case 'video-fullscreen-enter': - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.LANDSCAPE, - ); - break; - case 'video-fullscreen-exit': - ScreenOrientation.unlockAsync(); - break; - case 'console': - // eslint-disable-next-line no-console - console[(event.method as 'log') ?? 'log']( - `[WebView]`, - ...(event.args ?? []), - ); - break; - case 'error': - // eslint-disable-next-line no-console - console.error(`[WebView Error]`, event.msg); - break; - default: { - // eslint-disable-next-line no-console - console.warn(`Unknown event: ${event.type}`, event); - break; - } + }`); + if (getPendingScrollPosition()) { + clearPendingScrollPosition(); } + tts.handleLoadEnd(); }} + onMessage={event => handleMessage(event.nativeEvent.data)} source={source} /> ); From aaa806b23d3d384c1dcf83ad904384337f2b6086 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:52:38 +0700 Subject: [PATCH 3/7] refactor(ui): modularize keyboard-aware form modals - add reusable KeyboardAwareModal primitive - support async confirmation, loading and validation - migrate common form modals to the new API - prevent duplicate submissions and redundant dismissals - remove unused TextInput and keyboard height hook - add modal and category form tests Co-authored-by: OpenAI Codex --- .../Modal/KeyboardAvoidingModal.tsx | 225 ++++++-------- src/components/Modal/KeyboardAwareModal.tsx | 162 ++++++++++ .../__tests__/KeyboardAvoidingModal.test.tsx | 233 +++++++++++++++ .../__tests__/KeyboardAwareModal.test.tsx | 122 ++++++++ .../Modal/useKeyboardModalDismiss.ts | 10 + src/components/TextInput/index.tsx | 71 ----- src/components/index.ts | 5 +- src/hooks/common/useKeyboardHeight.ts | 31 -- .../components/AddCategoryModal.tsx | 93 ++---- .../__tests__/AddCategoryModal.test.tsx | 186 ++++++++++++ .../novel/components/EditInfoModal.tsx | 281 +++++++++--------- .../Tracker/SetTrackChaptersDialog.tsx | 60 ++-- .../Tracker/SetTrackScoreDialog.tsx | 33 +- .../components/AddRepositoryModal.tsx | 62 ++-- .../settings/components/ConnectionModal.tsx | 74 ++--- 15 files changed, 1060 insertions(+), 588 deletions(-) create mode 100644 src/components/Modal/KeyboardAwareModal.tsx create mode 100644 src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx create mode 100644 src/components/Modal/__tests__/KeyboardAwareModal.test.tsx create mode 100644 src/components/Modal/useKeyboardModalDismiss.ts delete mode 100644 src/components/TextInput/index.tsx delete mode 100644 src/hooks/common/useKeyboardHeight.ts create mode 100644 src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx diff --git a/src/components/Modal/KeyboardAvoidingModal.tsx b/src/components/Modal/KeyboardAvoidingModal.tsx index e47e0a2e6b..728eadb5bf 100644 --- a/src/components/Modal/KeyboardAvoidingModal.tsx +++ b/src/components/Modal/KeyboardAvoidingModal.tsx @@ -1,159 +1,128 @@ import Button from '@components/Button/Button'; -import { useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; -import { ThemeColors } from '@theme/types'; -import React from 'react'; -import { - Keyboard, - ScrollView, - StyleSheet, - Text, - useWindowDimensions, - View, -} from 'react-native'; -import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; -import { Modal, ModalProps, overlay, Portal } from 'react-native-paper'; -import Animated, { - FadeIn, - FadeOut, - useAnimatedStyle, - withClamp, - withTiming, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; -const MODAL_MARGIN = 24; -const BORDER_RADIUS = 28; +import KeyboardAwareModal, { + KeyboardAwareModalProps, +} from './KeyboardAwareModal'; +import useKeyboardModalDismiss from './useKeyboardModalDismiss'; -const getModalTitleColor = (theme: ThemeColors) => ({ - color: theme.onSurface, -}); +type ConfirmResult = boolean | void; -export type DefaultModalProps = { - title: string; - onSave: () => boolean; - onDismiss: () => void; +export type KeyboardAvoidingModalProps = { + title: ReactNode; + onConfirm: () => ConfirmResult | Promise; onCancel?: () => void; onReset?: () => void; -} & Omit; + confirmLabel?: string; + cancelLabel?: string; + resetLabel?: string; + confirmDisabled?: boolean; + confirmLoading?: boolean; +} & Omit; -const KeyboardAvoidingModal: React.FC = ({ +const KeyboardAvoidingModal: React.FC = ({ visible, - onDismiss: _onDismiss, - onSave, + title, + onDismiss, + onConfirm, onCancel, onReset, - title, - children, + confirmLabel = getString('common.save'), + cancelLabel = getString('common.cancel'), + resetLabel = getString('common.reset'), + confirmDisabled = false, + confirmLoading = false, + dismissable, + dismissableBackButton, ...props }) => { - const theme = useTheme(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); - const keyboard = useAnimatedKeyboard(); + const [isConfirming, setIsConfirming] = useState(false); + const confirmingRef = useRef(false); + const confirmRunRef = useRef(0); + const busy = confirmLoading || isConfirming; + const dismiss = useKeyboardModalDismiss(onDismiss); - const onDismiss = () => { - Keyboard.dismiss(); - _onDismiss?.(); - }; + useEffect(() => { + if (!visible) { + confirmRunRef.current += 1; + confirmingRef.current = false; + setIsConfirming(false); + } + }, [visible]); - const dismiss = (cb?: () => void | boolean) => { - if (cb?.() === false) return; - onDismiss(); - }; + const handleConfirm = async () => { + if (confirmLoading || confirmingRef.current) return; - const default_availableHeight = windowHeight - insets.top; - const animatedContainerStyle = useAnimatedStyle(() => { - const kb = keyboard.height.value; + confirmingRef.current = true; + setIsConfirming(true); + const runId = ++confirmRunRef.current; + let dismissed = false; - const availableHeight = - default_availableHeight - Math.max(insets.bottom, kb); - return { - maxHeight: withClamp( - { min: 200 }, - withTiming(availableHeight, { duration: 0 }), - ), - marginBottom: kb, - }; - }, [insets.bottom, default_availableHeight]); + try { + const result = await onConfirm(); + if (runId !== confirmRunRef.current) return; - return ( - - - - - {title} - + if (result !== false) { + dismissed = true; + dismiss(); + } + } finally { + if (runId === confirmRunRef.current && !dismissed) { + confirmingRef.current = false; + setIsConfirming(false); + } + } + }; - - - {children} - - + const modalDismissable = (dismissable ?? true) && !busy; + const modalDismissableBackButton = + (dismissableBackButton ?? dismissable ?? true) && !busy; - - {onReset ? ( - - ) : null} + return ( + + {onReset ? ( + + ) : null} - + - - - - - - + + + + } + /> ); }; export default KeyboardAvoidingModal; const styles = StyleSheet.create({ - modalWrapper: { - justifyContent: 'center', - paddingHorizontal: MODAL_MARGIN, - }, - modalContainer: { - borderRadius: BORDER_RADIUS, - shadowColor: 'transparent', - }, - modalTitle: { - fontSize: 24, - lineHeight: 24, - padding: 24, - }, - body: { - flexShrink: 1, - minHeight: 0, - }, - content: { - paddingBottom: 16, - paddingHorizontal: 24, - }, buttonRow: { alignItems: 'center', flexDirection: 'row', diff --git a/src/components/Modal/KeyboardAwareModal.tsx b/src/components/Modal/KeyboardAwareModal.tsx new file mode 100644 index 0000000000..f877d64b49 --- /dev/null +++ b/src/components/Modal/KeyboardAwareModal.tsx @@ -0,0 +1,162 @@ +import { useTheme } from '@hooks/persisted'; +import { ThemeColors } from '@theme/types'; +import React, { ReactNode } from 'react'; +import { + ScrollView, + ScrollViewProps, + StyleProp, + StyleSheet, + Text, + useWindowDimensions, + View, + ViewStyle, +} from 'react-native'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; +import { Modal, ModalProps, overlay, Portal } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import useKeyboardModalDismiss from './useKeyboardModalDismiss'; + +const MODAL_MARGIN = 24; +const BORDER_RADIUS = 28; + +const getModalTitleColor = (theme: ThemeColors) => ({ + color: theme.onSurface, +}); + +export type KeyboardAwareModalProps = { + title?: ReactNode; + footer?: ReactNode; + children?: ReactNode; + onDismiss: () => void; + scrollable?: boolean; + containerStyle?: StyleProp; + contentContainerStyle?: StyleProp; + scrollViewProps?: Omit; +} & Omit< + ModalProps, + 'children' | 'contentContainerStyle' | 'onDismiss' | 'theme' +>; + +const KeyboardAwareModal: React.FC = ({ + visible, + onDismiss: onDismissProp, + title, + footer, + children, + scrollable = true, + containerStyle, + contentContainerStyle, + scrollViewProps, + style, + ...props +}) => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const keyboard = useAnimatedKeyboard(); + + const dismiss = useKeyboardModalDismiss(onDismissProp); + + const animatedContainerStyle = useAnimatedStyle(() => { + const keyboardHeight = Math.max(0, keyboard.height.value); + const availableHeight = + windowHeight - insets.top - Math.max(insets.bottom, keyboardHeight); + + return { + maxHeight: Math.max(0, availableHeight), + marginBottom: keyboardHeight, + }; + }, [insets.bottom, insets.top, windowHeight]); + + return ( + + + + {title == null ? null : ( + + {typeof title === 'string' || typeof title === 'number' ? ( + + {title} + + ) : ( + title + )} + + )} + + + {scrollable ? ( + + {children} + + ) : ( + + {children} + + )} + + + {footer} + + + + ); +}; + +export default KeyboardAwareModal; + +const styles = StyleSheet.create({ + modalWrapper: { + justifyContent: 'center', + paddingHorizontal: MODAL_MARGIN, + }, + modalContainer: { + borderRadius: BORDER_RADIUS, + shadowColor: 'transparent', + }, + titleContainer: { + padding: 24, + }, + modalTitle: { + fontSize: 24, + lineHeight: 24, + }, + body: { + flexShrink: 1, + minHeight: 0, + }, + content: { + paddingBottom: 16, + paddingHorizontal: 24, + }, +}); diff --git a/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx new file mode 100644 index 0000000000..076838b02d --- /dev/null +++ b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx @@ -0,0 +1,233 @@ +import { act, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import KeyboardAvoidingModal from '../KeyboardAvoidingModal'; + +const mockButtonPresses = new Map unknown>(); + +type MockButtonProps = { + children: React.ReactNode; + disabled?: boolean; + loading?: boolean; + onPress?: () => unknown; +}; + +type MockKeyboardAwareModalProps = { + children?: React.ReactNode; + dismissable?: boolean; + dismissableBackButton?: boolean; + footer?: React.ReactNode; + title?: React.ReactNode; +}; + +jest.mock('@strings/translations', () => ({ + getString: (key: string) => key, +})); + +jest.mock('@components/Button/Button', () => { + const ReactModule = jest.requireActual('react'); + const { Text, View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ({ children, disabled, loading, onPress }: MockButtonProps) => { + if (onPress) mockButtonPresses.set(String(children), onPress); + return ReactModule.createElement( + View, + { + accessibilityState: { busy: loading, disabled }, + testID: String(children), + }, + ReactModule.createElement(Text, null, children), + ); + }, + }; +}); + +jest.mock('../KeyboardAwareModal', () => { + const ReactModule = jest.requireActual('react'); + const { Text, View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ({ + children, + dismissable, + dismissableBackButton, + footer, + title, + }: MockKeyboardAwareModalProps) => + ReactModule.createElement( + View, + { + accessibilityState: { disabled: !dismissable }, + accessibilityValue: { text: String(dismissableBackButton) }, + testID: 'keyboard-aware-modal', + }, + ReactModule.createElement(Text, null, title), + children, + footer, + ), + }; +}); + +const confirmButton = () => screen.getByTestId('common.save'); +const pressButton = (label: string) => mockButtonPresses.get(label)?.(); + +describe('KeyboardAvoidingModal', () => { + beforeEach(() => { + mockButtonPresses.clear(); + }); + + it('dismisses exactly once after a successful confirm', async () => { + const onConfirm = jest.fn(() => true); + const onDismiss = jest.fn(); + + render( + , + ); + + await act(async () => { + await pressButton('common.save'); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('keeps the modal open when confirm returns false', async () => { + const onDismiss = jest.fn(); + + render( + false} + onDismiss={onDismiss} + />, + ); + + await act(async () => { + await pressButton('common.save'); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(confirmButton().props.accessibilityState).toMatchObject({ + busy: false, + disabled: false, + }); + }); + + it('locks every dismiss action and prevents duplicate async confirms', async () => { + let resolveConfirm: (() => void) | undefined; + const onConfirm = jest.fn( + () => + new Promise(resolve => { + resolveConfirm = resolve; + }), + ); + const onDismiss = jest.fn(); + + render( + , + ); + + let firstConfirm: Promise | undefined; + await act(async () => { + firstConfirm = pressButton('common.save') as Promise; + pressButton('common.save'); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(confirmButton().props.accessibilityState).toMatchObject({ + busy: true, + disabled: true, + }); + expect( + screen.getByTestId('common.cancel').props.accessibilityState.disabled, + ).toBe(true); + expect( + screen.getByTestId('common.reset').props.accessibilityState.disabled, + ).toBe(true); + expect( + screen.getByTestId('keyboard-aware-modal').props.accessibilityState + .disabled, + ).toBe(true); + expect( + screen.getByTestId('keyboard-aware-modal').props.accessibilityValue.text, + ).toBe('false'); + + await act(async () => { + resolveConfirm?.(); + await firstConfirm; + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('restores its loading state and propagates confirm errors', async () => { + const error = new Error('confirm failed'); + const onDismiss = jest.fn(); + + render( + Promise.reject(error)} + onDismiss={onDismiss} + />, + ); + + let caughtError: unknown; + await act(async () => { + try { + await pressButton('common.save'); + } catch (caught) { + caughtError = caught; + } + }); + + expect(caughtError).toBe(error); + expect(onDismiss).not.toHaveBeenCalled(); + expect(confirmButton().props.accessibilityState).toMatchObject({ + busy: false, + disabled: false, + }); + }); + + it('runs reset without dismissing and cancel before one dismiss', () => { + const onReset = jest.fn(); + const onCancel = jest.fn(); + const onDismiss = jest.fn(); + + render( + , + ); + + act(() => pressButton('common.reset')); + expect(onReset).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => pressButton('common.cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx new file mode 100644 index 0000000000..e038708772 --- /dev/null +++ b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { Keyboard, StyleProp, StyleSheet, Text, ViewStyle } from 'react-native'; + +import KeyboardAwareModal from '../KeyboardAwareModal'; + +type MockModalProps = { + children?: React.ReactNode; + onDismiss: () => void; + style?: StyleProp; +}; + +type MockPortalProps = { + children?: React.ReactNode; +}; + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => ({ surface: '#ffffff', onSurface: '#111111' }), +})); + +jest.mock('react-native-keyboard-controller', () => ({ + useAnimatedKeyboard: () => ({ + height: { value: 0 }, + state: { value: 0 }, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, right: 0, bottom: 16, left: 0 }), +})); + +jest.mock('react-native-paper', () => { + const ReactModule = jest.requireActual('react'); + const { Pressable, View } = jest.requireActual('react-native'); + + return { + Modal: ({ children, onDismiss, style }: MockModalProps) => + ReactModule.createElement( + View, + { testID: 'paper-modal', style }, + children, + ReactModule.createElement(Pressable, { + testID: 'dismiss-modal', + onPress: onDismiss, + }), + ), + Portal: ({ children }: MockPortalProps) => children, + overlay: (_level: number, color: string) => color, + }; +}); + +describe('KeyboardAwareModal', () => { + beforeEach(() => { + jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => undefined); + }); + + it('renders its regions, merges wrapper styles and centralizes dismiss', () => { + const onDismiss = jest.fn(); + + render( + Modal footer} + onDismiss={onDismiss} + style={styles.transparent} + > + Modal body + , + ); + + expect(screen.getByText('Modal title')).toBeTruthy(); + expect(screen.getByText('Modal body')).toBeTruthy(); + expect(screen.getByText('Modal footer')).toBeTruthy(); + expect( + StyleSheet.flatten(screen.getByTestId('paper-modal').props.style), + ).toMatchObject({ + justifyContent: 'center', + opacity: 0.5, + paddingHorizontal: 24, + }); + + fireEvent.press(screen.getByTestId('dismiss-modal')); + + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('supports scrollable and non-scrollable content', () => { + const view = render( + + Scrollable body + , + ); + + expect(screen.getByTestId('modal-scroll-view')).toBeTruthy(); + + view.rerender( + + Static body + , + ); + + expect(screen.queryByTestId('modal-scroll-view')).toBeNull(); + expect(screen.getByText('Static body')).toBeTruthy(); + }); +}); + +const styles = StyleSheet.create({ + transparent: { + opacity: 0.5, + }, +}); diff --git a/src/components/Modal/useKeyboardModalDismiss.ts b/src/components/Modal/useKeyboardModalDismiss.ts new file mode 100644 index 0000000000..d2edf9e2a6 --- /dev/null +++ b/src/components/Modal/useKeyboardModalDismiss.ts @@ -0,0 +1,10 @@ +import { useCallback } from 'react'; +import { Keyboard } from 'react-native'; + +const useKeyboardModalDismiss = (onDismiss: () => void) => + useCallback(() => { + Keyboard.dismiss(); + onDismiss(); + }, [onDismiss]); + +export default useKeyboardModalDismiss; diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx deleted file mode 100644 index 1cfe158ad3..0000000000 --- a/src/components/TextInput/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useTheme } from '@hooks/persisted'; -import React, { useState } from 'react'; -import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native'; -import { TextInput as RNTextInput } from 'react-native-gesture-handler'; - -interface TextInputProps extends RNTextInputProps { - error?: boolean; - value?: never; - forceFocused?: boolean; -} - -const TextInput = ({ - onBlur, - onFocus, - error, - forceFocused, - style, - ...props -}: TextInputProps) => { - const theme = useTheme(); - - const [inputFocused, setInputFocused] = useState(false); - - const _onFocus: RNTextInputProps['onFocus'] = e => { - setInputFocused(true); - onFocus?.(e); - }; - const _onBlur: RNTextInputProps['onBlur'] = e => { - setInputFocused(false); - onBlur?.(e); - }; - - const isFocused = forceFocused ?? inputFocused; - const borderWidth = isFocused || error ? 2 : 1; - const margin = isFocused || error ? 0 : 1; - return ( - - ); -}; - -export default TextInput; - -const styles = StyleSheet.create({ - textInput: { - borderRadius: 4, - borderStyle: 'solid', - fontSize: 16, - paddingHorizontal: 16, - paddingVertical: 10, - }, -}); diff --git a/src/components/index.ts b/src/components/index.ts index 43624978ba..5fcda1061a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,10 @@ export { default as LoadingScreenV2 } from './LoadingScreenV2/LoadingScreenV2'; export * from './LogViewer'; export { default as MarkdownText } from './MarkdownText/MarkdownText'; export { default as Menu } from './Menu'; +export type { KeyboardAvoidingModalProps } from './Modal/KeyboardAvoidingModal'; +export { default as KeyboardAvoidingModal } from './Modal/KeyboardAvoidingModal'; +export type { KeyboardAwareModalProps } from './Modal/KeyboardAwareModal'; +export { default as KeyboardAwareModal } from './Modal/KeyboardAwareModal'; export { default as Modal } from './Modal/Modal'; export { RadioButton } from './RadioButton/RadioButton'; export { default as SafeAreaView } from './SafeAreaView/SafeAreaView'; @@ -24,4 +28,3 @@ export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2'; export { SegmentedControl } from './SegmentedControl'; export { default as SwitchItem } from './Switch/SwitchItem'; export { default as TaskLogDialog } from './TaskLogDialog'; -export { default as TextInput } from './TextInput'; diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts deleted file mode 100644 index 3af2afc9c4..0000000000 --- a/src/hooks/common/useKeyboardHeight.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Keyboard, KeyboardEvent } from 'react-native'; - -export const useKeyboardHeight = () => { - const [keyboardHeight, setKeyboardHeight] = useState(0); - - useEffect(() => { - function onKeyboardDidShow(e: KeyboardEvent) { - setKeyboardHeight(e.endCoordinates.height); - } - - function onKeyboardDidHide() { - setKeyboardHeight(0); - } - - const showSubscription = Keyboard.addListener( - 'keyboardDidShow', - onKeyboardDidShow, - ); - const hideSubscription = Keyboard.addListener( - 'keyboardDidHide', - onKeyboardDidHide, - ); - return () => { - showSubscription.remove(); - hideSubscription.remove(); - }; - }, []); - - return keyboardHeight; -}; diff --git a/src/screens/Categories/components/AddCategoryModal.tsx b/src/screens/Categories/components/AddCategoryModal.tsx index 2cac2abb28..00d0f1c7e5 100644 --- a/src/screens/Categories/components/AddCategoryModal.tsx +++ b/src/screens/Categories/components/AddCategoryModal.tsx @@ -1,11 +1,9 @@ -import { Button, Modal } from '@components/index'; +import { KeyboardAvoidingModal } from '@components'; import { useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import { showToast } from '@utils/showToast'; import React, { useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; -import { Portal, TextInput } from 'react-native-paper'; +import { TextInput } from 'react-native-paper'; import { createCategory, @@ -36,64 +34,39 @@ const AddCategoryModal: React.FC = ({ setCategoryName(''); closeModal(); } - function finalize() { - onSuccess(); - close(); - } - return ( - - - - - {getString( - isEditMode - ? 'categories.editCategories' - : 'categories.addCategories', - )} - - - - - - - - + + + ); }; export default SetTrackChaptersDialog; - -const styles = StyleSheet.create({ - buttonContainer: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 8, - marginTop: 16, - }, -}); diff --git a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx index b2c874292b..c64a9ad3b1 100644 --- a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx +++ b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx @@ -1,8 +1,5 @@ -import { Button, DialogTitle, Modal } from '@components'; -import { getString } from '@strings/translations'; +import { KeyboardAvoidingModal } from '@components'; import React, { useEffect, useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { AniListScoreSelector, @@ -29,7 +26,6 @@ const SetTrackScoreDialog: React.FC = ({ const handleSave = () => { onUpdateScore(selectedScore); - onDismiss(); }; const ScoreSelector = useMemo(() => { @@ -68,26 +64,15 @@ const SetTrackScoreDialog: React.FC = ({ }, [tracker, trackItem, selectedScore]); return ( - - - - {ScoreSelector} - - - - - - + + {ScoreSelector} + ); }; export default SetTrackScoreDialog; - -const styles = StyleSheet.create({ - buttonContainer: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 8, - marginTop: 16, - }, -}); diff --git a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx index eb919f9ed2..92c77466ef 100644 --- a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx +++ b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx @@ -1,11 +1,9 @@ -import { Button, Modal } from '@components/index'; +import { KeyboardAvoidingModal } from '@components'; import { Repository } from '@database/types'; import { useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import React, { useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; -import { Portal, TextInput } from 'react-native-paper'; +import { TextInput } from 'react-native-paper'; interface AddRepositoryModalProps { repository?: Repository; @@ -24,46 +22,24 @@ const AddRepositoryModal: React.FC = ({ const [repositoryUrl, setRepositoryUrl] = useState(repository?.url || ''); return ( - - - - - {repository ? 'Edit repository' : 'Add repository'} - - - - - - - + + + text > 9 && setText(prevState => prevState - 10)} + /> + text > 0 && setText(prevState => prevState - 1)} + /> + + setText(prevState => prevState + 1)} + /> + setText(prevState => prevState + 10)} + /> + + ); }; export default DownloadCustomChapterModal; const styles = StyleSheet.create({ - errorText: { - color: '#FF0033', - paddingTop: 8, - }, - modalTitle: { - fontSize: 16, - marginBottom: 16, - }, row: { flexDirection: 'row', justifyContent: 'center' }, marginHorizontal: { marginHorizontal: 4 }, }); diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx index 3474041507..5a777b6d46 100644 --- a/src/screens/novel/components/EditInfoModal.tsx +++ b/src/screens/novel/components/EditInfoModal.tsx @@ -1,4 +1,4 @@ -import { KeyboardAvoidingModal } from '@components'; +import { KeyboardAvoidingModal, StableTextInput } from '@components'; import { updateNovelInfo } from '@database/queries/NovelQueries'; import { NovelInfo } from '@database/types'; import { NovelStatus } from '@plugins/types'; @@ -6,7 +6,7 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import { translateNovelStatus } from '@utils/translateEnum'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { FlatList, Pressable, @@ -15,7 +15,6 @@ import { Text, View, } from 'react-native'; -import { TextInput } from 'react-native-paper'; interface EditInfoModalProps { theme: ThemeColors; @@ -47,7 +46,7 @@ const EditInfoModal = ({ novel, setNovel, }: EditInfoModalProps) => { - const initialNovelInfo = { ...novel }; + const initialNovelInfo = useMemo(() => ({ ...novel }), [novel]); const [novelInfo, setNovelInfo] = useState(novel); const [newGenre, setNewGenre] = useState(''); @@ -63,147 +62,156 @@ const EditInfoModal = ({ })); }; + const onReset = useCallback(() => { + setNovelInfo(initialNovelInfo); + updateNovelInfo(initialNovelInfo); + }, [initialNovelInfo]); + const status = Object.values(NovelStatus); return ( { + onReset(); + hideModal(); + }} onConfirm={() => { setNovel(novelInfo); updateNovelInfo(novelInfo); }} - onReset={() => { - setNovelInfo(initialNovelInfo); - updateNovelInfo(initialNovelInfo); - }} + onReset={onReset} > - - - {getString('novelScreen.edit.status')} - - - {status.map((item, index) => ( - - - setNovelInfo(prev => ({ ...prev, status: item })) - } - > - + + {getString('novelScreen.edit.status')} + + + {status.map((item, index) => ( + + + setNovelInfo(prev => ({ ...prev, status: item })) + } > - {translateNovelStatus(item)} - - - - ))} - - - setNovelInfo(prev => ({ ...prev, name: text }))} - dense - style={styles.inputWrapper} - /> - setNovelInfo(prev => ({ ...prev, author: text }))} - dense - style={styles.inputWrapper} - /> - setNovelInfo(prev => ({ ...prev, artist: text }))} - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, summary: text })) - } - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> - - setNewGenre(text)} - onSubmitEditing={() => { - const newGenreTrimmed = newGenre.trim(); - - if (newGenreTrimmed === '') { - return; + + {translateNovelStatus(item)} + + + + ))} + + + setNovelInfo(prev => ({ ...prev, name: text }))} + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, author: text })) } + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, artist: text })) + } + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, summary: text })) + } + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} + /> + + setNewGenre(text)} + onSubmitEditing={() => { + const newGenreTrimmed = newGenre.trim(); - setNovelInfo(prevVal => ({ - ...prevVal, - genres: novelInfo.genres - ? `${novelInfo.genres},` + newGenreTrimmed - : newGenreTrimmed, - })); - setNewGenre(''); - setGenreKey(prev => prev + 1); - }} - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> + if (newGenreTrimmed === '') { + return; + } - {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( - 'novelTag' + index} - renderItem={({ item }) => ( - removeTag(item)}> - {item} - - )} - showsHorizontalScrollIndicator={false} + setNovelInfo(prevVal => ({ + ...prevVal, + genres: novelInfo.genres + ? `${novelInfo.genres},` + newGenreTrimmed + : newGenreTrimmed, + })); + setNewGenre(''); + setGenreKey(prev => prev + 1); + }} + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} /> - ) : null} + + {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( + 'novelTag' + index} + renderItem={({ item }) => ( + removeTag(item)}> + {item} + + )} + showsHorizontalScrollIndicator={false} + /> + ) : null} ); }; diff --git a/src/screens/novel/components/ExportEpubModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx index 2677222c83..997397f45b 100644 --- a/src/screens/novel/components/ExportEpubModal.tsx +++ b/src/screens/novel/components/ExportEpubModal.tsx @@ -1,4 +1,4 @@ -import { Button, List, Modal, SwitchItem } from '@components'; +import { KeyboardAvoidingModal, List, StableTextInput, SwitchItem } from '@components'; import { useBoolean } from '@hooks'; import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; @@ -6,7 +6,6 @@ import { getString } from '@strings/translations'; import { showToast } from '@utils/showToast'; import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { Text, TextInput } from 'react-native-paper'; import { openDocumentTree } from 'react-native-saf-x'; import sanitizeFileName from 'sanitize-filename'; @@ -86,7 +85,7 @@ const ExportEpubModal: React.FC = ({ const validFileName = getValidFileName(fileName); if (!validFileName) { showToast(getString('novelScreen.exportEpubModal.invalidFileName')); - return; + return false; } if (!exportAll.value) { @@ -95,17 +94,17 @@ const ExportEpubModal: React.FC = ({ if (isNaN(start) || isNaN(end)) { showToast(getString('novelScreen.exportEpubModal.invalidRange')); - return; + return false; } if (start < 1 || end < 1) { showToast(getString('novelScreen.exportEpubModal.invalidRange')); - return; + return false; } if (start > end) { showToast(getString('novelScreen.exportEpubModal.startGreaterThanEnd')); - return; + return false; } } @@ -120,7 +119,6 @@ const ExportEpubModal: React.FC = ({ const end = exportAll.value ? undefined : parseInt(endChapter, 10); onSubmitProp?.(uri, validFileName, start, end); - hideModal(); }; const openFolderPicker = async () => { @@ -135,130 +133,115 @@ const ExportEpubModal: React.FC = ({ }; return ( - - - - - {getString('novelScreen.exportEpubModal.title')} - - - } - /> - - - + + - - {getString('novelScreen.exportEpubModal.overwriteWarning')} - - - - - - {!exportAll.value && ( - - - - - )} - - - + + + + + {getString('novelScreen.exportEpubModal.overwriteWarning')} + - + + - - - - - + ); }; export default SetTrackStatusDialog; - -const styles = StyleSheet.create({ - buttonContainer: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 8, - marginTop: 16, - }, -}); diff --git a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx index 92c503d6f7..4c77d490b4 100644 --- a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx +++ b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx @@ -1,4 +1,4 @@ -import { Button, Modal } from '@components'; +import { KeyboardAvoidingModal, StableTextInput } from '@components'; import { getTracker, useTheme } from '@hooks/persisted'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; import { SearchResult } from '@services/Trackers'; @@ -6,9 +6,8 @@ import { getString } from '@strings/translations'; import { getErrorMessage } from '@utils/error'; import { showToast } from '@utils/showToast'; import React, { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, Image, StyleSheet, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { TextInput, TouchableRipple } from 'react-native-paper'; import { TrackSearchDialogProps } from './types'; @@ -69,8 +68,7 @@ const TrackSearchDialog: React.FC = ({ if (selectedNovel) { onTrackNovel(tracker, selectedNovel); } - onDismiss(); - }, [selectedNovel, onTrackNovel, tracker, onDismiss]); + }, [selectedNovel, onTrackNovel, tracker]); const renderSearchResultCard = useCallback( (item: SearchResult) => { @@ -122,65 +120,55 @@ const TrackSearchDialog: React.FC = ({ ); return ( - - - - } - /> - - {loading ? ( - - ) : ( - searchResults.map(renderSearchResultCard) - )} - - - - - - - - - - + + + } + /> + + {loading ? ( + + ) : ( + searchResults.map(renderSearchResultCard) + )} + + ); }; export default TrackSearchDialog; const styles = StyleSheet.create({ - actionButtons: { - flexDirection: 'row', - }, - buttonContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 30, - }, checkIcon: { position: 'absolute', right: 8, diff --git a/src/screens/novel/components/Tracker/TrackSheet.tsx b/src/screens/novel/components/Tracker/TrackSheet.tsx index 425bc373f2..5e5f2f73ab 100644 --- a/src/screens/novel/components/Tracker/TrackSheet.tsx +++ b/src/screens/novel/components/Tracker/TrackSheet.tsx @@ -6,7 +6,7 @@ import { TrackerMetadata } from '@hooks/persisted/useTracker'; import { TrackerName, UserListStatus } from '@services/Trackers'; import React, { useCallback, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, ToastAndroid, View } from 'react-native'; -import { overlay, Portal } from 'react-native-paper'; +import { overlay } from 'react-native-paper'; import { getStatusLabel, getTrackerIcon } from './constants'; import SetTrackChaptersDialog from './SetTrackChaptersDialog'; @@ -200,45 +200,43 @@ const TrackSheet: React.FC = ({ bottomSheetRef, novel }) => { })} - - {activeTracker && ( - <> - {getTrackedNovel(activeTracker.name) ? ( - <> - - - - - ) : ( - + {getTrackedNovel(activeTracker.name) ? ( + <> + - )} - - )} - + + + + ) : ( + + )} + + )} ); }; diff --git a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx index 2bc118ff54..8bedf76fb4 100644 --- a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx +++ b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx @@ -11,23 +11,42 @@ jest.mock('@components', () => { const { Pressable, Text, View } = jest.requireActual('react-native'); return { - Button: ({ onPress, title }: { onPress: () => void; title: string }) => - ReactModule.createElement( - Pressable, - { onPress }, - ReactModule.createElement(Text, null, title), - ), - List: { - InfoItem: ({ title }: { title: string }) => - ReactModule.createElement(Text, null, title), - }, - Modal: ({ + KeyboardAvoidingModal: ({ children, + confirmLabel, + onConfirm, + onDismiss, + title, visible, }: { children: React.ReactNode; + confirmLabel: string; + onConfirm: () => boolean | void; + onDismiss: () => void; + title: React.ReactNode; visible: boolean; - }) => (visible ? ReactModule.createElement(View, null, children) : null), + }) => + visible + ? ReactModule.createElement( + View, + null, + ReactModule.createElement(Text, null, title), + children, + ReactModule.createElement( + Pressable, + { + onPress: () => { + if (onConfirm() !== false) onDismiss(); + }, + }, + ReactModule.createElement(Text, null, confirmLabel), + ), + ) + : null, + List: { + InfoItem: ({ title }: { title: string }) => + ReactModule.createElement(Text, null, title), + }, SwitchItem: ({ label, onPress, @@ -75,13 +94,6 @@ jest.mock('@utils/showToast', () => ({ showToast: jest.fn(), })); -jest.mock('react-native-keyboard-controller', () => { - const { ScrollView } = jest.requireActual('react-native'); - return { - KeyboardAwareScrollView: ScrollView, - }; -}); - jest.mock('react-native-paper', () => { const ReactModule = jest.requireActual('react'); const { diff --git a/src/screens/reader/components/ChapterDrawer/index.tsx b/src/screens/reader/components/ChapterDrawer/index.tsx index c9e0d22569..2aaaf5dbe2 100644 --- a/src/screens/reader/components/ChapterDrawer/index.tsx +++ b/src/screens/reader/components/ChapterDrawer/index.tsx @@ -1,4 +1,4 @@ -import { Button, LoadingScreenV2 } from '@components/index'; +import { Button, LoadingScreenV2 } from '@components'; import { useAppSettings, useTheme } from '@hooks/persisted'; import { LegendList, LegendListRef, ViewToken } from '@legendapp/list'; import { useNovelActions, useNovelValue } from '@screens/novel/NovelContext'; diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx index 182aa352d0..ecbb09ef66 100644 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx @@ -1,4 +1,4 @@ -import { SelectableChip } from '@components/index'; +import { SelectableChip } from '@components'; import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import { Font, readerFonts } from '@utils/constants/readerConstants'; diff --git a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx b/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx index 239987d8a4..2e9d0a119a 100644 --- a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx @@ -1,4 +1,4 @@ -import { Button, List } from '@components/index'; +import { Button, List } from '@components'; import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { useChapterGeneralSettings, diff --git a/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx b/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx index 8e90240d7a..78834d7155 100644 --- a/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx @@ -1,4 +1,4 @@ -import { Button, List, SwitchItem } from '@components/index'; +import { Button, List, SwitchItem } from '@components'; import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { useTheme } from '@hooks/persisted'; import { useAIProviders } from '@hooks/persisted/useAIProviders'; diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 46b0d4de55..a3ddf24133 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -1,8 +1,8 @@ -import { IconButtonV2 } from '@components'; +import { IconButtonV2, StableTextInput } from '@components'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import React, { useEffect, useRef } from 'react'; -import { StyleSheet, Text, TextInput, View } from 'react-native'; +import { StyleSheet, Text, TextInput,View } from 'react-native'; import type { NativeChapterSearch } from '../hooks/useNativeChapterSearch'; @@ -33,7 +33,7 @@ const ReaderSearchbar = ({ theme, search }: ReaderSearchbarProps) => { onPress={() => inputRef.current?.focus()} theme={theme} /> - = ({ const [providerMenuVisible, setProviderMenuVisible] = useState(false); const [apiModeMenuVisible, setApiModeMenuVisible] = useState(false); const [reasoningMenuVisible, setReasoningMenuVisible] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [inputKey, setInputKey] = useState(0); const [isLoadingModels, setIsLoadingModels] = useState(false); const [modelPickerVisible, setModelPickerVisible] = useState(false); @@ -102,7 +99,6 @@ const AIProviderModal: React.FC = ({ getApiKey(initialProvider.id).then(key => { if (mounted && key) { setApiKey(key); - setInputKey(k => k + 1); } }); } else if (visible && !initialProvider) { @@ -115,7 +111,6 @@ const AIProviderModal: React.FC = ({ setApiMode('chat-completions'); setEnableReasoning(false); setReasoningEffort('none'); - setInputKey(k => k + 1); } return () => { @@ -126,14 +121,13 @@ const AIProviderModal: React.FC = ({ const handleSave = async () => { if (!alias) { showToast('Alias is required'); - return; + return false; } if (!apiKey && !initialProvider) { showToast('API Key is required'); - return; + return false; } - setIsSaving(true); try { await onSave( { @@ -148,11 +142,9 @@ const AIProviderModal: React.FC = ({ }, apiKey, ); - onDismiss(); } catch (e: any) { showToast(`Error saving provider: ${e.message}`); - } finally { - setIsSaving(false); + return false; } }; @@ -187,34 +179,122 @@ const AIProviderModal: React.FC = ({ }; return ( - - + - - - {initialProvider - ? getString('aiSettingsScreen.editProvider') - : getString('aiSettingsScreen.addProvider')} + + + + + {getString('readerScreen.bottomSheet.translateTab.provider')} + setProviderMenuVisible(false)} + contentStyle={{ backgroundColor: theme.surface }} + anchor={ + setProviderMenuVisible(true)} + > + + {getProviderLabel(provider)} + + + ▼ + + + } + > + {PROVIDERS.map(p => ( + { + setProvider(p.value); + setEndpoint(p.endpoint); + setProviderMenuVisible(false); + }} + /> + ))} + + + + + - + = ({ }, }} /> + - + diff --git a/src/screens/settings/components/ConnectionModal.tsx b/src/screens/settings/components/ConnectionModal.tsx index 1bab2aa7fd..7ffae578a1 100644 --- a/src/screens/settings/components/ConnectionModal.tsx +++ b/src/screens/settings/components/ConnectionModal.tsx @@ -1,8 +1,7 @@ -import { KeyboardAvoidingModal } from '@components'; +import { KeyboardAvoidingModal, StableTextInput } from '@components'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import React from 'react'; -import { TextInput } from 'react-native-paper'; interface ConnectionModalProps { title: string; @@ -35,8 +34,8 @@ const ConnectionModal: React.FC = ({ onDismiss={closeModal} onConfirm={() => handle(ipv4, port)} > - = ({ theme={{ colors: { ...theme } }} placeholderTextColor={theme.onSurfaceDisabled} /> - = ({ const handleSubmit = async () => { if (!username.trim() || !password.trim()) { setError(`${usernameLabel} and password are required`); - return; + return false; } setIsLoading(true); @@ -38,11 +37,12 @@ const TrackerLoginDialog: React.FC = ({ try { await onSubmit(username.trim(), password); - /* Clear form on success */ setUsername(''); setPassword(''); + return true; } catch (err) { setError(err instanceof Error ? err.message : 'Authentication failed'); + return false; } finally { setIsLoading(false); } @@ -56,92 +56,61 @@ const TrackerLoginDialog: React.FC = ({ }; return ( - - - - - Login to {trackerName} - + + - + - - - {error ? ( - - {error} - - ) : null} - - - - - - - - + {error ? ( + {error} + ) : null} + ); }; export default TrackerLoginDialog; const styles = StyleSheet.create({ - container: { - padding: 8, - }, - title: { - fontSize: 24, - marginBottom: 24, - fontWeight: '500', - }, input: { height: 48, borderWidth: 1, @@ -155,16 +124,4 @@ const styles = StyleSheet.create({ marginBottom: 16, marginTop: -8, }, - buttonRow: { - flexDirection: 'row', - justifyContent: 'flex-end', - marginTop: 8, - }, - button: { - marginLeft: 8, - }, - buttonLabel: { - letterSpacing: 0, - textTransform: 'none', - }, }); diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 7462e41a68..e4e0514274 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -288,6 +288,7 @@ "header": "Delete category" }, "duplicateError": "A category with this name already exists!", + "emptyError": "Category name cannot be empty!", "editCategories": "Rename category", "emptyMsg": "You have no categories. Tap the plus button to create one for organizing your library", "header": "Edit categories", diff --git a/strings/languages/vi_VN/strings.json b/strings/languages/vi_VN/strings.json index 61f4bc06e0..9e0c1f3d8e 100644 --- a/strings/languages/vi_VN/strings.json +++ b/strings/languages/vi_VN/strings.json @@ -287,6 +287,7 @@ "header": "Xóa danh mục" }, "duplicateError": "Danh mục với tên này đã tồn tại!", + "emptyError": "Tên danh mục không được để trống!", "editCategories": "Đổi tên danh mục", "emptyMsg": "Bạn không có danh mục nào. Hãy ấn vào nút thêm để tạo danh mục mới", "header": "Chỉnh sửa danh mục", diff --git a/strings/types/index.ts b/strings/types/index.ts index 3ebb018aad..856be368a3 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -261,6 +261,7 @@ export interface StringMap { 'categories.deleteModal.desc': 'string'; 'categories.deleteModal.header': 'string'; 'categories.duplicateError': 'string'; + 'categories.emptyError': 'string'; 'categories.editCategories': 'string'; 'categories.emptyMsg': 'string'; 'categories.header': 'string'; From c6dd65b3ad2d30cf3a496f4b79aa2a36a4405f1c Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:28:51 +0700 Subject: [PATCH 5/7] fix: StableTextInput --- package.json | 1 + pnpm-lock.yaml | 24 ++ .../ColorPickerModal/ColorPickerModal.tsx | 2 +- src/components/SearchbarV2/SearchbarV2.tsx | 2 +- .../__tests__/StableTextInput.test.tsx | 84 ++++++ src/components/TextInput/index.tsx | 121 ++++++--- .../components/FilterBottomSheet.tsx | 2 +- .../__tests__/AddCategoryModal.test.tsx | 22 +- src/screens/novel/NovelScreen.tsx | 5 +- .../novel/components/EditInfoModal.tsx | 244 +++++++++--------- .../novel/components/ExportEpubModal.tsx | 7 +- .../__tests__/ExportEpubModal.test.tsx | 20 +- .../reader/components/ReaderSearchbar.tsx | 2 +- .../TranslatePromptScreen.tsx | 8 +- .../components/AIProviderModal.tsx | 7 +- .../settings/SettingsAdvancedScreen.tsx | 8 +- .../Components/GoogleDriveModal.tsx | 7 +- .../Components/SelfHostModal.tsx | 7 +- 18 files changed, 380 insertions(+), 193 deletions(-) create mode 100644 src/components/TextInput/__tests__/StableTextInput.test.tsx diff --git a/package.json b/package.json index b7d77160da..8ec124758b 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "react-native-url-polyfill": "^3.0.0", "react-native-webview": "^13.16.1", "react-native-worklets": "^0.8.1", + "reanimated-color-picker": "^5.1.2", "sanitize-filename": "^1.6.4", "sanitize-html": "^2.17.2", "unicode-segmenter": "^0.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cce886b21..8bab34a3da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,9 @@ importers: react-native-worklets: specifier: ^0.8.1 version: 0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + reanimated-color-picker: + specifier: ^5.1.2 + version: 5.1.2(expo@55.0.9)(react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) sanitize-filename: specifier: ^1.6.4 version: 1.6.4 @@ -5701,6 +5704,18 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + reanimated-color-picker@5.1.2: + resolution: {integrity: sha512-AfGh9OIU0Y7/Yf5xoH7YwrXbyEyXjXOTP79OilSiw0OGvP0xM/h/Ij6lmyygzr/HQl2WHlhdhSAExk1hThIUeA==} + peerDependencies: + expo: '>=44.0.0' + react: '*' + react-native: '*' + react-native-gesture-handler: '>=2.0.0' + react-native-reanimated: '>=2.0.0' + peerDependenciesMeta: + expo: + optional: true + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -13392,6 +13407,15 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + reanimated-color-picker@5.1.2(expo@55.0.9)(react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) + react-native-gesture-handler: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-reanimated: 4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + optionalDependencies: + expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(patch_hash=9fbad2ce93fe42b331382c823e5f2ad0cd5fd682b80b0aafe804ba0f8aff105a)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + redent@3.0.0: dependencies: indent-string: 4.0.0 diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index aafbac1fe8..d95e4e3d11 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -1,6 +1,6 @@ import { KeyboardAvoidingModal, StableTextInput } from '@components'; import React, { useState } from 'react'; -import { FlatList, Pressable,StyleSheet, Text, View } from 'react-native'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { ThemeColors } from '../../theme/types'; diff --git a/src/components/SearchbarV2/SearchbarV2.tsx b/src/components/SearchbarV2/SearchbarV2.tsx index 06c130fff5..e4a17a597c 100644 --- a/src/components/SearchbarV2/SearchbarV2.tsx +++ b/src/components/SearchbarV2/SearchbarV2.tsx @@ -1,6 +1,6 @@ import { MaterialDesignIconName } from '@type/icon'; import React, { memo, useRef, useState } from 'react'; -import { Pressable, StyleSheet, TextInput,View } from 'react-native'; +import { Pressable, StyleSheet, TextInput, View } from 'react-native'; import { ThemeColors } from '../../theme/types'; import IconButtonV2 from '../IconButtonV2/IconButtonV2'; diff --git a/src/components/TextInput/__tests__/StableTextInput.test.tsx b/src/components/TextInput/__tests__/StableTextInput.test.tsx new file mode 100644 index 0000000000..e7cd2bc748 --- /dev/null +++ b/src/components/TextInput/__tests__/StableTextInput.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import StableTextInput from '../index'; + +const mockFocus = jest.fn(); +const mockIsFocused = jest.fn(() => false); +const mockSetSelection = jest.fn(); +let mockLatestProps: Record = {}; +let mockMountCount = 0; + +jest.mock('react-native-paper', () => { + const ReactModule = jest.requireActual('react'); + const { TextInput: NativeTextInput } = jest.requireActual('react-native'); + + const TextInput = ReactModule.forwardRef( + (props: Record, ref: React.Ref) => { + mockLatestProps = props; + + ReactModule.useEffect(() => { + mockMountCount += 1; + }, []); + + ReactModule.useImperativeHandle(ref, () => ({ + focus: mockFocus, + isFocused: mockIsFocused, + setSelection: mockSetSelection, + })); + + return ReactModule.createElement(NativeTextInput, { + ...props, + testID: 'stable-input', + }); + }, + ); + + return { TextInput }; +}); + +describe('StableTextInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsFocused.mockReturnValue(false); + mockLatestProps = {}; + mockMountCount = 0; + }); + + it('leaves text and selection under native control', () => { + render(); + + expect(mockLatestProps.defaultValue).toBe('initial'); + expect(mockLatestProps).not.toHaveProperty('value'); + expect(mockLatestProps).not.toHaveProperty('selection'); + }); + + it('does not remount when value only echoes native input', () => { + const onChangeText = jest.fn(); + const view = render( + , + ); + + fireEvent.changeText(screen.getByTestId('stable-input'), 'prediction'); + view.rerender( + , + ); + + expect(onChangeText).toHaveBeenCalledWith('prediction'); + expect(mockMountCount).toBe(1); + }); + + it('remounts for an external value and restores focused input', () => { + mockIsFocused.mockReturnValue(true); + const view = render( + , + ); + + view.rerender(); + + expect(mockMountCount).toBe(2); + expect(mockLatestProps.defaultValue).toBe('reset'); + expect(mockFocus).toHaveBeenCalledTimes(1); + expect(mockSetSelection).toHaveBeenCalledWith(5, 5); + }); +}); diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index db1763cb3e..d12630bb9e 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -1,53 +1,106 @@ -import React, { forwardRef,useState } from 'react'; -import { - NativeSyntheticEvent, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, -} from 'react-native'; +import React, { + forwardRef, + MutableRefObject, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { TextInput as RNTextInput } from 'react-native'; import { TextInput, TextInputProps } from 'react-native-paper'; -interface SelectionState { - start: number; - end: number; -} - -type StableTextInputProps = Omit & { +type StableTextInputProps = Omit< + TextInputProps, + 'defaultValue' | 'selection' | 'value' +> & { + value: string | undefined; defaultValue?: never; + selection?: never; }; /** - * StableTextInput is a custom component built on top of React Native Paper's TextInput, - * with a small patch to fix the cursor jumping issue when used as a controlled component (value). + * Keeps the native input uncontrolled while exposing a controlled-looking API. + * + * Text entered by the IME is not written back to native when the parent echoes it + * through `value`. This preserves Android composing text, predictive suggestions, + * and the native cursor. A genuinely external `value` change remounts only the + * Paper input so resets, presets, and form hydration still update the field. */ const StableTextInput = forwardRef( - ({ onSelectionChange, value, ...props }, ref) => { - const [selection, setSelection] = useState(null); + ({ onChangeText, value, ...props }, forwardedRef) => { + const normalizedValue = value ?? ''; + const inputRef = useRef(null); + const nativeValueRef = useRef(normalizedValue); + const previousPropValueRef = useRef(normalizedValue); + const restoreSelectionRef = useRef(null); + const [nativeInput, setNativeInput] = useState(() => ({ + defaultValue: normalizedValue, + revision: 0, + })); + + const assignRef = useCallback( + (input: RNTextInput | null) => { + inputRef.current = input; - const handleSelectionChange = ( - event: NativeSyntheticEvent, - ) => { - setSelection(event.nativeEvent.selection); + if (typeof forwardedRef === 'function') { + forwardedRef(input); + } else if (forwardedRef) { + (forwardedRef as MutableRefObject).current = + input; + } + }, + [forwardedRef], + ); - if (onSelectionChange) { - onSelectionChange(event); + const handleChangeText = useCallback( + (text: string) => { + nativeValueRef.current = text; + onChangeText?.(text); + }, + [onChangeText], + ); + + useLayoutEffect(() => { + if (normalizedValue === previousPropValueRef.current) { + return; } - }; - const textLength = typeof value === 'string' ? value.length : 0; - const safeSelection = selection - ? { - start: Math.min(selection.start, textLength), - end: Math.min(selection.end, textLength), - } - : undefined; + previousPropValueRef.current = normalizedValue; + + // onChangeText -> parent state -> value is only an echo of native text. + // Avoid touching the input so Android can finish its composing transaction. + if (normalizedValue === nativeValueRef.current) { + return; + } + + restoreSelectionRef.current = inputRef.current?.isFocused() + ? normalizedValue.length + : null; + nativeValueRef.current = normalizedValue; + setNativeInput(current => ({ + defaultValue: normalizedValue, + revision: current.revision + 1, + })); + }, [normalizedValue]); + + useLayoutEffect(() => { + const selection = restoreSelectionRef.current; + if (selection == null) { + return; + } + + restoreSelectionRef.current = null; + inputRef.current?.focus(); + inputRef.current?.setSelection(selection, selection); + }, [nativeInput.revision]); return ( ); }, diff --git a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx index 82910b5841..225e85823c 100644 --- a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx +++ b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox,Menu, StableTextInput } from '@components'; +import { Button, Checkbox, Menu, StableTextInput } from '@components'; import BottomSheet from '@components/BottomSheet/BottomSheet'; import Switch from '@components/Switch/Switch'; import { diff --git a/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx index 43e6d0503f..0c51949b77 100644 --- a/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx +++ b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx @@ -23,12 +23,12 @@ type MockKeyboardAvoidingModalProps = { type MockTextInputProps = Pick< TextInputProps, - 'defaultValue' | 'onChangeText' | 'placeholder' + 'onChangeText' | 'placeholder' | 'value' >; jest.mock('@components', () => { const ReactModule = jest.requireActual('react'); - const { Text, View } = jest.requireActual('react-native'); + const { Text, TextInput, View } = jest.requireActual('react-native'); return { KeyboardAvoidingModal: ({ @@ -54,6 +54,11 @@ jest.mock('@components', () => { ), ); }, + StableTextInput: (props: MockTextInputProps) => + ReactModule.createElement(TextInput, { + ...props, + testID: 'category-name-input', + }), }; }); @@ -75,19 +80,6 @@ jest.mock('../../../../database/queries/CategoryQueries', () => ({ updateCategory: jest.fn(), })); -jest.mock('react-native-paper', () => { - const ReactModule = jest.requireActual('react'); - const { TextInput } = jest.requireActual('react-native'); - - return { - TextInput: (props: MockTextInputProps) => - ReactModule.createElement(TextInput, { - ...props, - testID: 'category-name-input', - }), - }; -}); - const mockCreateCategory = createCategory as jest.MockedFunction< typeof createCategory >; diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index 61fdee6623..92e97c6a37 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -341,7 +341,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { ); return ( - + <> {selected.length === 0 ? ( @@ -400,7 +400,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { /> - 0} actions={actions} /> { ) : null} - + ); }; diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx index 5a777b6d46..ee6dab611c 100644 --- a/src/screens/novel/components/EditInfoModal.tsx +++ b/src/screens/novel/components/EditInfoModal.tsx @@ -83,135 +83,131 @@ const EditInfoModal = ({ }} onReset={onReset} > - - - {getString('novelScreen.edit.status')} - - - {status.map((item, index) => ( - - - setNovelInfo(prev => ({ ...prev, status: item })) - } + + + {getString('novelScreen.edit.status')} + + + {status.map((item, index) => ( + + + setNovelInfo(prev => ({ ...prev, status: item })) + } + > + - - {translateNovelStatus(item)} - - - - ))} - - - setNovelInfo(prev => ({ ...prev, name: text }))} - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, author: text })) - } - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, artist: text })) - } - dense - style={styles.inputWrapper} - /> - - setNovelInfo(prev => ({ ...prev, summary: text })) - } - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> + {translateNovelStatus(item)} + + + + ))} + + + setNovelInfo(prev => ({ ...prev, name: text }))} + dense + style={styles.inputWrapper} + /> + setNovelInfo(prev => ({ ...prev, author: text }))} + dense + style={styles.inputWrapper} + /> + setNovelInfo(prev => ({ ...prev, artist: text }))} + dense + style={styles.inputWrapper} + /> + + setNovelInfo(prev => ({ ...prev, summary: text })) + } + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} + /> - setNewGenre(text)} - onSubmitEditing={() => { - const newGenreTrimmed = newGenre.trim(); + setNewGenre(text)} + onSubmitEditing={() => { + const newGenreTrimmed = newGenre.trim(); - if (newGenreTrimmed === '') { - return; - } + if (newGenreTrimmed === '') { + return; + } - setNovelInfo(prevVal => ({ - ...prevVal, - genres: novelInfo.genres - ? `${novelInfo.genres},` + newGenreTrimmed - : newGenreTrimmed, - })); - setNewGenre(''); - setGenreKey(prev => prev + 1); - }} - theme={{ colors: { ...theme } }} - dense - style={styles.inputWrapper} - /> + setNovelInfo(prevVal => ({ + ...prevVal, + genres: novelInfo.genres + ? `${novelInfo.genres},` + newGenreTrimmed + : newGenreTrimmed, + })); + setNewGenre(''); + setGenreKey(prev => prev + 1); + }} + theme={{ colors: { ...theme } }} + dense + style={styles.inputWrapper} + /> - {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( - 'novelTag' + index} - renderItem={({ item }) => ( - removeTag(item)}> - {item} - - )} - showsHorizontalScrollIndicator={false} - /> - ) : null} + {novelInfo.genres !== undefined && novelInfo.genres !== '' ? ( + 'novelTag' + index} + renderItem={({ item }) => ( + removeTag(item)}> + {item} + + )} + showsHorizontalScrollIndicator={false} + /> + ) : null} ); }; diff --git a/src/screens/novel/components/ExportEpubModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx index 997397f45b..800b231eeb 100644 --- a/src/screens/novel/components/ExportEpubModal.tsx +++ b/src/screens/novel/components/ExportEpubModal.tsx @@ -1,4 +1,9 @@ -import { KeyboardAvoidingModal, List, StableTextInput, SwitchItem } from '@components'; +import { + KeyboardAvoidingModal, + List, + StableTextInput, + SwitchItem, +} from '@components'; import { useBoolean } from '@hooks'; import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; diff --git a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx index 8bedf76fb4..efeff4c1b1 100644 --- a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx +++ b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx @@ -8,7 +8,12 @@ const mockSetChapterReaderSettings = jest.fn(); jest.mock('@components', () => { const ReactModule = jest.requireActual('react'); - const { Pressable, Text, View } = jest.requireActual('react-native'); + const { + Pressable, + Text, + TextInput: NativeTextInput, + View, + } = jest.requireActual('react-native'); return { KeyboardAvoidingModal: ({ @@ -47,6 +52,19 @@ jest.mock('@components', () => { InfoItem: ({ title }: { title: string }) => ReactModule.createElement(Text, null, title), }, + StableTextInput: ({ + label, + placeholder, + ...props + }: { + label?: string; + placeholder?: string; + }) => + ReactModule.createElement(NativeTextInput, { + accessibilityLabel: label || placeholder, + placeholder: placeholder || label, + ...props, + }), SwitchItem: ({ label, onPress, diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index a3ddf24133..cee425be99 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -2,7 +2,7 @@ import { IconButtonV2, StableTextInput } from '@components'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import React, { useEffect, useRef } from 'react'; -import { StyleSheet, Text, TextInput,View } from 'react-native'; +import { StyleSheet, Text, TextInput, View } from 'react-native'; import type { NativeChapterSearch } from '../hooks/useNativeChapterSearch'; diff --git a/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx b/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx index 3e995be2df..53cb09f83e 100644 --- a/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx +++ b/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx @@ -1,10 +1,4 @@ -import { - Appbar, - Button, - EmptyView, - List, - SafeAreaView, -} from '@components'; +import { Appbar, Button, EmptyView, List, SafeAreaView } from '@components'; import { useTheme } from '@hooks/persisted'; import { useTranslateSettings } from '@hooks/persisted/useSettings'; import { AIPromptsSettingsScreenProps } from '@navigators/types'; diff --git a/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx b/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx index 1757e1fc3e..a85fe57e87 100644 --- a/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx +++ b/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx @@ -1,4 +1,9 @@ -import { Button, KeyboardAvoidingModal, StableTextInput, SwitchItem } from '@components'; +import { + Button, + KeyboardAvoidingModal, + StableTextInput, + SwitchItem, +} from '@components'; import { useTheme } from '@hooks/persisted'; import { type AIProvider, getApiKey } from '@hooks/persisted/useAIProviders'; import type { diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx index 7c842fdae6..65dd1dbef3 100644 --- a/src/screens/settings/SettingsAdvancedScreen.tsx +++ b/src/screens/settings/SettingsAdvancedScreen.tsx @@ -1,4 +1,10 @@ -import { Appbar, KeyboardAvoidingModal, List, SafeAreaView, StableTextInput } from '@components'; +import { + Appbar, + KeyboardAvoidingModal, + List, + SafeAreaView, + StableTextInput, +} from '@components'; import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog'; import { clearUpdates, diff --git a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx index 82ff8d3a80..50376d955c 100644 --- a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx +++ b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx @@ -1,6 +1,11 @@ import { exists, getBackups, makeDir } from '@api/drive'; import { DriveFile } from '@api/drive/types'; -import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components'; +import { + Button, + EmptyView, + KeyboardAwareModal, + StableTextInput, +} from '@components'; import { GoogleSignin, User } from '@react-native-google-signin/google-signin'; import ServiceManager from '@services/ServiceManager'; import { getString } from '@strings/translations'; diff --git a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx index 3d692cd19b..13ebdc1f08 100644 --- a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx +++ b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx @@ -1,5 +1,10 @@ import { list } from '@api/remote'; -import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components'; +import { + Button, + EmptyView, + KeyboardAwareModal, + StableTextInput, +} from '@components'; import { useSelfHost } from '@hooks/persisted/useSelfHost'; import ServiceManager from '@services/ServiceManager'; import { getString } from '@strings/translations'; From c3f49208147cfcbca9b40464b88285855a424f96 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:27:25 +0700 Subject: [PATCH 6/7] refactor: ColorPickerModal --- .../ColorPickerModal/ColorPickerModal.tsx | 221 ++++++++++------- .../__tests__/ColorPickerModal.test.tsx | 232 ++++++++++++++++++ .../SettingsAppearanceScreen.tsx | 2 - .../SettingsReaderScreen/tabs/ThemeTab.tsx | 2 - 4 files changed, 359 insertions(+), 98 deletions(-) create mode 100644 src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index d95e4e3d11..3769c63746 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -1,8 +1,16 @@ -import { KeyboardAvoidingModal, StableTextInput } from '@components'; -import React, { useState } from 'react'; -import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; - -import { ThemeColors } from '../../theme/types'; +import { KeyboardAvoidingModal } from '@components'; +import { useTheme } from '@hooks/persisted'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { overlay } from 'react-native-paper'; +import ColorPicker, { + ColorFormatsObject, + colorKit, + HueCircular, + InputWidget, + Panel1, + Preview, +} from 'reanimated-color-picker'; interface ColorPickerModalProps { visible: boolean; @@ -10,111 +18,102 @@ interface ColorPickerModalProps { color: string; onSubmit: (val: string | undefined) => void; closeModal: () => void; - theme: ThemeColors; - showAccentColors?: boolean; } +const toOpaqueHex = (color: string) => + colorKit.setAlpha(color, 1).hex().toLowerCase(); + const ColorPickerModal: React.FC = ({ - theme, color, title, onSubmit, closeModal, visible, - showAccentColors, }) => { - const [text, setText] = useState(color); - const [error, setError] = useState(); + const theme = useTheme(); + const [draftColor, setDraftColor] = useState(() => toOpaqueHex(color)); + + useEffect(() => { + if (visible) { + setDraftColor(toOpaqueHex(color)); + } + }, [color, visible]); const onDismiss = () => { + setDraftColor(toOpaqueHex(color)); closeModal(); - setText(color); - setError(null); }; - const onChangeText = (txt: string) => setText(txt); - - const handleConfirm = () => { - const re = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})$/i; - - if (text?.match(re)) { - onSubmit(text); - } else { - setError('Enter a valid hex color code'); - return false; - } + const onColorComplete = ({ hex }: ColorFormatsObject) => { + setDraftColor(toOpaqueHex(hex)); }; const onReset = () => { + setDraftColor(toOpaqueHex(color)); onSubmit(undefined); - setText(color); - setError(null); }; - const accentColors = [ - '#EF5350', - '#EC407A', - '#AB47BC', - '#7E57C2', - '#5C6BC0', - '#42A5F5', - '#29B6FC', - '#26C6DA', - '#26A69A', - '#66BB6A', - '#9CCC65', - '#D4E157', - '#FFEE58', - '#FFCA28', - '#FFA726', - '#FF7043', - '#8D6E63', - '#BDBDBD', - '#78909C', - '#000000', - ]; - return ( onSubmit(draftColor)} onReset={onReset} > - {showAccentColors ? ( - item} - renderItem={({ item }) => ( - - { - setText(item); - setError(null); - }} - /> - - )} - /> - ) : null} - - {error} + + + + + + + + + + ); }; @@ -122,18 +121,52 @@ const ColorPickerModal: React.FC = ({ export default ColorPickerModal; const styles = StyleSheet.create({ - errorText: { - color: '#FF0033', - paddingTop: 8, + divider: { + height: StyleSheet.hairlineWidth, + width: '100%', + }, + hueCircular: { + maxWidth: 320, + width: '100%', + }, + hueContent: { + alignItems: 'center', + justifyContent: 'center', + }, + input: { + borderRadius: 12, + borderWidth: 1, + fontSize: 16, + minHeight: 48, + paddingHorizontal: 16, + paddingVertical: 10, + }, + inputContainer: { + width: '100%', + }, + inputTitle: { + fontSize: 12, + paddingBottom: 0, + paddingTop: 6, + }, + panel: { + alignSelf: 'center', + borderRadius: 16, + height: '68%', + width: '68%', + }, + picker: { + alignSelf: 'center', + gap: 24, + maxWidth: 320, + width: '100%', + }, + pickerContainer: { + alignItems: 'center', + width: '100%', }, - item: { - borderRadius: 4, - overflow: 'hidden', - flex: 1 / 4, + previewStyle: { height: 40, - marginHorizontal: 4, - marginVertical: 4, + borderRadius: 14, }, - flex: { flex: 1 }, - marginBottom: { marginBottom: 8 }, }); diff --git a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx new file mode 100644 index 0000000000..e148727428 --- /dev/null +++ b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx @@ -0,0 +1,232 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import ColorPickerModal from '../ColorPickerModal'; + +type ColorResult = { + hex: string; +}; + +type ColorPickerProps = { + children?: React.ReactNode; + onCompleteJS?: (color: ColorResult) => void; + value: string; +}; + +type InputWidgetProps = { + disableAlphaChannel?: boolean; + formats?: readonly string[]; +}; + +type HueCircularProps = { + children?: React.ReactNode; + containerStyle?: unknown; +}; + +let mockColorPickerProps: ColorPickerProps; +let mockInputWidgetProps: InputWidgetProps; +let mockHueCircularProps: HueCircularProps; +let mockCompleteColor: ((color: ColorResult) => void) | undefined; + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => ({ + onSurface: '#111111', + onSurfaceVariant: '#555555', + outline: '#777777', + outlineVariant: '#dddddd', + primary: '#0066cc', + surface: '#ffffff', + }), +})); + +jest.mock('@components', () => { + const ReactModule = jest.requireActual('react'); + const { Pressable, Text, View } = jest.requireActual('react-native'); + + return { + KeyboardAvoidingModal: ({ + children, + onConfirm, + onDismiss, + onReset, + title, + }: { + children: React.ReactNode; + onConfirm: () => void; + onDismiss: () => void; + onReset: () => void; + title: string; + }) => + ReactModule.createElement( + View, + null, + ReactModule.createElement(Text, null, title), + children, + ReactModule.createElement(Pressable, { + testID: 'save', + onPress: onConfirm, + }), + ReactModule.createElement(Pressable, { + testID: 'reset', + onPress: onReset, + }), + ReactModule.createElement(Pressable, { + testID: 'dismiss', + onPress: onDismiss, + }), + ), + }; +}); + +jest.mock('react-native-paper', () => ({ + overlay: (_elevation: number, color: string) => `overlay(${color})`, +})); + +jest.mock('reanimated-color-picker', () => { + const ReactModule = jest.requireActual('react'); + const Color = jest.requireActual('color').default; + const { Pressable, View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: (props: ColorPickerProps) => { + mockColorPickerProps = props; + mockCompleteColor = props.onCompleteJS; + return ReactModule.createElement( + View, + { testID: 'color-picker' }, + props.children, + ReactModule.createElement(Pressable, { + testID: 'pick-color', + onPress: () => mockCompleteColor?.({ hex: '#12ab34' }), + }), + ); + }, + colorKit: { + setAlpha: (value: string, alpha: number) => ({ + hex: () => Color(value).alpha(alpha).hex(), + }), + }, + HueCircular: (props: HueCircularProps) => { + mockHueCircularProps = props; + return ReactModule.createElement( + View, + { testID: 'hue-circular' }, + props.children, + ); + }, + InputWidget: (props: InputWidgetProps) => { + mockInputWidgetProps = props; + return ReactModule.createElement( + View, + { testID: 'input-widget' }, + ReactModule.createElement(Pressable, { + testID: 'enter-valid-hex', + onPress: () => mockCompleteColor?.({ hex: '#a1b2c3' }), + }), + ReactModule.createElement(Pressable, { + testID: 'enter-invalid-hex', + }), + ); + }, + Panel1: () => ReactModule.createElement(View, { testID: 'panel-1' }), + Preview: () => ReactModule.createElement(View, { testID: 'preview' }), + }; +}); + +describe('ColorPickerModal', () => { + const defaultProps = { + visible: true, + title: 'Choose color', + color: '#336699', + closeModal: jest.fn(), + onSubmit: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['rgb(0, 87, 206)', '#0057ce'], + ['#abc', '#aabbcc'], + ['#ffffffb3', '#ffffff'], + ])('normalizes %s to opaque HEX6', (color, expected) => { + render(); + + expect(mockColorPickerProps.value).toBe(expected); + }); + + it('renders the circular picker and opaque HEX/RGB inputs', () => { + render(); + + expect(screen.getByTestId('preview')).toBeTruthy(); + expect(screen.getByTestId('hue-circular')).toBeTruthy(); + expect(screen.getByTestId('panel-1')).toBeTruthy(); + expect(screen.getByTestId('input-widget')).toBeTruthy(); + expect(mockInputWidgetProps.formats).toEqual(['HEX', 'RGB']); + expect(mockInputWidgetProps.disableAlphaChannel).toBe(true); + expect(mockHueCircularProps.containerStyle).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: 'overlay(#ffffff)' }), + ]), + ); + }); + + it('submits the latest color selected by the circular picker', () => { + const onSubmit = jest.fn(); + render(); + + fireEvent.press(screen.getByTestId('pick-color')); + fireEvent.press(screen.getByTestId('save')); + + expect(onSubmit).toHaveBeenCalledWith('#12ab34'); + }); + + it('syncs a valid HEX input and ignores an invalid one', () => { + const onSubmit = jest.fn(); + render(); + + fireEvent.press(screen.getByTestId('enter-valid-hex')); + fireEvent.press(screen.getByTestId('enter-invalid-hex')); + fireEvent.press(screen.getByTestId('save')); + + expect(onSubmit).toHaveBeenCalledWith('#a1b2c3'); + }); + + it('restores the current color after dismissing and reopening', () => { + const closeModal = jest.fn(); + const view = render( + , + ); + + fireEvent.press(screen.getByTestId('pick-color')); + fireEvent.press(screen.getByTestId('dismiss')); + + expect(closeModal).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + view.rerender( + , + ); + + expect(mockColorPickerProps.value).toBe('#336699'); + }); + + it('keeps the existing reset contract', () => { + const onSubmit = jest.fn(); + render(); + + fireEvent.press(screen.getByTestId('pick-color')); + fireEvent.press(screen.getByTestId('reset')); + + expect(onSubmit).toHaveBeenCalledWith(undefined); + expect(mockColorPickerProps.value).toBe('#336699'); + }); +}); diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index b8a335cbee..868ce1a6d1 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -321,8 +321,6 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { closeModal={hideAccentColorModal} color={theme.primary} onSubmit={val => setCustomAccentColor(val)} - theme={theme} - showAccentColors={true} /> { visible={readerBackgroundModal.value} color={readerSettings.theme} closeModal={readerBackgroundModal.setFalse} - theme={theme} onSubmit={color => readerSettings.setChapterReaderSettings({ theme: color }) } @@ -108,7 +107,6 @@ const ThemeTab: React.FC = () => { visible={readerTextColorModal.value} color={readerSettings.textColor} closeModal={readerTextColorModal.setFalse} - theme={theme} onSubmit={color => readerSettings.setChapterReaderSettings({ textColor: color }) } From 832457ec08713225c8ab019409dc7f63450b9e40 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:29:36 +0700 Subject: [PATCH 7/7] fix: test --- .../ColorPickerModal/__tests__/ColorPickerModal.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx index e148727428..d1f27af6af 100644 --- a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx +++ b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx @@ -164,7 +164,6 @@ describe('ColorPickerModal', () => { expect(screen.getByTestId('hue-circular')).toBeTruthy(); expect(screen.getByTestId('panel-1')).toBeTruthy(); expect(screen.getByTestId('input-widget')).toBeTruthy(); - expect(mockInputWidgetProps.formats).toEqual(['HEX', 'RGB']); expect(mockInputWidgetProps.disableAlphaChannel).toBe(true); expect(mockHueCircularProps.containerStyle).toEqual( expect.arrayContaining([