refactor: Global Search Screen

Closes #302
This commit is contained in:
Rajarshee Chatterjee
2022-06-18 12:32:15 +05:30
parent 054124a004
commit bece88663f
16 changed files with 443 additions and 262 deletions
+1 -1
View File
@@ -254,7 +254,7 @@ try {
return novels;`
: `
showToast('Search is not available in this source')
showToast('${source.sourceName}: Search is not available in this source')
return [];
`
+2 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
const useSearch = () => {
const [searchText, setSearchText] = useState<string>('');
const useSearch = (defaultSearchText?: string) => {
const [searchText, setSearchText] = useState<string>(defaultSearchText || '');
const clearSearchbar = useCallback(() => setSearchText(''), []);
return { searchText, setSearchText, clearSearchbar };
+10 -3
View File
@@ -20,7 +20,7 @@ import MoreStack from './MoreStack';
import Novel from '../screens/novel/NovelScreen';
import Reader from '../screens/reader/ReaderScreen';
import BrowseSourceScreen from '../screens/BrowseSourceScreen/BrowseSourceScreen';
import GlobalSearch from '../screens/browse/globalsearch/GlobalSearch';
import GlobalSearchScreen from '../screens/GlobalSearchScreen/GlobalSearchScreen';
import Migration from '../screens/browse/migration/Migration';
import SourceNovels from '../screens/browse/SourceNovels';
import MigrateNovel from '../screens/browse/migration/MigrationNovels';
@@ -35,10 +35,14 @@ const MainNavigator = () => {
const theme = useTheme();
useEffect(() => {
setTimeout(async () => {
const timer = setTimeout(async () => {
await SplashScreen.hideAsync();
setBarColor(theme);
}, 500);
return () => {
clearTimeout(timer);
};
}, [theme]);
const { isNewVersion, latestRelease } = useGithubUpdateChecker() || {};
@@ -56,7 +60,10 @@ const MainNavigator = () => {
<Stack.Screen name="SourceScreen" component={BrowseSourceScreen} />
<Stack.Screen name="BrowseMal" component={MalTopNovels} />
<Stack.Screen name="BrowseSettings" component={BrowseSettings} />
<Stack.Screen name="GlobalSearch" component={GlobalSearch} />
<Stack.Screen
name="GlobalSearchScreen"
component={GlobalSearchScreen}
/>
<Stack.Screen name="Migration" component={Migration} />
<Stack.Screen name="SourceNovels" component={SourceNovels} />
<Stack.Screen name="MigrateNovel" component={MigrateNovel} />
@@ -0,0 +1,66 @@
import React from 'react';
import { ProgressBar } from 'react-native-paper';
import { EmptyView, SearchbarV2 } from '@components/index';
import GlobalSearchResultsList from './components/GlobalSearchResultsList';
import useSearch from '@hooks/useSearch';
import { useTheme } from '@redux/hooks';
import { getString } from '@strings/translations';
import { useGlobalSearch } from './hooks/useGlobalSearch';
interface Props {
route?: {
params?: {
searchText?: string;
};
};
}
const GlobalSearchScreen = (props: Props) => {
const theme = useTheme();
const { searchText, setSearchText, clearSearchbar } = useSearch(
props?.route?.params?.searchText,
);
const onChangeText = (text: string) => setSearchText(text);
const onSubmitEditing = () => globalSearch(searchText);
const { searchResults, globalSearch, searchAllSources, progress } =
useGlobalSearch({
defaultSearchText: searchText,
});
return (
<>
<SearchbarV2
searchText={searchText}
placeholder={getString('browseScreen.globalSearch')}
leftIcon="magnify"
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
clearSearchbar={clearSearchbar}
theme={theme}
/>
{progress ? (
<ProgressBar color={theme.colorAccent} progress={progress} />
) : null}
<GlobalSearchResultsList
searchResults={searchResults}
ListEmptyComponent={
<EmptyView
icon="__φ(..)"
description={`${getString('globalSearch.searchIn')} ${getString(
searchAllSources
? 'globalSearch.allSources'
: 'globalSearch.pinnedSources',
)}`}
theme={theme}
/>
}
/>
</>
);
};
export default GlobalSearchScreen;
@@ -0,0 +1,78 @@
import { Dimensions, Pressable, StyleSheet, Text, View } from 'react-native';
import React, { useMemo } from 'react';
import FastImage from 'react-native-fast-image';
import { coverPlaceholderColor } from '../../../theme/colors';
import { SourceNovelItem } from '../../../sources/types';
import { ThemeType } from '../../../theme/types';
interface Props {
novel: SourceNovelItem;
navigateToNovel: (novel: SourceNovelItem) => void;
theme: ThemeType;
}
const GlobalSearchNovelItem: React.FC<Props> = ({
novel,
navigateToNovel,
theme,
}) => {
const novelItemDimensions = useMemo(
() => ({
width: Dimensions.get('window').width / 3 - 16,
height: ((Dimensions.get('window').width / 3 - 16) * 4) / 3,
}),
[],
);
return (
<View style={styles.novelItem}>
<Pressable
style={styles.pressable}
android_ripple={{ color: theme.rippleColor }}
onPress={() => navigateToNovel(novel)}
>
<FastImage
source={{ uri: novel.novelCover }}
style={[styles.novelCover, { ...novelItemDimensions }]}
/>
<Text
style={[
{ color: theme.textColorPrimary, width: novelItemDimensions.width },
styles.novelName,
]}
numberOfLines={2}
>
{novel.novelName}
</Text>
</Pressable>
</View>
);
};
export default GlobalSearchNovelItem;
const styles = StyleSheet.create({
novelItem: {
flex: 1,
borderRadius: 6,
overflow: 'hidden',
},
pressable: {
padding: 4,
flex: 1,
},
novelName: {
paddingHorizontal: 4,
paddingVertical: 8,
flexWrap: 'wrap',
fontSize: 12,
fontWeight: 'bold',
},
novelCover: {
borderRadius: 4,
backgroundColor: coverPlaceholderColor,
},
});
@@ -0,0 +1,165 @@
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import React, { useCallback, useMemo } from 'react';
import { StackNavigationProp } from '@react-navigation/stack';
import { useNavigation } from '@react-navigation/native';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { getString } from '@strings/translations';
import { useTheme } from '@redux/hooks';
import { GlobalSearchResult } from '../hooks/useGlobalSearch';
import GlobalSearchNovelItem from './GlobalSearchNovelItem';
interface GlobalSearchResultsListProps {
searchResults: GlobalSearchResult[];
ListEmptyComponent?: JSX.Element;
}
const GlobalSearchResultsList: React.FC<GlobalSearchResultsListProps> = ({
searchResults,
ListEmptyComponent,
}) => {
const theme = useTheme();
const navigation = useNavigation<StackNavigationProp<any>>();
const keyExtractor = useCallback(item => item.source.sourceId.toString(), []);
const errorColor = useMemo(
() => (theme.statusBar === 'dark-content' ? '#B3261E' : '#F2B8B5'),
[],
);
const navigateToNovel = useCallback(
item => navigation.push('Novel', item),
[],
);
return (
<FlatList<GlobalSearchResult>
keyExtractor={keyExtractor}
data={searchResults}
contentContainerStyle={styles.resultList}
renderItem={({ item }) => (
<>
<View>
<Pressable
android_ripple={{ color: theme.rippleColor }}
style={styles.sourceHeader}
onPress={() =>
navigation.navigate('SourceScreen', {
sourceId: item.source.sourceId,
sourceName: item.source.sourceName,
url: item.source.url,
})
}
>
<View>
<Text
style={[styles.sourceName, { color: theme.textColorPrimary }]}
>
{item.source.sourceName}
</Text>
<Text
style={[styles.language, { color: theme.textColorSecondary }]}
>
{item.source.lang}
</Text>
</View>
<MaterialCommunityIcons
name="arrow-right"
size={24}
color={theme.textColorPrimary}
/>
</Pressable>
{item.isLoading ? (
<ActivityIndicator
color={theme.colorAccent}
style={styles.loading}
/>
) : item.error ? (
<Text style={[styles.error, { color: errorColor }]}>
{item.error}
</Text>
) : (
<FlatList
horizontal
contentContainerStyle={styles.novelsContainer}
keyExtractor={novelItem =>
novelItem.novelUrl + novelItem.sourceId
}
data={item.novels}
ListEmptyComponent={
<Text
style={[
styles.listEmpty,
{ color: theme.textColorSecondary },
]}
>
{getString('sourceScreen.noResultsFound')}
</Text>
}
renderItem={({ item: novelItem }) => (
<GlobalSearchNovelItem
novel={novelItem}
navigateToNovel={navigateToNovel}
theme={theme}
/>
)}
/>
)}
</View>
</>
)}
ListEmptyComponent={ListEmptyComponent}
/>
);
};
export default GlobalSearchResultsList;
const styles = StyleSheet.create({
resultList: {
flexGrow: 1,
paddingTop: 8,
paddingBottom: 60,
},
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 16,
},
sourceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: 8,
},
sourceName: {
marginTop: 8,
marginBottom: 4,
paddingHorizontal: 16,
},
language: {
fontSize: 12,
marginBottom: 8,
paddingHorizontal: 16,
},
error: {
padding: 16,
marginBottom: 16,
},
novelsContainer: {
padding: 8,
},
listEmpty: {
paddingHorizontal: 8,
marginBottom: 16,
},
});
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useBrowseSettings, useSourcesReducer } from '@redux/hooks';
import { Source, SourceNovelItem } from '../../../sources/types';
import { sourceManager } from '../../../sources/sourceManager';
interface Props {
defaultSearchText?: string;
}
export interface GlobalSearchResult {
isLoading: boolean;
source: Source;
novels: SourceNovelItem[];
error?: string | null;
}
export const useGlobalSearch = ({ defaultSearchText }: Props) => {
const isMounted = useRef(true);
const { searchAllSources = false } = useBrowseSettings();
const { allSources, pinnedSourceIds = [] } = useSourcesReducer();
const isPinned = useCallback(
(sourceId: number) => pinnedSourceIds.indexOf(sourceId) > -1,
[pinnedSourceIds],
);
const pinnedSources = useMemo(
() => allSources.filter(source => isPinned(source.sourceId)),
[allSources, isPinned],
);
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([]);
const [progress, setProgress] = useState(0);
const globalSearch = (searchText: string) => {
const sources = searchAllSources ? allSources : pinnedSources;
const defaultResult: GlobalSearchResult[] = sources.map(source => ({
isLoading: true,
source,
novels: [],
error: null,
}));
setSearchResults(defaultResult);
sources.forEach(async source => {
if (isMounted.current) {
try {
const res = await sourceManager(source.sourceId).searchNovels(
searchText,
);
setSearchResults(prevState =>
prevState.map(prevResult =>
prevResult.source.sourceId === source.sourceId
? { ...prevResult, novels: res, isLoading: false }
: { ...prevResult },
),
);
setSearchResults(prevState =>
prevState.sort(
(
{ novels: a, source: { sourceName: aName } },
{ novels: b, source: { sourceName: bName } },
) => {
if (!a.length) {
return 1;
}
if (!b.length) {
return -1;
}
return aName.localeCompare(bName);
},
),
);
} catch (error) {
setSearchResults(prevState =>
prevState.map(prevResult =>
prevResult.source.sourceId === source.sourceId
? {
...prevResult,
novels: [],
isLoading: false,
error: error.message,
}
: { ...prevResult },
),
);
} finally {
setProgress(prevState => prevState + 1 / sources.length);
}
}
});
};
useEffect(() => {
if (defaultSearchText) {
globalSearch(defaultSearchText);
}
}, []);
return { searchResults, globalSearch, searchAllSources, progress };
};
+1 -1
View File
@@ -79,7 +79,7 @@ const BrowseScreen = () => {
() => [
{
iconName: 'book-search',
onPress: () => navigate('GlobalSearch' as never),
onPress: () => navigate('GlobalSearchScreen' as never),
},
{
iconName: 'swap-vertical-variant',
+1 -1
View File
@@ -69,7 +69,7 @@ const BrowseMalScreen = ({ navigation, route }) => {
novel={item}
theme={theme}
onPress={() =>
navigation.navigate('GlobalSearch', {
navigation.navigate('GlobalSearchScreen', {
novelName: item.novelName,
})
}
@@ -1,149 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { FlatList } from 'react-native';
import { ProgressBar } from 'react-native-paper';
import { Searchbar } from '../../../components/Searchbar/Searchbar';
import EmptyView from '../../../components/EmptyView';
import { ScreenContainer } from '../../../components/Common';
import GlobalSearchSourceItem from './GlobalSearchSourceItem';
import { useLibrary, useTheme } from '../../../hooks/reduxHooks';
import { sourceManager } from '../../../sources/sourceManager';
import { useBrowseSettings, useSourcesReducer } from '../../../redux/hooks';
const GlobalSearch = ({ route, navigation }) => {
const theme = useTheme();
let novelName = '';
if (route.params) {
novelName = route.params.novelName;
}
const { allSources, pinnedSourceIds = [] } = useSourcesReducer();
const isPinned = sourceId => pinnedSourceIds.indexOf(sourceId) > -1;
const pinnedSources = allSources.filter(source => isPinned(source.sourceId));
const { searchAllSources = false } = useBrowseSettings();
const [searchText, setSearchText] = useState(novelName);
const [searchResults, setSearchResults] = useState([]);
const [progress, setProgress] = useState(0);
const library = useLibrary();
const isMounted = useRef(true);
useEffect(() => {
if (novelName) {
onSubmitEditing();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const clearSearchbar = () => setSearchText('');
const onChangeText = text => setSearchText(text);
const onSubmitEditing = async () => {
setProgress(0);
let globalSearchSources = searchAllSources ? allSources : pinnedSources;
setSearchResults(
globalSearchSources.map(item => ({
sourceId: item.sourceId,
sourceName: item.sourceName,
lang: item.lang,
loading: true,
novels: [],
error: null,
})),
);
globalSearchSources.map(async item => {
if (isMounted.current === true) {
try {
const source = sourceManager(item.sourceId);
const data = await source.searchNovels(searchText);
setSearchResults(prevState =>
prevState.map(sourceItem =>
sourceItem.sourceId === item.sourceId
? { ...sourceItem, novels: data, loading: false }
: { ...sourceItem },
),
);
} catch (e) {
setSearchResults(prevState =>
prevState.map(sourceItem =>
sourceItem.sourceId === item.sourceId
? {
...sourceItem,
loading: false,
error: e.message,
}
: sourceItem,
),
);
}
setProgress(before => before + 1 / globalSearchSources.length);
}
});
};
const renderItem = ({ item }) => (
<GlobalSearchSourceItem
source={item}
library={library}
theme={theme}
navigation={navigation}
/>
);
return (
<ScreenContainer theme={theme}>
<Searchbar
theme={theme}
placeholder="Global Search"
backAction="arrow-left"
onBackAction={navigation.goBack}
searchText={searchText}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
clearSearchbar={clearSearchbar}
/>
{progress > 0 && (
<ProgressBar color={theme.colorAccent} progress={progress} />
)}
<FlatList
contentContainerStyle={{ flexGrow: 1, padding: 4 }}
data={searchResults}
keyExtractor={item => item.sourceId.toString()}
renderItem={renderItem}
extraData={pinnedSources}
ListEmptyComponent={
<EmptyView
icon="__φ(..)"
description={`Search a novel in ${
searchAllSources
? 'all sources'
: pinnedSources.length === 0
? 'pinned sources\n(No sources pinned)'
: 'pinned sources'
}`}
/>
}
/>
</ScreenContainer>
);
};
export default GlobalSearch;
@@ -1,51 +0,0 @@
import React from 'react';
import { StyleSheet, FlatList, Text } from 'react-native';
import GlobalSearchNovelCover from './GlobalSearchNovelCover';
const GlobalSearchNovelList = ({ data, theme, library, navigation }) => {
const inLibrary = (sourceId, novelUrl) =>
library.some(obj => obj.novelUrl === novelUrl && obj.sourceId === sourceId);
const navigateToNovel = item => navigation.push('Novel', item);
const renderItem = ({ item }) => (
<GlobalSearchNovelCover
novel={item}
theme={theme}
onPress={() => navigateToNovel(item)}
inLibrary={inLibrary(item.sourceId, item.novelUrl)}
/>
);
const ListEmptyComponent = (
<Text style={[{ color: theme.textColorSecondary }, styles.emptyList]}>
No results found
</Text>
);
return (
<FlatList
contentContainerStyle={styles.listContainer}
horizontal={true}
data={data}
keyExtractor={item => item.sourceId + item.novelUrl}
renderItem={renderItem}
ListEmptyComponent={ListEmptyComponent}
/>
);
};
export default GlobalSearchNovelList;
const styles = StyleSheet.create({
listContainer: {
flexGrow: 1,
paddingVertical: 8,
paddingHorizontal: 4,
},
emptyList: {
padding: 8,
paddingVertical: 4,
},
});
@@ -1,51 +0,0 @@
import React from 'react';
import { StyleSheet, View, Text, ActivityIndicator } from 'react-native';
import GlobalSearchNovelList from './GlobalSearchNovelList';
const GlobalSearchSourceItem = ({ source, library, theme, navigation }) => {
const { sourceName, lang, loading, novels, error } = source;
const colorError = {
color: theme.statusBar === 'dark-content' ? '#B3261E' : '#F2B8B5',
};
return (
<>
<View style={styles.sourceContainer}>
<Text style={{ color: theme.textColorPrimary }}>{sourceName}</Text>
<Text style={{ color: theme.textColorSecondary, fontSize: 12 }}>
{lang}
</Text>
</View>
{error ? (
<Text style={[styles.error, colorError]}>{error}</Text>
) : loading ? (
<ActivityIndicator
color={theme.colorAccent}
style={{ marginVertical: 16 }}
/>
) : (
<GlobalSearchNovelList
data={novels}
theme={theme}
library={library}
navigation={navigation}
/>
)}
</>
);
};
export default GlobalSearchSourceItem;
const styles = StyleSheet.create({
sourceContainer: {
padding: 8,
paddingVertical: 16,
},
error: {
paddingHorizontal: 8,
paddingVertical: 16,
},
});
+2 -2
View File
@@ -185,8 +185,8 @@ const LibraryScreen = ({ navigation }) => {
<View style={styles.globalSearch}>
<Pressable
onPress={() =>
navigation.navigate('GlobalSearch', {
novelName: searchText,
navigation.navigate('GlobalSearchScreen', {
searchText,
})
}
android_ripple={{ color: theme.rippleColor }}
@@ -70,7 +70,7 @@ const NovelInfoHeader = ({
<NovelTitle
theme={theme}
onPress={() =>
navigation.navigate('GlobalSearch', {
navigation.navigate('GlobalSearchScreen', {
novelName: item.novelName,
})
}
+5
View File
@@ -119,5 +119,10 @@
},
"sourceScreen": {
"noResultsFound": "No results found"
},
"globalSearch": {
"searchIn": "Search a novel in",
"allSources": "all sources",
"pinnedSources": "pinned sources"
}
}
+3
View File
@@ -87,4 +87,7 @@ export interface StringMap {
'moreScreen.settingsScreen.browseSettingsScreen.searchAllSources': 'string';
'moreScreen.settingsScreen.browseSettingsScreen.searchAllWarning': 'string';
'sourceScreen.noResultsFound': 'string';
'globalSearch.searchIn': 'string';
'globalSearch.allSources': 'string';
'globalSearch.pinnedSources': 'string';
}