258 lines
7.8 KiB
TypeScript
258 lines
7.8 KiB
TypeScript
import { AppStateEventType, AppStateType } from '@Lib/ApplicationState'
|
|
import { useSignedIn } from '@Lib/SnjsHelperHooks'
|
|
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
|
import { ApplicationContext } from '@Root/ApplicationContext'
|
|
import { AppStackNavigationProp } from '@Root/AppStack'
|
|
import { Chip } from '@Root/Components/Chip'
|
|
import { SearchBar } from '@Root/Components/SearchBar'
|
|
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
|
import { CollectionSortProperty, SNNote } from '@standardnotes/snjs'
|
|
import React, { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
|
import { Animated, FlatList, ListRenderItem, RefreshControl } from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import IosSearchBar from 'react-native-search-bar'
|
|
import AndroidSearchBar from 'react-native-search-box'
|
|
import { ThemeContext } from 'styled-components'
|
|
import { NoteCell } from './NoteCell'
|
|
import {
|
|
Container,
|
|
HeaderContainer,
|
|
LoadingContainer,
|
|
LoadingText,
|
|
SearchBarContainer,
|
|
SearchOptionsContainer,
|
|
styles,
|
|
} from './NoteList.styled'
|
|
import { OfflineBanner } from './OfflineBanner'
|
|
|
|
type Props = {
|
|
onSearchChange: (text: string) => void
|
|
onSearchCancel: () => void
|
|
searchText: string
|
|
searchOptions: {
|
|
selected: boolean
|
|
onPress: () => void
|
|
label: string
|
|
}[]
|
|
onPressItem: (noteUuid: SNNote['uuid']) => void
|
|
selectedNoteId: string | undefined
|
|
sortType: CollectionSortProperty
|
|
hideDates: boolean
|
|
hidePreviews: boolean
|
|
hideEditorIcon: boolean
|
|
decrypting: boolean
|
|
loading: boolean
|
|
hasRefreshControl: boolean
|
|
notes: SNNote[]
|
|
refreshing: boolean
|
|
onRefresh: () => void
|
|
shouldFocusSearch: boolean
|
|
setShouldFocusSearch: Dispatch<SetStateAction<boolean>>
|
|
}
|
|
|
|
export const NoteList = (props: Props) => {
|
|
// Context
|
|
const [signedIn] = useSignedIn()
|
|
const application = useContext(ApplicationContext)
|
|
const theme = useContext(ThemeContext)
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const [collapseSearchBarOnBlur, setCollapseSearchBarOnBlur] = useState(true)
|
|
const [noteListScrolled, setNoteListScrolled] = useState(false)
|
|
|
|
// Ref
|
|
const opacityAnimationValue = useRef(new Animated.Value(0)).current
|
|
const marginTopAnimationValue = useRef(new Animated.Value(-40)).current
|
|
const iosSearchBarInputRef = useRef<IosSearchBar>(null)
|
|
const androidSearchBarInputRef = useRef<typeof AndroidSearchBar>(null)
|
|
const noteListRef = useRef<FlatList>(null)
|
|
|
|
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
|
|
|
const dismissKeyboard = () => {
|
|
iosSearchBarInputRef.current?.blur()
|
|
}
|
|
|
|
useEffect(() => {
|
|
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
|
setCollapseSearchBarOnBlur(false)
|
|
})
|
|
|
|
return removeBlurScreenListener
|
|
})
|
|
|
|
useEffect(() => {
|
|
const unsubscribeStateEventObserver = application?.getAppState().addStateEventObserver((state) => {
|
|
if (state === AppStateEventType.DrawerOpen) {
|
|
dismissKeyboard()
|
|
}
|
|
})
|
|
|
|
return unsubscribeStateEventObserver
|
|
}, [application])
|
|
|
|
const scrollListToTop = useCallback(() => {
|
|
if (noteListScrolled && props.notes && props.notes.length > 0) {
|
|
noteListRef.current?.scrollToIndex({ animated: false, index: 0 })
|
|
setNoteListScrolled(false)
|
|
}
|
|
}, [noteListScrolled, props.notes])
|
|
|
|
useEffect(() => {
|
|
const unsubscribeTagChangedEventObserver = application?.getAppState().addStateChangeObserver((event) => {
|
|
if (event === AppStateType.TagChanged) {
|
|
scrollListToTop()
|
|
}
|
|
})
|
|
|
|
return unsubscribeTagChangedEventObserver
|
|
}, [application, scrollListToTop])
|
|
|
|
const { shouldFocusSearch, searchText } = props
|
|
|
|
const focusSearch = useCallback(() => {
|
|
setCollapseSearchBarOnBlur(true)
|
|
|
|
if (shouldFocusSearch) {
|
|
iosSearchBarInputRef.current?.focus()
|
|
androidSearchBarInputRef.current?.focus(searchText)
|
|
}
|
|
}, [shouldFocusSearch, searchText])
|
|
|
|
useFocusEffect(focusSearch)
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
return dismissKeyboard
|
|
}, []),
|
|
)
|
|
|
|
const onChangeSearchText = (text: string) => {
|
|
props.onSearchChange(text)
|
|
scrollListToTop()
|
|
}
|
|
|
|
const toggleSearchOptions = (showOptions: boolean) => {
|
|
Animated.parallel([
|
|
Animated.timing(opacityAnimationValue, {
|
|
toValue: showOptions ? 1 : 0,
|
|
duration: 200,
|
|
useNativeDriver: false,
|
|
}),
|
|
Animated.timing(marginTopAnimationValue, {
|
|
toValue: showOptions ? 0 : -40,
|
|
duration: 200,
|
|
useNativeDriver: false,
|
|
}),
|
|
]).start()
|
|
}
|
|
|
|
const onSearchFocus = () => {
|
|
toggleSearchOptions(true)
|
|
props.setShouldFocusSearch(false)
|
|
}
|
|
|
|
const onSearchBlur = () => {
|
|
toggleSearchOptions(false)
|
|
}
|
|
|
|
const onScroll = () => {
|
|
setNoteListScrolled(true)
|
|
}
|
|
|
|
const renderItem: ListRenderItem<SNNote> | null | undefined = ({ item }) => {
|
|
if (!item) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<NoteCell
|
|
note={item}
|
|
onPressItem={props.onPressItem}
|
|
sortType={props.sortType}
|
|
hideDates={props.hideDates}
|
|
hidePreviews={props.hidePreviews}
|
|
hideEditorIcon={props.hideEditorIcon}
|
|
highlighted={item.uuid === props.selectedNoteId}
|
|
/>
|
|
)
|
|
}
|
|
let placeholderText = ''
|
|
if (props.decrypting) {
|
|
placeholderText = 'Decrypting notes...'
|
|
} else if (props.loading) {
|
|
placeholderText = 'Loading notes...'
|
|
} else if (props.notes.length === 0) {
|
|
placeholderText = 'No notes.'
|
|
}
|
|
return (
|
|
<Container>
|
|
<HeaderContainer>
|
|
<SearchBarContainer>
|
|
<SearchBar
|
|
onChangeText={onChangeSearchText}
|
|
onSearchCancel={props.onSearchCancel}
|
|
onSearchFocusCallback={onSearchFocus}
|
|
onSearchBlurCallback={onSearchBlur}
|
|
iosSearchBarInputRef={iosSearchBarInputRef}
|
|
androidSearchBarInputRef={androidSearchBarInputRef}
|
|
collapseSearchBarOnBlur={collapseSearchBarOnBlur}
|
|
/>
|
|
</SearchBarContainer>
|
|
<SearchOptionsContainer
|
|
as={Animated.ScrollView}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
keyboardShouldPersistTaps={'always'}
|
|
style={{
|
|
opacity: opacityAnimationValue,
|
|
marginTop: marginTopAnimationValue,
|
|
}}
|
|
>
|
|
{props.searchOptions.map(({ selected, onPress, label }, index) => (
|
|
<Chip
|
|
key={label}
|
|
selected={selected}
|
|
onPress={onPress}
|
|
label={label}
|
|
last={index === props.searchOptions.length - 1}
|
|
/>
|
|
))}
|
|
</SearchOptionsContainer>
|
|
</HeaderContainer>
|
|
<FlatList
|
|
ref={noteListRef}
|
|
style={styles.list}
|
|
keyExtractor={(item) => item?.uuid}
|
|
contentContainerStyle={[{ paddingBottom: insets.bottom }, props.notes.length > 0 ? {} : { height: '100%' }]}
|
|
initialNumToRender={6}
|
|
windowSize={6}
|
|
maxToRenderPerBatch={6}
|
|
ListEmptyComponent={() => {
|
|
return placeholderText.length > 0 ? (
|
|
<LoadingContainer>
|
|
<LoadingText>{placeholderText}</LoadingText>
|
|
</LoadingContainer>
|
|
) : null
|
|
}}
|
|
keyboardDismissMode={'interactive'}
|
|
keyboardShouldPersistTaps={'never'}
|
|
refreshControl={
|
|
!props.hasRefreshControl ? undefined : (
|
|
<RefreshControl
|
|
tintColor={theme.stylekitContrastForegroundColor}
|
|
refreshing={props.refreshing}
|
|
onRefresh={props.onRefresh}
|
|
/>
|
|
)
|
|
}
|
|
data={props.notes}
|
|
renderItem={renderItem}
|
|
extraData={signedIn}
|
|
ListHeaderComponent={() => <HeaderContainer>{!signedIn && <OfflineBanner />}</HeaderContainer>}
|
|
onScroll={onScroll}
|
|
/>
|
|
</Container>
|
|
)
|
|
}
|