feat: mobile app package (#1075)
This commit is contained in:
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user