feat: mobile app package (#1075)

This commit is contained in:
Mo
2022-06-09 09:45:15 -05:00
committed by GitHub
parent 58b63898de
commit 8248a38280
336 changed files with 47696 additions and 22563 deletions

View File

@@ -0,0 +1,79 @@
import { hexToRGBA } from '@Style/Utils'
import { StyleSheet } from 'react-native'
import styled from 'styled-components/native'
export const TouchableContainer = styled.TouchableWithoutFeedback``
export const Container = styled.View<{ selected: boolean; distance: number }>`
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: ${props => props.distance}px 0 0 ${props => props.distance}px;
background-color: ${({ theme, selected }) => {
return selected ? theme.stylekitInfoColor : theme.stylekitBackgroundColor
}};
`
export const NoteDataContainer = styled.View<{ distance: number }>`
border-bottom-color: ${({ theme }) => hexToRGBA(theme.stylekitBorderColor, 0.75)};
border-bottom-width: 1px;
padding-bottom: ${props => props.distance}px;
flex-grow: 1;
flex-shrink: 1;
padding-right: ${props => props.distance}px;
`
export const DeletedText = styled.Text`
color: ${({ theme }) => theme.stylekitInfoColor};
margin-bottom: 5px;
`
export const NoteText = styled.Text<{ selected: boolean }>`
font-size: 15px;
color: ${({ theme, selected }) => {
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
}};
opacity: 0.8;
line-height: 19px;
`
export const TitleText = styled.Text<{ selected: boolean }>`
font-weight: bold;
font-size: 16px;
color: ${({ theme, selected }) => {
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
}};
flex-grow: 1;
flex-shrink: 1;
margin-bottom: 4px;
`
export const TagsContainter = styled.View`
flex: 1;
flex-direction: row;
margin-top: 7px;
`
export const TagText = styled.Text<{ selected: boolean }>`
margin-right: 2px;
font-size: 12px;
color: ${({ theme, selected }) => {
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
}};
opacity: ${props => (props.selected ? 0.8 : 0.5)};
`
export const DetailsText = styled(TagText)`
margin-right: 0;
margin-top: 5px;
`
export const FlexContainer = styled.View`
display: flex;
flex-direction: row;
justify-content: space-between;
`
export const NoteContentsContainer = styled.View`
display: flex;
flex-shrink: 1;
`
export const styles = StyleSheet.create({
editorIcon: {
marginTop: 2,
marginRight: 10,
width: 16,
height: 16,
},
})

View File

@@ -0,0 +1,241 @@
import { useChangeNote, useDeleteNoteWithPrivileges, useProtectOrUnprotectNote } from '@Lib/SnjsHelperHooks'
import { ApplicationContext } from '@Root/ApplicationContext'
import { SnIcon } from '@Root/Components/SnIcon'
import { NoteCellIconFlags } from '@Root/Screens/Notes/NoteCellIconFlags'
import { CollectionSort, CollectionSortProperty, IconType, isNullOrUndefined, SNNote } from '@standardnotes/snjs'
import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet'
import { getTintColorForEditor } from '@Style/Utils'
import React, { useContext, useRef, useState } from 'react'
import { Text, View } from 'react-native'
import { ThemeContext } from 'styled-components'
import {
Container,
DetailsText,
FlexContainer,
NoteContentsContainer,
NoteDataContainer,
NoteText,
styles,
TitleText,
TouchableContainer,
} from './NoteCell.styled'
import { NoteCellFlags } from './NoteCellFlags'
type Props = {
note: SNNote
highlighted?: boolean
onPressItem: (noteUuid: SNNote['uuid']) => void
hideDates: boolean
hidePreviews: boolean
hideEditorIcon: boolean
sortType: CollectionSortProperty
}
export const NoteCell = ({
note,
onPressItem,
highlighted,
sortType,
hideDates,
hidePreviews,
hideEditorIcon,
}: Props) => {
// Context
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const [changeNote] = useChangeNote(note)
const [protectOrUnprotectNote] = useProtectOrUnprotectNote(note)
// State
const [selected, setSelected] = useState(false)
// Ref
const selectionTimeout = useRef<ReturnType<typeof setTimeout>>()
const elementRef = useRef<View>(null)
const { showActionSheet } = useCustomActionSheet()
const [deleteNote] = useDeleteNoteWithPrivileges(
note,
async () => {
await application?.mutator.deleteItem(note)
},
() => {
void changeNote(mutator => {
mutator.trashed = true
}, false)
},
undefined,
)
const highlight = Boolean(selected || highlighted)
const _onPress = () => {
setSelected(true)
selectionTimeout.current = setTimeout(() => {
setSelected(false)
onPressItem(note.uuid)
}, 25)
}
const _onPressIn = () => {
setSelected(true)
}
const _onPressOut = () => {
setSelected(false)
}
const onLongPress = () => {
if (note.protected) {
showActionSheet({
title: note.title,
options: [
{
text: 'Note Protected',
},
],
anchor: elementRef.current ?? undefined,
})
} else {
let options: CustomActionSheetOption[] = []
options.push({
text: note.pinned ? 'Unpin' : 'Pin',
key: 'pin',
callback: () =>
changeNote(mutator => {
mutator.pinned = !note.pinned
}, false),
})
options.push({
text: note.archived ? 'Unarchive' : 'Archive',
key: 'archive',
callback: () => {
if (note.locked) {
void application?.alertService.alert(
`This note has editing disabled. If you'd like to ${
note.archived ? 'unarchive' : 'archive'
} it, enable editing on it, and try again.`,
)
return
}
void changeNote(mutator => {
mutator.archived = !note.archived
}, false)
},
})
options.push({
text: note.locked ? 'Enable editing' : 'Prevent editing',
key: 'lock',
callback: () =>
changeNote(mutator => {
mutator.locked = !note.locked
}, false),
})
options.push({
text: note.protected ? 'Unprotect' : 'Protect',
key: 'protect',
callback: async () => await protectOrUnprotectNote(),
})
if (!note.trashed) {
options.push({
text: 'Move to Trash',
key: 'trash',
destructive: true,
callback: async () => deleteNote(false),
})
} else {
options = options.concat([
{
text: 'Restore',
key: 'restore-note',
callback: () => {
void changeNote(mutator => {
mutator.trashed = false
}, false)
},
},
{
text: 'Delete permanently',
key: 'delete-forever',
destructive: true,
callback: async () => deleteNote(true),
},
])
}
showActionSheet({
title: note.title,
options,
anchor: elementRef.current ?? undefined,
})
}
}
const padding = 14
const showPreview = !hidePreviews && !note.protected && !note.hidePreview
const hasPlainPreview = !isNullOrUndefined(note.preview_plain) && note.preview_plain.length > 0
const showDetails = !hideDates || note.protected
const editorForNote = application?.componentManager.editorForNote(note)
const [icon, tint] = application?.iconsController.getIconAndTintForNoteType(
editorForNote?.package_info.note_type,
) as [IconType, number]
return (
<TouchableContainer
onPress={_onPress}
onPressIn={_onPressIn}
onPressOut={_onPressOut}
onLongPress={onLongPress}
delayPressIn={150}
>
<Container ref={elementRef as any} selected={highlight} distance={padding}>
{!hideEditorIcon && <SnIcon type={icon} fill={getTintColorForEditor(theme, tint)} style={styles.editorIcon} />}
<NoteDataContainer distance={padding}>
<NoteCellFlags note={note} highlight={highlight} />
<FlexContainer>
<NoteContentsContainer>
{note.title.length > 0 ? <TitleText selected={highlight}>{note.title}</TitleText> : <View />}
{hasPlainPreview && showPreview && (
<NoteText selected={highlight} numberOfLines={2}>
{note.preview_plain}
</NoteText>
)}
{!hasPlainPreview && showPreview && note.text.length > 0 && (
<NoteText selected={highlight} numberOfLines={2}>
{note.text}
</NoteText>
)}
</NoteContentsContainer>
<NoteCellIconFlags note={note} />
</FlexContainer>
{showDetails && (
<DetailsText numberOfLines={1} selected={highlight}>
{note.protected && (
<Text>
Protected
{!hideDates && ' • '}
</Text>
)}
{!hideDates && (
<Text>
{sortType === CollectionSort.UpdatedAt ? 'Modified ' + note.updatedAtString : note.createdAtString}
</Text>
)}
</DetailsText>
)}
</NoteDataContainer>
</Container>
</TouchableContainer>
)
}

View File

@@ -0,0 +1,56 @@
import { SNNote } from '@standardnotes/snjs'
import React, { useContext } from 'react'
import { ThemeContext } from 'styled-components'
import styled from 'styled-components/native'
type NoteFlag = {
text: string
color: string
}
const FlagsContainer = styled.View`
flex-direction: row;
margin-bottom: 8px;
`
const FlagContainer = styled.View<{ color: string; selected: boolean }>`
background-color: ${({ theme, selected, color }) => {
return selected ? theme.stylekitInfoContrastColor : color
}};
padding: 4px;
padding-left: 6px;
padding-right: 6px;
border-radius: 3px;
margin-right: 4px;
`
const FlagLabel = styled.Text<{ selected: boolean }>`
color: ${({ theme, selected }) => {
return selected ? theme.stylekitInfoColor : theme.stylekitInfoContrastColor
}};
font-size: 10px;
font-weight: bold;
`
export const NoteCellFlags = ({ note, highlight }: { note: SNNote; highlight: boolean }) => {
const theme = useContext(ThemeContext)
const flags: NoteFlag[] = []
if (note.conflictOf) {
flags.push({
text: 'Conflicted Copy',
color: theme.stylekitDangerColor,
})
}
return flags.length > 0 ? (
<FlagsContainer>
{flags.map(flag => (
<FlagContainer key={flag.text.concat(flag.color)} color={flag.color} selected={highlight}>
<FlagLabel selected={highlight}>{flag.text}</FlagLabel>
</FlagContainer>
))}
</FlagsContainer>
) : (
<></>
)
}

View File

@@ -0,0 +1,59 @@
import { SnIcon } from '@Root/Components/SnIcon'
import { IconType, SNNote } from '@standardnotes/snjs'
import React, { useContext } from 'react'
import { ThemeContext } from 'styled-components'
import styled from 'styled-components/native'
const FlagIconsContainer = styled.View`
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-top: 2px;
`
type Props = {
note: SNNote
}
type TFlagIcon = {
icon: IconType
fillColor?: string
}
export const NoteCellIconFlags = ({ note }: Props) => {
const theme = useContext(ThemeContext)
const { stylekitCorn, stylekitDangerColor, stylekitInfoColor } = theme
const flagIcons = [] as TFlagIcon[]
if (note.archived) {
flagIcons.push({
icon: 'archive',
fillColor: stylekitCorn,
})
}
if (note.locked) {
flagIcons.push({
icon: 'pencil-off',
fillColor: stylekitInfoColor,
})
}
if (note.trashed) {
flagIcons.push({
icon: 'trash-filled',
fillColor: stylekitDangerColor,
})
}
if (note.pinned) {
flagIcons.push({
icon: 'pin-filled',
fillColor: stylekitInfoColor,
})
}
return flagIcons.length ? (
<FlagIconsContainer>
{flagIcons.map((flagIcon, index) => (
<SnIcon key={index} type={flagIcon.icon} fill={flagIcon.fillColor} />
))}
</FlagIconsContainer>
) : null
}

View File

@@ -0,0 +1,60 @@
import { Platform, StyleSheet } from 'react-native'
import styled, { css } from 'styled-components/native'
// no support for generic types in Flatlist
export const styles = StyleSheet.create({
list: {
height: '100%',
},
inputStyle: {
height: 30,
},
androidSearch: {
height: 30,
},
})
export const Container = styled.View`
background-color: ${props => props.theme.stylekitBackgroundColor};
flex: 1;
`
export const LoadingContainer = styled.View`
flex: 1;
align-items: center;
justify-content: center;
`
interface LoadingTextProps {
textAlign?: 'left' | 'center' | 'right' | 'justify'
}
export const LoadingText = styled.Text<LoadingTextProps>`
position: absolute;
opacity: 0.5;
color: ${props => props.theme.stylekitForegroundColor};
text-align: ${props => props.textAlign ?? 'left'};
`
export const HeaderContainer = styled.View`
padding-top: 3px;
padding-left: 5px;
padding-right: 5px;
`
export const SearchBarContainer = styled.View`
background-color: ${props => props.theme.stylekitBackgroundColor};
z-index: 2;
`
export const SearchOptionsContainer = styled.ScrollView`
display: flex;
flex-direction: row;
margin-left: 8px;
margin-bottom: 12px;
${() =>
Platform.OS === 'android' &&
css`
padding-top: 4px;
`}
`

View 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>
)
}

View File

@@ -0,0 +1,7 @@
import Icon from 'react-native-vector-icons/Ionicons'
import styled from 'styled-components/native'
export const StyledIcon = styled(Icon)`
text-align-vertical: center;
margin-left: 2px;
`

View File

@@ -0,0 +1,543 @@
import { AppStateType } from '@Lib/ApplicationState'
import { useSignedIn, useSyncStatus } from '@Lib/SnjsHelperHooks'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { AppStackNavigationProp } from '@Root/AppStack'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
import {
ApplicationEvent,
CollectionSort,
CollectionSortProperty,
ContentType,
PrefKey,
SmartView,
SNNote,
SNTag,
SystemViewId,
UuidString,
} from '@standardnotes/snjs'
import { ICON_ADD } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import FAB from 'react-native-fab'
import { ThemeContext } from 'styled-components'
import { NoteList } from './NoteList'
import { StyledIcon } from './Notes.styled'
type SearchOptions = {
selected: boolean
onPress: () => void
label: string
}[]
export const Notes = React.memo(
({ isInTabletMode, keyboardHeight }: { isInTabletMode: boolean | undefined; keyboardHeight: number | undefined }) => {
const application = useSafeApplicationContext()
const theme = useContext(ThemeContext)
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
const [loading, decrypting, refreshing, startRefreshing] = useSyncStatus()
const [signedIn] = useSignedIn()
const [sortBy, setSortBy] = useState<CollectionSortProperty>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileSortNotesBy, CollectionSort.CreatedAt),
)
const [sortReverse, setSortReverse] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileSortNotesReverse, false),
)
const [hideDates, setHideDates] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideDate, false),
)
const [hidePreviews, setHidePreviews] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false),
)
const [hideEditorIcon, setHideEditorIcon] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false),
)
const [notes, setNotes] = useState<SNNote[]>([])
const [selectedNoteId, setSelectedNoteId] = useState<SNNote['uuid']>()
const [searchText, setSearchText] = useState('')
const [searchOptions, setSearchOptions] = useState<SearchOptions>([])
const [includeProtectedNoteText, setIncludeProtectedNoteText] = useState<boolean>(
() => !(application.hasProtectionSources() && !application.hasUnprotectedAccessSession()),
)
const [includeArchivedNotes, setIncludeArchivedNotes] = useState<boolean>(false)
const [includeTrashedNotes, setIncludeTrashedNotes] = useState<boolean>(false)
const [includeProtectedStarted, setIncludeProtectedStarted] = useState<boolean>(false)
const [shouldFocusSearch, setShouldFocusSearch] = useState<boolean>(false)
const haveDisplayOptions = useRef(false)
const protectionsEnabled = useRef(application.hasProtectionSources() && !application.hasUnprotectedAccessSession())
const reloadTitle = useCallback(
(newNotes?: SNNote[], newFilter?: string) => {
let title = ''
let subTitle: string | undefined
const selectedTag = application.getAppState().selectedTag
if (newNotes && (newFilter ?? searchText).length > 0) {
const resultCount = newNotes.length
title = resultCount === 1 ? `${resultCount} search result` : `${resultCount} search results`
} else if (selectedTag) {
title = selectedTag.title
if (selectedTag instanceof SNTag && selectedTag.parentId) {
const parents = application.items.getTagParentChain(selectedTag)
const hierarchy = parents.map(tag => tag.title).join(' ⫽ ')
subTitle = hierarchy.length > 0 ? `in ${hierarchy}` : undefined
}
}
navigation.setParams({
title,
subTitle,
})
},
[application, navigation, searchText],
)
const openCompose = useCallback(
(newNote: boolean, noteUuid: UuidString, replaceScreen = false) => {
if (!isInTabletMode) {
if (replaceScreen) {
navigation.replace(SCREEN_COMPOSE, {
title: newNote ? 'Compose' : 'Note',
noteUuid,
})
} else {
navigation.navigate(SCREEN_COMPOSE, {
title: newNote ? 'Compose' : 'Note',
noteUuid,
})
}
}
},
[navigation, isInTabletMode],
)
const openNote = useCallback(
async (noteUuid: SNNote['uuid'], replaceScreen = false) => {
await application.getAppState().openEditor(noteUuid)
openCompose(false, noteUuid, replaceScreen)
},
[application, openCompose],
)
const onNoteSelect = useCallback(
async (noteUuid: SNNote['uuid']) => {
const note = application.items.findItem<SNNote>(noteUuid)
if (note) {
if (note.protected && !application.hasProtectionSources()) {
return navigation.navigate(SCREEN_VIEW_PROTECTED_NOTE, {
onPressView: () => openNote(noteUuid, true),
})
}
if (await application.authorizeNoteAccess(note)) {
if (!isInTabletMode) {
await openNote(noteUuid)
} else {
/**
* @TODO: remove setTimeout after SNJS navigation feature
* https://app.asana.com/0/1201653402817596/1202360754617865
*/
setTimeout(async () => {
await openNote(noteUuid)
})
}
}
}
},
[application, isInTabletMode, navigation, openNote],
)
useEffect(() => {
const removeBlurScreenListener = navigation.addListener('blur', () => {
if (includeProtectedStarted) {
setIncludeProtectedStarted(false)
setShouldFocusSearch(true)
}
})
return removeBlurScreenListener
}, [navigation, includeProtectedStarted])
useEffect(() => {
let mounted = true
const removeEditorObserver = application.editorGroup.addActiveControllerChangeObserver(activeEditor => {
if (mounted) {
setSelectedNoteId(activeEditor?.note?.uuid)
}
})
return () => {
mounted = false
removeEditorObserver && removeEditorObserver()
}
}, [application])
/**
* Note that reloading display options destroys the current index and rebuilds it,
* so call sparingly. The runtime complexity of destroying and building
* an index is roughly O(n^2).
* There are optional parameters to force using the new values,
* use when React is too slow when updating the state.
*/
const reloadNotesDisplayOptions = useCallback(
(
searchFilter?: string,
sortOptions?: {
sortBy?: CollectionSortProperty
sortReverse: boolean
},
includeProtected?: boolean,
includeArchived?: boolean,
includeTrashed?: boolean,
) => {
const tag = application.getAppState().selectedTag
const searchQuery =
searchText || searchFilter
? {
query: searchFilter?.toLowerCase() ?? searchText.toLowerCase(),
includeProtectedNoteText: includeProtected ?? includeProtectedNoteText,
}
: undefined
let applyFilters = false
if (typeof searchFilter !== 'undefined') {
applyFilters = searchFilter !== ''
} else if (typeof searchText !== 'undefined') {
applyFilters = searchText !== ''
}
application.items.setPrimaryItemDisplayOptions({
sortBy: sortOptions?.sortBy ?? sortBy,
sortDirection: sortOptions?.sortReverse ?? sortReverse ? 'asc' : 'dsc',
tags: tag instanceof SNTag ? [tag] : [],
views: tag instanceof SmartView ? [tag] : [],
searchQuery: searchQuery,
includeArchived: applyFilters && (includeArchived ?? includeArchivedNotes),
includeTrashed: applyFilters && (includeTrashed ?? includeTrashedNotes),
})
},
[
application,
includeArchivedNotes,
includeProtectedNoteText,
includeTrashedNotes,
sortBy,
sortReverse,
searchText,
],
)
const toggleIncludeProtected = useCallback(async () => {
const includeProtected = !includeProtectedNoteText
let allowToggling: boolean | undefined = true
if (includeProtected) {
setIncludeProtectedStarted(true)
allowToggling = await application.authorizeSearchingProtectedNotesText()
}
setIncludeProtectedStarted(false)
if (allowToggling) {
reloadNotesDisplayOptions(undefined, undefined, includeProtected)
setIncludeProtectedNoteText(includeProtected)
}
}, [application, includeProtectedNoteText, reloadNotesDisplayOptions])
const toggleIncludeArchived = useCallback(() => {
const includeArchived = !includeArchivedNotes
reloadNotesDisplayOptions(undefined, undefined, undefined, includeArchived)
setIncludeArchivedNotes(includeArchived)
}, [includeArchivedNotes, reloadNotesDisplayOptions])
const toggleIncludeTrashed = useCallback(() => {
const includeTrashed = !includeTrashedNotes
reloadNotesDisplayOptions(undefined, undefined, undefined, undefined, includeTrashed)
setIncludeTrashedNotes(includeTrashed)
}, [includeTrashedNotes, reloadNotesDisplayOptions])
const reloadSearchOptions = useCallback(() => {
const protections = application.hasProtectionSources() && !application.hasUnprotectedAccessSession()
if (protections !== protectionsEnabled.current) {
protectionsEnabled.current = !!protections
setIncludeProtectedNoteText(!protections)
}
const selectedTag = application.getAppState().selectedTag
const options = [
{
label: 'Include Protected Contents',
selected: includeProtectedNoteText,
onPress: toggleIncludeProtected,
},
]
const isArchiveView = selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.ArchivedNotes
const isTrashView = selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.TrashedNotes
if (!isArchiveView && !isTrashView) {
setSearchOptions([
...options,
{
label: 'Archived',
selected: includeArchivedNotes,
onPress: toggleIncludeArchived,
},
{
label: 'Trashed',
selected: includeTrashedNotes,
onPress: toggleIncludeTrashed,
},
])
} else {
setSearchOptions(options)
}
}, [
application,
includeProtectedNoteText,
includeArchivedNotes,
includeTrashedNotes,
toggleIncludeProtected,
toggleIncludeArchived,
toggleIncludeTrashed,
])
const getFirstSelectableNote = useCallback((newNotes: SNNote[]) => newNotes.find(note => !note.protected), [])
const selectFirstNote = useCallback(
(newNotes: SNNote[]) => {
const note = getFirstSelectableNote(newNotes)
if (note && !loading && !decrypting) {
void onNoteSelect(note.uuid)
}
},
[decrypting, getFirstSelectableNote, loading, onNoteSelect],
)
const selectNextOrCreateNew = useCallback(
(newNotes: SNNote[]) => {
const note = getFirstSelectableNote(newNotes)
if (note) {
void onNoteSelect(note.uuid)
} else {
application.getAppState().closeActiveEditor()
}
},
[application, getFirstSelectableNote, onNoteSelect],
)
const reloadNotes = useCallback(
(reselectNote?: boolean, tagChanged?: boolean, searchFilter?: string) => {
const tag = application.getAppState().selectedTag
if (!tag) {
return
}
reloadSearchOptions()
if (!haveDisplayOptions.current) {
haveDisplayOptions.current = true
reloadNotesDisplayOptions()
}
const newNotes = application.items.getDisplayableNotes()
const renderedNotes: SNNote[] = newNotes
setNotes(renderedNotes)
reloadTitle(renderedNotes, searchFilter)
if (!application.getAppState().isTabletDevice || !reselectNote) {
return
}
if (tagChanged) {
if (renderedNotes.length > 0) {
selectFirstNote(renderedNotes)
} else {
application.getAppState().closeActiveEditor()
}
} else {
const activeNote = application.getAppState().getActiveNoteController()?.note
if (activeNote) {
const isTrashView =
application.getAppState().selectedTag instanceof SmartView &&
application.getAppState().selectedTag.uuid === SystemViewId.TrashedNotes
if (activeNote.trashed && !isTrashView) {
selectNextOrCreateNew(renderedNotes)
}
} else {
selectFirstNote(renderedNotes)
}
}
},
[
application,
reloadNotesDisplayOptions,
reloadSearchOptions,
reloadTitle,
selectFirstNote,
selectNextOrCreateNew,
],
)
const onNoteCreate = useCallback(async () => {
const title = application.getAppState().isTabletDevice ? `Note ${notes.length + 1}` : undefined
const noteView = await application.getAppState().createEditor(title)
openCompose(true, noteView.note.uuid)
reloadNotes(true)
}, [application, notes.length, openCompose, reloadNotes])
const reloadPreferences = useCallback(async () => {
let newSortBy = application.getLocalPreferences().getValue(PrefKey.MobileSortNotesBy, CollectionSort.CreatedAt)
if (newSortBy === CollectionSort.UpdatedAt || (newSortBy as string) === 'client_updated_at') {
newSortBy = CollectionSort.UpdatedAt
}
let displayOptionsChanged = false
const newSortReverse = application.getLocalPreferences().getValue(PrefKey.MobileSortNotesReverse, false)
const newHidePreview = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false)
const newHideDate = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideDate, false)
const newHideEditorIcon = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false)
if (sortBy !== newSortBy) {
setSortBy(newSortBy)
displayOptionsChanged = true
}
if (sortReverse !== newSortReverse) {
setSortReverse(newSortReverse)
displayOptionsChanged = true
}
if (hidePreviews !== newHidePreview) {
setHidePreviews(newHidePreview)
displayOptionsChanged = true
}
if (hideDates !== newHideDate) {
setHideDates(newHideDate)
displayOptionsChanged = true
}
if (hideEditorIcon !== newHideEditorIcon) {
setHideEditorIcon(newHideEditorIcon)
displayOptionsChanged = true
}
if (displayOptionsChanged) {
reloadNotesDisplayOptions(undefined, {
sortBy: newSortBy,
sortReverse: newSortReverse,
})
}
reloadNotes()
}, [
application,
sortBy,
sortReverse,
hidePreviews,
hideDates,
hideEditorIcon,
reloadNotes,
reloadNotesDisplayOptions,
])
const onRefresh = useCallback(() => {
startRefreshing()
void application.sync.sync()
}, [application, startRefreshing])
const onSearchChange = useCallback(
(filter: string) => {
reloadNotesDisplayOptions(filter)
setSearchText(filter)
reloadNotes(undefined, undefined, filter)
},
[reloadNotes, reloadNotesDisplayOptions],
)
useEffect(() => {
const removeEventObserver = application?.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
await reloadPreferences()
})
return () => {
removeEventObserver?.()
}
}, [application, reloadPreferences])
useFocusEffect(
useCallback(() => {
void reloadPreferences()
}, [reloadPreferences]),
)
useEffect(() => {
const removeAppStateChangeHandler = application.getAppState().addStateChangeObserver(state => {
if (state === AppStateType.TagChanged) {
reloadNotesDisplayOptions()
reloadNotes(true, true)
}
if (state === AppStateType.PreferencesChanged) {
void reloadPreferences()
}
})
const removeStreamNotes = application.streamItems([ContentType.Note], async () => {
/** If a note changes, it will be queried against the existing filter;
* we dont need to reload display options */
reloadNotes(true)
})
const removeStreamTags = application.streamItems([ContentType.Tag], async () => {
/** A tag could have changed its relationships, so we need to reload the filter */
reloadNotesDisplayOptions()
reloadNotes()
})
return () => {
removeStreamNotes()
removeStreamTags()
removeAppStateChangeHandler()
}
}, [application, reloadNotes, reloadNotesDisplayOptions, reloadPreferences])
return (
<>
<NoteList
onRefresh={onRefresh}
hasRefreshControl={signedIn}
onPressItem={onNoteSelect}
refreshing={refreshing}
searchText={searchText}
onSearchChange={onSearchChange}
onSearchCancel={() => onSearchChange('')}
notes={notes}
sortType={sortBy}
decrypting={decrypting}
loading={loading}
hidePreviews={hidePreviews}
hideDates={hideDates}
hideEditorIcon={hideEditorIcon}
selectedNoteId={application.getAppState().isInTabletMode ? selectedNoteId : undefined}
searchOptions={searchOptions}
shouldFocusSearch={shouldFocusSearch}
setShouldFocusSearch={setShouldFocusSearch}
/>
<FAB
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore style prop does not exist in types
style={application.getAppState().isInTabletMode ? { bottom: keyboardHeight } : undefined}
buttonColor={theme.stylekitInfoColor}
iconTextColor={theme.stylekitInfoContrastColor}
onClickAction={onNoteCreate}
visible={true}
size={30}
iconTextComponent={<StyledIcon testID="newNoteButton" name={ThemeService.nameForIcon(ICON_ADD)} />}
/>
</>
)
},
)

View File

@@ -0,0 +1,41 @@
import Icon from 'react-native-vector-icons/Ionicons'
import styled from 'styled-components/native'
const MARGIN = 4
const PADDING = 12
const Touchable = styled.TouchableWithoutFeedback``
const Container = styled.View`
flex-direction: row;
margin: ${MARGIN}px;
padding: ${PADDING}px;
border-width: 1px;
border-radius: 4px;
border-color: ${props => props.theme.stylekitBorderColor};
`
const CenterContainer = styled.View`
justify-content: center;
`
const UserIcon = styled(Icon)`
font-size: 24px;
color: ${props => props.theme.stylekitInfoColor};
`
const ForwardIcon = styled(UserIcon)`
color: ${props => props.theme.stylekitNeutralColor};
`
const TextContainer = styled.View`
flex: 1;
padding-left: ${PADDING}px;
`
const BoldText = styled.Text`
font-size: 15px;
font-weight: 600;
color: ${props => props.theme.stylekitForegroundColor};
`
const SubText = styled.Text`
margin-top: 2px;
font-size: 11px;
color: ${props => props.theme.stylekitNeutralColor};
`
export { Touchable, Container, CenterContainer, UserIcon, ForwardIcon, TextContainer, BoldText, SubText }

View File

@@ -0,0 +1,42 @@
import { useNavigation } from '@react-navigation/native'
import { SCREEN_SETTINGS } from '@Root/Screens/screens'
import { ICON_FORWARD, ICON_USER } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React from 'react'
import {
BoldText,
CenterContainer,
Container,
ForwardIcon,
SubText,
TextContainer,
Touchable,
UserIcon,
} from './OfflineBanner.styled'
const NOT_BACKED_UP_TEXT = 'Data not backed up'
const SIGN_IN_TEXT = 'Sign in or register to backup your notes'
export const OfflineBanner: React.FC = () => {
const navigation = useNavigation()
const onPress = () => {
navigation.navigate(SCREEN_SETTINGS as never)
}
return (
<Touchable onPress={onPress}>
<Container>
<CenterContainer>
<UserIcon name={ThemeService.nameForIcon(ICON_USER)} />
</CenterContainer>
<TextContainer>
<BoldText>{NOT_BACKED_UP_TEXT}</BoldText>
<SubText>{SIGN_IN_TEXT}</SubText>
</TextContainer>
<CenterContainer>
<ForwardIcon name={ThemeService.nameForIcon(ICON_FORWARD)} />
</CenterContainer>
</Container>
</Touchable>
)
}