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

159
packages/mobile/src/App.tsx Normal file
View File

@@ -0,0 +1,159 @@
import { ToastWrapper } from '@Components/ToastWrapper'
import { ActionSheetProvider } from '@expo/react-native-action-sheet'
import { MobileApplication } from '@Lib/Application'
import { ApplicationGroup } from '@Lib/ApplicationGroup'
import { navigationRef } from '@Lib/NavigationService'
import { DefaultTheme, NavigationContainer } from '@react-navigation/native'
import { MobileThemeVariables } from '@Root/Style/Themes/styled-components'
import { ApplicationGroupEvent, DeinitMode, DeinitSource } from '@standardnotes/snjs'
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { StatusBar } from 'react-native'
import { ThemeProvider } from 'styled-components/native'
import { ApplicationContext } from './ApplicationContext'
import { MainStackComponent } from './ModalStack'
export type HeaderTitleParams = {
title?: string
subTitle?: string
subTitleColor?: string
}
export type TEnvironment = 'prod' | 'dev'
const AppComponent: React.FC<{
application: MobileApplication
env: TEnvironment
}> = ({ application, env }) => {
const themeService = useRef<ThemeService>()
const appReady = useRef(false)
const navigationReady = useRef(false)
const [activeTheme, setActiveTheme] = useState<MobileThemeVariables | undefined>()
const setThemeServiceRef = useCallback((node: ThemeService | undefined) => {
if (node) {
node.addThemeChangeObserver(() => {
setActiveTheme(node.variables)
})
}
/**
* We check if both application and navigation are ready and launch application afterwads
*/
themeService.current = node
}, [])
/**
* We check if both application and navigation are ready and launch application afterwads
*/
const launchApp = useCallback(
(setAppReady: boolean, setNavigationReady: boolean) => {
if (setAppReady) {
appReady.current = true
}
if (setNavigationReady) {
navigationReady.current = true
}
if (navigationReady.current && appReady.current) {
void application.launch()
}
},
[application],
)
useEffect(() => {
let themeServiceInstance: ThemeService
const loadApplication = async () => {
themeServiceInstance = new ThemeService(application)
setThemeServiceRef(themeServiceInstance)
await application.prepareForLaunch({
receiveChallenge: async challenge => {
application.promptForChallenge(challenge)
},
})
await themeServiceInstance.init()
launchApp(true, false)
}
void loadApplication()
return () => {
themeServiceInstance?.deinit()
setThemeServiceRef(undefined)
if (!application.hasStartedDeinit()) {
application.deinit(DeinitMode.Soft, DeinitSource.Lock)
}
}
}, [application, application.Uuid, env, launchApp, setThemeServiceRef])
if (!themeService.current || !activeTheme) {
return null
}
return (
<NavigationContainer
onReady={() => launchApp(false, true)}
theme={{
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: activeTheme.stylekitBackgroundColor,
border: activeTheme.stylekitBorderColor,
},
}}
ref={navigationRef}
>
<StatusBar translucent />
{themeService.current && (
<>
<ThemeProvider theme={activeTheme}>
<ActionSheetProvider>
<ThemeServiceContext.Provider value={themeService.current}>
<MainStackComponent env={env} />
</ThemeServiceContext.Provider>
</ActionSheetProvider>
<ToastWrapper />
</ThemeProvider>
</>
)}
</NavigationContainer>
)
}
export const App = (props: { env: TEnvironment }) => {
const [application, setApplication] = useState<MobileApplication | undefined>()
const createNewAppGroup = useCallback(() => {
const group = new ApplicationGroup()
void group.initialize()
return group
}, [])
const [appGroup, setAppGroup] = useState<ApplicationGroup>(() => createNewAppGroup())
useEffect(() => {
const removeAppChangeObserver = appGroup.addEventObserver(event => {
if (event === ApplicationGroupEvent.PrimaryApplicationSet) {
const mobileApplication = appGroup.primaryApplication as MobileApplication
setApplication(mobileApplication)
} else if (event === ApplicationGroupEvent.DeviceWillRestart) {
setApplication(undefined)
setAppGroup(createNewAppGroup())
}
})
return removeAppChangeObserver
}, [appGroup, appGroup.primaryApplication, setAppGroup, createNewAppGroup])
if (!application) {
return null
}
return (
<ApplicationContext.Provider value={application}>
<AppComponent env={props.env} key={application.Uuid} application={application} />
</ApplicationContext.Provider>
)
}

View File

@@ -0,0 +1,242 @@
import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState'
import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks'
import { ScreenStatus } from '@Lib/StatusManager'
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { Compose } from '@Root/Screens/Compose/Compose'
import { Root } from '@Root/Screens/Root'
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
import { MainSideMenu } from '@Root/Screens/SideMenu/MainSideMenu'
import { NoteSideMenu } from '@Root/Screens/SideMenu/NoteSideMenu'
import { ViewProtectedNote } from '@Root/Screens/ViewProtectedNote/ViewProtectedNote'
import { UuidString } from '@standardnotes/snjs'
import { ICON_MENU } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import { getDefaultDrawerWidth } from '@Style/Utils'
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Dimensions, Keyboard, ScaledSize } from 'react-native'
import DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App'
import { ApplicationContext } from './ApplicationContext'
import { ModalStackNavigationProp } from './ModalStack'
export type AppStackNavigatorParamList = {
[SCREEN_NOTES]: HeaderTitleParams
[SCREEN_COMPOSE]: HeaderTitleParams & {
noteUuid: UuidString
}
[SCREEN_VIEW_PROTECTED_NOTE]: {
onPressView: () => void
}
}
export type AppStackNavigationProp<T extends keyof AppStackNavigatorParamList> = {
navigation: CompositeNavigationProp<
ModalStackNavigationProp<'AppStack'>['navigation'],
StackNavigationProp<AppStackNavigatorParamList, T>
>
route: RouteProp<AppStackNavigatorParamList, T>
}
export const AppStack = createStackNavigator<AppStackNavigatorParamList>()
export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) => {
// Context
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const [isLocked] = useIsLocked()
const [hasEditor] = useHasEditor()
// State
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'))
const [isInTabletMode, setIsInTabletMode] = useState(() => application?.getAppState().isInTabletMode)
const [notesStatus, setNotesStatus] = useState<ScreenStatus>()
const [composeStatus, setComposeStatus] = useState<ScreenStatus>()
const [noteDrawerOpen, setNoteDrawerOpen] = useState(false)
// Ref
const drawerRef = useRef<DrawerLayout>(null)
const noteDrawerRef = useRef<DrawerLayout>(null)
useEffect(() => {
const removeObserver = application?.getAppState().addStateChangeObserver(event => {
if (event === AppStateType.EditorClosed) {
noteDrawerRef.current?.closeDrawer()
if (!isInTabletMode && props.navigation.canGoBack()) {
props.navigation.popToTop()
}
}
})
return removeObserver
}, [application, props.navigation, isInTabletMode])
useEffect(() => {
const removeObserver = application?.getStatusManager().addHeaderStatusObserver(messages => {
setNotesStatus(messages[SCREEN_NOTES])
setComposeStatus(messages[SCREEN_COMPOSE])
})
return removeObserver
}, [application, isInTabletMode])
useEffect(() => {
const updateDimensions = ({ window }: { window: ScaledSize }) => {
setDimensions(window)
}
const removeDimensionsChangeListener = Dimensions.addEventListener('change', updateDimensions)
return () => removeDimensionsChangeListener.remove()
}, [])
useEffect(() => {
const remoteTabletModeSubscription = application?.getAppState().addStateEventObserver((event, data) => {
if (event === AppStateEventType.TabletModeChange) {
const eventData = data as TabletModeChangeData
if (eventData.new_isInTabletMode && !eventData.old_isInTabletMode) {
setIsInTabletMode(true)
} else if (!eventData.new_isInTabletMode && eventData.old_isInTabletMode) {
setIsInTabletMode(false)
}
}
})
return remoteTabletModeSubscription
}, [application])
const handleDrawerStateChange = useCallback(
(newState: DrawerState, drawerWillShow: boolean) => {
if (newState !== 'Idle' && drawerWillShow) {
application?.getAppState().onDrawerOpen()
}
},
[application],
)
return (
<DrawerLayout
ref={drawerRef}
drawerWidth={getDefaultDrawerWidth(dimensions)}
drawerPosition={'left'}
drawerType="slide"
drawerLockMode={hasEditor && !isInTabletMode ? 'locked-closed' : 'unlocked'}
onDrawerStateChanged={handleDrawerStateChange}
renderNavigationView={() => !isLocked && <MainSideMenu drawerRef={drawerRef.current} />}
>
<DrawerLayout
ref={noteDrawerRef}
drawerWidth={getDefaultDrawerWidth(dimensions)}
onDrawerStateChanged={handleDrawerStateChange}
onDrawerOpen={() => setNoteDrawerOpen(true)}
onDrawerClose={() => setNoteDrawerOpen(false)}
drawerPosition={'right'}
drawerType="slide"
drawerLockMode={hasEditor ? 'unlocked' : 'locked-closed'}
renderNavigationView={() =>
hasEditor && <NoteSideMenu drawerOpen={noteDrawerOpen} drawerRef={noteDrawerRef.current} />
}
>
<AppStack.Navigator
screenOptions={() => ({
headerStyle: {
backgroundColor: theme.stylekitContrastBackgroundColor,
},
headerTintColor: theme.stylekitInfoColor,
headerTitle: ({ children }) => {
return <HeaderTitleView title={children || ''} />
},
})}
initialRouteName={SCREEN_NOTES}
>
<AppStack.Screen
name={SCREEN_NOTES}
options={({ route }) => ({
title: 'All notes',
headerTitle: ({ children }) => {
const screenStatus = isInTabletMode ? composeStatus || notesStatus : notesStatus
const title = route.params?.title ?? (children || '')
const subtitle = [screenStatus?.status, route.params?.subTitle].filter(x => !!x).join(' • ')
return <HeaderTitleView title={title} subtitle={subtitle} subtitleColor={screenStatus?.color} />
},
headerLeft: () => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="drawerButton"
disabled={false}
title={''}
iconName={ThemeService.nameForIcon(ICON_MENU)}
onPress={() => {
Keyboard.dismiss()
drawerRef.current?.openDrawer()
}}
/>
</HeaderButtons>
),
headerRight: () =>
isInTabletMode &&
hasEditor && (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="noteDrawerButton"
disabled={false}
title={''}
iconName={ThemeService.nameForIcon(ICON_MENU)}
onPress={() => {
Keyboard.dismiss()
noteDrawerRef.current?.openDrawer()
}}
/>
</HeaderButtons>
),
})}
component={Root}
/>
<AppStack.Screen
name={SCREEN_COMPOSE}
options={({ route }) => ({
headerTitle: ({ children }) => {
return (
<HeaderTitleView
title={route.params?.title ?? (children || '')}
subtitle={composeStatus?.status}
subtitleColor={composeStatus?.color}
/>
)
},
headerRight: () =>
!isInTabletMode && (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="noteDrawerButton"
disabled={false}
title={''}
iconName={ThemeService.nameForIcon(ICON_MENU)}
onPress={() => {
Keyboard.dismiss()
noteDrawerRef.current?.openDrawer()
}}
/>
</HeaderButtons>
),
})}
component={Compose}
/>
<AppStack.Screen
name={SCREEN_VIEW_PROTECTED_NOTE}
options={() => ({
title: 'View Protected Note',
})}
component={ViewProtectedNote}
/>
</AppStack.Navigator>
</DrawerLayout>
</DrawerLayout>
)
}

View File

@@ -0,0 +1,6 @@
import { MobileApplication } from '@Lib/Application'
import React from 'react'
export const ApplicationContext = React.createContext<MobileApplication | undefined>(undefined)
export const SafeApplicationContext = ApplicationContext as React.Context<MobileApplication>

View File

@@ -0,0 +1,29 @@
import styled from 'styled-components/native'
export const Container = styled.View`
flex: 1;
background-color: transparent;
align-items: center;
justify-content: center;
`
export const Content = styled.View`
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
padding: 20px;
border-radius: 10px;
width: 80%;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 16px;
font-weight: bold;
text-align: center;
margin-bottom: 5px;
`
export const Subtitle = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 14px;
text-align: center;
`

View File

@@ -0,0 +1,17 @@
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { MODAL_BLOCKING_ALERT } from '@Root/Screens/screens'
import React from 'react'
import { Container, Content, Subtitle, Title } from './BlockingModal.styled'
type Props = ModalStackNavigationProp<typeof MODAL_BLOCKING_ALERT>
export const BlockingModal = ({ route: { params } }: Props) => {
return (
<Container>
<Content>
{params.title && <Title>{params.title}</Title>}
<Subtitle>{params.text}</Subtitle>
</Content>
</Container>
)
}

View File

@@ -0,0 +1,75 @@
import React from 'react'
import styled, { css } from 'styled-components/native'
type Props = {
onPress: () => void
label: string
primary?: boolean
fullWidth?: boolean
last?: boolean
}
const PrimaryButtonContainer = styled.TouchableOpacity.attrs({
activeOpacity: 0.84,
})<{
fullWidth?: boolean
last?: boolean
}>`
background-color: ${({ theme }) => theme.stylekitInfoColor};
padding: 12px 24px;
border-radius: 4px;
border-width: 1px;
border-color: ${({ theme }) => theme.stylekitInfoColor};
margin-bottom: ${({ fullWidth, last }) => (fullWidth && !last ? '16px' : 0)};
${({ fullWidth }) =>
!fullWidth &&
css`
align-self: center;
`};
`
const SecondaryButtonContainer = styled.TouchableHighlight.attrs(({ theme }) => ({
activeOpacity: 0.84,
underlayColor: theme.stylekitBorderColor,
}))<{
fullWidth?: boolean
last?: boolean
}>`
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
padding: 12px 24px;
border-radius: 4px;
border-width: 1px;
border-color: ${({ theme }) => theme.stylekitBorderColor};
margin-bottom: ${({ fullWidth, last }) => (fullWidth && !last ? '16px' : 0)};
${({ fullWidth }) =>
!fullWidth &&
css`
align-self: center;
`};
`
const ButtonLabel = styled.Text<{ primary?: boolean }>`
text-align: center;
text-align-vertical: center;
font-weight: bold;
color: ${({ theme, primary }) => {
return primary ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
}};
font-size: ${props => props.theme.mainTextFontSize}px;
`
export const Button: React.FC<Props> = ({ onPress, label, primary, fullWidth, last }: Props) => {
if (primary) {
return (
<PrimaryButtonContainer onPress={onPress} fullWidth={fullWidth} last={last}>
<ButtonLabel primary={primary}>{label}</ButtonLabel>
</PrimaryButtonContainer>
)
} else {
return (
<SecondaryButtonContainer onPress={onPress} fullWidth={fullWidth} last={last}>
<ButtonLabel primary={primary}>{label}</ButtonLabel>
</SecondaryButtonContainer>
)
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import { Platform } from 'react-native'
import styled, { css } from 'styled-components/native'
import { Props as TableCellProps, SectionedTableCellTouchableHighlight } from './SectionedTableCell'
type Props = {
testID?: string
maxHeight?: number
leftAligned?: boolean
bold?: boolean
disabled?: boolean
important?: boolean
onPress: () => void
first?: boolean
last?: boolean
title?: string
}
type ContainerProps = Pick<Props, 'maxHeight'> & TableCellProps
const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
underlayColor: props.theme.stylekitBorderColor,
}))<ContainerProps>`
padding-top: ${12}px;
justify-content: center;
${({ maxHeight }) =>
maxHeight &&
css`
max-height: 50px;
`};
`
const ButtonContainer = styled.View``
type ButtonLabelProps = Pick<Props, 'leftAligned' | 'bold' | 'disabled' | 'important'>
const ButtonLabel = styled.Text<ButtonLabelProps>`
text-align: ${props => (props.leftAligned ? 'left' : 'center')};
text-align-vertical: center;
color: ${props => {
let color = Platform.OS === 'android' ? props.theme.stylekitForegroundColor : props.theme.stylekitInfoColor
if (props.disabled) {
color = 'gray'
} else if (props.important) {
color = props.theme.stylekitDangerColor
}
return color
}};
font-size: ${props => props.theme.mainTextFontSize}px;
${({ bold }) =>
bold &&
css`
font-weight: bold;
`}
${({ disabled }) =>
disabled &&
css`
opacity: 0.6;
`}
`
export const ButtonCell: React.FC<Props> = props => (
<Container
first={props.first}
last={props.last}
maxHeight={props.maxHeight}
testID={props.testID}
disabled={props.disabled}
onPress={props.onPress}
>
<ButtonContainer>
<ButtonLabel
important={props.important}
disabled={props.disabled}
bold={props.bold}
leftAligned={props.leftAligned}
>
{props.title}
</ButtonLabel>
{props.children && props.children}
</ButtonContainer>
</Container>
)

View File

@@ -0,0 +1,112 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { Animated } from 'react-native'
import { TouchableWithoutFeedback } from 'react-native-gesture-handler'
import styled, { css } from 'styled-components/native'
type Props = {
selected: boolean
onPress: () => void
label: string
last?: boolean
}
const Container = styled.View<{
last?: boolean
}>`
border-radius: 100px;
padding: 5px 10px;
border-width: 1px;
${({ last }) =>
!last &&
css`
margin-right: 8px;
`};
`
const Label = styled.Text<{ selected: boolean }>`
font-size: 14px;
`
const ActiveContainer = styled(Container)`
background-color: ${({ theme }) => theme.stylekitInfoColor};
border-color: ${({ theme }) => theme.stylekitInfoColor};
`
const InactiveContainer = styled(Container)`
position: absolute;
background-color: ${({ theme }) => theme.stylekitInfoContrastColor};
border-color: ${({ theme }) => theme.stylekitBorderColor};
`
const ActiveLabel = styled(Label)`
color: ${({ theme }) => theme.stylekitNeutralContrastColor};
`
const InactiveLabel = styled(Label)`
color: ${({ theme }) => theme.stylekitNeutralColor};
`
export const Chip: React.FC<Props> = ({ selected, onPress, label, last }) => {
const animationValue = useRef(new Animated.Value(selected ? 1 : 0)).current
const selectedRef = useRef<boolean>(selected)
const toggleChip = useCallback(() => {
Animated.timing(animationValue, {
toValue: selected ? 1 : 0,
duration: 100,
useNativeDriver: true,
}).start()
}, [animationValue, selected])
useEffect(() => {
if (selected !== selectedRef.current) {
toggleChip()
selectedRef.current = selected
}
}, [selected, toggleChip])
return (
<TouchableWithoutFeedback onPress={onPress}>
<ActiveContainer
as={Animated.View}
last={last}
style={{
opacity: animationValue,
}}
>
<ActiveLabel
as={Animated.Text}
selected={selected}
style={{
opacity: animationValue,
}}
>
{label}
</ActiveLabel>
</ActiveContainer>
<InactiveContainer
as={Animated.View}
last={last}
style={{
opacity: animationValue.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
}}
>
<InactiveLabel
as={Animated.Text}
selected={selected}
style={{
opacity: animationValue.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
}),
}}
>
{label}
</InactiveLabel>
</InactiveContainer>
</TouchableWithoutFeedback>
)
}

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components/native'
type Props = {
size?: number
backgroundColor?: string
borderColor?: string
}
export const Circle = styled.View<Props>`
width: ${props => props.size ?? 12}px;
height: ${props => props.size ?? 12}px;
border-radius: ${props => (props.size ?? 12) / 2}px;
background-color: ${props => props.backgroundColor};
border-color: ${props => props.borderColor};
border-width: 1px;
`

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { Platform } from 'react-native'
import styled from 'styled-components/native'
type Props = {
subtitleColor?: string
title: string
subtitle?: string
}
const Container = styled.View`
/* background-color: ${props => props.theme.stylekitContrastBackgroundColor}; */
flex: 1;
justify-content: center;
${Platform.OS === 'android' && 'align-items: flex-start; min-width: 100px;'}
`
const Title = styled.Text`
color: ${props => props.theme.stylekitForegroundColor};
font-weight: bold;
font-size: 18px;
text-align: center;
`
const SubTitle = styled.Text.attrs(() => ({
adjustsFontSizeToFit: true,
numberOfLines: 1,
}))<{ color?: string }>`
color: ${props => props.color ?? props.theme.stylekitForegroundColor};
opacity: ${props => (props.color ? 1 : 0.6)};
font-size: ${Platform.OS === 'android' ? 13 : 12}px;
${Platform.OS === 'ios' && 'text-align: center'}
`
export const HeaderTitleView: React.FC<Props> = props => (
<Container>
<Title>{props.title}</Title>
{props.subtitle && props.subtitle.length > 0 ? (
<SubTitle color={props.subtitleColor}>{props.subtitle}</SubTitle>
) : undefined}
</Container>
)

View File

@@ -0,0 +1,8 @@
import { StyleSheet } from 'react-native'
export const iconStyles = StyleSheet.create({
icon: {
width: 14,
height: 14,
},
})

View File

@@ -0,0 +1,19 @@
import React, { useContext } from 'react'
import Icon from 'react-native-vector-icons/Ionicons'
import { HeaderButton, HeaderButtonProps } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
export const IoniconsHeaderButton = (passMeFurther: HeaderButtonProps) => {
// the `passMeFurther` variable here contains props from <Item .../> as well as <HeaderButtons ... />
// and it is important to pass those props to `HeaderButton`
// then you may add some information like icon size or color (if you use icons)
const theme = useContext(ThemeContext)
return (
<HeaderButton
IconComponent={Icon}
iconSize={30}
color={passMeFurther.disabled ? 'gray' : theme.stylekitInfoColor}
{...passMeFurther}
/>
)
}

View File

@@ -0,0 +1,7 @@
import { StyleSheet } from 'react-native'
export const searchBarStyles = StyleSheet.create({
androidSearch: {
height: 30,
},
})

View File

@@ -0,0 +1,94 @@
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { FC, RefObject, useCallback, useContext } from 'react'
import { Platform } from 'react-native'
import IosSearchBar from 'react-native-search-bar'
import AndroidSearchBar from 'react-native-search-box'
import { ThemeContext } from 'styled-components/native'
import { searchBarStyles } from './/SearchBar.styled'
type Props = {
onChangeText: (text: string) => void
onSearchCancel: () => void
iosSearchBarInputRef: RefObject<IosSearchBar>
androidSearchBarInputRef: RefObject<typeof AndroidSearchBar>
onSearchFocusCallback?: () => void
onSearchBlurCallback?: () => void
collapseSearchBarOnBlur?: boolean
}
export const SearchBar: FC<Props> = ({
onChangeText,
onSearchCancel,
iosSearchBarInputRef,
androidSearchBarInputRef,
onSearchFocusCallback,
onSearchBlurCallback,
collapseSearchBarOnBlur = true,
}) => {
const theme = useContext(ThemeContext)
const themeService = useContext(ThemeServiceContext)
const onSearchFocus = useCallback(() => {
onSearchFocusCallback?.()
}, [onSearchFocusCallback])
const onSearchBlur = useCallback(() => {
onSearchBlurCallback?.()
}, [onSearchBlurCallback])
return (
<>
{Platform.OS === 'ios' && (
<IosSearchBar
ref={iosSearchBarInputRef}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
placeholder="Search"
hideBackground
appearance={themeService?.keyboardColorForActiveTheme()}
barTintColor={theme.stylekitInfoColor}
textFieldBackgroundColor={theme.stylekitContrastBackgroundColor}
onChangeText={onChangeText}
onSearchButtonPress={() => {
iosSearchBarInputRef.current?.blur()
}}
onCancelButtonPress={() => {
iosSearchBarInputRef.current?.blur()
onSearchCancel()
}}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
/>
)}
{Platform.OS === 'android' && (
<AndroidSearchBar
ref={androidSearchBarInputRef}
onChangeText={onChangeText}
onCancel={() => {
onSearchBlur()
onSearchCancel()
}}
onDelete={onSearchCancel}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
collapseOnBlur={collapseSearchBarOnBlur}
blurOnSubmit={true}
backgroundColor={theme.stylekitBackgroundColor}
titleCancelColor={theme.stylekitInfoColor}
keyboardDismissMode={'interactive'}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
inputBorderRadius={4}
tintColorSearch={theme.stylekitForegroundColor}
inputStyle={[
searchBarStyles.androidSearch,
{
color: theme.stylekitForegroundColor,
backgroundColor: theme.stylekitContrastBackgroundColor,
},
]}
placeholderExpandedMargin={25}
searchIconCollapsedMargin={30}
/>
)}
</>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { Platform } from 'react-native'
import styled from 'styled-components/native'
type Props = {
title?: string
subtitle?: string
buttonText?: string
buttonAction?: () => void
buttonStyles?: any
tinted?: boolean
backgroundColor?: string
}
const Container = styled.View<Pick<Props, 'backgroundColor'>>`
/* flex: 1; */
/* flex-grow: 0; */
justify-content: space-between;
flex-direction: row;
padding-right: ${props => props.theme.paddingLeft}px;
padding-bottom: 10px;
padding-top: 10px;
background-color: ${props => props.backgroundColor ?? props.theme.stylekitBackgroundColor};
`
const TitleContainer = styled.View``
const Title = styled.Text<Pick<Props, 'tinted'>>`
background-color: ${props => props.theme.stylekitBackgroundColor};
font-size: ${props => {
return Platform.OS === 'android' ? props.theme.mainTextFontSize - 2 : props.theme.mainTextFontSize - 4
}}px;
padding-left: ${props => props.theme.paddingLeft}px;
color: ${props => {
if (props.tinted) {
return props.theme.stylekitInfoColor
}
return Platform.OS === 'android' ? props.theme.stylekitInfoColor : props.theme.stylekitNeutralColor
}};
font-weight: ${Platform.OS === 'android' ? 'bold' : 'normal'};
`
const SubTitle = styled.Text`
background-color: ${props => props.theme.stylekitBackgroundColor};
font-size: ${props => props.theme.mainTextFontSize - 5}px;
margin-top: 4px;
padding-left: ${props => props.theme.paddingLeft}px;
color: ${props => props.theme.stylekitNeutralColor};
`
const ButtonContainer = styled.TouchableOpacity`
flex: 1;
align-items: flex-end;
justify-content: center;
`
const Button = styled.Text`
color: ${props => props.theme.stylekitInfoColor};
`
export const SectionHeader: React.FC<Props> = props => (
<Container>
<TitleContainer>
{!!props.title && (
<Title>
{Platform.select({
ios: props.title.toUpperCase(),
android: props.title,
})}
</Title>
)}
{!!props.subtitle && <SubTitle>{props.subtitle}</SubTitle>}
</TitleContainer>
{!!props.buttonText && (
<ButtonContainer onPress={props.buttonAction}>
<Button style={props.buttonStyles}>{props.buttonText}</Button>
</ButtonContainer>
)}
</Container>
)

View File

@@ -0,0 +1,132 @@
import React, { useContext } from 'react'
import { Platform } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import styled, { css } from 'styled-components/native'
import { SectionedTableCellTouchableHighlight } from './/SectionedTableCell'
type Props = {
testID?: string
disabled?: boolean
onPress: () => void
onLongPress?: () => void
iconName?: string
selected?: () => boolean
leftAlignIcon?: boolean
color?: string
bold?: boolean
tinted?: boolean
dimmed?: boolean
text: string
first?: boolean
last?: boolean
}
const TouchableContainer = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
underlayColor: props.theme.stylekitBorderColor,
}))`
flex-direction: column;
padding-top: 0px;
padding-bottom: 0px;
min-height: 47px;
background-color: transparent;
`
const ContentContainer = styled.View<Pick<Props, 'leftAlignIcon'>>`
flex: 1;
justify-content: ${props => {
return props.leftAlignIcon ? 'flex-start' : 'space-between'
}};
flex-direction: row;
align-items: center;
`
const IconContainer = styled.View`
width: 30px;
max-width: 30px;
`
type LabelProps = Pick<Props, 'bold' | 'tinted' | 'dimmed' | 'selected' | 'color'>
const Label = styled.Text<LabelProps>`
min-width: 80%;
color: ${props => {
let color = props.theme.stylekitForegroundColor
if (props.tinted) {
color = props.theme.stylekitInfoColor
}
if (props.dimmed) {
color = props.theme.stylekitNeutralColor
}
if (props.color) {
color = props.color
}
return color
}};
font-size: ${props => props.theme.mainTextFontSize}px;
${({ bold, selected }) =>
((selected && selected() === true) || bold) &&
css`
font-weight: bold;
`};
`
export const SectionedAccessoryTableCell: React.FC<Props> = props => {
const themeContext = useContext(ThemeContext)
const onPress = () => {
if (props.disabled) {
return
}
props.onPress()
}
const onLongPress = () => {
if (props.disabled) {
return
}
if (props.onLongPress) {
props.onLongPress()
}
}
const checkmarkName = Platform.OS === 'android' ? 'md-checkbox' : 'ios-checkmark-circle'
const iconName = props.iconName ? props.iconName : props.selected && props.selected() ? checkmarkName : null
const left = props.leftAlignIcon
let iconSize = left ? 25 : 30
let color = left ? themeContext.stylekitForegroundColor : themeContext.stylekitInfoColor
if (Platform.OS === 'android') {
iconSize -= 5
}
if (props.color) {
color = props.color
}
let icon: any = null
if (iconName) {
icon = (
<IconContainer key={iconName}>
<Icon name={iconName} size={iconSize} color={color} />
</IconContainer>
)
}
const textWrapper = (
<Label
tinted={props.tinted}
dimmed={props.dimmed}
bold={props.bold}
selected={props.selected}
color={props.color}
key={1}
>
{props.text}
</Label>
)
return (
<TouchableContainer first={props.first} last={props.last} onPress={onPress} onLongPress={onLongPress}>
<ContentContainer>{props.leftAlignIcon ? [icon, textWrapper] : [textWrapper, icon]}</ContentContainer>
</TouchableContainer>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import styled, { css } from 'styled-components/native'
export type Option = { selected: boolean; key: string; title: string }
type Props = {
testID?: string
title: string
first?: boolean
height?: number
onPress: (option: Option) => void
options: Option[]
leftAligned?: boolean
}
type ContainerProps = Omit<Props, 'title' | 'onPress' | 'options'>
export const Container = styled.View<ContainerProps>`
border-bottom-color: ${props => props.theme.stylekitBorderColor};
border-bottom-width: 1px;
padding-left: ${props => props.theme.paddingLeft}px;
padding-right: ${props => props.theme.paddingLeft}px;
background-color: ${props => props.theme.stylekitBackgroundColor};
${({ first, theme }) =>
first &&
css`
border-top-color: ${theme.stylekitBorderColor};
border-top-width: ${1}px;
`};
${({ height }) =>
height &&
css`
height: ${height}px;
`};
flex-direction: row;
justify-content: center;
align-items: center;
max-height: 45px;
`
const Title = styled.Text<{ leftAligned?: boolean }>`
font-size: ${props => props.theme.mainTextFontSize}px;
color: ${props => props.theme.stylekitForegroundColor};
text-align: ${props => (props.leftAligned ? 'left' : 'center')};
width: 42%;
min-width: 0px;
`
const OptionsContainer = styled.View`
width: 58%;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: ${props => props.theme.stylekitBackgroundColor};
`
const ButtonTouchable = styled.TouchableHighlight.attrs(props => ({
underlayColor: props.theme.stylekitBorderColor,
}))`
border-left-color: ${props => props.theme.stylekitBorderColor};
border-left-width: 1px;
flex-grow: 1;
padding: 10px;
padding-top: 12px;
`
const ButtonTitle = styled.Text<{ selected: boolean }>`
color: ${props => {
return props.selected ? props.theme.stylekitInfoColor : props.theme.stylekitNeutralColor
}};
font-size: 16px;
text-align: center;
width: 100%;
`
export const SectionedOptionsTableCell: React.FC<Props> = props => (
<Container first={props.first}>
<Title leftAligned={props.leftAligned}>{props.title}</Title>
<OptionsContainer>
{props.options.map(option => {
return (
<ButtonTouchable key={option.title} onPress={() => props.onPress(option)}>
<ButtonTitle selected={option.selected}>{option.title}</ButtonTitle>
</ButtonTouchable>
)
})}
</OptionsContainer>
</Container>
)

View File

@@ -0,0 +1,59 @@
import styled, { css } from 'styled-components/native'
export type Props = {
first?: boolean
last?: boolean
textInputCell?: any
height?: number
extraStyles?: any
}
export const SectionedTableCell = styled.View<Props>`
border-bottom-color: ${props => props.theme.stylekitBorderColor};
border-bottom-width: 1px;
padding-left: ${props => props.theme.paddingLeft}px;
padding-right: ${props => props.theme.paddingLeft}px;
padding-bottom: ${props => (props.textInputCell ? 0 : 12)}px;
background-color: ${props => props.theme.stylekitBackgroundColor};
${({ first, theme }) =>
first &&
css`
border-top-color: ${theme.stylekitBorderColor};
border-top-width: ${1}px;
`};
${({ textInputCell }) =>
textInputCell &&
css`
max-height: 50px;
`};
${({ height }) =>
height &&
css`
height: ${height}px;
`};
`
export const SectionedTableCellTouchableHighlight = styled.TouchableHighlight<Props>`
border-bottom-color: ${props => props.theme.stylekitBorderColor};
border-bottom-width: 1px;
padding-left: ${props => props.theme.paddingLeft}px;
padding-right: ${props => props.theme.paddingLeft}px;
padding-bottom: ${props => (props.textInputCell ? 0 : 12)}px;
background-color: ${props => props.theme.stylekitBackgroundColor};
${({ first, theme }) =>
first &&
css`
border-top-color: ${theme.stylekitBorderColor};
border-top-width: ${1}px;
`};
${({ textInputCell }) =>
textInputCell &&
css`
max-height: 50px;
`};
${({ height }) =>
height &&
css`
height: ${height}px;
`};
`

View File

@@ -0,0 +1,101 @@
import ArchiveIcon from '@standardnotes/icons/dist/mobile-exports/ic-archive.svg'
import AttachmentFileIcon from '@standardnotes/icons/dist/mobile-exports/ic-attachment-file.svg'
import AuthenticatorIcon from '@standardnotes/icons/dist/mobile-exports/ic-authenticator.svg'
import ClearCircleFilledIcon from '@standardnotes/icons/dist/mobile-exports/ic-clear-circle-filled.svg'
import CodeIcon from '@standardnotes/icons/dist/mobile-exports/ic-code.svg'
import FileDocIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-doc.svg'
import FileImageIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-image.svg'
import FileMovIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-mov.svg'
import FileMusicIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-music.svg'
import FileOtherIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-other.svg'
import FilePdfIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-pdf.svg'
import FilePptIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-ppt.svg'
import FileXlsIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-xls.svg'
import FileZipIcon from '@standardnotes/icons/dist/mobile-exports/ic-file-zip.svg'
import LockFilledIcon from '@standardnotes/icons/dist/mobile-exports/ic-lock-filled.svg'
import MarkdownIcon from '@standardnotes/icons/dist/mobile-exports/ic-markdown.svg'
import NotesIcon from '@standardnotes/icons/dist/mobile-exports/ic-notes.svg'
import OpenInIcon from '@standardnotes/icons/dist/mobile-exports/ic-open-in.svg'
import PencilOffIcon from '@standardnotes/icons/dist/mobile-exports/ic-pencil-off.svg'
import PinFilledIcon from '@standardnotes/icons/dist/mobile-exports/ic-pin-filled.svg'
import SpreadsheetsIcon from '@standardnotes/icons/dist/mobile-exports/ic-spreadsheets.svg'
import TasksIcon from '@standardnotes/icons/dist/mobile-exports/ic-tasks.svg'
import PlainTextIcon from '@standardnotes/icons/dist/mobile-exports/ic-text-paragraph.svg'
import RichTextIcon from '@standardnotes/icons/dist/mobile-exports/ic-text-rich.svg'
import TrashFilledIcon from '@standardnotes/icons/dist/mobile-exports/ic-trash-filled.svg'
import UserAddIcon from '@standardnotes/icons/dist/mobile-exports/ic-user-add.svg'
import FilesIllustration from '@standardnotes/icons/dist/mobile-exports/il-files.svg'
import { IconType } from '@standardnotes/snjs'
import React, { FC, useContext } from 'react'
import { SvgProps } from 'react-native-svg'
import { ThemeContext } from 'styled-components'
import { iconStyles } from './/Icon.styled'
type TIcons = {
[key in IconType]: FC<SvgProps>
}
const ICONS: Partial<TIcons> = {
'pencil-off': PencilOffIcon,
'plain-text': PlainTextIcon,
'rich-text': RichTextIcon,
code: CodeIcon,
markdown: MarkdownIcon,
spreadsheets: SpreadsheetsIcon,
tasks: TasksIcon,
authenticator: AuthenticatorIcon,
'trash-filled': TrashFilledIcon,
'pin-filled': PinFilledIcon,
archive: ArchiveIcon,
'user-add': UserAddIcon,
'open-in': OpenInIcon,
notes: NotesIcon,
'attachment-file': AttachmentFileIcon,
'files-illustration': FilesIllustration,
'file-doc': FileDocIcon,
'file-image': FileImageIcon,
'file-mov': FileMovIcon,
'file-music': FileMusicIcon,
'file-other': FileOtherIcon,
'file-pdf': FilePdfIcon,
'file-ppt': FilePptIcon,
'file-xls': FileXlsIcon,
'file-zip': FileZipIcon,
'clear-circle-filled': ClearCircleFilledIcon,
'lock-filled': LockFilledIcon,
}
type Props = {
type: IconType
fill?: string
style?: Record<string, unknown>
width?: number
height?: number
}
export const SnIcon = ({ type, fill, width, height, style = {} }: Props) => {
const theme = useContext(ThemeContext)
const fillColor = fill || theme.stylekitPalSky
const IconComponent = ICONS[type]
if (!IconComponent) {
return null
}
let customSizes = {}
if (width !== undefined) {
customSizes = {
...customSizes,
width,
}
}
if (height !== undefined) {
customSizes = {
...customSizes,
height,
}
}
return <IconComponent fill={fillColor} {...customSizes} style={[iconStyles.icon, style]} />
}

View File

@@ -0,0 +1,7 @@
import styled from 'styled-components/native'
export const TableSection = styled.View`
margin-top: 10px;
margin-bottom: 10px;
background-color: ${props => props.theme.stylekitBackgroundColor};
`

View File

@@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native'
import { DefaultTheme } from 'styled-components/native'
export const useToastStyles = (theme: DefaultTheme) => {
return (props: { [key: string]: unknown }) => {
return StyleSheet.create({
info: {
borderLeftColor: theme.stylekitInfoColor,
height: props.percentComplete !== undefined ? 70 : 60,
},
animatedViewContainer: {
height: 8,
borderWidth: 1,
borderRadius: 8,
borderColor: theme.stylekitInfoColor,
marginRight: 8,
marginLeft: 12,
marginTop: -16,
},
animatedView: {
backgroundColor: theme.stylekitInfoColor,
},
success: {
borderLeftColor: theme.stylekitSuccessColor,
},
error: {
borderLeftColor: theme.stylekitWarningColor,
},
})
}
}

View File

@@ -0,0 +1,53 @@
import { useToastStyles } from '@Components/ToastWrapper.styled'
import { useProgressBar } from '@Root/Hooks/useProgessBar'
import React, { FC, useContext } from 'react'
import { Animated, StyleSheet, View } from 'react-native'
import Toast, { ErrorToast, InfoToast, SuccessToast, ToastConfig } from 'react-native-toast-message'
import { ThemeContext } from 'styled-components'
export const ToastWrapper: FC = () => {
const theme = useContext(ThemeContext)
const styles = useToastStyles(theme)
const { updateProgressBar, progressBarWidth } = useProgressBar()
const toastStyles: ToastConfig = {
info: props => {
const percentComplete = props.props?.percentComplete || 0
updateProgressBar(percentComplete)
return (
<View>
<InfoToast {...props} style={styles(props.props).info} />
{props.props?.percentComplete !== undefined ? (
<View style={[styles(props.props).animatedViewContainer]}>
<Animated.View
style={[
StyleSheet.absoluteFill,
{
...styles(props.props).animatedView,
width: progressBarWidth,
},
]}
/>
</View>
) : null}
</View>
)
},
success: props => {
const percentComplete = props.props?.percentComplete || 0
updateProgressBar(percentComplete)
return <SuccessToast {...props} style={styles(props.props).success} />
},
error: props => {
const percentComplete = props.props?.percentComplete || 0
updateProgressBar(percentComplete)
return <ErrorToast {...props} style={styles(props.props).error} />
},
}
return <Toast config={toastStyles} />
}

View File

@@ -0,0 +1,3 @@
{
"name": "@Components"
}

View File

@@ -0,0 +1,91 @@
import { RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { NoteHistory } from '@Root/Screens/NoteHistory/NoteHistory'
import { NoteHistoryPreview } from '@Root/Screens/NoteHistory/NoteHistoryPreview'
import { SCREEN_NOTE_HISTORY, SCREEN_NOTE_HISTORY_PREVIEW } from '@Root/Screens/screens'
import { NoteHistoryEntry } from '@standardnotes/snjs'
import { ICON_CHECKMARK } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { useContext } from 'react'
import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App'
type HistoryStackNavigatorParamList = {
[SCREEN_NOTE_HISTORY]: (HeaderTitleParams & { noteUuid: string }) | (undefined & { noteUuid: string })
[SCREEN_NOTE_HISTORY_PREVIEW]: HeaderTitleParams & {
revision: NoteHistoryEntry
originalNoteUuid: string
}
}
export type HistoryStackNavigationProp<T extends keyof HistoryStackNavigatorParamList> = {
navigation: StackNavigationProp<HistoryStackNavigatorParamList, T>
route: RouteProp<HistoryStackNavigatorParamList, T>
}
const MainStack = createStackNavigator<HistoryStackNavigatorParamList>()
export const HistoryStack = () => {
const theme = useContext(ThemeContext)
return (
<MainStack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: theme.stylekitContrastBackgroundColor,
},
}}
initialRouteName={SCREEN_NOTE_HISTORY}
>
<MainStack.Screen
name={SCREEN_NOTE_HISTORY}
options={({ route }) => ({
title: 'Note history',
headerBackTitleVisible: false,
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Done' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CHECKMARK)}
onPress={onPress}
/>
</HeaderButtons>
),
headerTitle: ({ children }) => {
return (
<HeaderTitleView
title={route.params?.title ?? (children || '')}
subtitle={route.params?.subTitle}
subtitleColor={route.params?.subTitleColor}
/>
)
},
})}
component={NoteHistory}
/>
<MainStack.Screen
name={SCREEN_NOTE_HISTORY_PREVIEW}
options={({ route }) => ({
title: 'Preview',
headerBackTitleVisible: false,
headerTitle: ({ children }) => {
return (
<HeaderTitleView
title={route.params?.title ?? (children || '')}
subtitle={route.params?.subTitle || undefined}
subtitleColor={route.params?.subTitleColor || undefined}
/>
)
},
})}
component={NoteHistoryPreview}
/>
</MainStack.Navigator>
)
}

View File

@@ -0,0 +1,809 @@
import { ErrorMessage } from '@Lib/constants'
import { ToastType } from '@Lib/Types'
import { useNavigation } from '@react-navigation/native'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_INPUT_MODAL_FILE_NAME } from '@Root/Screens/screens'
import { TAppStackNavigationProp } from '@Root/Screens/UploadedFilesList/UploadedFileItem'
import {
UploadedFileItemAction,
UploadedFileItemActionType,
} from '@Root/Screens/UploadedFilesList/UploadedFileItemAction'
import { Tabs } from '@Screens/UploadedFilesList/UploadedFilesList'
import { FileDownloadProgress } from '@standardnotes/files/dist/Domain/Types/FileDownloadProgress'
import { ButtonType, ChallengeReason, ClientDisplayableError, ContentType, FileItem, SNNote } from '@standardnotes/snjs'
import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet'
import { useCallback, useEffect, useState } from 'react'
import { Platform } from 'react-native'
import DocumentPicker, { DocumentPickerResponse, isInProgress, pickMultiple } from 'react-native-document-picker'
import FileViewer from 'react-native-file-viewer'
import RNFS, { exists } from 'react-native-fs'
import { Asset, launchCamera, launchImageLibrary, MediaType } from 'react-native-image-picker'
import RNShare from 'react-native-share'
import Toast from 'react-native-toast-message'
type Props = {
note: SNNote
}
type TDownloadFileAndReturnLocalPathParams = {
file: FileItem
saveInTempLocation?: boolean
showSuccessToast?: boolean
}
type TUploadFileFromCameraOrImageGalleryParams = {
uploadFromGallery?: boolean
mediaType?: MediaType
}
export const isFileTypePreviewable = (fileType: string) => {
const isImage = fileType.startsWith('image/')
const isVideo = fileType.startsWith('video/')
const isAudio = fileType.startsWith('audio/')
const isPdf = fileType === 'application/pdf'
const isText = fileType === 'text/plain'
return isImage || isVideo || isAudio || isPdf || isText
}
export const useFiles = ({ note }: Props) => {
const application = useSafeApplicationContext()
const { showActionSheet } = useCustomActionSheet()
const navigation = useNavigation<TAppStackNavigationProp>()
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
const [allFiles, setAllFiles] = useState<FileItem[]>([])
const [isDownloading, setIsDownloading] = useState(false)
const { GeneralText } = ErrorMessage
const { Success, Info, Error } = ToastType
const filesService = application.getFilesService()
const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(application.items.getFilesForNote(note).sort(filesService.sortByName))
}, [application.items, filesService.sortByName, note])
const reloadAllFiles = useCallback(() => {
setAllFiles(application.items.getItems<FileItem>(ContentType.File).sort(filesService.sortByName) as FileItem[])
}, [application.items, filesService.sortByName])
const deleteFileAtPath = useCallback(async (path: string) => {
try {
if (await exists(path)) {
await RNFS.unlink(path)
}
} catch (err) {
console.error(err)
}
}, [])
const showDownloadToastWithProgressBar = useCallback(
(percentComplete: number | undefined) => {
const percentCompleteFormatted = filesService.formatCompletedPercent(percentComplete)
Toast.show({
type: Info,
text1: `Downloading and decrypting file... (${percentCompleteFormatted}%)`,
props: {
percentComplete: percentCompleteFormatted,
},
autoHide: false,
})
},
[Info, filesService],
)
const showUploadToastWithProgressBar = useCallback(
(fileName: string, percentComplete: number | undefined) => {
const percentCompleteFormatted = filesService.formatCompletedPercent(percentComplete)
Toast.show({
type: Info,
text1: `Uploading "${fileName}"... (${percentCompleteFormatted}%)`,
autoHide: false,
props: {
percentComplete: percentCompleteFormatted,
},
})
},
[Info, filesService],
)
const resetProgressState = useCallback(() => {
Toast.show({
type: Info,
props: {
percentComplete: 0,
},
onShow: Toast.hide,
})
}, [Info])
const updateProgressPercentOnDownload = useCallback(
(progress: FileDownloadProgress | undefined) => {
showDownloadToastWithProgressBar(progress?.percentComplete)
},
[showDownloadToastWithProgressBar],
)
const downloadFileAndReturnLocalPath = useCallback(
async ({
file,
saveInTempLocation = false,
showSuccessToast = true,
}: TDownloadFileAndReturnLocalPathParams): Promise<string | undefined> => {
if (isDownloading) {
return
}
const isGrantedStoragePermissionOnAndroid = await filesService.hasStoragePermissionOnAndroid()
if (!isGrantedStoragePermissionOnAndroid) {
return
}
setIsDownloading(true)
try {
showDownloadToastWithProgressBar(0)
const path = filesService.getDestinationPath({
fileName: file.name,
saveInTempLocation,
})
await deleteFileAtPath(path)
const response = await filesService.downloadFileInChunks(file, path, updateProgressPercentOnDownload)
resetProgressState()
if (response instanceof ClientDisplayableError) {
Toast.show({
type: Error,
text1: 'Error',
text2: response.text || GeneralText,
})
return
}
if (showSuccessToast) {
Toast.show({
type: Success,
text1: 'Success',
text2: 'Successfully downloaded. Press here to open the file.',
position: 'bottom',
onPress: async () => {
await FileViewer.open(path, { showOpenWithDialog: true })
},
})
} else {
Toast.hide()
}
return path
} catch (error) {
Toast.show({
type: Error,
text1: 'Error',
text2: 'An error occurred while downloading the file',
onPress: Toast.hide,
})
return
} finally {
setIsDownloading(false)
}
},
[
Error,
GeneralText,
Success,
deleteFileAtPath,
filesService,
isDownloading,
resetProgressState,
showDownloadToastWithProgressBar,
updateProgressPercentOnDownload,
],
)
const cleanupTempFileOnAndroid = useCallback(
async (downloadedFilePath: string) => {
if (Platform.OS === 'android') {
await deleteFileAtPath(downloadedFilePath)
}
},
[deleteFileAtPath],
)
const shareFile = useCallback(
async (file: FileItem) => {
const downloadedFilePath = await downloadFileAndReturnLocalPath({
file,
saveInTempLocation: true,
showSuccessToast: false,
})
if (!downloadedFilePath) {
return
}
await application.getAppState().performActionWithoutStateChangeImpact(async () => {
try {
// On Android this response always returns {success: false}, there is an open issue for that:
// https://github.com/react-native-share/react-native-share/issues/1059
const shareDialogResponse = await RNShare.open({
url: `file://${downloadedFilePath}`,
failOnCancel: false,
})
// On iOS the user can store files locally from "Share" screen, so we don't show "Download" option there.
// For Android the user has a separate "Download" action for the file, therefore after the file is shared,
// it's not needed anymore and we remove it from the storage.
await cleanupTempFileOnAndroid(downloadedFilePath)
if (shareDialogResponse.success) {
Toast.show({
type: Success,
text1: 'Successfully exported. Press here to open the file.',
position: 'bottom',
onPress: async () => {
await FileViewer.open(downloadedFilePath)
},
})
}
} catch (error) {
Toast.show({
type: Error,
text1: 'An error occurred while trying to share this file',
onPress: Toast.hide,
})
}
})
},
[Error, Success, application, cleanupTempFileOnAndroid, downloadFileAndReturnLocalPath],
)
const attachFileToNote = useCallback(
async (file: FileItem, showToastAfterAction = true) => {
await application.items.associateFileWithNote(file, note)
void application.sync.sync()
if (showToastAfterAction) {
Toast.show({
type: Success,
text1: 'Successfully attached file to note',
onPress: Toast.hide,
})
}
},
[Success, application, note],
)
const detachFileFromNote = useCallback(
async (file: FileItem) => {
await application.items.disassociateFileWithNote(file, note)
void application.sync.sync()
Toast.show({
type: Success,
text1: 'Successfully detached file from note',
onPress: Toast.hide,
})
},
[Success, application, note],
)
const toggleFileProtection = useCallback(
async (file: FileItem) => {
try {
let result: FileItem | undefined
if (file.protected) {
result = await application.mutator.unprotectFile(file)
} else {
result = await application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
} catch (error) {
console.error('An error occurred: ', error)
return file.protected
}
},
[application],
)
const authorizeProtectedActionForFile = useCallback(
async (file: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason)
return authorizedFiles.length > 0 && authorizedFiles.includes(file)
},
[application],
)
const renameFile = useCallback(
async (file: FileItem, fileName: string) => {
await application.items.renameFile(file, fileName)
},
[application],
)
const previewFile = useCallback(
async (file: FileItem) => {
let downloadedFilePath: string | undefined = ''
try {
const isPreviewable = isFileTypePreviewable(file.mimeType)
if (!isPreviewable) {
const tryToPreview = await application.alertService.confirm(
'This file may not be previewable. Do you wish to try anyway?',
'',
'Try to preview',
ButtonType.Info,
'Cancel',
)
if (!tryToPreview) {
return
}
}
downloadedFilePath = await downloadFileAndReturnLocalPath({
file,
saveInTempLocation: true,
showSuccessToast: false,
})
if (!downloadedFilePath) {
return
}
await FileViewer.open(downloadedFilePath, {
onDismiss: async () => {
await cleanupTempFileOnAndroid(downloadedFilePath as string)
},
})
return true
} catch (error) {
await cleanupTempFileOnAndroid(downloadedFilePath as string)
await application.alertService.alert('An error occurred while previewing the file.')
return false
}
},
[application, cleanupTempFileOnAndroid, downloadFileAndReturnLocalPath],
)
const deleteFile = useCallback(
async (file: FileItem) => {
const shouldDelete = await application.alertService.confirm(
`Are you sure you want to permanently delete "${file.name}"?`,
undefined,
'Confirm',
ButtonType.Danger,
'Cancel',
)
if (shouldDelete) {
Toast.show({
type: Info,
text1: `Deleting "${file.name}"...`,
})
const response = await application.files.deleteFile(file)
if (response instanceof ClientDisplayableError) {
Toast.show({
type: Error,
text1: 'Error',
text2: response.text || GeneralText,
})
return
}
Toast.show({
type: Success,
text1: `Successfully deleted "${file.name}"`,
})
}
},
[Error, GeneralText, Info, Success, application.alertService, application.files],
)
const handlePickFilesError = async (error: unknown) => {
if (DocumentPicker.isCancel(error)) {
// User canceled the picker, exit any dialogs or menus and move on
} else if (isInProgress(error)) {
Toast.show({
type: Info,
text2: 'Multiple pickers were opened; only the last one will be considered.',
})
} else {
Toast.show({
type: Error,
text1: 'An error occurred while attempting to select files.',
})
}
}
const handleUploadError = async () => {
Toast.show({
type: Error,
text1: 'Error',
text2: 'An error occurred while uploading file(s).',
})
}
const pickFiles = async (): Promise<DocumentPickerResponse[] | void> => {
try {
const selectedFiles = await pickMultiple()
return selectedFiles
} catch (error) {
await handlePickFilesError(error)
}
}
const uploadSingleFile = async (file: DocumentPickerResponse | Asset, size: number): Promise<FileItem | void> => {
try {
const fileName = filesService.getFileName(file)
const operation = await application.files.beginNewFileUpload(size)
if (operation instanceof ClientDisplayableError) {
Toast.show({
type: Error,
text1: operation.text,
})
return
}
const initialPercentComplete = operation.getProgress().percentComplete
showUploadToastWithProgressBar(fileName, initialPercentComplete)
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
await application.files.pushBytesForUpload(operation, chunk, index, isLast)
showUploadToastWithProgressBar(fileName, operation.getProgress().percentComplete)
}
const fileResult = await filesService.readFile(file, onChunk)
const fileObj = await application.files.finishUpload(operation, fileResult)
resetProgressState()
if (fileObj instanceof ClientDisplayableError) {
Toast.show({
type: Error,
text1: fileObj.text,
})
return
}
return fileObj
} catch (error) {
await handleUploadError()
}
}
const uploadFiles = async (): Promise<FileItem[] | void> => {
try {
const selectedFiles = await pickFiles()
if (!selectedFiles || selectedFiles.length === 0) {
return
}
const uploadedFiles: FileItem[] = []
for (const file of selectedFiles) {
if (!file.uri || !file.size) {
continue
}
const fileObject = await uploadSingleFile(file, file.size)
if (!fileObject) {
Toast.show({
type: Error,
text1: 'Error',
text2: `An error occurred while uploading ${file.name}.`,
})
continue
}
uploadedFiles.push(fileObject)
Toast.show({ text1: `Successfully uploaded ${fileObject.name}` })
}
if (selectedFiles.length > 1) {
Toast.show({ text1: 'Successfully uploaded' })
}
return uploadedFiles
} catch (error) {
await handleUploadError()
}
}
const handleAttachFromCamera = (currentTab: Tabs | undefined) => {
const options = [
{
text: 'Photo',
callback: async () => {
const uploadedFile = await uploadFileFromCameraOrImageGallery({
mediaType: 'photo',
})
if (!uploadedFile) {
return
}
if (shouldAttachToNote(currentTab)) {
await attachFileToNote(uploadedFile, false)
}
},
},
{
text: 'Video',
callback: async () => {
const uploadedFile = await uploadFileFromCameraOrImageGallery({
mediaType: 'video',
})
if (!uploadedFile) {
return
}
await attachFileToNote(uploadedFile, false)
},
},
]
showActionSheet({
title: 'Choose file type',
options,
})
}
const shouldAttachToNote = (currentTab: Tabs | undefined) => {
return currentTab === undefined || currentTab === Tabs.AttachedFiles
}
const handlePressAttachFile = (currentTab?: Tabs) => {
const options: CustomActionSheetOption[] = [
{
text: 'Attach from files',
key: 'files',
callback: async () => {
const uploadedFiles = await uploadFiles()
if (!uploadedFiles) {
return
}
if (shouldAttachToNote(currentTab)) {
uploadedFiles.forEach(file => attachFileToNote(file, false))
}
},
},
{
text: 'Attach from Photo Library',
key: 'library',
callback: async () => {
const uploadedFile = await uploadFileFromCameraOrImageGallery({
uploadFromGallery: true,
})
if (!uploadedFile) {
return
}
if (shouldAttachToNote(currentTab)) {
await attachFileToNote(uploadedFile, false)
}
},
},
{
text: 'Attach from Camera',
key: 'camera',
callback: async () => {
handleAttachFromCamera(currentTab)
},
},
]
const osSpecificOptions = Platform.OS === 'android' ? options.filter(option => option.key !== 'library') : options
showActionSheet({
title: 'Choose action',
options: osSpecificOptions,
})
}
const uploadFileFromCameraOrImageGallery = async ({
uploadFromGallery = false,
mediaType = 'photo',
}: TUploadFileFromCameraOrImageGalleryParams): Promise<FileItem | void> => {
try {
const result = uploadFromGallery
? await launchImageLibrary({ mediaType: 'mixed' })
: await launchCamera({ mediaType })
if (result.didCancel || !result.assets) {
return
}
const file = result.assets[0]
const fileObject = await uploadSingleFile(file, file.fileSize || 0)
if (!file.uri || !file.fileSize) {
return
}
if (!fileObject) {
Toast.show({
type: Error,
text1: 'Error',
text2: `An error occurred while uploading ${file.fileName}.`,
})
return
}
Toast.show({ text1: `Successfully uploaded ${fileObject.name}` })
return fileObject
} catch (error) {
await handleUploadError()
}
}
const handleFileAction = useCallback(
async (action: UploadedFileItemAction) => {
const file = action.payload
let isAuthorizedForAction = true
if (file.protected && action.type !== UploadedFileItemActionType.ToggleFileProtection) {
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
}
if (!isAuthorizedForAction) {
return false
}
switch (action.type) {
case UploadedFileItemActionType.AttachFileToNote:
await attachFileToNote(file)
break
case UploadedFileItemActionType.DetachFileToNote:
await detachFileFromNote(file)
break
case UploadedFileItemActionType.ShareFile:
await shareFile(file)
break
case UploadedFileItemActionType.DownloadFile:
await downloadFileAndReturnLocalPath({ file })
break
case UploadedFileItemActionType.ToggleFileProtection: {
await toggleFileProtection(file)
break
}
case UploadedFileItemActionType.RenameFile:
navigation.navigate(SCREEN_INPUT_MODAL_FILE_NAME, {
file,
renameFile,
})
break
case UploadedFileItemActionType.PreviewFile:
await previewFile(file)
break
case UploadedFileItemActionType.DeleteFile:
await deleteFile(file)
break
default:
break
}
await application.sync.sync()
return true
},
[
application.sync,
attachFileToNote,
authorizeProtectedActionForFile,
deleteFile,
detachFileFromNote,
downloadFileAndReturnLocalPath,
navigation,
previewFile,
renameFile,
shareFile,
toggleFileProtection,
],
)
useEffect(() => {
const unregisterFileStream = application.streamItems(ContentType.File, () => {
reloadAttachedFiles()
reloadAllFiles()
})
return () => {
unregisterFileStream()
}
}, [application, reloadAllFiles, reloadAttachedFiles])
const showActionsMenu = useCallback(
(file: FileItem | undefined) => {
if (!file) {
return
}
const isAttachedToNote = attachedFiles.includes(file)
const actions: CustomActionSheetOption[] = [
{
text: 'Preview',
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.PreviewFile,
payload: file,
})
},
},
{
text: isAttachedToNote ? 'Detach from note' : 'Attach to note',
callback: isAttachedToNote
? async () => {
await handleFileAction({
type: UploadedFileItemActionType.DetachFileToNote,
payload: file,
})
}
: async () => {
await handleFileAction({
type: UploadedFileItemActionType.AttachFileToNote,
payload: file,
})
},
},
{
text: `${file.protected ? 'Disable' : 'Enable'} password protection`,
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.ToggleFileProtection,
payload: file,
})
},
},
{
text: Platform.OS === 'ios' ? 'Export' : 'Share',
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.ShareFile,
payload: file,
})
},
},
{
text: 'Download',
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.DownloadFile,
payload: file,
})
},
},
{
text: 'Rename',
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.RenameFile,
payload: file,
})
},
},
{
text: 'Delete permanently',
callback: async () => {
await handleFileAction({
type: UploadedFileItemActionType.DeleteFile,
payload: file,
})
},
destructive: true,
},
]
const osDependentActions =
Platform.OS === 'ios' ? actions.filter(action => action.text !== 'Download') : [...actions]
showActionSheet({
title: file.name,
options: osDependentActions,
styles: {
titleTextStyle: {
fontWeight: 'bold',
},
},
})
},
[attachedFiles, handleFileAction, showActionSheet],
)
return {
showActionsMenu,
attachedFiles,
allFiles,
handleFileAction,
handlePressAttachFile,
uploadFileFromCameraOrImageGallery,
attachFileToNote,
}
}

View File

@@ -0,0 +1,28 @@
import { useCallback, useRef } from 'react'
import { Animated } from 'react-native'
export const useProgressBar = () => {
const counterRef = useRef(new Animated.Value(0)).current
const updateProgressBar = useCallback(
(percentComplete: number) => {
Animated.timing(counterRef, {
toValue: percentComplete,
duration: 500,
useNativeDriver: false,
}).start()
},
[counterRef],
)
const progressBarWidth = counterRef.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
extrapolate: 'identity',
})
return {
updateProgressBar,
progressBarWidth,
}
}

View File

@@ -0,0 +1,8 @@
import { MobileApplication } from '@Lib/Application'
import { ApplicationContext } from '@Root/ApplicationContext'
import { useContext } from 'react'
export const useSafeApplicationContext = () => {
const application = useContext(ApplicationContext) as MobileApplication
return application
}

View File

@@ -0,0 +1,62 @@
import { MODAL_BLOCKING_ALERT } from '@Root/Screens/screens'
import { AlertService, ButtonType, DismissBlockingDialog } from '@standardnotes/snjs'
import { Alert, AlertButton } from 'react-native'
import { goBack, navigate } from './NavigationService'
export class MobileAlertService extends AlertService {
blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise<DismissBlockingDialog> {
navigate(MODAL_BLOCKING_ALERT, { text, title })
return goBack
}
alert(text: string, title: string, closeButtonText?: string) {
return new Promise<void>(resolve => {
// On iOS, confirm should go first. On Android, cancel should go first.
const buttons = [
{
text: closeButtonText,
onPress: async () => {
resolve()
},
},
]
Alert.alert(title, text, buttons, {
cancelable: true,
})
})
}
confirm(
text: string,
title: string,
confirmButtonText = 'Confirm',
confirmButtonType?: ButtonType,
cancelButtonText = 'Cancel',
) {
return new Promise<boolean>((resolve, reject) => {
// On iOS, confirm should go first. On Android, cancel should go first.
const buttons: AlertButton[] = [
{
text: cancelButtonText,
style: 'cancel',
onPress: async () => {
resolve(false)
},
},
{
text: confirmButtonText,
style: confirmButtonType === ButtonType.Danger ? 'destructive' : 'default',
onPress: async () => {
resolve(true)
},
},
]
Alert.alert(title, text, buttons, {
cancelable: true,
onDismiss: async () => {
reject()
},
})
})
}
}

View File

@@ -0,0 +1,164 @@
import { SCREEN_AUTHENTICATE } from '@Root/Screens/screens'
import {
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
DeinitMode,
DeinitSource,
Environment,
IconsController,
NoteGroupController,
platformFromString,
SNApplication,
SNComponentManager,
} from '@standardnotes/snjs'
import { Platform } from 'react-native'
import VersionInfo from 'react-native-version-info'
import { version } from '../../package.json'
import { MobileAlertService } from './AlertService'
import { ApplicationState, UnlockTiming } from './ApplicationState'
import { BackupsService } from './BackupsService'
import { ComponentManager } from './ComponentManager'
import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface'
import { push } from './NavigationService'
import { PreferencesManager } from './PreferencesManager'
import { SNReactNativeCrypto } from './ReactNativeCrypto'
import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager'
type MobileServices = {
applicationState: ApplicationState
reviewService: ReviewService
backupsService: BackupsService
installationService: InstallationService
prefsService: PreferencesManager
statusManager: StatusManager
filesService: FilesService
}
const IsDev = VersionInfo.bundleIdentifier?.includes('dev')
export class MobileApplication extends SNApplication {
private MobileServices!: MobileServices
public editorGroup: NoteGroupController
public iconsController: IconsController
private startedDeinit = false
// UI remounts when Uuid changes
public Uuid: string
static previouslyLaunched = false
constructor(deviceInterface: MobileDeviceInterface, identifier: string) {
super({
environment: Environment.Mobile,
platform: platformFromString(Platform.OS),
deviceInterface: deviceInterface,
crypto: new SNReactNativeCrypto(),
alertService: new MobileAlertService(),
identifier,
swapClasses: [
{
swap: SNComponentManager,
with: ComponentManager,
},
],
defaultHost: IsDev ? 'https://api-dev.standardnotes.com' : 'https://api.standardnotes.com',
appVersion: version,
webSocketUrl: IsDev ? 'wss://sockets-dev.standardnotes.com' : 'wss://sockets.standardnotes.com',
})
this.Uuid = Math.random().toString()
this.editorGroup = new NoteGroupController(this)
this.iconsController = new IconsController()
void this.mobileComponentManager.initialize(this.protocolService)
}
get mobileComponentManager(): ComponentManager {
return this.componentManager as ComponentManager
}
static getPreviouslyLaunched() {
return this.previouslyLaunched
}
static setPreviouslyLaunched() {
this.previouslyLaunched = true
}
public hasStartedDeinit() {
return this.startedDeinit
}
override deinit(mode: DeinitMode, source: DeinitSource): void {
this.startedDeinit = true
for (const service of Object.values(this.MobileServices)) {
if (service.deinit) {
service.deinit()
}
if ('application' in service) {
const typedService = service as { application?: MobileApplication }
typedService.application = undefined
}
}
this.MobileServices = {} as MobileServices
this.editorGroup.deinit()
super.deinit(mode, source)
}
override getLaunchChallenge() {
const challenge = super.getLaunchChallenge()
if (!challenge) {
return undefined
}
const previouslyLaunched = MobileApplication.getPreviouslyLaunched()
const biometricsTiming = this.getAppState().biometricsTiming
if (previouslyLaunched && biometricsTiming === UnlockTiming.OnQuit) {
const filteredPrompts = challenge.prompts.filter(
(prompt: ChallengePrompt) => prompt.validation !== ChallengeValidation.Biometric,
)
return new Challenge(filteredPrompts, ChallengeReason.ApplicationUnlock, false)
}
return challenge
}
promptForChallenge(challenge: Challenge) {
push(SCREEN_AUTHENTICATE, { challenge, title: challenge.modalTitle })
}
setMobileServices(services: MobileServices) {
this.MobileServices = services
}
public getAppState() {
return this.MobileServices.applicationState
}
public getBackupsService() {
return this.MobileServices.backupsService
}
public getLocalPreferences() {
return this.MobileServices.prefsService
}
public getStatusManager() {
return this.MobileServices.statusManager
}
public getFilesService() {
return this.MobileServices.filesService
}
}

View File

@@ -0,0 +1,45 @@
import { InternalEventBus } from '@standardnotes/services'
import { ApplicationDescriptor, DeviceInterface, SNApplicationGroup } from '@standardnotes/snjs'
import { MobileApplication } from './Application'
import { ApplicationState } from './ApplicationState'
import { BackupsService } from './BackupsService'
import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface'
import { PreferencesManager } from './PreferencesManager'
import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager'
export class ApplicationGroup extends SNApplicationGroup {
constructor() {
super(new MobileDeviceInterface())
}
override async initialize(_callback?: any): Promise<void> {
await super.initialize({
applicationCreator: this.createApplication,
})
}
private createApplication = async (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => {
const application = new MobileApplication(deviceInterface as MobileDeviceInterface, descriptor.identifier)
const internalEventBus = new InternalEventBus()
const applicationState = new ApplicationState(application)
const reviewService = new ReviewService(application, internalEventBus)
const backupsService = new BackupsService(application, internalEventBus)
const prefsService = new PreferencesManager(application, internalEventBus)
const installationService = new InstallationService(application, internalEventBus)
const statusManager = new StatusManager(application, internalEventBus)
const filesService = new FilesService(application, internalEventBus)
application.setMobileServices({
applicationState,
reviewService,
backupsService,
prefsService,
installationService,
statusManager,
filesService,
})
return application
}
}

View File

@@ -0,0 +1,683 @@
import { InternalEventBus } from '@standardnotes/services'
import {
ApplicationEvent,
ApplicationService,
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
ContentType,
isNullOrUndefined,
NoteViewController,
PayloadEmitSource,
PrefKey,
removeFromArray,
SmartView,
SNNote,
SNTag,
SNUserPrefs,
StorageKey,
StorageValueModes,
SystemViewId,
Uuid,
} from '@standardnotes/snjs'
import {
AppState,
AppStateStatus,
EmitterSubscription,
InteractionManager,
Keyboard,
KeyboardEventListener,
NativeEventSubscription,
NativeModules,
Platform,
} from 'react-native'
import FlagSecure from 'react-native-flag-secure-android'
import { hide, show } from 'react-native-privacy-snapshot'
import VersionInfo from 'react-native-version-info'
import pjson from '../../package.json'
import { MobileApplication } from './Application'
import { associateComponentWithNote } from './ComponentManager'
const { PlatformConstants } = NativeModules
export enum AppStateType {
LosingFocus = 1,
EnteringBackground = 2,
GainingFocus = 3,
ResumingFromBackground = 4,
TagChanged = 5,
EditorClosed = 6,
PreferencesChanged = 7,
}
export enum LockStateType {
Locked = 1,
Unlocked = 2,
}
export enum AppStateEventType {
KeyboardChangeEvent = 1,
TabletModeChange = 2,
DrawerOpen = 3,
}
export type TabletModeChangeData = {
new_isInTabletMode: boolean
old_isInTabletMode: boolean
}
export enum UnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}
export enum PasscodeKeyboardType {
Default = 'default',
Numeric = 'numeric',
}
export enum MobileStorageKey {
PasscodeKeyboardTypeKey = 'passcodeKeyboardType',
}
type EventObserverCallback = (event: AppStateEventType, data?: TabletModeChangeData) => void | Promise<void>
type ObserverCallback = (event: AppStateType, data?: unknown) => void | Promise<void>
type LockStateObserverCallback = (event: LockStateType) => void | Promise<void>
export class ApplicationState extends ApplicationService {
override application: MobileApplication
observers: ObserverCallback[] = []
private stateObservers: EventObserverCallback[] = []
private lockStateObservers: LockStateObserverCallback[] = []
locked = true
keyboardDidShowListener?: EmitterSubscription
keyboardDidHideListener?: EmitterSubscription
keyboardHeight?: number
removeAppEventObserver!: () => void
selectedTagRestored = false
selectedTag: SNTag | SmartView = this.application.items.getSmartViews()[0]
userPreferences?: SNUserPrefs
tabletMode = false
ignoreStateChanges = false
mostRecentState?: AppStateType
authenticationInProgress = false
multiEditorEnabled = false
screenshotPrivacyEnabled?: boolean
passcodeTiming?: UnlockTiming
biometricsTiming?: UnlockTiming
removeHandleReactNativeAppStateChangeListener: NativeEventSubscription
removeItemChangesListener?: () => void
removePreferencesLoadedListener?: () => void
constructor(application: MobileApplication) {
super(application, new InternalEventBus())
this.application = application
this.setTabletModeEnabled(this.isTabletDevice)
this.handleApplicationEvents()
this.handleItemsChanges()
this.removeHandleReactNativeAppStateChangeListener = AppState.addEventListener(
'change',
this.handleReactNativeAppStateChange,
)
this.keyboardDidShowListener = Keyboard.addListener('keyboardWillShow', this.keyboardDidShow)
this.keyboardDidHideListener = Keyboard.addListener('keyboardWillHide', this.keyboardDidHide)
}
override deinit() {
this.removeAppEventObserver()
;(this.removeAppEventObserver as unknown) = undefined
this.removeHandleReactNativeAppStateChangeListener.remove()
if (this.removeItemChangesListener) {
this.removeItemChangesListener()
}
if (this.removePreferencesLoadedListener) {
this.removePreferencesLoadedListener()
}
this.observers.length = 0
this.keyboardDidShowListener = undefined
this.keyboardDidHideListener = undefined
}
restoreSelectedTag() {
if (this.selectedTagRestored) {
return
}
const savedTagUuid: string | undefined = this.prefService.getValue(PrefKey.MobileSelectedTagUuid, undefined)
if (isNullOrUndefined(savedTagUuid)) {
this.selectedTagRestored = true
return
}
const savedTag =
(this.application.items.findItem(savedTagUuid) as SNTag) ||
this.application.items.getSmartViews().find(tag => tag.uuid === savedTagUuid)
if (savedTag) {
this.setSelectedTag(savedTag, false)
this.selectedTagRestored = true
}
}
override async onAppStart() {
this.removePreferencesLoadedListener = this.prefService.addPreferencesLoadedObserver(() => {
this.notifyOfStateChange(AppStateType.PreferencesChanged)
})
await this.loadUnlockTiming()
}
override async onAppLaunch() {
MobileApplication.setPreviouslyLaunched()
this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true
void this.setAndroidScreenshotPrivacy(this.screenshotPrivacyEnabled)
}
/**
* Registers an observer for App State change
* @returns function that unregisters this observer
*/
public addStateChangeObserver(callback: ObserverCallback) {
this.observers.push(callback)
return () => {
removeFromArray(this.observers, callback)
}
}
/**
* Registers an observer for lock state change
* @returns function that unregisters this observer
*/
public addLockStateChangeObserver(callback: LockStateObserverCallback) {
this.lockStateObservers.push(callback)
return () => {
removeFromArray(this.lockStateObservers, callback)
}
}
/**
* Registers an observer for App State Event change
* @returns function that unregisters this observer
*/
public addStateEventObserver(callback: EventObserverCallback) {
this.stateObservers.push(callback)
return () => {
removeFromArray(this.stateObservers, callback)
}
}
/**
* Notify observers of ApplicationState change
*/
private notifyOfStateChange(state: AppStateType, data?: unknown) {
if (this.ignoreStateChanges) {
return
}
// Set most recent state before notifying observers, in case they need to query this value.
this.mostRecentState = state
for (const observer of this.observers) {
void observer(state, data)
}
}
/**
* Notify observers of ApplicationState Events
*/
private notifyEventObservers(event: AppStateEventType, data?: TabletModeChangeData) {
for (const observer of this.stateObservers) {
void observer(event, data)
}
}
/**
* Notify observers of ApplicationState Events
*/
private notifyLockStateObservers(event: LockStateType) {
for (const observer of this.lockStateObservers) {
void observer(event)
}
}
private async loadUnlockTiming() {
this.passcodeTiming = await this.getPasscodeTiming()
this.biometricsTiming = await this.getBiometricsTiming()
}
public async setAndroidScreenshotPrivacy(enable: boolean) {
if (Platform.OS === 'android') {
enable ? FlagSecure.activate() : FlagSecure.deactivate()
}
}
/**
* Creates a new editor if one doesn't exist. If one does, we'll replace the
* editor's note with an empty one.
*/
async createEditor(title?: string) {
const selectedTagUuid = this.selectedTag
? this.selectedTag instanceof SmartView
? undefined
: this.selectedTag.uuid
: undefined
this.application.editorGroup.closeActiveNoteController()
const noteView = await this.application.editorGroup.createNoteController(undefined, title, selectedTagUuid)
const defaultEditor = this.application.componentManager.getDefaultEditor()
if (defaultEditor) {
await associateComponentWithNote(this.application, defaultEditor, this.getActiveNoteController().note)
}
return noteView
}
async openEditor(noteUuid: string): Promise<NoteViewController> {
const note = this.application.items.findItem(noteUuid) as SNNote
const activeEditor = this.getActiveNoteController()
if (activeEditor) {
this.application.editorGroup.closeActiveNoteController()
}
const noteView = await this.application.editorGroup.createNoteController(noteUuid)
if (note && note.conflictOf) {
void InteractionManager.runAfterInteractions(() => {
void this.application?.mutator.changeAndSaveItem(note, mutator => {
mutator.conflictOf = undefined
})
})
}
return noteView
}
getActiveNoteController() {
return this.application.editorGroup.noteControllers[0]
}
getEditors() {
return this.application.editorGroup.noteControllers
}
closeEditor(editor: NoteViewController) {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeNoteController(editor)
}
closeActiveEditor() {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeActiveNoteController()
}
closeAllEditors() {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeAllNoteControllers()
}
editorForNote(uuid: Uuid): NoteViewController | void {
for (const editor of this.getEditors()) {
if (editor.note?.uuid === uuid) {
return editor
}
}
}
private keyboardDidShow: KeyboardEventListener = e => {
this.keyboardHeight = e.endCoordinates.height
this.notifyEventObservers(AppStateEventType.KeyboardChangeEvent)
}
private keyboardDidHide: KeyboardEventListener = () => {
this.keyboardHeight = 0
this.notifyEventObservers(AppStateEventType.KeyboardChangeEvent)
}
/**
* @returns Returns keybord height
*/
getKeyboardHeight() {
return this.keyboardHeight
}
/**
* Reacts to @SNNote and @SNTag Changes
*/
private handleItemsChanges() {
this.removeItemChangesListener = this.application.streamItems<SNNote | SNTag>(
[ContentType.Note, ContentType.Tag],
async ({ changed, inserted, removed, source }) => {
if (source === PayloadEmitSource.PreSyncSave || source === PayloadEmitSource.RemoteRetrieved) {
const removedNotes = removed.filter(i => i.content_type === ContentType.Note)
for (const removedNote of removedNotes) {
const editor = this.editorForNote(removedNote.uuid)
if (editor) {
this.closeEditor(editor)
}
}
const notes = [...changed, ...inserted].filter(candidate => candidate.content_type === ContentType.Note)
const isBrowswingTrashedNotes =
this.selectedTag instanceof SmartView && this.selectedTag.uuid === SystemViewId.TrashedNotes
const isBrowsingArchivedNotes =
this.selectedTag instanceof SmartView && this.selectedTag.uuid === SystemViewId.ArchivedNotes
for (const note of notes) {
const editor = this.editorForNote(note.uuid)
if (!editor) {
continue
}
if (note.trashed && !isBrowswingTrashedNotes) {
this.closeEditor(editor)
} else if (note.archived && !isBrowsingArchivedNotes) {
this.closeEditor(editor)
}
}
}
if (this.selectedTag) {
const matchingTag = [...changed, ...inserted].find(candidate => candidate.uuid === this.selectedTag.uuid)
if (matchingTag) {
this.selectedTag = matchingTag as SNTag
}
}
},
)
}
/**
* Registers for MobileApplication events
*/
private handleApplicationEvents() {
this.removeAppEventObserver = this.application.addEventObserver(async eventName => {
switch (eventName) {
case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded: {
this.restoreSelectedTag()
break
}
case ApplicationEvent.Started: {
this.locked = true
break
}
case ApplicationEvent.Launched: {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
break
}
}
})
}
/**
* Set selected @SNTag
*/
public setSelectedTag(tag: SNTag | SmartView, saveSelection = true) {
if (this.selectedTag.uuid === tag.uuid) {
return
}
const previousTag = this.selectedTag
this.selectedTag = tag
if (saveSelection) {
void this.application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSelectedTagUuid, tag.uuid)
}
this.notifyOfStateChange(AppStateType.TagChanged, {
tag,
previousTag,
})
}
/**
* @returns tags that are referencing note
*/
public getNoteTags(note: SNNote) {
return this.application.items.itemsReferencingItem(note).filter(ref => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}
/**
* @returns notes this tag references
*/
public getTagNotes(tag: SNTag | SmartView) {
if (tag instanceof SmartView) {
return this.application.items.notesMatchingSmartView(tag)
} else {
return this.application.items.referencesForItem(tag).filter(ref => {
return ref.content_type === ContentType.Note
}) as SNNote[]
}
}
public getSelectedTag() {
return this.selectedTag
}
static get version() {
return `${pjson['user-version']} (${VersionInfo.buildVersion})`
}
get isTabletDevice() {
const deviceType = PlatformConstants.interfaceIdiom
return deviceType === 'pad'
}
get isInTabletMode() {
return this.tabletMode
}
setTabletModeEnabled(enabledTabletMode: boolean) {
if (enabledTabletMode !== this.tabletMode) {
this.tabletMode = enabledTabletMode
this.notifyEventObservers(AppStateEventType.TabletModeChange, {
new_isInTabletMode: enabledTabletMode,
old_isInTabletMode: !enabledTabletMode,
})
}
}
getPasscodeTimingOptions() {
return [
{
title: 'Immediately',
key: UnlockTiming.Immediately,
selected: this.passcodeTiming === UnlockTiming.Immediately,
},
{
title: 'On Quit',
key: UnlockTiming.OnQuit,
selected: this.passcodeTiming === UnlockTiming.OnQuit,
},
]
}
getBiometricsTimingOptions() {
return [
{
title: 'Immediately',
key: UnlockTiming.Immediately,
selected: this.biometricsTiming === UnlockTiming.Immediately,
},
{
title: 'On Quit',
key: UnlockTiming.OnQuit,
selected: this.biometricsTiming === UnlockTiming.OnQuit,
},
]
}
private async checkAndLockApplication() {
const isLocked = await this.application.isLocked()
if (!isLocked) {
const hasBiometrics = await this.application.hasBiometrics()
const hasPasscode = this.application.hasPasscode()
if (hasPasscode && this.passcodeTiming === UnlockTiming.Immediately) {
await this.application.lock()
} else if (hasBiometrics && this.biometricsTiming === UnlockTiming.Immediately && !this.locked) {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.Biometric)],
ChallengeReason.ApplicationUnlock,
false,
)
void this.application.promptForCustomChallenge(challenge)
this.locked = true
this.notifyLockStateObservers(LockStateType.Locked)
this.application.addChallengeObserver(challenge, {
onComplete: () => {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
},
})
}
}
}
/**
* handles App State change from React Native
*/
private handleReactNativeAppStateChange = async (nextAppState: AppStateStatus) => {
if (this.ignoreStateChanges) {
return
}
// if the most recent state is not 'background' ('inactive'), then we're going
// from inactive to active, which doesn't really happen unless you, say, swipe
// notification center in iOS down then back up. We don't want to lock on this state change.
const isResuming = nextAppState === 'active'
const isResumingFromBackground = isResuming && this.mostRecentState === AppStateType.EnteringBackground
const isEnteringBackground = nextAppState === 'background'
const isLosingFocus = nextAppState === 'inactive'
if (isEnteringBackground) {
this.notifyOfStateChange(AppStateType.EnteringBackground)
return this.checkAndLockApplication()
}
if (isResumingFromBackground || isResuming) {
if (this.screenshotPrivacyEnabled) {
hide()
}
if (isResumingFromBackground) {
this.notifyOfStateChange(AppStateType.ResumingFromBackground)
}
// Notify of GainingFocus even if resuming from background
this.notifyOfStateChange(AppStateType.GainingFocus)
return
}
if (isLosingFocus) {
if (this.screenshotPrivacyEnabled) {
show()
}
this.notifyOfStateChange(AppStateType.LosingFocus)
return this.checkAndLockApplication()
}
}
/**
* Visibility change events are like active, inactive, background,
* while non-app cycle events are custom events like locking and unlocking
*/
isAppVisibilityChange(state: AppStateType) {
return (
[
AppStateType.LosingFocus,
AppStateType.EnteringBackground,
AppStateType.GainingFocus,
AppStateType.ResumingFromBackground,
] as Array<AppStateType>
).includes(state)
}
private async getScreenshotPrivacyEnabled(): Promise<boolean | undefined> {
return this.application.getValue(StorageKey.MobileScreenshotPrivacyEnabled, StorageValueModes.Default) as Promise<
boolean | undefined
>
}
private async getPasscodeTiming(): Promise<UnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise<
UnlockTiming | undefined
>
}
private async getBiometricsTiming(): Promise<UnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise<
UnlockTiming | undefined
>
}
public async setScreenshotPrivacyEnabled(enabled: boolean) {
await this.application.setValue(StorageKey.MobileScreenshotPrivacyEnabled, enabled, StorageValueModes.Default)
this.screenshotPrivacyEnabled = enabled
void this.setAndroidScreenshotPrivacy(enabled)
}
public async setPasscodeTiming(timing: UnlockTiming) {
await this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.passcodeTiming = timing
}
public async setBiometricsTiming(timing: UnlockTiming) {
await this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.biometricsTiming = timing
}
public async getPasscodeKeyboardType(): Promise<PasscodeKeyboardType> {
return this.application.getValue(
MobileStorageKey.PasscodeKeyboardTypeKey,
StorageValueModes.Nonwrapped,
) as Promise<PasscodeKeyboardType>
}
public async setPasscodeKeyboardType(type: PasscodeKeyboardType) {
await this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped)
}
public onDrawerOpen() {
this.notifyEventObservers(AppStateEventType.DrawerOpen)
}
/*
Allows other parts of the code to perform external actions without triggering state change notifications.
This is useful on Android when you present a share sheet and dont want immediate authentication to appear.
*/
async performActionWithoutStateChangeImpact(block: () => void | Promise<void>, notAwaited?: boolean) {
this.ignoreStateChanges = true
if (notAwaited) {
void block()
} else {
await block()
}
setTimeout(() => {
this.ignoreStateChanges = false
}, 350)
}
getMostRecentState() {
return this.mostRecentState
}
private get prefService() {
return this.application.getLocalPreferences()
}
public getEnvironment() {
const bundleId = VersionInfo.bundleIdentifier
return bundleId && bundleId.includes('dev') ? 'dev' : 'prod'
}
}

View File

@@ -0,0 +1,162 @@
import { ApplicationService, ButtonType, Platform } from '@standardnotes/snjs'
import { Base64 } from 'js-base64'
import { Alert, PermissionsAndroid, Share } from 'react-native'
import FileViewer from 'react-native-file-viewer'
import RNFS from 'react-native-fs'
import Mailer from 'react-native-mail'
import { MobileApplication } from './Application'
export class BackupsService extends ApplicationService {
/*
On iOS, we can use Share to share a file of arbitrary length.
This doesn't work on Android however. Seems to have a very low limit.
For Android, we'll use RNFS to save the file to disk, then FileViewer to
ask the user what application they would like to open the file with.
For .txt files, not many applications handle it. So, we'll want to notify the user
the path the file was saved to.
*/
async export(encrypted: boolean): Promise<boolean | void> {
const data = encrypted
? await this.application.createEncryptedBackupFile()
: await this.application.createDecryptedBackupFile()
const prettyPrint = 2
const stringifiedData = JSON.stringify(data, null, prettyPrint)
const modifier = encrypted ? 'Encrypted' : 'Decrypted'
const filename = `Standard Notes ${modifier} Backup - ${this.formattedDate()}.txt`
if (data) {
if (this.application?.platform === Platform.Ios) {
return this.exportIOS(filename, stringifiedData)
} else {
const result = await this.showAndroidEmailOrSaveOption()
if (result === 'email') {
return this.exportViaEmailAndroid(Base64.encode(stringifiedData), filename)
} else if (result === 'save') {
await this.exportAndroid(filename, stringifiedData)
} else {
return
}
}
}
}
private async showAndroidEmailOrSaveOption() {
try {
const confirmed = await this.application!.alertService?.confirm(
'Choose Export Method',
'',
'Email',
ButtonType.Info,
'Save to Disk',
)
if (confirmed) {
return 'email'
} else {
return 'save'
}
} catch (e) {
return undefined
}
}
private async exportIOS(filename: string, data: string) {
return new Promise<boolean>(resolve => {
void (this.application! as MobileApplication).getAppState().performActionWithoutStateChangeImpact(async () => {
Share.share({
title: filename,
message: data,
})
.then(result => {
resolve(result.action !== Share.dismissedAction)
})
.catch(() => {
resolve(false)
})
})
})
}
private async exportAndroid(filename: string, data: string) {
try {
let filepath = `${RNFS.ExternalDirectoryPath}/${filename}`
const granted = await this.requestStoragePermissionsAndroid()
if (granted) {
filepath = `${RNFS.DownloadDirectoryPath}/${filename}`
}
await RNFS.writeFile(filepath, data)
void this.showFileSavePromptAndroid(filepath)
} catch (err) {
console.error('Error exporting backup', err)
void this.application.alertService.alert('There was an issue exporting your backup.')
}
}
private async openFileAndroid(filepath: string) {
return FileViewer.open(filepath)
.then(() => {
// success
return true
})
.catch(error => {
console.error('Error opening file', error)
return false
})
}
private async showFileSavePromptAndroid(filepath: string) {
const confirmed = await this.application!.alertService?.confirm(
`Your backup file has been saved to your local disk at this location:\n\n${filepath}`,
'Backup Saved',
'Open File',
ButtonType.Info,
'Done',
)
if (confirmed) {
void this.openFileAndroid(filepath)
}
return true
}
private async exportViaEmailAndroid(data: string, filename: string) {
return new Promise<boolean>(resolve => {
const fileType = '.json' // Android creates a tmp file and expects dot with extension
let resolved = false
Mailer.mail(
{
subject: 'Standard Notes Backup',
recipients: [''],
body: '',
isHTML: true,
attachment: { data, type: fileType, name: filename },
},
(error: any) => {
if (error) {
Alert.alert('Error', 'Unable to send email.')
}
resolved = true
resolve(false)
},
)
// On Android the Mailer callback event isn't always triggered.
setTimeout(function () {
if (!resolved) {
resolve(true)
}
}, 2500)
})
}
private async requestStoragePermissionsAndroid() {
const writeStorageGranted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
return writeStorageGranted === PermissionsAndroid.RESULTS.GRANTED
}
/* Utils */
private formattedDate() {
return new Date().getTime()
}
}

View File

@@ -0,0 +1,339 @@
import { MobileTheme } from '@Root/Style/MobileTheme'
import FeatureChecksums from '@standardnotes/components/dist/checksums.json'
import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features'
import {
ComponentMutator,
EncryptionService,
isRightVersionGreaterThanLeft,
PermissionDialog,
SNApplication,
SNComponent,
SNComponentManager,
SNLog,
SNNote,
} from '@standardnotes/snjs'
import { objectToCss } from '@Style/CssParser'
import { Base64 } from 'js-base64'
import RNFS, { DocumentDirectoryPath } from 'react-native-fs'
import StaticServer from 'react-native-static-server'
import { unzip } from 'react-native-zip-archive'
import { MobileThemeContent } from '../Style/MobileTheme'
type TFeatureChecksums = {
[key in FeatureIdentifier]: {
version: string
base64: string
binary: string
}
}
export enum ComponentLoadingError {
FailedDownload = 'FailedDownload',
ChecksumMismatch = 'ChecksumMismatch',
LocalServerFailure = 'LocalServerFailure',
DoesntExist = 'DoesntExist',
Unknown = 'Unknown',
}
const STATIC_SERVER_PORT = 8080
const BASE_DOCUMENTS_PATH = DocumentDirectoryPath
const COMPONENTS_PATH = '/components'
export class ComponentManager extends SNComponentManager {
private mobileActiveTheme?: MobileTheme
private staticServer!: StaticServer
private staticServerUrl!: string
private protocolService!: EncryptionService
private thirdPartyIndexPaths: Record<string, string> = {}
public async initialize(protocolService: EncryptionService) {
this.loggingEnabled = false
this.protocolService = protocolService
await this.createServer()
}
private async createServer() {
const path = `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}`
const server = new StaticServer(STATIC_SERVER_PORT, path, {
localOnly: true,
})
try {
const serverUrl = await server.start()
this.staticServer = server
this.staticServerUrl = serverUrl
} catch (e) {
void this.alertService.alert(
'Unable to start component server. ' +
'Editors other than the Plain Editor will fail to load. ' +
'Please restart the app and try again.',
)
SNLog.error(e as any)
}
}
override deinit() {
super.deinit()
void this.staticServer!.stop()
}
public isComponentDownloadable(component: SNComponent): boolean {
const identifier = component.identifier
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
return !!downloadUrl
}
public async uninstallComponent(component: SNComponent) {
const path = this.pathForComponent(component.identifier)
if (await RNFS.exists(path)) {
this.log('Deleting dir at', path)
await RNFS.unlink(path)
}
}
public async doesComponentNeedDownload(component: SNComponent): Promise<boolean> {
const identifier = component.identifier
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
if (!downloadUrl) {
throw Error('Attempting to download component with no download url')
}
const version = nativeFeature?.version || component.package_info?.version
const existingPackageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
const existingVersion = existingPackageJson?.version
this.log('Existing package version', existingVersion)
this.log('Latest package version', version)
const shouldDownload = !existingPackageJson || isRightVersionGreaterThanLeft(existingVersion, version!)
return shouldDownload
}
public async downloadComponentOffline(component: SNComponent): Promise<ComponentLoadingError | undefined> {
const identifier = component.identifier
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
if (!downloadUrl) {
throw Error('Attempting to download component with no download url')
}
let error
try {
error = await this.performDownloadComponent(identifier, downloadUrl)
} catch (e) {
console.error(e)
return ComponentLoadingError.Unknown
}
if (error) {
return error
}
const componentPath = this.pathForComponent(identifier)
if (!(await RNFS.exists(componentPath))) {
this.log(`No component exists at path ${componentPath}, not using offline component`)
return ComponentLoadingError.DoesntExist
}
return error
}
public nativeFeatureForIdentifier(identifier: FeatureIdentifier) {
return GetFeatures().find((feature: FeatureDescription) => feature.identifier === identifier)
}
public isComponentThirdParty(identifier: FeatureIdentifier): boolean {
return !this.nativeFeatureForIdentifier(identifier)
}
public async preloadThirdPartyIndexPathFromDisk(identifier: FeatureIdentifier) {
const packageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
this.thirdPartyIndexPaths[identifier] = packageJson?.sn?.main || 'index.html'
}
private async passesChecksumValidation(filePath: string, featureIdentifier: FeatureIdentifier) {
this.log('Performing checksum verification on', filePath)
const zipContents = await RNFS.readFile(filePath, 'base64')
const checksum = await this.protocolService.crypto.sha256(zipContents)
const desiredChecksum = (FeatureChecksums as TFeatureChecksums)[featureIdentifier]?.base64
if (!desiredChecksum) {
this.log(`Checksum is missing for ${featureIdentifier}; aborting installation`)
return false
}
if (checksum !== desiredChecksum) {
this.log(`Checksums don't match for ${featureIdentifier}; ${checksum} != ${desiredChecksum}; aborting install`)
return false
}
this.log(`Checksum ${checksum} matches ${desiredChecksum} for ${featureIdentifier}`)
return true
}
private async performDownloadComponent(
identifier: FeatureIdentifier,
downloadUrl: string,
): Promise<ComponentLoadingError | undefined> {
const tmpLocation = `${BASE_DOCUMENTS_PATH}/${identifier}.zip`
if (await RNFS.exists(tmpLocation)) {
this.log('Deleting file at', tmpLocation)
await RNFS.unlink(tmpLocation)
}
this.log('Downloading component', identifier, 'from url', downloadUrl, 'to location', tmpLocation)
const result = await RNFS.downloadFile({
fromUrl: downloadUrl,
toFile: tmpLocation,
}).promise
if (!String(result.statusCode).startsWith('2')) {
console.error(`Error downloading file ${downloadUrl}`)
return ComponentLoadingError.FailedDownload
}
this.log('Finished download to tmp location', tmpLocation)
const requireChecksumVerification = !!this.nativeFeatureForIdentifier(identifier)
if (requireChecksumVerification) {
const passes = await this.passesChecksumValidation(tmpLocation, identifier)
if (!passes) {
return ComponentLoadingError.ChecksumMismatch
}
}
const componentPath = this.pathForComponent(identifier)
this.log(`Attempting to unzip ${tmpLocation} to ${componentPath}`)
await unzip(tmpLocation, componentPath)
this.log('Unzipped component to', componentPath)
const directoryContents = await RNFS.readDir(componentPath)
const isNestedArchive = directoryContents.length === 1 && directoryContents[0].isDirectory()
if (isNestedArchive) {
this.log('Component download includes base level dir that is not its identifier, fixing...')
const nestedDir = directoryContents[0]
const tmpMovePath = `${BASE_DOCUMENTS_PATH}/${identifier}`
await RNFS.moveFile(nestedDir.path, tmpMovePath)
await RNFS.unlink(componentPath)
await RNFS.moveFile(tmpMovePath, componentPath)
this.log(`Moved directory from ${directoryContents[0].path} to ${componentPath}`)
}
await RNFS.unlink(tmpLocation)
return
}
private pathForComponent(identifier: FeatureIdentifier) {
return `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}/${identifier}`
}
public async getFile(identifier: FeatureIdentifier, relativePath: string) {
const componentPath = this.pathForComponent(identifier)
if (!(await RNFS.exists(componentPath))) {
return undefined
}
const filePath = `${componentPath}/${relativePath}`
if (!(await RNFS.exists(filePath))) {
return undefined
}
const fileContents = await RNFS.readFile(filePath)
return fileContents
}
public async getIndexFile(identifier: FeatureIdentifier) {
if (this.isComponentThirdParty(identifier)) {
await this.preloadThirdPartyIndexPathFromDisk(identifier)
}
const relativePath = this.getIndexFileRelativePath(identifier)
return this.getFile(identifier, relativePath!)
}
private async getDownloadedComponentPackageJsonFile(
identifier: FeatureIdentifier,
): Promise<Record<string, any> | undefined> {
const file = await this.getFile(identifier, 'package.json')
if (!file) {
return undefined
}
const packageJson = JSON.parse(file)
return packageJson
}
override async presentPermissionsDialog(dialog: PermissionDialog) {
const text = `${dialog.component.name} would like to interact with your ${dialog.permissionsString}`
const approved = await this.alertService.confirm(text, 'Grant Permissions', 'Continue', undefined, 'Cancel')
dialog.callback(approved)
}
private getIndexFileRelativePath(identifier: FeatureIdentifier) {
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
if (nativeFeature) {
return nativeFeature.index_path
} else {
return this.thirdPartyIndexPaths[identifier]
}
}
override urlForComponent(component: SNComponent) {
if (component.isTheme() && (component.content as MobileThemeContent).isSystemTheme) {
const theme = component as MobileTheme
const cssData = objectToCss(theme.mobileContent.variables)
const encoded = Base64.encodeURI(cssData)
return `data:text/css;base64,${encoded}`
}
if (!this.isComponentDownloadable(component)) {
return super.urlForComponent(component)
}
const identifier = component.identifier
const componentPath = this.pathForComponent(identifier)
const indexFilePath = this.getIndexFileRelativePath(identifier)
if (!indexFilePath) {
throw Error('Third party index path was not preloaded')
}
const splitPackagePath = componentPath.split(COMPONENTS_PATH)
const relativePackagePath = splitPackagePath[splitPackagePath.length - 1]
const relativeMainFilePath = `${relativePackagePath}/${indexFilePath}`
return `${this.staticServerUrl}${relativeMainFilePath}`
}
public setMobileActiveTheme(theme: MobileTheme) {
this.mobileActiveTheme = theme
this.postActiveThemesToAllViewers()
}
override getActiveThemes() {
if (this.mobileActiveTheme) {
return [this.mobileActiveTheme]
} else {
return []
}
}
public async preloadThirdPartyThemeIndexPath() {
const theme = this.mobileActiveTheme
if (!theme) {
return
}
const { identifier } = theme
if (this.isComponentThirdParty(identifier)) {
await this.preloadThirdPartyIndexPathFromDisk(identifier)
}
}
}
export async function associateComponentWithNote(application: SNApplication, component: SNComponent, note: SNNote) {
return application.mutator.changeItem<ComponentMutator>(component, mutator => {
mutator.removeDisassociatedItemId(note.uuid)
mutator.associateWithItem(note.uuid)
})
}

View File

@@ -0,0 +1,98 @@
import { ByteChunker, FileSelectionResponse, OnChunkCallback } from '@standardnotes/filepicker'
import { FileDownloadProgress } from '@standardnotes/files/dist/Domain/Types/FileDownloadProgress'
import { ClientDisplayableError } from '@standardnotes/responses'
import { ApplicationService, FileItem } from '@standardnotes/snjs'
import { Buffer } from 'buffer'
import { Base64 } from 'js-base64'
import { PermissionsAndroid, Platform } from 'react-native'
import { DocumentPickerResponse } from 'react-native-document-picker'
import RNFS, { CachesDirectoryPath, DocumentDirectoryPath, DownloadDirectoryPath, read } from 'react-native-fs'
import { Asset } from 'react-native-image-picker'
type TGetFileDestinationPath = {
fileName: string
saveInTempLocation?: boolean
}
export class FilesService extends ApplicationService {
private fileChunkSizeForReading = 2000000
getDestinationPath({ fileName, saveInTempLocation = false }: TGetFileDestinationPath): string {
let directory = DocumentDirectoryPath
if (Platform.OS === 'android') {
directory = saveInTempLocation ? CachesDirectoryPath : DownloadDirectoryPath
}
return `${directory}/${fileName}`
}
async hasStoragePermissionOnAndroid(): Promise<boolean> {
if (Platform.OS !== 'android') {
return true
}
const grantedStatus = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
if (grantedStatus === PermissionsAndroid.RESULTS.GRANTED) {
return true
}
await this.application.alertService.alert(
'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.',
)
return false
}
async downloadFileInChunks(
file: FileItem,
path: string,
handleOnChunk: (progress: FileDownloadProgress | undefined) => unknown,
): Promise<ClientDisplayableError | undefined> {
const response = await this.application.files.downloadFile(file, async (decryptedBytes: Uint8Array, progress) => {
const base64String = new Buffer(decryptedBytes).toString('base64')
handleOnChunk(progress)
await RNFS.appendFile(path, base64String, 'base64')
})
return response
}
getFileName(file: DocumentPickerResponse | Asset) {
if ('name' in file) {
return file.name
}
return file.fileName as string
}
async readFile(file: DocumentPickerResponse | Asset, onChunk: OnChunkCallback): Promise<FileSelectionResponse> {
const fileUri = (Platform.OS === 'ios' ? decodeURI(file.uri!) : file.uri) as string
let positionShift = 0
let filePortion = ''
const chunker = new ByteChunker(this.application.files.minimumChunkSize(), onChunk)
let isFinalChunk = false
do {
filePortion = await read(fileUri, this.fileChunkSizeForReading, positionShift, 'base64')
const bytes = Base64.toUint8Array(filePortion)
isFinalChunk = bytes.length < this.fileChunkSizeForReading
await chunker.addBytes(bytes, isFinalChunk)
positionShift += this.fileChunkSizeForReading
} while (!isFinalChunk)
const fileName = this.getFileName(file)
return {
name: fileName,
mimeType: file.type || '',
}
}
sortByName(file1: FileItem, file2: FileItem): number {
return file1.name.toLocaleLowerCase() > file2.name.toLocaleLowerCase() ? 1 : -1
}
formatCompletedPercent(percent: number | undefined) {
return Math.round(percent || 0)
}
}

View File

@@ -0,0 +1,68 @@
import SNReactNative from '@standardnotes/react-native-utils'
import { ApplicationService, ButtonType, isNullOrUndefined, StorageValueModes } from '@standardnotes/snjs'
import { MobileDeviceInterface } from './Interface'
const FIRST_RUN_KEY = 'first_run'
export class InstallationService extends ApplicationService {
override async onAppStart() {
if (await this.needsWipe()) {
await this.wipeData()
} else {
void this.markApplicationAsRan()
}
}
async markApplicationAsRan() {
return this.application?.setValue(FIRST_RUN_KEY, false, StorageValueModes.Nonwrapped)
}
/**
* Needs wipe if has keys but no data. However, since "no data" can be incorrectly reported by underlying
* AsyncStorage failures, we want to confirm with the user before deleting anything.
*/
async needsWipe() {
const hasNormalKeys = this.application?.hasAccount() || this.application?.hasPasscode()
const deviceInterface = this.application?.deviceInterface as MobileDeviceInterface
const keychainKey = await deviceInterface?.getRawKeychainValue()
const hasKeychainValue = !(
isNullOrUndefined(keychainKey) ||
(typeof keychainKey === 'object' && Object.keys(keychainKey).length === 0)
)
const firstRunKey = await this.application?.getValue(FIRST_RUN_KEY, StorageValueModes.Nonwrapped)
let firstRunKeyMissing = isNullOrUndefined(firstRunKey)
/*
* Because of migration failure first run key might not be in non wrapped storage
*/
if (firstRunKeyMissing) {
const fallbackFirstRunValue = await this.application?.deviceInterface?.getRawStorageValue(FIRST_RUN_KEY)
firstRunKeyMissing = isNullOrUndefined(fallbackFirstRunValue)
}
return !hasNormalKeys && hasKeychainValue && firstRunKeyMissing
}
/**
* On iOS, keychain data is persisted between installs/uninstalls. (https://stackoverflow.com/questions/4747404/delete-keychain-items-when-an-app-is-uninstalled)
* This prevents the user from deleting the app and reinstalling if they forgot their local passocde
* or if fingerprint scanning isn't working. By deleting all data on first run, we allow the user to reset app
* state after uninstall.
*/
async wipeData() {
const confirmed = await this.application?.alertService?.confirm(
"We've detected a previous installation of Standard Notes based on your keychain data. You must wipe all data from previous installation to continue.\n\nIf you're seeing this message in error, it might mean we're having issues loading your local database. Please restart the app and try again.",
'Previous Installation',
'Delete Local Data',
ButtonType.Danger,
'Quit App',
)
if (confirmed) {
await this.application?.deviceInterface?.removeAllRawStorageValues()
await this.application?.deviceInterface?.removeAllRawDatabasePayloads(this.application?.identifier)
await this.application?.deviceInterface?.clearRawKeychainValue()
} else {
SNReactNative.exitApp()
}
}
}

View File

@@ -0,0 +1,325 @@
import AsyncStorage from '@react-native-community/async-storage'
import {
ApplicationIdentifier,
DeviceInterface,
Environment,
LegacyRawKeychainValue,
NamespacedRootKeyInKeychain,
RawKeychainValue,
TransferPayload,
} from '@standardnotes/snjs'
import { Alert, Linking, Platform } from 'react-native'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import Keychain from './Keychain'
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
/**
* This identifier was the database name used in Standard Notes web/desktop.
*/
const LEGACY_IDENTIFIER = 'standardnotes'
/**
* We use this function to decide if we need to prefix the identifier in getDatabaseKeyPrefix or not.
* It is also used to decide if the raw or the namespaced keychain is used.
* @param identifier The ApplicationIdentifier
*/
const isLegacyIdentifier = function (identifier: ApplicationIdentifier) {
return identifier && identifier === LEGACY_IDENTIFIER
}
const showLoadFailForItemIds = (failedItemIds: string[]) => {
let text =
'The following items could not be loaded. This may happen if you are in low-memory conditions, or if the note is very large in size. We recommend breaking up large notes into smaller chunks using the desktop or web app.\n\nItems:\n'
let index = 0
text += failedItemIds.map(id => {
let result = id
if (index !== failedItemIds.length - 1) {
result += '\n'
}
index++
return result
})
Alert.alert('Unable to load item(s)', text)
}
export class MobileDeviceInterface implements DeviceInterface {
environment: Environment.Mobile = Environment.Mobile
// eslint-disable-next-line @typescript-eslint/no-empty-function
deinit() {}
async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
await Keychain.setKeys(value)
}
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
const value = await this.getRawStorageValue(key)
if (value == undefined) {
return undefined
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
private getDatabaseKeyPrefix(identifier: ApplicationIdentifier) {
if (identifier && !isLegacyIdentifier(identifier)) {
return `${identifier}-Item-`
} else {
return 'Item-'
}
}
private keyForPayloadId(id: string, identifier: ApplicationIdentifier) {
return `${this.getDatabaseKeyPrefix(identifier)}${id}`
}
private async getAllDatabaseKeys(identifier: ApplicationIdentifier) {
const keys = await AsyncStorage.getAllKeys()
const filtered = keys.filter(key => {
return key.includes(this.getDatabaseKeyPrefix(identifier))
})
return filtered
}
getDatabaseKeys(): Promise<string[]> {
return AsyncStorage.getAllKeys()
}
private async getRawStorageKeyValues(keys: string[]) {
const results: { key: string; value: unknown }[] = []
if (Platform.OS === 'android') {
for (const key of keys) {
try {
const item = await AsyncStorage.getItem(key)
if (item) {
results.push({ key, value: item })
}
} catch (e) {
console.error('Error getting item', key, e)
}
}
} else {
try {
for (const item of await AsyncStorage.multiGet(keys)) {
if (item[1]) {
results.push({ key: item[0], value: item[1] })
}
}
} catch (e) {
console.error('Error getting items', e)
}
}
return results
}
private async getDatabaseKeyValues(keys: string[]) {
const results: (TransferPayload | unknown)[] = []
if (Platform.OS === 'android') {
const failedItemIds: string[] = []
for (const key of keys) {
try {
const item = await AsyncStorage.getItem(key)
if (item) {
try {
results.push(JSON.parse(item) as TransferPayload)
} catch (e) {
results.push(item)
}
}
} catch (e) {
console.error('Error getting item', key, e)
failedItemIds.push(key)
}
}
if (failedItemIds.length > 0) {
showLoadFailForItemIds(failedItemIds)
}
} else {
try {
for (const item of await AsyncStorage.multiGet(keys)) {
if (item[1]) {
try {
results.push(JSON.parse(item[1]))
} catch (e) {
results.push(item[1])
}
}
}
} catch (e) {
console.error('Error getting items', e)
}
}
return results
}
async getRawStorageValue(key: string) {
const item = await AsyncStorage.getItem(key)
if (item) {
try {
return JSON.parse(item)
} catch (e) {
return item
}
}
}
async getAllRawStorageKeyValues() {
const keys = await AsyncStorage.getAllKeys()
return this.getRawStorageKeyValues(keys)
}
setRawStorageValue(key: string, value: string): Promise<void> {
return AsyncStorage.setItem(key, JSON.stringify(value))
}
removeRawStorageValue(key: string): Promise<void> {
return AsyncStorage.removeItem(key)
}
removeAllRawStorageValues(): Promise<void> {
return AsyncStorage.clear()
}
openDatabase(): Promise<{ isNewDatabase?: boolean | undefined } | undefined> {
return Promise.resolve({ isNewDatabase: false })
}
async getAllRawDatabasePayloads<T extends TransferPayload = TransferPayload>(
identifier: ApplicationIdentifier,
): Promise<T[]> {
const keys = await this.getAllDatabaseKeys(identifier)
return this.getDatabaseKeyValues(keys) as Promise<T[]>
}
saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier): Promise<void> {
return this.saveRawDatabasePayloads([payload], identifier)
}
async saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier): Promise<void> {
if (payloads.length === 0) {
return
}
await Promise.all(
payloads.map(item => {
return AsyncStorage.setItem(this.keyForPayloadId(item.uuid, identifier), JSON.stringify(item))
}),
)
}
removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier): Promise<void> {
return this.removeRawStorageValue(this.keyForPayloadId(id, identifier))
}
async removeAllRawDatabasePayloads(identifier: ApplicationIdentifier): Promise<void> {
const keys = await this.getAllDatabaseKeys(identifier)
return AsyncStorage.multiRemove(keys)
}
async getNamespacedKeychainValue(
identifier: ApplicationIdentifier,
): Promise<NamespacedRootKeyInKeychain | undefined> {
const keychain = await this.getRawKeychainValue()
if (isLegacyIdentifier(identifier)) {
return keychain as unknown as NamespacedRootKeyInKeychain
}
if (!keychain) {
return
}
return keychain[identifier]
}
async setNamespacedKeychainValue(
value: NamespacedRootKeyInKeychain,
identifier: ApplicationIdentifier,
): Promise<void> {
if (isLegacyIdentifier(identifier)) {
await Keychain.setKeys(value)
}
let keychain = await this.getRawKeychainValue()
if (!keychain) {
keychain = {}
}
await Keychain.setKeys({
...keychain,
[identifier]: value,
})
}
async clearNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<void> {
if (isLegacyIdentifier(identifier)) {
await this.clearRawKeychainValue()
}
const keychain = await this.getRawKeychainValue()
if (!keychain) {
return
}
delete keychain[identifier]
await Keychain.setKeys(keychain)
}
async getDeviceBiometricsAvailability() {
try {
await FingerprintScanner.isSensorAvailable()
return true
} catch (e) {
return false
}
}
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
return Keychain.getKeys()
}
async clearRawKeychainValue(): Promise<void> {
await Keychain.clearKeys()
}
openUrl(url: string) {
const showAlert = () => {
Alert.alert('Unable to Open', `Unable to open URL ${url}.`)
}
Linking.canOpenURL(url)
.then(supported => {
if (!supported) {
showAlert()
return
} else {
return Linking.openURL(url)
}
})
.catch(() => showAlert())
}
async clearAllDataFromDevice(_workspaceIdentifiers: string[]): Promise<{ killsApplication: boolean }> {
await this.clearRawKeychainValue()
await this.removeAllRawStorageValues()
return { killsApplication: false }
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
performSoftReset() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
performHardReset() {}
isDeviceDestroyed() {
return false
}
}

View File

@@ -0,0 +1,31 @@
import { RawKeychainValue } from '@standardnotes/snjs'
import * as RCTKeychain from 'react-native-keychain'
export default class Keychain {
static async setKeys(keys: object) {
const iOSOptions = {
accessible: RCTKeychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}
return RCTKeychain.setGenericPassword('sn', JSON.stringify(keys), iOSOptions)
}
static async getKeys(): Promise<RawKeychainValue | undefined | null> {
return RCTKeychain.getGenericPassword()
.then(function (credentials) {
if (!credentials || !credentials.password) {
return null
} else {
const keys = JSON.parse(credentials.password)
return keys
}
})
.catch(function (error) {
console.error("Keychain couldn't be accessed! Maybe no value set?", error)
return undefined
})
}
static async clearKeys() {
return RCTKeychain.resetGenericPassword()
}
}

View File

@@ -0,0 +1,21 @@
import { NavigationContainerRef, StackActions } from '@react-navigation/native'
import { AppStackNavigatorParamList } from '@Root/AppStack'
import { ModalStackNavigatorParamList } from '@Root/ModalStack'
import * as React from 'react'
export const navigationRef =
React.createRef<NavigationContainerRef<AppStackNavigatorParamList & ModalStackNavigatorParamList>>()
export function navigate(name: keyof AppStackNavigatorParamList | keyof ModalStackNavigatorParamList, params?: any) {
navigationRef.current?.navigate(name, params)
}
export function push(name: string, params?: any) {
const pushAction = StackActions.push(name, params)
navigationRef.current?.dispatch(pushAction)
}
export function goBack() {
navigationRef.current?.goBack()
}

View File

@@ -0,0 +1,71 @@
import { ApplicationService, isNullOrUndefined, PrefKey, removeFromArray } from '@standardnotes/snjs'
import { MobileApplication } from './Application'
type Preferences = Record<PrefKey, any>
type PreferencesObserver = () => Promise<void> | void
export const LAST_EXPORT_DATE_KEY = 'LastExportDateKey'
const PREFS_KEY = 'preferences'
export class PreferencesManager extends ApplicationService {
private userPreferences!: Preferences
observers: PreferencesObserver[] = []
/** @override */
override async onAppLaunch() {
void super.onAppLaunch()
void this.loadPreferences()
}
override deinit() {
this.observers = []
}
/**
* Registers an observer for preferences loaded event
* @returns function that unregisters this observer
*/
public addPreferencesLoadedObserver(callback: PreferencesObserver) {
this.observers.push(callback)
return () => {
removeFromArray(this.observers, callback)
}
}
notifyObserversOfPreferencesLoaded() {
for (const observer of this.observers) {
void observer()
}
}
get mobileApplication() {
return this.application as MobileApplication
}
private async loadPreferences() {
const preferences = await this.application.getValue(PREFS_KEY)
this.userPreferences = (preferences as Preferences) ?? {}
this.notifyObserversOfPreferencesLoaded()
}
private async saveSingleton() {
return this.application.setValue(PREFS_KEY, this.userPreferences)
}
private async savePreference(key: PrefKey, value: any) {
return this.application.setPreference(key, value)
}
getValue(key: PrefKey, defaultValue?: any) {
if (!this.userPreferences) {
return defaultValue
}
const value = this.application.getPreference(key)
return !isNullOrUndefined(value) ? value : defaultValue
}
async setUserPrefValue(key: PrefKey, value: any) {
this.userPreferences[key] = value
await this.saveSingleton()
await this.savePreference(key, value)
}
}

View File

@@ -0,0 +1,179 @@
import {
Base64String,
HexString,
PureCryptoInterface,
SodiumConstant,
StreamDecryptorResult,
timingSafeEqual,
Utf8String,
} from '@standardnotes/sncrypto-common'
import { NativeModules } from 'react-native'
import * as Sodium from 'react-native-sodium-jsi'
const { Aes } = NativeModules
export class SNReactNativeCrypto implements PureCryptoInterface {
// eslint-disable-next-line @typescript-eslint/no-empty-function
deinit(): void {}
public timingSafeEqual(a: string, b: string) {
return timingSafeEqual(a, b)
}
async initialize(): Promise<void> {
return
}
pbkdf2(password: Utf8String, salt: Utf8String, iterations: number, length: number): Promise<string | null> {
return Aes.pbkdf2(password, salt, iterations, length)
}
public generateRandomKey(bits: number): string {
const bytes = bits / 8
const result = Sodium.randombytes_buf(bytes)
return result
}
aes256CbcEncrypt(plaintext: Utf8String, iv: HexString, key: HexString): Promise<Base64String> {
return Aes.encrypt(plaintext, key, iv)
}
async aes256CbcDecrypt(ciphertext: Base64String, iv: HexString, key: HexString): Promise<Utf8String | null> {
try {
return Aes.decrypt(ciphertext, key, iv)
} catch (e) {
return null
}
}
async hmac256(message: Utf8String, key: HexString): Promise<HexString | null> {
try {
return Aes.hmac256(message, key)
} catch (e) {
return null
}
}
public async sha256(text: string): Promise<string> {
return Aes.sha256(text)
}
public unsafeSha1(text: string): Promise<string> {
return Aes.sha1(text)
}
public argon2(password: Utf8String, salt: HexString, iterations: number, bytes: number, length: number): HexString {
return Sodium.crypto_pwhash(length, password, salt, iterations, bytes, Sodium.constants.crypto_pwhash_ALG_DEFAULT)
}
xchacha20Encrypt(plaintext: Utf8String, nonce: HexString, key: HexString, assocData: Utf8String): Base64String {
return Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, nonce, key, assocData)
}
public xchacha20Decrypt(
ciphertext: Base64String,
nonce: HexString,
key: HexString,
assocData: Utf8String,
): string | null {
try {
const result = Sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, nonce, key, assocData)
return result
} catch (e) {
return null
}
}
public xchacha20StreamInitEncryptor(key: HexString): Sodium.MobileStreamEncryptor {
const encryptor = Sodium.crypto_secretstream_xchacha20poly1305_init_push(key)
return encryptor
}
public xchacha20StreamEncryptorPush(
encryptor: Sodium.MobileStreamEncryptor,
plainBuffer: Uint8Array,
assocData: Utf8String,
tag: SodiumConstant = SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PUSH,
): Uint8Array {
const encryptedBuffer = Sodium.crypto_secretstream_xchacha20poly1305_push(
encryptor,
plainBuffer.buffer,
assocData,
tag,
)
return new Uint8Array(encryptedBuffer)
}
public xchacha20StreamInitDecryptor(header: Base64String, key: HexString): Sodium.MobileStreamDecryptor {
const decryptor = Sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key)
return decryptor
}
public xchacha20StreamDecryptorPush(
decryptor: Sodium.MobileStreamDecryptor,
encryptedBuffer: Uint8Array,
assocData: Utf8String,
): StreamDecryptorResult | false {
if (encryptedBuffer.length < SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES) {
throw new Error('Invalid ciphertext size')
}
const result = Sodium.crypto_secretstream_xchacha20poly1305_pull(decryptor, encryptedBuffer.buffer, assocData)
if (!result) {
return false
}
return {
message: new Uint8Array(result.message),
tag: result.tag,
}
}
public generateUUID() {
const randomBuf = Sodium.randombytes_buf(16)
const tempBuf = new Uint8Array(randomBuf.length / 2)
for (let i = 0; i < randomBuf.length; i += 2) {
tempBuf[i / 2] = parseInt(randomBuf.substring(i, i + 2), 16)
}
const buf = new Uint32Array(tempBuf.buffer)
let idx = -1
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
idx++
// eslint-disable-next-line no-bitwise
const r = (buf[idx >> 3] >> ((idx % 8) * 4)) & 15
// eslint-disable-next-line no-bitwise
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
public base64Encode(text: Utf8String): string {
return Sodium.to_base64(text)
}
public base64Decode(base64String: Base64String): string {
return Sodium.from_base64(base64String)
}
public base64URLEncode(text: string): string {
return Sodium.to_base64(text, Sodium.constants.base64_variant_VARIANT_URLSAFE_NO_PADDING)
}
public hmac1(): Promise<HexString | null> {
throw new Error('hmac1 is not implemented on mobile')
}
public generateOtpSecret(): Promise<string> {
throw new Error('generateOtpSecret is not implemented on mobile')
}
public hotpToken(): Promise<string> {
throw new Error('hotpToken is not implemented on mobile')
}
public totpToken(): Promise<string> {
throw new Error('totpToken is not implemented on mobile')
}
}

View File

@@ -0,0 +1,25 @@
import { ApplicationService, Platform } from '@standardnotes/snjs'
import * as StoreReview from 'react-native-store-review'
const RUN_COUNTS_BEFORE_REVIEW = [18, 45, 105]
export class ReviewService extends ApplicationService {
override async onAppLaunch() {
if (this.application?.platform === Platform.Android || !StoreReview.isAvailable) {
return
}
const runCount = await this.getRunCount()
void this.setRunCount(runCount + 1)
if (RUN_COUNTS_BEFORE_REVIEW.includes(runCount)) {
setTimeout(function () {
StoreReview.requestReview()
}, 1000)
}
}
async getRunCount() {
return Number(this.application?.getValue('runCount'))
}
async setRunCount(runCount: number) {
return this.application?.setValue('runCount', runCount)
}
}

View File

@@ -0,0 +1,426 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_NOTES } from '@Root/Screens/screens'
import {
ApplicationEvent,
ButtonType,
isSameDay,
NoteMutator,
NoteViewController,
SNNote,
StorageEncryptionPolicy,
} from '@standardnotes/snjs'
import React, { useCallback, useEffect } from 'react'
import { LockStateType } from './ApplicationState'
export const useSignedIn = (signedInCallback?: () => void, signedOutCallback?: () => void) => {
// Context
const application = useSafeApplicationContext()
const [isLocked] = useIsLocked()
// State
const [signedIn, setSignedIn] = React.useState(false)
React.useEffect(() => {
let mounted = true
const getSignedIn = async () => {
if (mounted && !isLocked) {
setSignedIn(!application.noAccount())
}
}
void getSignedIn()
const removeSignedInObserver = application.addEventObserver(async event => {
if (event === ApplicationEvent.Launched) {
void getSignedIn()
}
if (event === ApplicationEvent.SignedIn) {
setSignedIn(true)
signedInCallback && signedInCallback()
} else if (event === ApplicationEvent.SignedOut) {
setSignedIn(false)
signedOutCallback && signedOutCallback()
}
})
return () => {
mounted = false
removeSignedInObserver && removeSignedInObserver()
}
}, [application, signedInCallback, signedOutCallback, isLocked])
return [signedIn]
}
export const useOutOfSync = () => {
// Context
const application = useSafeApplicationContext()
// State
const [outOfSync, setOutOfSync] = React.useState<boolean>(false)
React.useEffect(() => {
let isMounted = true
const getOutOfSync = async () => {
const outOfSyncInitial = await application.sync.isOutOfSync()
if (isMounted) {
setOutOfSync(Boolean(outOfSyncInitial))
}
}
void getOutOfSync()
return () => {
isMounted = false
}
}, [application])
React.useEffect(() => {
const removeSignedInObserver = application.addEventObserver(async event => {
if (event === ApplicationEvent.EnteredOutOfSync) {
setOutOfSync(true)
} else if (event === ApplicationEvent.ExitedOutOfSync) {
setOutOfSync(false)
}
})
return removeSignedInObserver
}, [application])
return [outOfSync]
}
export const useIsLocked = () => {
// Context
const application = React.useContext(ApplicationContext)
// State
const [isLocked, setIsLocked] = React.useState<boolean>(() => {
if (!application || !application.getAppState()) {
return true
}
return Boolean(application?.getAppState().locked)
})
useEffect(() => {
let isMounted = true
const removeSignedInObserver = application?.getAppState().addLockStateChangeObserver(event => {
if (isMounted) {
if (event === LockStateType.Locked) {
setIsLocked(true)
}
if (event === LockStateType.Unlocked) {
setIsLocked(false)
}
}
})
return () => {
isMounted = false
removeSignedInObserver && removeSignedInObserver()
}
}, [application])
return [isLocked]
}
export const useHasEditor = () => {
// Context
const application = React.useContext(ApplicationContext)
// State
const [hasEditor, setHasEditor] = React.useState<boolean>(false)
useEffect(() => {
const removeEditorObserver = application?.editorGroup.addActiveControllerChangeObserver(newEditor => {
setHasEditor(Boolean(newEditor))
})
return removeEditorObserver
}, [application])
return [hasEditor]
}
export const useSyncStatus = () => {
// Context
const application = React.useContext(ApplicationContext)
// State
const [completedInitialSync, setCompletedInitialSync] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [decrypting, setDecrypting] = React.useState(false)
const [refreshing, setRefreshing] = React.useState(false)
const setStatus = useCallback(
(status = '') => {
application?.getStatusManager().setMessage(SCREEN_NOTES, status)
},
[application],
)
const updateLocalDataStatus = useCallback(() => {
const syncStatus = application!.sync.getSyncStatus()
const stats = syncStatus.getStats()
const encryption =
application!.isEncryptionAvailable() &&
application!.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
if (stats.localDataCurrent === 0 || stats.localDataTotal === 0 || stats.localDataDone) {
setStatus()
return
}
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items…`
const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
setStatus(loadingStatus)
}, [application, setStatus])
useEffect(() => {
let mounted = true
const isEncryptionAvailable =
application!.isEncryptionAvailable() &&
application!.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
if (mounted) {
setDecrypting(!completedInitialSync && isEncryptionAvailable)
updateLocalDataStatus()
setLoading(!completedInitialSync && !isEncryptionAvailable)
}
return () => {
mounted = false
}
}, [application, completedInitialSync, updateLocalDataStatus])
const updateSyncStatus = useCallback(() => {
const syncStatus = application!.sync.getSyncStatus()
const stats = syncStatus.getStats()
if (syncStatus.hasError()) {
setRefreshing(false)
setStatus('Unable to Sync')
} else if (stats.downloadCount > 20) {
const text = `Downloading ${stats.downloadCount} items. Keep app open.`
setStatus(text)
} else if (stats.uploadTotalCount > 20) {
setStatus(`Syncing ${stats.uploadCompletionCount}/${stats.uploadTotalCount} items...`)
} else if (syncStatus.syncInProgress && !completedInitialSync) {
setStatus('Syncing…')
} else {
setStatus()
}
}, [application, completedInitialSync, setStatus])
useEffect(() => {
const unsubscribeAppEvents = application?.addEventObserver(async eventName => {
if (eventName === ApplicationEvent.LocalDataIncrementalLoad) {
updateLocalDataStatus()
} else if (eventName === ApplicationEvent.SyncStatusChanged || eventName === ApplicationEvent.FailedSync) {
updateSyncStatus()
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
setDecrypting(false)
setLoading(false)
updateLocalDataStatus()
} else if (eventName === ApplicationEvent.CompletedFullSync) {
if (completedInitialSync) {
setRefreshing(false)
} else {
setCompletedInitialSync(true)
}
setLoading(false)
updateSyncStatus()
} else if (eventName === ApplicationEvent.LocalDatabaseReadError) {
void application!.alertService!.alert('Unable to load local storage. Please restart the app and try again.')
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
void application!.alertService!.alert('Unable to write to local storage. Please restart the app and try again.')
} else if (eventName === ApplicationEvent.SignedIn) {
setLoading(true)
}
})
return unsubscribeAppEvents
}, [application, completedInitialSync, setStatus, updateLocalDataStatus, updateSyncStatus])
const startRefreshing = () => {
setRefreshing(true)
}
return [loading, decrypting, refreshing, startRefreshing] as [boolean, boolean, boolean, () => void]
}
export const useDeleteNoteWithPrivileges = (
note: SNNote,
onDeleteCallback: () => void,
onTrashCallback: () => void,
editor?: NoteViewController,
) => {
// Context
const application = React.useContext(ApplicationContext)
const trashNote = useCallback(async () => {
const title = 'Move to Trash'
const message = 'Are you sure you want to move this note to the trash?'
const confirmed = await application?.alertService?.confirm(message, title, 'Confirm', ButtonType.Danger)
if (confirmed) {
onTrashCallback()
}
}, [application?.alertService, onTrashCallback])
const deleteNotePermanently = useCallback(async () => {
const title = `Delete ${note!.title}`
const message = 'Are you sure you want to permanently delete this note?'
if (editor?.isTemplateNote) {
void application?.alertService!.alert(
'This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.',
)
return
}
const confirmed = await application?.alertService?.confirm(message, title, 'Delete', ButtonType.Danger, 'Cancel')
if (confirmed) {
onDeleteCallback()
}
}, [application?.alertService, editor?.isTemplateNote, note, onDeleteCallback])
const deleteNote = useCallback(
async (permanently: boolean) => {
if (note?.locked) {
void application?.alertService.alert(
"This note has editing disabled. If you'd like to delete it, enable editing on it, and try again.",
)
return
}
if (permanently) {
void deleteNotePermanently()
} else {
void trashNote()
}
},
[application, deleteNotePermanently, note?.locked, trashNote],
)
return [deleteNote]
}
export const useProtectionSessionExpiry = () => {
// Context
const application = useSafeApplicationContext()
const getProtectionsDisabledUntil = React.useCallback(() => {
const protectionExpiry = application?.getProtectionSessionExpiryDate()
const now = new Date()
if (protectionExpiry && protectionExpiry > now) {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
let f: Intl.DateTimeFormat
if (isSameDay(protectionExpiry, now)) {
f = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
} else {
f = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric',
})
}
return f.format(protectionExpiry)
} else {
if (isSameDay(protectionExpiry, now)) {
return protectionExpiry.toLocaleTimeString()
} else {
return `${protectionExpiry.toDateString()} ${protectionExpiry.toLocaleTimeString()}`
}
}
}
return null
}, [application])
// State
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = React.useState(getProtectionsDisabledUntil())
useEffect(() => {
const removeProtectionLengthSubscriber = application?.addEventObserver(async event => {
if ([ApplicationEvent.UnprotectedSessionBegan, ApplicationEvent.UnprotectedSessionExpired].includes(event)) {
setProtectionsDisabledUntil(getProtectionsDisabledUntil())
}
})
return () => {
removeProtectionLengthSubscriber && removeProtectionLengthSubscriber()
}
}, [application, getProtectionsDisabledUntil])
return [protectionsDisabledUntil]
}
export const useChangeNoteChecks = (note: SNNote | undefined, editor: NoteViewController | undefined = undefined) => {
// Context
const application = useSafeApplicationContext()
const canChangeNote = useCallback(async () => {
if (!note) {
return false
}
if (editor && editor.isTemplateNote) {
await editor.insertTemplatedNote()
}
if (!application.items.findItem(note.uuid)) {
void application.alertService!.alert(
"The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.",
)
return false
}
return true
}, [application, editor, note])
return [canChangeNote]
}
export const useChangeNote = (note: SNNote | undefined, editor: NoteViewController | undefined = undefined) => {
const application = React.useContext(ApplicationContext)
const [canChangeNote] = useChangeNoteChecks(note, editor)
const changeNote = useCallback(
async (mutate: (mutator: NoteMutator) => void, updateTimestamps: boolean) => {
if (await canChangeNote()) {
await application?.mutator.changeAndSaveItem(
note!,
mutator => {
const noteMutator = mutator as NoteMutator
mutate(noteMutator)
},
updateTimestamps,
)
}
},
[application, note, canChangeNote],
)
return [changeNote]
}
export const useProtectOrUnprotectNote = (
note: SNNote | undefined,
editor: NoteViewController | undefined = undefined,
) => {
// Context
const application = React.useContext(ApplicationContext)
const [canChangeNote] = useChangeNoteChecks(note, editor)
const protectOrUnprotectNote = useCallback(async () => {
if (await canChangeNote()) {
if (note!.protected) {
await application?.mutator.unprotectNote(note!)
} else {
await application?.mutator.protectNote(note!)
}
}
}, [application, note, canChangeNote])
return [protectOrUnprotectNote]
}

View File

@@ -0,0 +1,73 @@
import { SCREEN_COMPOSE, SCREEN_NOTES } from '@Root/Screens/screens'
import { ApplicationService, removeFromArray } from '@standardnotes/snjs'
export type ScreenStatus = {
status: string
color?: string
}
type StatusState = {
[SCREEN_NOTES]: ScreenStatus
[SCREEN_COMPOSE]: ScreenStatus
}
type HeaderStatusObserverCallback = (status: StatusState) => void
export class StatusManager extends ApplicationService {
private messages: StatusState = {
[SCREEN_NOTES]: {
status: '',
},
[SCREEN_COMPOSE]: {
status: '',
},
}
private observers: HeaderStatusObserverCallback[] = []
override deinit() {
this.observers = []
this.messages = {
[SCREEN_NOTES]: {
status: '',
},
[SCREEN_COMPOSE]: {
status: '',
},
}
}
/**
* Registers an observer for UI header status change
* @returns function that unregisters this observer
*/
public addHeaderStatusObserver(callback: HeaderStatusObserverCallback) {
this.observers.push(callback)
return () => {
removeFromArray(this.observers, callback)
}
}
setMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES, message: string, color?: string) {
this.messages[screen] = {
status: message,
color,
}
this.notifyObservers()
}
hasMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES) {
const message = this.getMessage(screen)
if (!message || message.status.length === 0) {
return false
}
return true
}
getMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES) {
return this.messages[screen]
}
private notifyObservers() {
for (const observer of this.observers) {
observer(this.messages)
}
}
}

View File

@@ -0,0 +1,5 @@
export enum ToastType {
Success = 'success',
Info = 'info',
Error = 'error',
}

View File

@@ -0,0 +1,42 @@
import { TEnvironment } from '@Root/App'
export function isNullOrUndefined(value: unknown) {
return value === null || value === undefined
}
/**
* Returns a string with non-alphanumeric characters stripped out
*/
export function stripNonAlphanumeric(str: string) {
return str.replace(/\W/g, '')
}
export function isMatchCaseInsensitive(a: string, b: string) {
return a.toLowerCase() === b.toLowerCase()
}
/**
* Returns a Date object from a JSON stringified date
*/
export function dateFromJsonString(str: string) {
if (str) {
return new Date(JSON.parse(str))
}
return str
}
/**
* Returns a boolean representing whether two dates are on the same day
*/
export function isSameDay(dateA: Date, dateB: Date) {
return (
dateA.getFullYear() === dateB.getFullYear() &&
dateA.getMonth() === dateB.getMonth() &&
dateA.getDate() === dateB.getDate()
)
}
export function isUnfinishedFeaturesEnabled(env: TEnvironment): boolean {
return env === 'dev' || __DEV__
}

View File

@@ -0,0 +1,3 @@
export enum ErrorMessage {
GeneralText = 'An error occurred. Please try again later.',
}

View File

@@ -0,0 +1,12 @@
// moment.js
import moment from 'moment'
import { NativeModules, Platform } from 'react-native'
// moment.js
const locale =
Platform.OS === 'android'
? NativeModules.I18nManager.localeIdentifier
: NativeModules.SettingsManager.settings.AppleLocale
moment.locale(locale)
export default moment

View File

@@ -0,0 +1,3 @@
{
"name": "@Lib"
}

View File

@@ -0,0 +1,280 @@
import { RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { BlockingModal } from '@Root/Components/BlockingModal'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { Authenticate } from '@Root/Screens/Authenticate/Authenticate'
import { FileInputModal } from '@Root/Screens/InputModal/FileInputModal'
import { PasscodeInputModal } from '@Root/Screens/InputModal/PasscodeInputModal'
import { TagInputModal } from '@Root/Screens/InputModal/TagInputModal'
import { ManageSessions } from '@Root/Screens/ManageSessions/ManageSessions'
import {
MODAL_BLOCKING_ALERT,
SCREEN_AUTHENTICATE,
SCREEN_INPUT_MODAL_FILE_NAME,
SCREEN_INPUT_MODAL_PASSCODE,
SCREEN_INPUT_MODAL_TAG,
SCREEN_MANAGE_SESSIONS,
SCREEN_SETTINGS,
SCREEN_UPLOADED_FILES_LIST,
} from '@Root/Screens/screens'
import { Settings } from '@Root/Screens/Settings/Settings'
import { UploadedFilesList } from '@Root/Screens/UploadedFilesList/UploadedFilesList'
import { Challenge, DeinitMode, DeinitSource, FileItem, SNNote } from '@standardnotes/snjs'
import { ICON_CHECKMARK, ICON_CLOSE } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { memo, useContext } from 'react'
import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams, TEnvironment } from './App'
import { ApplicationContext } from './ApplicationContext'
import { AppStackComponent } from './AppStack'
import { HistoryStack } from './HistoryStack'
export type ModalStackNavigatorParamList = {
AppStack: undefined
HistoryStack: undefined
[SCREEN_SETTINGS]: undefined
[SCREEN_MANAGE_SESSIONS]: undefined
[SCREEN_INPUT_MODAL_TAG]: HeaderTitleParams & {
tagUuid?: string
noteUuid?: string
}
[SCREEN_INPUT_MODAL_FILE_NAME]: HeaderTitleParams & {
file: FileItem
renameFile: (file: FileItem, fileName: string) => Promise<void>
}
[SCREEN_UPLOADED_FILES_LIST]: HeaderTitleParams & {
note: SNNote
}
[SCREEN_INPUT_MODAL_PASSCODE]: undefined
[SCREEN_AUTHENTICATE]: {
challenge: Challenge
title?: string
}
[MODAL_BLOCKING_ALERT]: {
title?: string
text: string
}
}
export type ModalStackNavigationProp<T extends keyof ModalStackNavigatorParamList> = {
navigation: StackNavigationProp<ModalStackNavigatorParamList, T>
route: RouteProp<ModalStackNavigatorParamList, T>
}
const MainStack = createStackNavigator<ModalStackNavigatorParamList>()
export const MainStackComponent = ({ env }: { env: TEnvironment }) => {
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const MemoizedAppStackComponent = memo((props: ModalStackNavigationProp<'AppStack'>) => (
<AppStackComponent {...props} />
))
return (
<MainStack.Navigator
screenOptions={{
gestureEnabled: false,
presentation: 'modal',
headerStyle: {
backgroundColor: theme.stylekitContrastBackgroundColor,
},
}}
initialRouteName="AppStack"
>
<MainStack.Screen
name={'AppStack'}
options={{
headerShown: false,
}}
component={MemoizedAppStackComponent}
/>
<MainStack.Screen
options={{
headerShown: false,
}}
name="HistoryStack"
component={HistoryStack}
/>
<MainStack.Screen
name={SCREEN_SETTINGS}
options={() => ({
title: 'Settings',
headerTitle: ({ children }) => {
return <HeaderTitleView title={children || ''} />
},
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Done' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CHECKMARK)}
onPress={onPress}
/>
</HeaderButtons>
),
headerRight: () =>
(env === 'dev' || __DEV__) && (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
title={'Destroy Data'}
onPress={async () => {
await application?.deviceInterface?.removeAllRawStorageValues()
await application?.deviceInterface?.removeAllRawDatabasePayloads(application?.identifier)
application?.deinit(DeinitMode.Soft, DeinitSource.SignOut)
}}
/>
</HeaderButtons>
),
})}
component={Settings}
/>
<MainStack.Screen
name={SCREEN_MANAGE_SESSIONS}
options={() => ({
title: 'Active Sessions',
headerTitle: ({ children }) => {
return <HeaderTitleView title={children || ''} />
},
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Done' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CHECKMARK)}
onPress={onPress}
/>
</HeaderButtons>
),
})}
component={ManageSessions}
/>
<MainStack.Screen
name={SCREEN_INPUT_MODAL_PASSCODE}
options={{
title: 'Setup Passcode',
headerTitle: ({ children }) => {
return <HeaderTitleView title={children || ''} />
},
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Cancel' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
onPress={onPress}
/>
</HeaderButtons>
),
}}
component={PasscodeInputModal}
/>
<MainStack.Screen
name={SCREEN_INPUT_MODAL_TAG}
options={({ route }) => ({
title: 'Tag',
gestureEnabled: false,
headerTitle: ({ children }) => {
return <HeaderTitleView title={route.params?.title ?? (children || '')} />
},
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Cancel' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
onPress={onPress}
/>
</HeaderButtons>
),
})}
component={TagInputModal}
/>
<MainStack.Screen
name={SCREEN_INPUT_MODAL_FILE_NAME}
options={({ route }) => ({
title: 'File',
gestureEnabled: false,
headerTitle: ({ children }) => {
return <HeaderTitleView title={route.params?.title ?? (children || '')} />
},
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Cancel' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
onPress={onPress}
/>
</HeaderButtons>
),
})}
component={FileInputModal}
/>
<MainStack.Screen
name={SCREEN_AUTHENTICATE}
options={({ route }) => ({
title: 'Authenticate',
headerLeft: () => undefined,
headerTitle: ({ children }) => <HeaderTitleView title={route.params?.title ?? (children || '')} />,
})}
component={Authenticate}
/>
<MainStack.Screen
name={SCREEN_UPLOADED_FILES_LIST}
options={({ route }) => ({
title: 'Files',
headerLeft: ({ disabled, onPress }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="headerButton"
disabled={disabled}
title={Platform.OS === 'ios' ? 'Close' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
onPress={onPress}
/>
</HeaderButtons>
),
headerTitle: ({ children }) => {
return <HeaderTitleView title={route.params?.title ?? (children || '')} />
},
})}
component={UploadedFilesList}
/>
<MainStack.Screen
name={MODAL_BLOCKING_ALERT}
options={() => ({
headerShown: false,
cardStyle: { backgroundColor: 'rgba(0, 0, 0, 0.15)' },
cardOverlayEnabled: true,
cardStyleInterpolator: ({ current: { progress } }) => ({
cardStyle: {
opacity: progress.interpolate({
inputRange: [0, 0.5, 0.9, 1],
outputRange: [0, 0.25, 0.7, 1],
}),
},
overlayStyle: {
opacity: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.5],
extrapolate: 'clamp',
}),
},
}),
})}
component={BlockingModal}
/>
</MainStack.Navigator>
)
}

View File

@@ -0,0 +1,49 @@
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { TableSection } from '@Root/Components/TableSection'
import styled, { css } from 'styled-components/native'
export const StyledKeyboardAvoidingView = styled.KeyboardAvoidingView`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const BaseView = styled.View``
export const StyledSectionedTableCell = styled(SectionedTableCell)`
padding-top: 4px;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 14px;
font-weight: bold;
`
export const Subtitle = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
font-size: 14px;
margin-top: 4px;
`
export const Input = styled.TextInput.attrs(({ theme }) => ({
placeholderTextColor: theme.stylekitNeutralColor,
}))`
font-size: ${({ theme }) => theme.mainTextFontSize}px;
padding: 0px;
color: ${({ theme }) => theme.stylekitForegroundColor};
height: 100%;
`
export const SectionContainer = styled.View``
export const SourceContainer = styled.View``
export const SessionLengthContainer = styled.View``
export const StyledTableSection = styled(TableSection)<{ last?: boolean }>`
${({ last }) =>
last &&
css`
margin-bottom: 0px;
`};
`

View File

@@ -0,0 +1,678 @@
import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState'
import { MobileDeviceInterface } from '@Lib/Interface'
import { HeaderHeightContext } from '@react-navigation/elements'
import { useFocusEffect } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_AUTHENTICATE } from '@Root/Screens/screens'
import { ChallengeReason, ChallengeValidation, ChallengeValue, ProtectionSessionDurations } from '@standardnotes/snjs'
import { ICON_CLOSE } from '@Style/Icons'
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Alert, BackHandler, Keyboard, Platform, ScrollView, TextInput } from 'react-native'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import { hide } from 'react-native-privacy-snapshot'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import styled, { ThemeContext } from 'styled-components'
import {
BaseView,
Input,
SectionContainer,
SessionLengthContainer,
SourceContainer,
StyledKeyboardAvoidingView,
StyledSectionedTableCell,
StyledTableSection,
Subtitle,
Title,
} from './Authenticate.styled'
import {
authenticationReducer,
AuthenticationValueStateType,
findIndexInObject,
getChallengePromptTitle,
getLabelForStateAndType,
isInActiveState,
} from './helpers'
type Props = ModalStackNavigationProp<typeof SCREEN_AUTHENTICATE>
function isValidChallengeValue(challengeValue: ChallengeValue): boolean {
switch (challengeValue.prompt.validation) {
case ChallengeValidation.ProtectionSessionDuration:
return typeof challengeValue.value === 'number'
default:
return !!challengeValue.value
}
}
const ItemStyled = styled(Item)`
width: 100px;
`
export const Authenticate = ({
route: {
params: { challenge },
},
navigation,
}: Props) => {
// Context
const application = useContext(ApplicationContext)
const themeService = useContext(ThemeServiceContext)
const theme = useContext(ThemeContext)
// State
const [supportsBiometrics, setSupportsBiometrics] = useState<boolean | undefined>(undefined)
const [passcodeKeyboardType, setPasscodeKeyboardType] = useState<PasscodeKeyboardType | undefined>(
PasscodeKeyboardType.Default,
)
const [singleValidation] = useState(() => !(challenge.prompts.filter(prompt => prompt.validates).length > 0))
const [showSwitchKeyboard, setShowSwitchKeyboard] = useState<boolean>(false)
const [{ challengeValues, challengeValueStates }, dispatch] = useReducer(
authenticationReducer,
{
challengeValues: challenge.prompts.reduce((map, current) => {
map[current.id] = {
prompt: current,
value: current.initialValue ?? null,
} as ChallengeValue
return map
}, {} as Record<string, ChallengeValue>),
challengeValueStates: challenge.prompts.reduce((map, current, index) => {
if (index === 0) {
map[current.id] = AuthenticationValueStateType.WaitingInput
} else {
map[current.id] = AuthenticationValueStateType.WaitingTurn
}
return map
}, {} as Record<string, AuthenticationValueStateType>),
},
undefined,
)
const [pending, setPending] = useState(false)
// Refs
const isAuthenticating = useRef(false)
const firstInputRef = useRef<TextInput>(null)
const secondInputRef = useRef<TextInput>(null)
const thirdInputRef = useRef<TextInput>(null)
const fourthInputRef = useRef<TextInput>(null)
React.useLayoutEffect(() => {
if (challenge.cancelable) {
navigation.setOptions({
headerLeft: ({ disabled }) => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<ItemStyled
testID="headerButton"
disabled={disabled || pending}
title={Platform.OS === 'ios' ? 'Cancel' : ''}
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
onPress={() => {
if (!pending) {
application?.cancelChallenge(challenge)
}
}}
/>
</HeaderButtons>
),
})
}
}, [navigation, challenge, application, pending])
const validateChallengeValue = useCallback(
async (challengeValue: ChallengeValue) => {
if (singleValidation) {
setPending(true)
return application?.submitValuesForChallenge(challenge, Object.values(challengeValues))
} else {
const state = challengeValueStates[challengeValue.prompt.id]
if (
state === AuthenticationValueStateType.Locked ||
state === AuthenticationValueStateType.Success ||
!isValidChallengeValue(challengeValue)
) {
return
}
return application?.submitValuesForChallenge(challenge, [challengeValue])
}
},
[challengeValueStates, singleValidation, challengeValues, application, challenge],
)
const onValueLocked = useCallback((challengeValue: ChallengeValue) => {
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.Locked,
})
setTimeout(() => {
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.WaitingTurn,
})
}, 30 * 1000)
}, [])
const checkForBiometrics = useCallback(
async () => (application?.deviceInterface as MobileDeviceInterface).getDeviceBiometricsAvailability(),
[application],
)
const checkPasscodeKeyboardType = useCallback(
async () => application?.getAppState().getPasscodeKeyboardType(),
[application],
)
const authenticateBiometrics = useCallback(
async (challengeValue: ChallengeValue) => {
let hasBiometrics = supportsBiometrics
if (supportsBiometrics === undefined) {
hasBiometrics = await checkForBiometrics()
setSupportsBiometrics(hasBiometrics)
}
if (!hasBiometrics) {
FingerprintScanner.release()
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.Fail,
})
Alert.alert('Unsuccessful', 'This device either does not have a biometric sensor or it may not configured.')
return
}
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.Pending,
})
if (application?.getAppState().screenshotPrivacyEnabled) {
hide()
}
if (Platform.OS === 'android') {
await application?.getAppState().performActionWithoutStateChangeImpact(async () => {
isAuthenticating.current = true
FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts type does not exist for deviceCredentialAllowed
deviceCredentialAllowed: true,
description: 'Biometrics are required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
const newChallengeValue = { ...challengeValue, value: true }
onValueChange(newChallengeValue)
return validateChallengeValue(newChallengeValue)
})
.catch(error => {
FingerprintScanner.release()
if (error.name === 'DeviceLocked') {
onValueLocked(challengeValue)
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
} else {
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.Fail,
})
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
})
.finally(() => {
isAuthenticating.current = false
})
}, true)
} else {
// iOS
await application?.getAppState().performActionWithoutStateChangeImpact(async () => {
isAuthenticating.current = true
FingerprintScanner.authenticate({
fallbackEnabled: true,
description: 'This is required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
const newChallengeValue = { ...challengeValue, value: true }
onValueChange(newChallengeValue)
return validateChallengeValue(newChallengeValue)
})
.catch(error_1 => {
onValueChange({ ...challengeValue, value: false })
FingerprintScanner.release()
if (error_1.name !== 'SystemCancel') {
if (error_1.name !== 'UserCancel') {
Alert.alert('Unsuccessful')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
}
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.Fail,
})
})
.finally(() => {
isAuthenticating.current = false
})
}, true)
}
},
[application, checkForBiometrics, onValueLocked, supportsBiometrics, validateChallengeValue],
)
const firstNotSuccessful = useMemo(() => {
for (const id in challengeValueStates) {
if (challengeValueStates[id] !== AuthenticationValueStateType.Success) {
return id
}
}
return
}, [challengeValueStates])
const beginAuthenticatingForNextChallengeReason = useCallback(
(completedChallengeValue?: ChallengeValue) => {
let challengeValue
if (completedChallengeValue === undefined) {
challengeValue = challengeValues[firstNotSuccessful!]
} else {
const index = findIndexInObject(challengeValues, completedChallengeValue.prompt.id.toString())
const hasNextItem = Object.prototype.hasOwnProperty.call(Object.keys(challengeValues), index + 1)
if (!hasNextItem) {
return
}
const nextItemId = Object.keys(challengeValues)[index + 1]
challengeValue = challengeValues[nextItemId]
}
/**
* Authentication modal may be displayed on lose focus just before the app
* is closing. In this state however, we don't want to begin auth. We'll
* wait until the app gains focus.
*/
const isLosingFocusOrInBackground =
application?.getAppState().getMostRecentState() === AppStateType.LosingFocus ||
application?.getAppState().getMostRecentState() === AppStateType.EnteringBackground
if (challengeValue.prompt.validation === ChallengeValidation.Biometric && !isLosingFocusOrInBackground) {
/** Begin authentication right away, we're not waiting for any input */
void authenticateBiometrics(challengeValue)
} else {
const index = findIndexInObject(challengeValues, challengeValue.prompt.id.toString())
switch (index) {
case 0:
firstInputRef.current?.focus()
break
case 1:
secondInputRef.current?.focus()
break
case 2:
thirdInputRef.current?.focus()
break
case 3:
fourthInputRef.current?.focus()
break
}
}
dispatch({
type: 'setState',
id: challengeValue.prompt.id.toString(),
state: AuthenticationValueStateType.WaitingInput,
})
},
[application, authenticateBiometrics, challengeValues, firstNotSuccessful],
)
useEffect(() => {
const remove = application?.getAppState().addStateChangeObserver(state => {
if (state === AppStateType.ResumingFromBackground) {
if (!isAuthenticating.current) {
beginAuthenticatingForNextChallengeReason()
}
} else if (state === AppStateType.EnteringBackground) {
FingerprintScanner.release()
dispatch({
type: 'setState',
id: firstNotSuccessful!,
state: AuthenticationValueStateType.WaitingInput,
})
}
})
return remove
}, [application, beginAuthenticatingForNextChallengeReason, challengeValueStates, firstNotSuccessful])
const onValidValue = useCallback(
(value: ChallengeValue) => {
setPending(false)
dispatch({
type: 'setState',
id: value.prompt.id.toString(),
state: AuthenticationValueStateType.Success,
})
beginAuthenticatingForNextChallengeReason(value)
},
[beginAuthenticatingForNextChallengeReason],
)
const onInvalidValue = (value: ChallengeValue) => {
setPending(false)
dispatch({
type: 'setState',
id: value.prompt.id.toString(),
state: AuthenticationValueStateType.Fail,
})
}
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
let removeObserver: () => void = () => {}
if (application?.addChallengeObserver) {
removeObserver = application?.addChallengeObserver(challenge, {
onValidValue,
onInvalidValue,
onComplete: () => {
navigation.goBack()
},
onCancel: () => {
navigation.goBack()
},
})
}
return removeObserver
}, [application, challenge, navigation, onValidValue])
useEffect(() => {
let mounted = true
const setBiometricsAsync = async () => {
if (challenge.reason === ChallengeReason.ApplicationUnlock) {
const hasBiometrics = await checkForBiometrics()
if (mounted) {
setSupportsBiometrics(hasBiometrics)
}
}
}
void setBiometricsAsync()
const setInitialPasscodeKeyboardType = async () => {
const initialPasscodeKeyboardType = await checkPasscodeKeyboardType()
if (mounted) {
setPasscodeKeyboardType(initialPasscodeKeyboardType)
}
}
void setInitialPasscodeKeyboardType()
return () => {
mounted = false
}
}, [challenge.reason, checkForBiometrics, checkPasscodeKeyboardType])
/**
* Authenticate for challenge reasons like biometrics as soon as possible,
* unless a prompt has a prefilled control value, in which case give the
* option to adjust them first.
*/
useEffect(() => {
if (
challenge.prompts &&
challenge.prompts.length > 0 &&
challenge.prompts[0].validation !== ChallengeValidation.ProtectionSessionDuration
) {
beginAuthenticatingForNextChallengeReason()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onBiometricDirectPress = () => {
Keyboard.dismiss()
const biometricChallengeValue = Object.values(challengeValues).find(
value => value.prompt.validation === ChallengeValidation.Biometric,
)
const state = challengeValueStates[biometricChallengeValue?.prompt.id as number]
if (state === AuthenticationValueStateType.Locked || state === AuthenticationValueStateType.Success) {
return
}
beginAuthenticatingForNextChallengeReason()
}
const onValueChange = (newValue: ChallengeValue, dismissKeyboard = false) => {
if (dismissKeyboard) {
Keyboard.dismiss()
}
dispatch({
type: 'setValue',
id: newValue.prompt.id.toString(),
value: newValue.value,
})
}
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
// Always block back button on Android
return true
}
BackHandler.addEventListener('hardwareBackPress', onBackPress)
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress)
}, []),
)
const onSubmitPress = () => {
const challengeValue = challengeValues[firstNotSuccessful!]
if (!isValidChallengeValue(challengeValue)) {
return
}
if (singleValidation) {
void validateChallengeValue(challengeValue)
} else {
const state = challengeValueStates[firstNotSuccessful!]
if (
challengeValue.prompt.validation === ChallengeValidation.Biometric &&
(state === AuthenticationValueStateType.Locked || state === AuthenticationValueStateType.Fail)
) {
beginAuthenticatingForNextChallengeReason()
return
}
void validateChallengeValue(challengeValue)
}
}
const switchKeyboard = () => {
if (passcodeKeyboardType === PasscodeKeyboardType.Numeric) {
setPasscodeKeyboardType(PasscodeKeyboardType.Default)
} else {
setPasscodeKeyboardType(PasscodeKeyboardType.Numeric)
}
}
const readyToSubmit = useMemo(
() =>
Object.values(challengeValues)
.map(challengeValue => challengeValue.value)
.filter(value => !value).length === 0,
[challengeValues],
)
const renderAuthenticationSource = (challengeValue: ChallengeValue, index: number) => {
const last = index === Object.keys(challengeValues).length - 1
const state = challengeValueStates[challengeValue.prompt.id]
const active = isInActiveState(state)
const isBiometric = challengeValue.prompt.validation === ChallengeValidation.Biometric
const isProtectionSessionDuration =
challengeValue.prompt.validation === ChallengeValidation.ProtectionSessionDuration
const isInput = !isBiometric && !isProtectionSessionDuration
const stateLabel = getLabelForStateAndType(challengeValue.prompt.validation, state)
const stateTitle = getChallengePromptTitle(challengeValue.prompt, state)
const keyboardType =
challengeValue.prompt.keyboardType ??
(challengeValue.prompt.validation === ChallengeValidation.LocalPasscode ? passcodeKeyboardType : 'default')
return (
<SourceContainer key={challengeValue.prompt.id}>
<StyledTableSection last={last}>
<SectionHeader
title={stateTitle}
subtitle={isInput ? stateLabel : undefined}
tinted={active}
buttonText={
challengeValue.prompt.validation === ChallengeValidation.LocalPasscode && showSwitchKeyboard
? 'Change Keyboard'
: undefined
}
buttonAction={switchKeyboard}
buttonStyles={
challengeValue.prompt.validation === ChallengeValidation.LocalPasscode
? {
color: theme.stylekitNeutralColor,
fontSize: theme.mainTextFontSize - 5,
}
: undefined
}
/>
{isInput && (
<SectionContainer>
<SectionedTableCell textInputCell={true} first={true}>
<Input
key={Platform.OS === 'android' ? keyboardType : undefined}
ref={Array.of(firstInputRef, secondInputRef, thirdInputRef, fourthInputRef)[index] as any}
placeholder={challengeValue.prompt.placeholder}
onChangeText={text => {
onValueChange({ ...challengeValue, value: text })
}}
value={(challengeValue.value || '') as string}
autoCorrect={false}
autoFocus={false}
autoCapitalize={'none'}
secureTextEntry={challengeValue.prompt.secureTextEntry}
keyboardType={keyboardType}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
underlineColorAndroid={'transparent'}
onSubmitEditing={
!singleValidation
? () => {
void validateChallengeValue(challengeValue)
}
: undefined
}
onFocus={() => setShowSwitchKeyboard(true)}
onBlur={() => setShowSwitchKeyboard(false)}
/>
</SectionedTableCell>
</SectionContainer>
)}
{isBiometric && (
<SectionContainer>
<SectionedAccessoryTableCell
first={true}
dimmed={active}
tinted={active}
text={stateLabel}
onPress={onBiometricDirectPress}
/>
</SectionContainer>
)}
{isProtectionSessionDuration && (
<SessionLengthContainer>
{ProtectionSessionDurations.map((duration, i) => (
<SectionedAccessoryTableCell
text={duration.label}
key={duration.valueInSeconds}
first={i === 0}
last={i === ProtectionSessionDurations.length - 1}
selected={() => {
return duration.valueInSeconds === challengeValue.value
}}
onPress={() => {
onValueChange(
{
...challengeValue,
value: duration.valueInSeconds,
},
true,
)
}}
/>
))}
</SessionLengthContainer>
)}
</StyledTableSection>
</SourceContainer>
)
}
const isPending = useMemo(
() => Object.values(challengeValueStates).findIndex(state => state === AuthenticationValueStateType.Pending) >= 0,
[challengeValueStates],
)
let submitButtonTitle: 'Submit' | 'Next'
if (singleValidation) {
submitButtonTitle = 'Submit'
} else if (!firstNotSuccessful) {
submitButtonTitle = 'Next'
} else {
const stateKeys = Object.keys(challengeValueStates)
submitButtonTitle = 'Submit'
/** Check the next values; if one of them is not successful, show 'Next' */
for (let i = stateKeys.indexOf(firstNotSuccessful) + 1; i < stateKeys.length; i++) {
const nextValueState = challengeValueStates[stateKeys[i]]
if (nextValueState !== AuthenticationValueStateType.Success) {
submitButtonTitle = 'Next'
}
}
}
return (
<HeaderHeightContext.Consumer>
{headerHeight => (
<StyledKeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
>
<ScrollView keyboardShouldPersistTaps="handled">
{(challenge.heading || challenge.subheading) && (
<StyledTableSection>
<StyledSectionedTableCell>
<BaseView>
{challenge.heading && <Title>{challenge.heading}</Title>}
{challenge.subheading && <Subtitle>{challenge.subheading}</Subtitle>}
</BaseView>
</StyledSectionedTableCell>
</StyledTableSection>
)}
{Object.values(challengeValues).map((challengeValue, index) =>
renderAuthenticationSource(challengeValue, index),
)}
<ButtonCell
maxHeight={45}
disabled={singleValidation ? !readyToSubmit || pending : isPending}
title={submitButtonTitle}
bold={true}
onPress={onSubmitPress}
/>
</ScrollView>
</StyledKeyboardAvoidingView>
)}
</HeaderHeightContext.Consumer>
)
}

View File

@@ -0,0 +1,114 @@
import { ChallengePrompt, ChallengeValidation, ChallengeValue } from '@standardnotes/snjs'
export const isInActiveState = (state: AuthenticationValueStateType) =>
state !== AuthenticationValueStateType.WaitingInput && state !== AuthenticationValueStateType.Success
export enum AuthenticationValueStateType {
WaitingTurn = 0,
WaitingInput = 1,
Success = 2,
Fail = 3,
Pending = 4,
Locked = 5,
}
type ChallengeValueState = {
challengeValues: Record<string, ChallengeValue>
challengeValueStates: Record<string, AuthenticationValueStateType>
}
type SetChallengeValueState = {
type: 'setState'
id: string
state: AuthenticationValueStateType
}
type SetChallengeValue = {
type: 'setValue'
id: string
value: ChallengeValue['value']
}
type Action = SetChallengeValueState | SetChallengeValue
export const authenticationReducer = (state: ChallengeValueState, action: Action): ChallengeValueState => {
switch (action.type) {
case 'setState': {
return {
...state,
challengeValueStates: {
...state.challengeValueStates,
[action.id]: action.state,
},
}
}
case 'setValue': {
const updatedChallengeValue = state.challengeValues[action.id]
return {
...state,
challengeValues: {
...state.challengeValues,
[action.id]: {
...updatedChallengeValue,
value: action.value,
},
},
}
}
default:
return state
}
}
export const findIndexInObject = (
map: ChallengeValueState['challengeValues'] | ChallengeValueState['challengeValueStates'],
id: string,
) => {
return Object.keys(map).indexOf(id)
}
export const getChallengePromptTitle = (prompt: ChallengePrompt, state: AuthenticationValueStateType) => {
const title = prompt.title
switch (state) {
case AuthenticationValueStateType.WaitingTurn:
return title ?? 'Waiting'
case AuthenticationValueStateType.Locked:
return title ?? 'Locked'
default:
return title
}
}
export const getLabelForStateAndType = (validation: ChallengeValidation, state: AuthenticationValueStateType) => {
switch (validation) {
case ChallengeValidation.Biometric: {
switch (state) {
case AuthenticationValueStateType.WaitingTurn:
return 'Waiting for passcode'
case AuthenticationValueStateType.WaitingInput:
return 'Press here to begin biometrics scan'
case AuthenticationValueStateType.Pending:
return 'Waiting for unlock'
case AuthenticationValueStateType.Success:
return 'Success | Biometrics'
case AuthenticationValueStateType.Fail:
return 'Biometrics failed. Tap to try again.'
case AuthenticationValueStateType.Locked:
return 'Biometrics locked. Try again in 30 seconds.'
default:
return ''
}
}
default:
switch (state) {
case AuthenticationValueStateType.WaitingTurn:
case AuthenticationValueStateType.WaitingInput:
return 'Waiting'
case AuthenticationValueStateType.Pending:
return 'Verifying keys...'
case AuthenticationValueStateType.Success:
return 'Success'
case AuthenticationValueStateType.Fail:
return 'Invalid value. Please try again.'
default:
return ''
}
}
}

View File

@@ -0,0 +1,70 @@
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import { SafeAreaView } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/Ionicons'
import WebView from 'react-native-webview'
import styled, { css } from 'styled-components/native'
export const FlexContainer = styled(SafeAreaView).attrs(() => ({
edges: ['bottom'],
}))`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const LockedContainer = styled.View`
justify-content: flex-start;
flex-direction: row;
align-items: center;
padding: 10px;
background-color: ${({ theme }) => theme.stylekitWarningColor};
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
border-bottom-width: 1px;
`
export const LockedText = styled.Text`
font-weight: bold;
font-size: 12px;
color: ${({ theme }) => theme.stylekitBackgroundColor};
padding-left: 10px;
`
export const StyledWebview = styled(WebView)<{ showWebView: boolean }>`
flex: 1;
background-color: transparent;
opacity: 0.99;
min-height: 1px;
${({ showWebView }) =>
!showWebView &&
css`
display: none;
`};
`
export const StyledIcon = styled(Icon).attrs(({ theme }) => ({
color: theme.stylekitBackgroundColor,
size: 16,
name: ThemeService.nameForIcon(ICON_LOCK),
}))``
export const DeprecatedContainer = styled.View`
justify-content: flex-start;
flex-direction: row;
align-items: center;
padding: 10px;
background-color: ${({ theme }) => theme.stylekitWarningColor};
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
border-bottom-width: 1px;
`
export const DeprecatedText = styled.Text`
font-weight: bold;
font-size: 12px;
color: ${({ theme }) => theme.stylekitBackgroundColor};
padding-left: 10px;
`
export const DeprecatedIcon = styled(Icon).attrs(({ theme }) => ({
color: theme.stylekitBackgroundColor,
size: 16,
name: ThemeService.nameForIcon(ICON_ALERT),
}))``

View File

@@ -0,0 +1,315 @@
import { ComponentLoadingError } from '@Lib/ComponentManager'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { AppStackNavigationProp } from '@Root/AppStack'
import { SCREEN_NOTES } from '@Root/Screens/screens'
import { ButtonType, ComponentViewer, PrefKey } from '@standardnotes/snjs'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Platform } from 'react-native'
import { WebView } from 'react-native-webview'
import {
OnShouldStartLoadWithRequest,
WebViewErrorEvent,
WebViewMessageEvent,
} from 'react-native-webview/lib/WebViewTypes'
import {
DeprecatedContainer,
DeprecatedIcon,
DeprecatedText,
FlexContainer,
LockedContainer,
LockedText,
StyledIcon,
StyledWebview,
} from './ComponentView.styled'
type Props = {
componentViewer: ComponentViewer
onLoadEnd: () => void
onLoadStart: () => void
onLoadError: (error: ComponentLoadingError, desc?: string) => void
onDownloadEditorStart: () => void
onDownloadEditorEnd: () => void
}
const log = (message?: any, ...optionalParams: any[]) => {
const LOGGING_ENABLED = false
if (LOGGING_ENABLED) {
console.log(message, optionalParams, '\n\n')
console.log('\n\n')
}
}
/** On Android, webview.onShouldStartLoadWithRequest is not called by react-native-webview*/
const SupportsShouldLoadRequestHandler = Platform.OS === 'ios'
export const ComponentView = ({
onLoadEnd,
onLoadError,
onLoadStart,
onDownloadEditorStart,
onDownloadEditorEnd,
componentViewer,
}: Props) => {
// Context
const application = useContext(ApplicationContext)
const themeService = useContext(ThemeServiceContext)
// State
const [showWebView, setShowWebView] = useState<boolean>(true)
const [requiresLocalEditor, setRequiresLocalEditor] = useState<boolean>(false)
const [localEditorReady, setLocalEditorReady] = useState<boolean>(false)
// Ref
const didLoadRootUrl = useRef<boolean>(false)
const webViewRef = useRef<WebView>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
useEffect(() => {
const removeBlurScreenListener = navigation.addListener('blur', () => {
setShowWebView(false)
})
return removeBlurScreenListener
}, [navigation])
useFocusEffect(() => {
setShowWebView(true)
})
useEffect(() => {
const warnIfUnsupportedEditors = async () => {
let platformVersionRequirements
switch (Platform.OS) {
case 'ios':
if (parseInt(Platform.Version.toString(), 10) < 11) {
// WKWebView has issues on iOS < 11
platformVersionRequirements = 'iOS 11 or greater'
}
break
case 'android':
if (Platform.Version <= 23) {
/**
* postMessage doesn't work on Android <= 6 (API version 23)
* https://github.com/facebook/react-native/issues/11594
*/
platformVersionRequirements = 'Android 7.0 or greater'
}
break
}
if (!platformVersionRequirements) {
return
}
const doNotShowAgainUnsupportedEditors = application
?.getLocalPreferences()
.getValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, false)
if (!doNotShowAgainUnsupportedEditors) {
const alertText =
`Web editors require ${platformVersionRequirements}. ` +
'Your version does not support web editors. ' +
'Changes you make may not be properly saved. Please switch to the Plain Editor for the best experience.'
const confirmed = await application?.alertService?.confirm(
alertText,
'Editors Not Supported',
"Don't show again",
ButtonType.Info,
'OK',
)
if (confirmed) {
void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, true)
}
}
}
void warnIfUnsupportedEditors()
}, [application])
const onLoadErrorHandler = useCallback(
(error?: WebViewErrorEvent) => {
log('On load error', error)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
onLoadError(ComponentLoadingError.Unknown, error?.nativeEvent?.description)
},
[onLoadError, timeoutRef],
)
useEffect(() => {
const componentManager = application!.mobileComponentManager
const component = componentViewer.component
const isDownloadable = componentManager.isComponentDownloadable(component)
setRequiresLocalEditor(isDownloadable)
if (isDownloadable) {
const asyncFunc = async () => {
if (await componentManager.doesComponentNeedDownload(component)) {
onDownloadEditorStart()
const error = await componentManager.downloadComponentOffline(component)
log('Download component error', error)
onDownloadEditorEnd()
if (error) {
onLoadError(error)
}
}
setLocalEditorReady(true)
}
void asyncFunc()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onMessage = (event: WebViewMessageEvent) => {
let data
try {
data = JSON.parse(event.nativeEvent.data)
} catch (e) {
log('Message is not valid JSON, returning')
return
}
componentViewer?.handleMessage(data)
}
const onFrameLoad = useCallback(() => {
log('Iframe did load', webViewRef.current?.props.source)
/**
* We have no way of knowing if the webview load is successful or not. We
* have to wait to see if the error event is fired. Looking at the code,
* the error event is fired right after this, so we can wait just a few ms
* to see if the error event is fired before registering the component
* window. Otherwise, on error, this component will be dealloced, and a
* pending postMessage will cause a memory leak crash on Android in the
* form of "react native attempt to invoke virtual method
* double java.lang.double.doublevalue() on a null object reference"
*/
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
if (didLoadRootUrl.current === true || !SupportsShouldLoadRequestHandler) {
log('Setting component viewer webview')
timeoutRef.current = setTimeout(() => {
componentViewer?.setWindow(webViewRef.current as unknown as Window)
}, 1)
/**
* The parent will remove their loading screen on load end. We want to
* delay this to avoid flicker that may result if using a dark theme.
* This delay will allow editor to load its theme.
*/
const isDarkTheme = themeService?.isLikelyUsingDarkColorTheme()
const delayToAvoidFlicker = isDarkTheme ? 50 : 0
setTimeout(() => {
onLoadEnd()
}, delayToAvoidFlicker)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onLoadStartHandler = () => {
onLoadStart()
}
const onShouldStartLoadWithRequest: OnShouldStartLoadWithRequest = request => {
log('Setting last iframe URL to', request.url)
/** The first request can typically be 'about:blank', which we want to ignore */
if (!didLoadRootUrl.current) {
didLoadRootUrl.current = request.url === componentViewer.url!
}
/**
* We want to handle link clicks within an editor by opening the browser
* instead of loading inline. On iOS, onShouldStartLoadWithRequest is
* called for all requests including the initial request to load the editor.
* On iOS, clicks in the editors have a navigationType of 'click', but on
* Android, this is not the case (no navigationType).
* However, on Android, this function is not called for the initial request.
* So that might be one way to determine if this request is a click or the
* actual editor load request. But I don't think it's safe to rely on this
* being the case in the future. So on Android, we'll handle url loads only
* if the url isn't equal to the editor url.
*/
if (
(Platform.OS === 'ios' && request.navigationType === 'click') ||
(Platform.OS === 'android' && request.url !== componentViewer.url!)
) {
application!.deviceInterface!.openUrl(request.url)
return false
}
return true
}
const defaultInjectedJavaScript = () => {
return `(function() {
window.parent.postMessage = function(data) {
window.parent.ReactNativeWebView.postMessage(data);
};
const meta = document.createElement('meta');
meta.setAttribute('content', 'width=device-width, initial-scale=1, user-scalable=no');
meta.setAttribute('name', 'viewport');
document.getElementsByTagName('head')[0].appendChild(meta);
return true;
})()`
}
const deprecationMessage = componentViewer.component.deprecationMessage
const renderWebview = !requiresLocalEditor || localEditorReady
return (
<FlexContainer>
{componentViewer.component.isExpired && (
<LockedContainer>
<StyledIcon />
<LockedText>
Subscription expired. Editors are in a read-only state. To edit immediately, please switch to the Plain
Editor.
</LockedText>
</LockedContainer>
)}
{componentViewer.component.isDeprecated && (
<DeprecatedContainer>
<DeprecatedIcon />
<DeprecatedText>{deprecationMessage || 'This extension is deprecated.'}</DeprecatedText>
</DeprecatedContainer>
)}
{renderWebview && (
<StyledWebview
showWebView={showWebView}
source={{ uri: componentViewer.url! }}
key={componentViewer.component.uuid}
ref={webViewRef}
/**
* onLoad and onLoadEnd seem to be the same exact thing, except
* that when an error occurs, onLoadEnd is called twice, whereas
* onLoad is called once (what we want)
*/
onLoad={onFrameLoad}
onLoadStart={onLoadStartHandler}
onError={onLoadErrorHandler}
onHttpError={() => onLoadErrorHandler()}
onMessage={onMessage}
hideKeyboardAccessoryView={true}
setSupportMultipleWindows={false}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
cacheEnabled={true}
autoManageStatusBarEnabled={false /* To prevent StatusBar from changing colors when focusing */}
injectedJavaScript={defaultInjectedJavaScript()}
onContentProcessDidTerminate={() => onLoadErrorHandler()}
/>
)}
</FlexContainer>
)
}

View File

@@ -0,0 +1,121 @@
import SNTextView from '@standardnotes/react-native-textview'
import React, { ComponentProps } from 'react'
import { Platform } from 'react-native'
import styled, { css } from 'styled-components/native'
const PADDING = 14
const NOTE_TITLE_HEIGHT = 50
export const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const LockedContainer = styled.View`
justify-content: flex-start;
flex-direction: row;
align-items: center;
padding-left: ${PADDING}px;
padding: 8px;
background-color: ${({ theme }) => theme.stylekitNeutralColor};
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
border-bottom-width: 1px;
`
export const LockedText = styled.Text`
font-weight: bold;
font-size: 12px;
color: ${({ theme }) => theme.stylekitBackgroundColor};
padding-left: 10px;
padding-right: 100px;
`
export const WebViewReloadButton = styled.TouchableOpacity`
position: absolute;
right: ${PADDING}px;
height: 100%;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
`
export const WebViewReloadButtonText = styled.Text`
color: ${({ theme }) => theme.stylekitBackgroundColor};
font-size: 12px;
font-weight: bold;
`
export const NoteTitleInput = styled.TextInput`
font-weight: ${Platform.OS === 'ios' ? 600 : 'bold'};
font-size: ${Platform.OS === 'ios' ? 17 : 18}px;
color: ${({ theme }) => theme.stylekitForegroundColor};
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
height: ${NOTE_TITLE_HEIGHT}px;
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
border-bottom-width: 1px;
padding-top: ${Platform.OS === 'ios' ? 5 : 12}px;
padding-left: ${PADDING}px;
padding-right: ${PADDING}px;
`
export const LoadingWebViewContainer = styled.View<{ locked?: boolean }>`
position: absolute;
height: 100%;
width: 100%;
top: ${({ locked }) => (locked ? NOTE_TITLE_HEIGHT + 26 : NOTE_TITLE_HEIGHT)}px;
bottom: 0px;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const LoadingText = styled.Text`
padding-left: 0px;
color: ${({ theme }) => theme.stylekitForegroundColor};
opacity: 0.7;
margin-top: 5px;
`
export const ContentContainer = styled.View`
flex-grow: 1;
`
export const TextContainer = styled.View`
flex: 1;
`
export const StyledKeyboardAvoidngView = styled.KeyboardAvoidingView`
flex: 1;
${({ theme }) => theme.stylekitBackgroundColor};
`
const StyledTextViewComponent = styled(SNTextView)<{ errorState: boolean }>`
padding-top: 10px;
color: ${({ theme }) => theme.stylekitForegroundColor};
padding-left: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
padding-right: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
padding-bottom: ${({ errorState }) => (errorState ? 36 : 10)}px;
${Platform.OS === 'ios' &&
css`
height: 96%;
`}
${Platform.OS === 'android' &&
css`
flex: 1;
`}
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
/* ${Platform.OS === 'ios' && 'padding-bottom: 10px'}; */
`
export const StyledTextView = React.memo(
StyledTextViewComponent,
(newProps: ComponentProps<typeof SNTextView>, prevProps: ComponentProps<typeof SNTextView>) => {
if (
newProps.value !== prevProps.value ||
newProps.selectionColor !== prevProps.selectionColor ||
newProps.handlesColor !== prevProps.handlesColor ||
newProps.autoFocus !== prevProps.autoFocus ||
newProps.editable !== prevProps.editable ||
newProps.keyboardDismissMode !== prevProps.keyboardDismissMode ||
newProps.keyboardAppearance !== prevProps.keyboardAppearance ||
newProps.testID !== prevProps.testID ||
newProps.multiline !== prevProps.multiline
) {
return false
}
return true
},
)

View File

@@ -0,0 +1,587 @@
import { AppStateEventType } from '@Lib/ApplicationState'
import { ComponentLoadingError, ComponentManager } from '@Lib/ComponentManager'
import { isNullOrUndefined } from '@Lib/Utils'
import { ApplicationContext, SafeApplicationContext } from '@Root/ApplicationContext'
import { AppStackNavigationProp } from '@Root/AppStack'
import { SCREEN_COMPOSE } from '@Root/Screens/screens'
import SNTextView from '@standardnotes/react-native-textview'
import {
ApplicationEvent,
ComponentMutator,
ComponentViewer,
ContentType,
isPayloadSourceInternalChange,
isPayloadSourceRetrieved,
ItemMutator,
NoteMutator,
NoteViewController,
PayloadEmitSource,
SNComponent,
UuidString,
} from '@standardnotes/snjs'
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import { lighten } from '@Style/Utils'
import React, { createRef } from 'react'
import { Keyboard, Platform, View } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import { ComponentView } from './ComponentView'
import {
Container,
LoadingText,
LoadingWebViewContainer,
LockedContainer,
LockedText,
NoteTitleInput,
StyledTextView,
TextContainer,
WebViewReloadButton,
WebViewReloadButtonText,
} from './Compose.styled'
const NOTE_PREVIEW_CHAR_LIMIT = 80
const MINIMUM_STATUS_DURATION = 400
const SAVE_TIMEOUT_DEBOUNCE = 250
const SAVE_TIMEOUT_NO_DEBOUNCE = 100
type State = {
title: string
text: string
saveError: boolean
webViewError?: ComponentLoadingError
webViewErrorDesc?: string
loadingWebview: boolean
downloadingEditor: boolean
componentViewer?: ComponentViewer
}
type PropsWhenNavigating = AppStackNavigationProp<typeof SCREEN_COMPOSE>
type PropsWhenRenderingDirectly = {
noteUuid: UuidString
}
const EditingIsDisabledText = 'This note has editing disabled. Please enable editing on this note to make changes.'
export class Compose extends React.Component<PropsWhenNavigating | PropsWhenRenderingDirectly, State> {
static override contextType = ApplicationContext
override context: React.ContextType<typeof ApplicationContext>
editor: NoteViewController
editorViewRef: React.RefObject<SNTextView> = createRef()
saveTimeout: ReturnType<typeof setTimeout> | undefined
alreadySaved = false
statusTimeout: ReturnType<typeof setTimeout> | undefined
downloadingMessageTimeout: ReturnType<typeof setTimeout> | undefined
removeNoteInnerValueObserver?: () => void
removeComponentsObserver?: () => void
removeStreamComponents?: () => void
removeStateEventObserver?: () => void
removeAppEventObserver?: () => void
removeComponentHandler?: () => void
constructor(
props: PropsWhenNavigating | PropsWhenRenderingDirectly,
context: React.ContextType<typeof SafeApplicationContext>,
) {
super(props)
this.context = context
const noteUuid = 'noteUuid' in props ? props.noteUuid : props.route.params.noteUuid
const editor = this.context.editorGroup.noteControllers.find(c => c.note.uuid === noteUuid)
if (!editor) {
throw 'Unable to to find note controller'
}
this.editor = editor
this.state = {
title: this.editor.note.title,
text: this.editor.note.text,
componentViewer: undefined,
saveError: false,
webViewError: undefined,
loadingWebview: false,
downloadingEditor: false,
}
}
override componentDidMount() {
this.removeNoteInnerValueObserver = this.editor.addNoteInnerValueChangeObserver((note, source) => {
if (isPayloadSourceRetrieved(source)) {
this.setState({
title: note.title,
text: note.text,
})
}
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadEmitSource.LocalInserted && note.dirty
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
return
}
if (note.lastSyncBegan || note.dirty) {
if (note.lastSyncEnd) {
if (note.dirty || (note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime())) {
this.showSavingStatus()
} else if (
this.context?.getStatusManager().hasMessage(SCREEN_COMPOSE) &&
note.lastSyncBegan &&
note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
) {
this.showAllChangesSavedStatus()
}
} else {
this.showSavingStatus()
}
}
})
this.removeStreamComponents = this.context?.streamItems(ContentType.Component, async ({ source }) => {
if (isPayloadSourceInternalChange(source)) {
return
}
if (!this.note) {
return
}
void this.reloadComponentEditorState()
})
this.removeAppEventObserver = this.context?.addEventObserver(async eventName => {
if (eventName === ApplicationEvent.CompletedFullSync) {
/** if we're still dirty, don't change status, a sync is likely upcoming. */
if (!this.note.dirty && this.state.saveError) {
this.showAllChangesSavedStatus()
}
} else if (eventName === ApplicationEvent.FailedSync) {
/**
* Only show error status in editor if the note is dirty.
* Otherwise, it means the originating sync came from somewhere else
* and we don't want to display an error here.
*/
if (this.note.dirty) {
this.showErrorStatus('Sync Unavailable (changes saved offline)')
}
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
this.showErrorStatus('Offline Saving Issue (changes not saved)')
}
})
this.removeStateEventObserver = this.context?.getAppState().addStateEventObserver(state => {
if (state === AppStateEventType.DrawerOpen) {
this.dismissKeyboard()
/**
* Saves latest note state before any change might happen in the drawer
*/
}
})
if (this.editor.isTemplateNote && Platform.OS === 'ios') {
setTimeout(() => {
this.editorViewRef?.current?.focus()
}, 0)
}
}
override componentWillUnmount() {
this.dismissKeyboard()
this.removeNoteInnerValueObserver && this.removeNoteInnerValueObserver()
this.removeAppEventObserver && this.removeAppEventObserver()
this.removeStreamComponents && this.removeStreamComponents()
this.removeStateEventObserver && this.removeStateEventObserver()
this.removeComponentHandler && this.removeComponentHandler()
this.removeStateEventObserver = undefined
this.removeNoteInnerValueObserver = undefined
this.removeComponentHandler = undefined
this.removeStreamComponents = undefined
this.removeAppEventObserver = undefined
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, '')
if (this.state.componentViewer && this.componentManager) {
this.componentManager.destroyComponentViewer(this.state.componentViewer)
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
if (this.statusTimeout) {
clearTimeout(this.statusTimeout)
}
if (this.downloadingMessageTimeout) {
clearTimeout(this.downloadingMessageTimeout)
}
}
/**
* Because note.locked accesses note.content.appData,
* we do not want to expose the template to direct access to note.locked,
* otherwise an exception will occur when trying to access note.locked if the note
* is deleted. There is potential for race conditions to occur with setState, where a
* previous setState call may have queued a digest cycle, and the digest cycle triggers
* on a deleted note.
*/
get noteLocked() {
if (!this.note) {
return false
}
return this.note.locked
}
setStatus = (status: string, color?: string, wait = true) => {
if (this.statusTimeout) {
clearTimeout(this.statusTimeout)
}
if (wait) {
this.statusTimeout = setTimeout(() => {
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
}, MINIMUM_STATUS_DURATION)
} else {
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
}
}
showSavingStatus = () => {
this.setStatus('Saving...', undefined, false)
}
showAllChangesSavedStatus = () => {
this.setState({
saveError: false,
})
const offlineStatus = this.context?.hasAccount() ? '' : ' (offline)'
this.setStatus('All changes saved' + offlineStatus)
}
showErrorStatus = (message: string) => {
this.setState({
saveError: true,
})
this.setStatus(message)
}
get note() {
return this.editor.note
}
dismissKeyboard = () => {
Keyboard.dismiss()
this.editorViewRef.current?.blur()
}
get componentManager() {
return this.context?.mobileComponentManager as ComponentManager
}
async associateComponentWithCurrentNote(component: SNComponent) {
const note = this.note
if (!note) {
return
}
return this.context?.mutator.changeItem(component, (m: ItemMutator) => {
const mutator = m as ComponentMutator
mutator.removeDisassociatedItemId(note.uuid)
mutator.associateWithItem(note.uuid)
})
}
reloadComponentEditorState = async () => {
this.setState({
downloadingEditor: false,
loadingWebview: false,
webViewError: undefined,
})
const associatedEditor = this.componentManager.editorForNote(this.note)
/** Editors cannot interact with template notes so the note must be inserted */
if (associatedEditor && this.editor.isTemplateNote) {
await this.editor.insertTemplatedNote()
void this.associateComponentWithCurrentNote(associatedEditor)
}
if (!associatedEditor) {
if (this.state.componentViewer) {
this.componentManager.destroyComponentViewer(this.state.componentViewer)
this.setState({ componentViewer: undefined })
}
} else if (associatedEditor.uuid !== this.state.componentViewer?.component.uuid) {
if (this.state.componentViewer) {
this.componentManager.destroyComponentViewer(this.state.componentViewer)
}
if (this.componentManager.isComponentThirdParty(associatedEditor.identifier)) {
await this.componentManager.preloadThirdPartyIndexPathFromDisk(associatedEditor.identifier)
}
this.loadComponentViewer(associatedEditor)
}
}
loadComponentViewer(component: SNComponent) {
this.setState({
componentViewer: this.componentManager.createComponentViewer(component, this.note.uuid),
})
}
async forceReloadExistingEditor() {
if (this.state.componentViewer) {
this.componentManager.destroyComponentViewer(this.state.componentViewer)
}
this.setState({
componentViewer: undefined,
loadingWebview: false,
webViewError: undefined,
})
const associatedEditor = this.componentManager.editorForNote(this.note)
if (associatedEditor) {
this.loadComponentViewer(associatedEditor)
}
}
saveNote = async (params: { newTitle?: string; newText?: string }) => {
if (this.editor.isTemplateNote) {
await this.editor.insertTemplatedNote()
}
if (!this.context?.items.findItem(this.note.uuid)) {
void this.context?.alertService.alert('Attempting to save this note has failed. The note cannot be found.')
return
}
const { newTitle, newText } = params
await this.context.mutator.changeItem(
this.note,
mutator => {
const noteMutator = mutator as NoteMutator
if (newTitle != null) {
noteMutator.title = newTitle
}
if (newText != null) {
noteMutator.text = newText
const substring = newText.substring(0, NOTE_PREVIEW_CHAR_LIMIT)
const shouldTruncate = newText.length > NOTE_PREVIEW_CHAR_LIMIT
const previewPlain = substring + (shouldTruncate ? '...' : '')
noteMutator.preview_plain = previewPlain
noteMutator.preview_html = undefined
}
},
true,
)
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = this.context?.noAccount()
const syncDebouceMs = noDebounce ? SAVE_TIMEOUT_NO_DEBOUNCE : SAVE_TIMEOUT_DEBOUNCE
this.saveTimeout = setTimeout(() => {
void this.context?.sync.sync()
}, syncDebouceMs)
}
onTitleChange = (newTitle: string) => {
if (this.note.locked) {
void this.context?.alertService?.alert(EditingIsDisabledText)
return
}
this.setState(
{
title: newTitle,
},
() => this.saveNote({ newTitle: newTitle }),
)
}
onContentChange = (text: string) => {
if (this.note.locked) {
void this.context?.alertService?.alert(EditingIsDisabledText)
return
}
void this.saveNote({ newText: text })
}
onLoadWebViewStart = () => {
this.setState({
loadingWebview: true,
webViewError: undefined,
})
}
onLoadWebViewEnd = () => {
this.setState({
loadingWebview: false,
})
}
onLoadWebViewError = (error: ComponentLoadingError, desc?: string) => {
this.setState({
loadingWebview: false,
webViewError: error,
webViewErrorDesc: desc,
})
}
onDownloadEditorStart = () => {
this.setState({
downloadingEditor: true,
})
}
onDownloadEditorEnd = () => {
if (this.downloadingMessageTimeout) {
clearTimeout(this.downloadingMessageTimeout)
}
this.downloadingMessageTimeout = setTimeout(
() =>
this.setState({
downloadingEditor: false,
}),
this.state.webViewError ? 0 : 200,
)
}
getErrorText(): string {
let text = ''
switch (this.state.webViewError) {
case ComponentLoadingError.ChecksumMismatch:
text = 'The remote editor signature differs from the expected value.'
break
case ComponentLoadingError.DoesntExist:
text = 'The local editor files do not exist.'
break
case ComponentLoadingError.FailedDownload:
text = 'The editor failed to download.'
break
case ComponentLoadingError.LocalServerFailure:
text = 'The local component server has an error.'
break
case ComponentLoadingError.Unknown:
text = 'An unknown error occurred.'
break
default:
break
}
if (this.state.webViewErrorDesc) {
text += `Webview Error: ${this.state.webViewErrorDesc}`
}
return text
}
override render() {
const shouldDisplayEditor =
this.state.componentViewer && Boolean(this.note) && !this.note.prefersPlainEditor && !this.state.webViewError
return (
<Container>
<ThemeContext.Consumer>
{theme => (
<>
{this.noteLocked && (
<LockedContainer>
<Icon name={ThemeService.nameForIcon(ICON_LOCK)} size={16} color={theme.stylekitBackgroundColor} />
<LockedText>Note Editing Disabled</LockedText>
</LockedContainer>
)}
{this.state.webViewError && (
<LockedContainer>
<Icon name={ThemeService.nameForIcon(ICON_ALERT)} size={16} color={theme.stylekitBackgroundColor} />
<LockedText>
Unable to load {this.state.componentViewer?.component.name} {this.getErrorText()}
</LockedText>
<WebViewReloadButton
onPress={() => {
void this.forceReloadExistingEditor()
}}
>
<WebViewReloadButtonText>Reload</WebViewReloadButtonText>
</WebViewReloadButton>
</LockedContainer>
)}
<ThemeServiceContext.Consumer>
{themeService => (
<>
<NoteTitleInput
testID="noteTitleField"
onChangeText={this.onTitleChange}
value={this.state.title}
placeholder={'Add Title'}
selectionColor={theme.stylekitInfoColor}
underlineColorAndroid={'transparent'}
placeholderTextColor={theme.stylekitNeutralColor}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
autoCorrect={true}
autoCapitalize={'sentences'}
/>
{(this.state.downloadingEditor ||
(this.state.loadingWebview && themeService?.isLikelyUsingDarkColorTheme())) && (
<LoadingWebViewContainer locked={this.noteLocked}>
<LoadingText>
{'Loading '}
{this.state.componentViewer?.component.name}...
</LoadingText>
</LoadingWebViewContainer>
)}
{/* setting webViewError to false on onLoadEnd will cause an infinite loop on Android upon webview error, so, don't do that. */}
{shouldDisplayEditor && this.state.componentViewer && (
<ComponentView
key={this.state.componentViewer?.identifier}
componentViewer={this.state.componentViewer}
onLoadStart={this.onLoadWebViewStart}
onLoadEnd={this.onLoadWebViewEnd}
onLoadError={this.onLoadWebViewError}
onDownloadEditorStart={this.onDownloadEditorStart}
onDownloadEditorEnd={this.onDownloadEditorEnd}
/>
)}
{!shouldDisplayEditor && !isNullOrUndefined(this.note) && Platform.OS === 'android' && (
<TextContainer>
<StyledTextView
testID="noteContentField"
ref={this.editorViewRef}
autoFocus={false}
value={this.state.text}
selectionColor={lighten(theme.stylekitInfoColor, 0.35)}
handlesColor={theme.stylekitInfoColor}
onChangeText={this.onContentChange}
errorState={false}
/>
</TextContainer>
)}
{/* Empty wrapping view fixes native textview crashing */}
{!shouldDisplayEditor && Platform.OS === 'ios' && (
<View key={this.note.uuid}>
<StyledTextView
testID="noteContentField"
ref={this.editorViewRef}
autoFocus={false}
multiline
value={this.state.text}
keyboardDismissMode={'interactive'}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
selectionColor={lighten(theme.stylekitInfoColor)}
onChangeText={this.onContentChange}
editable={!this.noteLocked}
errorState={!!this.state.webViewError}
/>
</View>
)}
</>
)}
</ThemeServiceContext.Consumer>
</>
)}
</ThemeContext.Consumer>
</Container>
)
}
}

View File

@@ -0,0 +1,61 @@
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { TableSection } from '@Root/Components/TableSection'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_INPUT_MODAL_FILE_NAME } from '@Root/Screens/screens'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { FC, useContext, useEffect, useRef, useState } from 'react'
import { TextInput } from 'react-native'
import { Container, Input } from './InputModal.styled'
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_FILE_NAME>
export const FileInputModal: FC<Props> = props => {
const { file, renameFile } = props.route.params
const themeService = useContext(ThemeServiceContext)
const application = useSafeApplicationContext()
const fileNameInputRef = useRef<TextInput>(null)
const [fileName, setFileName] = useState(file.name)
const onSubmit = async () => {
const trimmedFileName = fileName.trim()
if (trimmedFileName === '') {
setFileName(file.name)
await application?.alertService.alert('File name cannot be empty')
fileNameInputRef.current?.focus()
return
}
await renameFile(file, trimmedFileName)
void application.sync.sync()
props.navigation.goBack()
}
useEffect(() => {
fileNameInputRef.current?.focus()
}, [])
return (
<Container>
<TableSection>
<SectionedTableCell textInputCell first={true}>
<Input
ref={fileNameInputRef as any}
placeholder={'File name'}
onChangeText={setFileName}
value={fileName}
autoCorrect={false}
autoCapitalize={'none'}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
underlineColorAndroid={'transparent'}
onSubmitEditing={onSubmit}
/>
</SectionedTableCell>
<ButtonCell maxHeight={45} disabled={fileName.length === 0} title={'Save'} bold onPress={onSubmit} />
</TableSection>
</Container>
)
}

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components/native'
export const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const Input = styled.TextInput.attrs(({ theme }) => ({
placeholderTextColor: theme.stylekitNeutralColor,
}))`
font-size: ${({ theme }) => theme.mainTextFontSize}px;
padding: 0px;
color: ${({ theme }) => theme.stylekitForegroundColor};
height: 100%;
`

View File

@@ -0,0 +1,134 @@
import { PasscodeKeyboardType, UnlockTiming } from '@Lib/ApplicationState'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { Option, SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { TableSection } from '@Root/Components/TableSection'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_INPUT_MODAL_PASSCODE } from '@Root/Screens/screens'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { useContext, useMemo, useRef, useState } from 'react'
import { Keyboard, KeyboardType, Platform, TextInput } from 'react-native'
import { Container, Input } from './InputModal.styled'
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_PASSCODE>
export const PasscodeInputModal = (props: Props) => {
// Context
const application = useContext(ApplicationContext)
const themeService = useContext(ThemeServiceContext)
// State
const [settingPassocode, setSettingPassocode] = useState(false)
const [text, setText] = useState('')
const [confirmText, setConfirmText] = useState('')
const [keyboardType, setKeyboardType] = useState<KeyboardType>('default')
// Refs
const textRef = useRef<TextInput>(null)
const confirmTextRef = useRef<TextInput>(null)
const onTextSubmit = () => {
if (!confirmText) {
confirmTextRef.current?.focus()
} else {
Keyboard.dismiss()
}
}
const onSubmit = async () => {
if (settingPassocode) {
return
}
setSettingPassocode(true)
if (text !== confirmText) {
void application?.alertService?.alert(
'The two values you entered do not match. Please try again.',
'Invalid Confirmation',
'OK',
)
setSettingPassocode(false)
} else {
await application?.addPasscode(text)
await application?.getAppState().setPasscodeKeyboardType(keyboardType as PasscodeKeyboardType)
await application?.getAppState().setPasscodeTiming(UnlockTiming.OnQuit)
setSettingPassocode(false)
props.navigation.goBack()
}
}
const keyboardOptions: Option[] = useMemo(
() => [
{
title: 'General',
key: 'default' as PasscodeKeyboardType,
selected: keyboardType === 'default',
},
{
title: 'Numeric',
key: 'numeric' as PasscodeKeyboardType,
selected: keyboardType === 'numeric',
},
],
[keyboardType],
)
const onKeyboardTypeSelect = (option: Option) => {
setKeyboardType(option.key as KeyboardType)
}
return (
<Container>
<TableSection>
<SectionedTableCell textInputCell first={true}>
<Input
ref={textRef as any}
key={Platform.OS === 'android' ? keyboardType + '1' : undefined}
placeholder="Enter a passcode"
onChangeText={setText}
value={text}
secureTextEntry
autoCorrect={false}
autoCapitalize={'none'}
keyboardType={keyboardType}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
autoFocus={true}
underlineColorAndroid={'transparent'}
onSubmitEditing={onTextSubmit}
/>
</SectionedTableCell>
<SectionedTableCell textInputCell first={false}>
<Input
ref={confirmTextRef as any}
key={Platform.OS === 'android' ? keyboardType + '2' : undefined}
placeholder="Confirm passcode"
onChangeText={setConfirmText}
value={confirmText}
secureTextEntry
autoCorrect={false}
autoCapitalize={'none'}
keyboardType={keyboardType}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
underlineColorAndroid={'transparent'}
onSubmitEditing={onSubmit}
/>
</SectionedTableCell>
<SectionedOptionsTableCell
title={'Keyboard Type'}
leftAligned
options={keyboardOptions}
onPress={onKeyboardTypeSelect}
/>
<ButtonCell
maxHeight={45}
disabled={settingPassocode || text.length === 0}
title={'Save'}
bold
onPress={onSubmit}
/>
</TableSection>
</Container>
)
}

View File

@@ -0,0 +1,92 @@
import { useFocusEffect } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { TableSection } from '@Root/Components/TableSection'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_INPUT_MODAL_TAG } from '@Root/Screens/screens'
import { SNNote, SNTag, TagMutator } from '@standardnotes/snjs'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { TextInput } from 'react-native'
import { Container, Input } from './InputModal.styled'
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_TAG>
export const TagInputModal = (props: Props) => {
// Context
const application = useContext(ApplicationContext)
const themeService = useContext(ThemeServiceContext)
// State
const [text, setText] = useState('')
// Refs
const textRef = useRef<TextInput>(null)
useEffect(() => {
if (props.route.params.tagUuid) {
const tag = application?.items.findItem(props.route.params.tagUuid) as SNTag
setText(tag.title)
}
}, [application, props.route.params.tagUuid])
useFocusEffect(
useCallback(() => {
setTimeout(() => {
textRef.current?.focus()
}, 1)
}, []),
)
const onSubmit = useCallback(async () => {
if (props.route.params.tagUuid) {
const tag = application?.items.findItem(props.route.params.tagUuid) as SNTag
await application?.mutator.changeItem(tag, mutator => {
const tagMutator = mutator as TagMutator
tagMutator.title = text
if (props.route.params.noteUuid) {
const note = application.items.findItem(props.route.params.noteUuid)
if (note) {
tagMutator.addNote(note as SNNote)
}
}
})
} else {
const tag = await application!.mutator.findOrCreateTag(text)
if (props.route.params.noteUuid) {
await application?.mutator.changeItem(tag, mutator => {
const tagMutator = mutator as TagMutator
const note = application.items.findItem(props.route.params.noteUuid!)
if (note) {
tagMutator.addNote(note as SNNote)
}
})
}
}
void application?.sync.sync()
props.navigation.goBack()
}, [application, props.navigation, props.route.params.noteUuid, props.route.params.tagUuid, text])
return (
<Container>
<TableSection>
<SectionedTableCell textInputCell first={true}>
<Input
ref={textRef as any}
placeholder={props.route.params.tagUuid ? 'Tag name' : 'New tag name'}
onChangeText={setText}
value={text}
autoCorrect={false}
autoCapitalize={'none'}
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
underlineColorAndroid={'transparent'}
onSubmitEditing={onSubmit}
/>
</SectionedTableCell>
<ButtonCell maxHeight={45} disabled={text.length === 0} title={'Save'} bold onPress={onSubmit} />
</TableSection>
</Container>
)
}

View File

@@ -0,0 +1,154 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { LoadingContainer, LoadingText } from '@Root/Screens/Notes/NoteList.styled'
import { ButtonType, RemoteSession, SessionStrings, UuidString } from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { FlatList, ListRenderItem, RefreshControl } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { ThemeContext } from 'styled-components'
import { SessionCell } from './SessionCell'
const useSessions = (): [
RemoteSession[],
() => void,
() => void,
boolean,
(uuid: UuidString) => Promise<void>,
string,
] => {
// Context
const application = useContext(ApplicationContext)
// State
const [sessions, setSessions] = useState<RemoteSession[]>([])
const [refreshing, setRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const getSessions = useCallback(async () => {
const response = await application?.getSessions()
if (!response) {
setErrorMessage('An unknown error occurred while loading sessions.')
return
}
if ('error' in response || !response.data) {
if (response.error?.message) {
setErrorMessage(response.error.message)
} else {
setErrorMessage('An unknown error occurred while loading sessions.')
}
} else {
const newSessions = response.data as RemoteSession[]
setSessions(newSessions)
setErrorMessage('')
}
}, [application])
const refreshSessions = useCallback(async () => {
setRefreshing(true)
await getSessions()
setRefreshing(false)
}, [getSessions])
useEffect(() => {
void refreshSessions()
}, [application, refreshSessions])
async function revokeSession(uuid: UuidString) {
const response = await application?.revokeSession(uuid)
if (response && 'error' in response) {
if (response.error?.message) {
setErrorMessage(response.error?.message)
} else {
setErrorMessage('An unknown error occurred while revoking the session.')
}
} else {
setSessions(sessions.filter(session => session.uuid !== uuid))
}
}
return [sessions, getSessions, refreshSessions, refreshing, revokeSession, errorMessage]
}
export const ManageSessions: React.FC = () => {
// Context
const application = useContext(ApplicationContext)
const { showActionSheet } = useCustomActionSheet()
const theme = useContext(ThemeContext)
const insets = useSafeAreaInsets()
const [sessions, getSessions, refreshSessions, refreshing, revokeSession, errorMessage] = useSessions()
const onItemPress = (item: RemoteSession) => {
showActionSheet({
title: item.device_info,
options: [
{
text: 'Revoke',
destructive: true,
callback: () => showRevokeSessionAlert(item),
},
],
})
}
const showRevokeSessionAlert = useCallback(
async (item: RemoteSession) => {
const confirmed = await application?.alertService.confirm(
SessionStrings.RevokeText,
SessionStrings.RevokeTitle,
SessionStrings.RevokeConfirmButton,
ButtonType.Danger,
SessionStrings.RevokeCancelButton,
)
if (confirmed) {
try {
await revokeSession(item.uuid)
getSessions()
} catch (e) {
void application?.alertService.alert('Action failed. Please try again.')
}
}
},
[application?.alertService, getSessions, revokeSession],
)
const RenderItem: ListRenderItem<RemoteSession> | null | undefined = ({ item }) => {
return (
<SessionCell
onPress={() => onItemPress(item)}
title={item.device_info}
subTitle={item.updated_at.toLocaleDateString()}
currentSession={item.current}
disabled={item.current}
/>
)
}
if (errorMessage) {
return (
<LoadingContainer>
<LoadingText>{errorMessage}</LoadingText>
</LoadingContainer>
)
}
return (
<FlatList<RemoteSession>
keyExtractor={item => item.uuid}
contentContainerStyle={{ paddingBottom: insets.bottom }}
initialNumToRender={7}
windowSize={7}
data={sessions}
refreshControl={
<RefreshControl
tintColor={theme.stylekitContrastForegroundColor}
refreshing={refreshing}
onRefresh={refreshSessions}
/>
}
renderItem={RenderItem}
/>
)
}

View File

@@ -0,0 +1,58 @@
import { Props as TableCellProps, SectionedTableCellTouchableHighlight } from '@Root/Components/SectionedTableCell'
import React from 'react'
import styled, { css } from 'styled-components/native'
type Props = {
testID?: string
disabled?: boolean
onPress: () => void
title: string
subTitle: string
currentSession: boolean
}
const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
underlayColor: props.theme.stylekitBorderColor,
}))<TableCellProps>`
padding-top: ${12}px;
justify-content: center;
`
const ButtonContainer = styled.View``
type ButtonLabelProps = Pick<Props, 'disabled'>
const ButtonLabel = styled.Text<ButtonLabelProps>`
color: ${props => {
let color = props.theme.stylekitForegroundColor
if (props.disabled) {
color = 'gray'
}
return color
}};
font-weight: bold;
font-size: ${props => props.theme.mainTextFontSize}px;
${({ disabled }) =>
disabled &&
css`
opacity: 0.6;
`}
`
export const SubTitleText = styled.Text<{ current: boolean }>`
font-size: 14px;
margin-top: 4px;
color: ${({ theme, current }) => {
return current ? theme.stylekitInfoColor : theme.stylekitForegroundColor
}};
opacity: 0.8;
line-height: 21px;
`
export const SessionCell: React.FC<Props> = props => (
<Container testID={props.testID} disabled={props.disabled} onPress={props.onPress}>
<ButtonContainer>
<ButtonLabel disabled={props.disabled}>{props.title}</ButtonLabel>
<SubTitleText current={props.currentSession}>
{props.currentSession ? 'Current session' : 'Signed in on ' + props.subTitle}
</SubTitleText>
</ButtonContainer>
</Container>
)

View File

@@ -0,0 +1,17 @@
import styled from 'styled-components/native'
export const Container = styled.View``
export const DateText = styled.Text`
font-size: 15px;
margin-top: 4px;
opacity: 0.8;
line-height: 21px;
`
export const IosTabBarContainer = styled.View`
padding-top: 10px;
padding-bottom: 5px;
padding-left: 12px;
padding-right: 12px;
`

View File

@@ -0,0 +1,95 @@
import SegmentedControl from '@react-native-community/segmented-control'
import { ApplicationContext } from '@Root/ApplicationContext'
import { HistoryStackNavigationProp } from '@Root/HistoryStack'
import { SCREEN_NOTE_HISTORY, SCREEN_NOTE_HISTORY_PREVIEW } from '@Root/Screens/screens'
import { NoteHistoryEntry, SNNote } from '@standardnotes/snjs'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { useContext, useState } from 'react'
import { Dimensions, Platform } from 'react-native'
import { NavigationState, Route, SceneRendererProps, TabBar, TabView } from 'react-native-tab-view'
import { ThemeContext } from 'styled-components'
import { IosTabBarContainer } from './NoteHistory.styled'
import { RemoteHistory } from './RemoteHistory'
import { SessionHistory } from './SessionHistory'
const initialLayout = { width: Dimensions.get('window').width }
type Props = HistoryStackNavigationProp<typeof SCREEN_NOTE_HISTORY>
export const NoteHistory = (props: Props) => {
// Context
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const themeService = useContext(ThemeServiceContext)
// State
const [note] = useState<SNNote>(() => application?.items.findItem(props.route.params.noteUuid) as SNNote)
const [routes] = React.useState([
{ key: 'session', title: 'Session' },
{ key: 'remote', title: 'Remote' },
])
const [index, setIndex] = useState(0)
const openPreview = (_uuid: string, revision: NoteHistoryEntry, title: string) => {
props.navigation.navigate(SCREEN_NOTE_HISTORY_PREVIEW, {
title,
revision,
originalNoteUuid: note.uuid,
})
}
const renderScene = ({ route }: { route: { key: string; title: string } }) => {
switch (route.key) {
case 'session':
return <SessionHistory onPress={openPreview} note={note} />
case 'remote':
return <RemoteHistory onPress={openPreview} note={note} />
default:
return null
}
}
const renderTabBar = (
tabBarProps: SceneRendererProps & {
navigationState: NavigationState<Route>
},
) => {
return Platform.OS === 'ios' && parseInt(Platform.Version as string, 10) >= 13 ? (
<IosTabBarContainer>
<SegmentedControl
backgroundColor={theme.stylekitContrastBackgroundColor}
appearance={themeService?.keyboardColorForActiveTheme()}
fontStyle={{
color: theme.stylekitForegroundColor,
}}
values={routes.map(route => route.title)}
selectedIndex={tabBarProps.navigationState.index}
onChange={event => {
setIndex(event.nativeEvent.selectedSegmentIndex)
}}
/>
</IosTabBarContainer>
) : (
<TabBar
{...tabBarProps}
indicatorStyle={{ backgroundColor: theme.stylekitInfoColor }}
inactiveColor={theme.stylekitBorderColor}
activeColor={theme.stylekitInfoColor}
style={{
backgroundColor: theme.stylekitBackgroundColor,
shadowColor: theme.stylekitShadowColor,
}}
labelStyle={{ color: theme.stylekitInfoColor }}
/>
)
}
return (
<TabView
renderTabBar={renderTabBar}
navigationState={{ index, routes }}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={initialLayout}
/>
)
}

View File

@@ -0,0 +1,61 @@
import { Props as TableCellProps, SectionedTableCellTouchableHighlight } from '@Root/Components/SectionedTableCell'
import React from 'react'
import styled, { css } from 'styled-components/native'
type Props = {
testID?: string
disabled?: boolean
onPress: () => void
first?: boolean
last?: boolean
title: string
subTitle?: string
}
const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
underlayColor: props.theme.stylekitBorderColor,
}))<TableCellProps>`
padding-top: ${12}px;
justify-content: center;
`
const ButtonContainer = styled.View``
type ButtonLabelProps = Pick<Props, 'disabled'>
const ButtonLabel = styled.Text<ButtonLabelProps>`
color: ${props => {
let color = props.theme.stylekitForegroundColor
if (props.disabled) {
color = 'gray'
}
return color
}};
font-weight: bold;
font-size: ${props => props.theme.mainTextFontSize}px;
${({ disabled }) =>
disabled &&
css`
opacity: 0.6;
`}
`
export const SubTitleText = styled.Text`
font-size: 14px;
margin-top: 4px;
color: ${({ theme }) => theme.stylekitForegroundColor};
opacity: 0.8;
line-height: 21px;
`
export const NoteHistoryCell: React.FC<Props> = props => (
<Container
first={props.first}
last={props.last}
testID={props.testID}
disabled={props.disabled}
onPress={props.onPress}
>
<ButtonContainer>
<ButtonLabel disabled={props.disabled}>{props.title}</ButtonLabel>
{props.subTitle && <SubTitleText>{props.subTitle}</SubTitleText>}
</ButtonContainer>
</Container>
)

View File

@@ -0,0 +1,43 @@
import { Platform } from 'react-native'
import styled, { css } from 'styled-components/native'
const PADDING = 14
const NOTE_TITLE_HEIGHT = 50
export const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const StyledTextView = styled.Text`
padding-top: 10px;
color: ${({ theme }) => theme.stylekitForegroundColor};
padding-left: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
padding-right: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
/* padding-bottom: 10px; */
${Platform.OS === 'ios' &&
css`
font-size: 17px;
`}
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
/* ${Platform.OS === 'ios' && 'padding-bottom: 10px'}; */
`
export const TextContainer = styled.ScrollView``
export const TitleContainer = styled.View`
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
border-bottom-width: 1px;
align-content: center;
justify-content: center;
height: ${NOTE_TITLE_HEIGHT}px;
padding-top: ${Platform.OS === 'ios' ? 5 : 12}px;
padding-left: ${PADDING}px;
padding-right: ${PADDING}px;
`
export const Title = styled.Text`
font-weight: 600;
font-size: 16px;
color: ${({ theme }) => theme.stylekitForegroundColor};
`

View File

@@ -0,0 +1,131 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { HistoryStackNavigationProp } from '@Root/HistoryStack'
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_NOTE_HISTORY_PREVIEW } from '@Root/Screens/screens'
import { ButtonType, PayloadEmitSource, SNNote } from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import { ELIPSIS } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { useCallback, useContext, useLayoutEffect } from 'react'
import { LogBox } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { Container, StyledTextView, TextContainer, Title, TitleContainer } from './NoteHistoryPreview.styled'
LogBox.ignoreLogs(['Non-serializable values were found in the navigation state'])
type Props = HistoryStackNavigationProp<typeof SCREEN_NOTE_HISTORY_PREVIEW>
export const NoteHistoryPreview = ({
navigation,
route: {
params: { revision, title, originalNoteUuid },
},
}: Props) => {
// Context
const application = useContext(ApplicationContext)
const { showActionSheet } = useCustomActionSheet()
// State
const restore = useCallback(
async (asCopy: boolean) => {
const originalNote = application!.items.findSureItem<SNNote>(originalNoteUuid)
const run = async () => {
if (asCopy) {
await application?.mutator.duplicateItem(originalNote!, {
...revision.payload.content,
title: revision.payload.content.title ? revision.payload.content.title + ' (copy)' : undefined,
})
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
navigation.navigate(SCREEN_NOTES)
} else {
await application?.mutator.changeAndSaveItem(
originalNote,
mutator => {
mutator.setCustomContent(revision.payload.content)
},
true,
PayloadEmitSource.RemoteRetrieved,
)
if (application?.getAppState().isTabletDevice) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
navigation.navigate(SCREEN_NOTES)
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
navigation.navigate(SCREEN_COMPOSE)
}
}
}
if (!asCopy) {
if (originalNote.locked) {
void application?.alertService.alert(
"This note has editing disabled. If you'd like to restore it to a previous revision, enable editing and try again.",
)
return
}
const confirmed = await application?.alertService?.confirm(
"Are you sure you want to replace the current note's contents with what you see in this preview?",
'Restore note',
'Restore',
ButtonType.Info,
)
if (confirmed) {
void run()
}
} else {
void run()
}
},
[application, navigation, originalNoteUuid, revision.payload.content],
)
const onPress = useCallback(() => {
showActionSheet({
title: title!,
options: [
{
text: 'Restore',
callback: () => restore(false),
},
{
text: 'Restore as copy',
callback: async () => restore(true),
},
],
})
}, [showActionSheet, title, restore])
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
<Item
testID="notePreviewOptions"
disabled={false}
iconSize={25}
title={''}
iconName={ThemeService.nameForIcon(ELIPSIS)}
onPress={onPress}
/>
</HeaderButtons>
),
})
}, [navigation, onPress])
return (
<Container>
<TitleContainer>
<Title testID="notePreviewTitleField">{revision.payload.content.title}</Title>
</TitleContainer>
<TextContainer>
<StyledTextView testID="notePreviewText">{revision.payload.content.text}</StyledTextView>
</TextContainer>
</Container>
)
}

View File

@@ -0,0 +1,82 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { LoadingContainer, LoadingText } from '@Root/Screens/Notes/NoteList.styled'
import { NoteHistoryEntry, RevisionListEntry, SNNote } from '@standardnotes/snjs'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { FlatList, ListRenderItem } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { NoteHistoryCell } from './NoteHistoryCell'
type Props = {
note: SNNote
onPress: (uuid: string, revision: NoteHistoryEntry, title: string) => void
}
export const RemoteHistory: React.FC<Props> = ({ note, onPress }) => {
// Context
const application = useContext(ApplicationContext)
const insets = useSafeAreaInsets()
// State
const [remoteHistoryList, setRemoteHistoryList] = useState<RevisionListEntry[]>()
const [fetchingRemoteHistory, setFetchingRemoteHistory] = useState(false)
useEffect(() => {
let isMounted = true
const fetchRemoteHistoryList = async () => {
if (note) {
setFetchingRemoteHistory(true)
const newRemoteHistory = await application?.historyManager?.remoteHistoryForItem(note)
if (isMounted) {
setFetchingRemoteHistory(false)
setRemoteHistoryList(newRemoteHistory)
}
}
}
void fetchRemoteHistoryList()
return () => {
isMounted = false
}
}, [application?.historyManager, note])
const onItemPress = useCallback(
async (item: RevisionListEntry) => {
const remoteRevision = await application?.historyManager!.fetchRemoteRevision(note, item)
if (remoteRevision) {
onPress(item.uuid, remoteRevision as NoteHistoryEntry, new Date(item.updated_at).toLocaleString())
} else {
void application?.alertService!.alert(
'The remote revision could not be loaded. Please try again later.',
'Error',
)
return
}
},
[application?.alertService, application?.historyManager, note, onPress],
)
const renderItem: ListRenderItem<RevisionListEntry> | null | undefined = ({ item }) => {
return <NoteHistoryCell onPress={() => onItemPress(item)} title={new Date(item.updated_at).toLocaleString()} />
}
if (fetchingRemoteHistory || !remoteHistoryList || (remoteHistoryList && remoteHistoryList.length === 0)) {
const placeholderText = fetchingRemoteHistory ? 'Loading entries...' : 'No entries.'
return (
<LoadingContainer>
<LoadingText>{placeholderText}</LoadingText>
</LoadingContainer>
)
}
return (
<FlatList<RevisionListEntry>
keyExtractor={item => item.uuid}
contentContainerStyle={{ paddingBottom: insets.bottom }}
initialNumToRender={10}
windowSize={10}
keyboardShouldPersistTaps={'never'}
data={remoteHistoryList}
renderItem={renderItem}
/>
)
}

View File

@@ -0,0 +1,54 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { HistoryEntry, NoteHistoryEntry, SNNote } from '@standardnotes/snjs'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { FlatList, ListRenderItem } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { NoteHistoryCell } from './NoteHistoryCell'
type Props = {
note: SNNote
onPress: (uuid: string, revision: NoteHistoryEntry, title: string) => void
}
export const SessionHistory: React.FC<Props> = ({ note, onPress }) => {
// Context
const application = useContext(ApplicationContext)
const insets = useSafeAreaInsets()
// State
const [sessionHistory, setSessionHistory] = useState<HistoryEntry[]>()
useEffect(() => {
if (note) {
setSessionHistory(application?.historyManager?.sessionHistoryForItem(note))
}
}, [application?.historyManager, note])
const onItemPress = useCallback(
(item: NoteHistoryEntry) => {
onPress(item.payload.uuid, item, item.previewTitle())
},
[onPress],
)
const RenderItem: ListRenderItem<NoteHistoryEntry> | null | undefined = ({ item }) => {
return (
<NoteHistoryCell
onPress={() => onItemPress(item)}
title={item.previewTitle()}
subTitle={item.previewSubTitle()}
/>
)
}
return (
<FlatList<NoteHistoryEntry>
keyExtractor={item => item.previewTitle()}
contentContainerStyle={{ paddingBottom: insets.bottom }}
initialNumToRender={10}
windowSize={10}
keyboardShouldPersistTaps={'never'}
data={sessionHistory as NoteHistoryEntry[]}
renderItem={RenderItem}
/>
)
}

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

View File

@@ -0,0 +1,42 @@
import styled, { css } from 'styled-components/native'
export const Container = styled.View`
flex: 1;
flex-direction: row;
`
export const NotesContainer = styled.View<{
isInTabletMode?: boolean
notesListCollapsed?: boolean
}>`
${({ isInTabletMode, notesListCollapsed, theme }) => {
return isInTabletMode
? css`
border-right-color: ${theme.stylekitBorderColor};
border-right-width: ${notesListCollapsed ? 0 : 1}px;
width: ${notesListCollapsed ? 0 : '40%'};
`
: css`
flex: 1;
`
}}
`
export const ComposeContainer = styled.View`
flex: 1;
`
export const ExpandTouchable = styled.TouchableHighlight.attrs(({ theme }) => ({
underlayColor: theme.stylekitBackgroundColor,
}))`
justify-content: center;
position: absolute;
left: 0px;
padding: 7px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
margin-top: -12px;
`
export const iconNames = {
md: ['arrow-dropright', 'arrow-dropleft'],
ios: ['arrow-forward', 'arrow-back'],
}

View File

@@ -0,0 +1,123 @@
import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState'
import { useIsLocked } from '@Lib/SnjsHelperHooks'
import { ApplicationContext } from '@Root/ApplicationContext'
import { NoteViewController } from '@standardnotes/snjs'
import { ThemeService } from '@Style/ThemeService'
import { hexToRGBA } from '@Style/Utils'
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import { Compose } from './Compose/Compose'
import { Notes } from './Notes/Notes'
import { ComposeContainer, Container, ExpandTouchable, iconNames, NotesContainer } from './Root.styled'
export const Root = () => {
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const [isLocked] = useIsLocked()
const [, setWidth] = useState<number | undefined>(undefined)
const [height, setHeight] = useState<number | undefined>(undefined)
const [, setX] = useState<number | undefined>(undefined)
const [noteListCollapsed, setNoteListCollapsed] = useState<boolean>(false)
const [activeNoteView, setActiveNoteView] = useState<NoteViewController | undefined>()
const [isInTabletMode, setIsInTabletMode] = useState<boolean | undefined>(application?.getAppState().isInTabletMode)
const [keyboardHeight, setKeyboardHeight] = useState<number | undefined>(undefined)
useEffect(() => {
const removeStateObserver = application?.getAppState().addStateChangeObserver(state => {
if (state === AppStateType.GainingFocus) {
void application.sync.sync()
}
})
const removeApplicationStateEventHandler = application
?.getAppState()
.addStateEventObserver((event: AppStateEventType, data: TabletModeChangeData | undefined) => {
if (event === AppStateEventType.TabletModeChange) {
const eventData = data as TabletModeChangeData
if (eventData.new_isInTabletMode && !eventData.old_isInTabletMode) {
setIsInTabletMode(true)
} else if (!eventData.new_isInTabletMode && eventData.old_isInTabletMode) {
setIsInTabletMode(false)
}
}
if (event === AppStateEventType.KeyboardChangeEvent) {
// need to refresh the height of the keyboard when it opens so that we can change the position
// of the sidebar collapse icon
if (application?.getAppState().isInTabletMode) {
setKeyboardHeight(application?.getAppState().getKeyboardHeight())
}
}
})
const removeNoteObserver = application?.editorGroup.addActiveControllerChangeObserver(activeController => {
setActiveNoteView(activeController)
})
return () => {
if (removeApplicationStateEventHandler) {
removeApplicationStateEventHandler()
}
if (removeStateObserver) {
removeStateObserver()
}
if (removeNoteObserver) {
removeNoteObserver()
}
}
}, [application])
const collapseIconName = useMemo(() => {
const collapseIconPrefix = ThemeService.platformIconPrefix()
return collapseIconPrefix + '-' + iconNames[collapseIconPrefix][noteListCollapsed ? 0 : 1]
}, [noteListCollapsed])
const onLayout = (e: LayoutChangeEvent) => {
const tempWidth = e.nativeEvent.layout.width
/**
If you're in tablet mode, but on an iPad where this app is running side by
side by another app, we only want to show the Compose window and not the
list, because there isn't enough space.
*/
const MinWidthToSplit = 450
if (application?.getAppState().isTabletDevice) {
if (tempWidth < MinWidthToSplit) {
application?.getAppState().setTabletModeEnabled(false)
} else {
application?.getAppState().setTabletModeEnabled(true)
}
}
setWidth(tempWidth)
setHeight(e.nativeEvent.layout.height)
setX(e.nativeEvent.layout.x)
setIsInTabletMode(application?.getAppState().isInTabletMode)
setKeyboardHeight(application?.getAppState().getKeyboardHeight())
}
const toggleNoteList = () => {
setNoteListCollapsed(value => !value)
}
const collapseIconBottomPosition = (keyboardHeight ?? 0) > (height ?? 0) / 2 ? (keyboardHeight ?? 0) + 40 : '50%'
if (isLocked) {
return null
}
return (
<Container testID="rootView" onLayout={onLayout}>
<NotesContainer notesListCollapsed={noteListCollapsed} isInTabletMode={isInTabletMode}>
<Notes keyboardHeight={keyboardHeight} isInTabletMode={isInTabletMode} />
</NotesContainer>
{activeNoteView && !activeNoteView.dealloced && isInTabletMode && (
<ComposeContainer>
<Compose noteUuid={activeNoteView.note.uuid} />
<ExpandTouchable style={{ bottom: collapseIconBottomPosition }} onPress={toggleNoteList}>
<Icon name={collapseIconName} size={24} color={hexToRGBA(theme.stylekitInfoColor, 0.85)} />
</ExpandTouchable>
</ComposeContainer>
)}
</Container>
)
}

View File

@@ -0,0 +1,23 @@
import styled from 'styled-components/native'
const PADDING = 14
export const RegistrationDescription = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: ${({ theme }) => theme.mainTextFontSize}px;
padding-left: ${PADDING}px;
padding-right: ${PADDING}px;
margin-bottom: ${PADDING}px;
`
export const RegistrationInput = styled.TextInput.attrs(({ theme }) => ({
underlineColorAndroid: 'transparent',
placeholderTextColor: theme.stylekitNeutralColor,
}))`
font-size: ${({ theme }) => theme.mainTextFontSize}px;
padding: 0px;
color: ${({ theme }) => theme.stylekitForegroundColor};
height: 100%;
`
export const RegularView = styled.View``

View File

@@ -0,0 +1,254 @@
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { Keyboard } from 'react-native'
import { RegistrationDescription, RegistrationInput, RegularView } from './AuthSection.styled'
const DEFAULT_SIGN_IN_TEXT = 'Sign In'
const DEFAULT_REGISTER_TEXT = 'Register'
const SIGNIN_IN = 'Generating Keys...'
type Props = {
title: string
signedIn: boolean
}
export const AuthSection = (props: Props) => {
// Context
const application = useContext(ApplicationContext)
const themeService = useContext(ThemeServiceContext)
// State
const [registering, setRegistering] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [strictSignIn, setStrictSignIn] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [server, setServer] = useState('')
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const [confirmRegistration, setConfirmRegistration] = useState(false)
// set initial server
useEffect(() => {
const getServer = async () => {
const host = await application?.getHost()
setServer(host!)
}
void getServer()
}, [application])
const updateServer = useCallback(
async (host: string) => {
setServer(host)
await application?.setCustomHost(host)
},
[application],
)
if (props.signedIn) {
return null
}
const validate = () => {
if (!email) {
void application?.alertService?.alert('Please enter a valid email address.', 'Missing Email', 'OK')
return false
}
if (!password) {
void application?.alertService?.alert('Please enter your password.', 'Missing Password', 'OK')
return false
}
return true
}
const signIn = async () => {
setSigningIn(true)
if (!validate()) {
setSigningIn(false)
return
}
Keyboard.dismiss()
const result = await application!.signIn(email, password, strictSignIn, undefined, true, false)
if (result?.error) {
if (result?.error.message) {
void application?.alertService?.alert(result?.error.message)
}
setSigningIn(false)
return
}
setSigningIn(false)
setPassword('')
setPasswordConfirmation('')
}
const onRegisterPress = () => {
if (!validate()) {
return
}
setConfirmRegistration(true)
}
const register = async () => {
setRegistering(true)
if (password !== passwordConfirmation) {
void application?.alertService?.alert(
'The passwords you entered do not match. Please try again.',
"Passwords Don't Match",
'OK',
)
} else {
try {
Keyboard.dismiss()
await application!.register(email, password, undefined, true)
} catch (error) {
void application?.alertService?.alert((error as Error).message)
}
}
setRegistering(false)
}
const _renderRegistrationConfirm = () => {
return (
<TableSection>
<SectionHeader title={'Confirm Password'} />
<RegistrationDescription>
Due to the nature of our encryption, Standard Notes cannot offer password reset functionality. If you forget
your password, you will permanently lose access to your data.
</RegistrationDescription>
<SectionedTableCell first textInputCell>
<RegistrationInput
testID="passwordConfirmationField"
placeholder={'Password confirmation'}
onChangeText={setPasswordConfirmation}
value={passwordConfirmation}
secureTextEntry
autoFocus
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
/>
</SectionedTableCell>
<ButtonCell
testID="registerConfirmButton"
disabled={registering}
title={registering ? 'Generating Keys...' : 'Register'}
bold
onPress={register}
/>
<ButtonCell
title="Cancel"
onPress={() => {
setConfirmRegistration(false)
setPasswordConfirmation('')
setPassword('')
}}
/>
</TableSection>
)
}
const _renderDefaultContent = () => {
const keyboardApperance = themeService?.keyboardColorForActiveTheme()
return (
<TableSection>
{props.title && <SectionHeader title={props.title} />}
<>
<RegularView>
<SectionedTableCell textInputCell first>
<RegistrationInput
testID="emailField"
placeholder={'Email'}
onChangeText={setEmail}
value={email ?? undefined}
autoCorrect={false}
autoCapitalize={'none'}
keyboardType={'email-address'}
textContentType={'emailAddress'}
keyboardAppearance={keyboardApperance}
/>
</SectionedTableCell>
<SectionedTableCell textInputCell>
<RegistrationInput
testID="passwordField"
placeholder={'Password'}
onChangeText={setPassword}
value={password ?? undefined}
textContentType={'password'}
secureTextEntry
keyboardAppearance={keyboardApperance}
/>
</SectionedTableCell>
</RegularView>
{(showAdvanced || !server) && (
<RegularView>
<SectionHeader title={'Advanced'} />
<SectionedTableCell textInputCell first>
<RegistrationInput
testID="syncServerField"
placeholder={'Sync Server'}
onChangeText={updateServer}
value={server}
autoCorrect={false}
autoCapitalize={'none'}
keyboardType={'url'}
keyboardAppearance={keyboardApperance}
/>
</SectionedTableCell>
<SectionedAccessoryTableCell
onPress={() => setStrictSignIn(!strictSignIn)}
text={'Use strict sign in'}
selected={() => {
return strictSignIn
}}
/>
</RegularView>
)}
</>
<ButtonCell
testID="signInButton"
title={signingIn ? SIGNIN_IN : DEFAULT_SIGN_IN_TEXT}
disabled={signingIn}
bold={true}
onPress={signIn}
/>
<ButtonCell
testID="registerButton"
title={DEFAULT_REGISTER_TEXT}
disabled={registering}
bold
onPress={onRegisterPress}
/>
{!showAdvanced && (
<ButtonCell testID="advancedOptionsButton" title="Advanced Options" onPress={() => setShowAdvanced(true)} />
)}
</TableSection>
)
}
return (
<RegularView>
{confirmRegistration && _renderRegistrationConfirm()}
{!confirmRegistration && _renderDefaultContent()}
</RegularView>
)
}

View File

@@ -0,0 +1,10 @@
import styled from 'styled-components/native'
export const Label = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
margin-top: 3px;
`
export const ContentContainer = styled.View`
display: flex;
flex-direction: column;
`

View File

@@ -0,0 +1,98 @@
import { ApplicationState } from '@Lib/ApplicationState'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import React, { useContext } from 'react'
import { Platform, Share } from 'react-native'
import { ContentContainer, Label } from './CompanySection.styled'
const URLS = {
feedback: `mailto:help@standardnotes.com?subject=${Platform.OS === 'android' ? 'Android' : 'iOS'} app feedback (v${
ApplicationState.version
})`,
learn_more: 'https://standardnotes.com',
privacy: 'https://standardnotes.com/privacy',
help: 'https://standardnotes.com/help',
rate: Platform.select({
ios: 'https://itunes.apple.com/us/app/standard-notes/id1285392450?ls=1&mt=8',
android: 'market://details?id=com.standardnotes',
}) as string,
}
type Props = {
title: string
}
export const CompanySection = (props: Props) => {
const application = useContext(ApplicationContext)
const storeName = Platform.OS === 'android' ? 'Play Store' : 'App Store'
const openUrl = (action: keyof typeof URLS) => {
application?.deviceInterface!.openUrl(URLS[action])
}
const shareEncryption = () => {
const title = 'The Unexpected Benefits of Encrypted Writing'
let message = Platform.OS === 'ios' ? title : ''
const url = 'https://standardnotes.com/why-encrypted'
// Android ignores url. iOS ignores title.
if (Platform.OS === 'android') {
message += '\n\nhttps://standardnotes.com/why-encrypted'
}
void application?.getAppState().performActionWithoutStateChangeImpact(() => {
void Share.share({ title: title, message: message, url: url })
})
}
const shareWithFriend = () => {
const title = 'Standard Notes'
let message = 'Check out Standard Notes, a free, open-source, and completely encrypted notes app.'
const url = 'https://standardnotes.com'
// Android ignores url. iOS ignores title.
if (Platform.OS === 'android') {
message += '\n\nhttps://standardnotes.com'
}
void application?.getAppState().performActionWithoutStateChangeImpact(() => {
void Share.share({ title: title, message: message, url: url })
})
}
return (
<TableSection>
<SectionHeader title={props.title} />
<ButtonCell first leftAligned={true} title="Help" onPress={() => openUrl('help')}>
<Label>https://standardnotes.com/help</Label>
</ButtonCell>
<ButtonCell leftAligned={true} title="Contact Support" onPress={() => openUrl('feedback')}>
<ContentContainer>
<Label>help@standardnotes.com</Label>
</ContentContainer>
</ButtonCell>
<ButtonCell leftAligned={true} title="Spread Encryption" onPress={shareEncryption}>
<Label>Share the unexpected benefits of encrypted writing.</Label>
</ButtonCell>
<ButtonCell leftAligned={true} title="Tell a Friend" onPress={shareWithFriend}>
<Label>Share Standard Notes with a friend.</Label>
</ButtonCell>
<ButtonCell leftAligned={true} title="Learn About Standard Notes" onPress={() => openUrl('learn_more')}>
<Label>https://standardnotes.com</Label>
</ButtonCell>
<ButtonCell leftAligned={true} title="Our Privacy Manifesto" onPress={() => openUrl('privacy')}>
<Label>https://standardnotes.com/privacy</Label>
</ButtonCell>
<ButtonCell leftAligned={true} title="Rate Standard Notes" onPress={() => openUrl('rate')}>
<ContentContainer>
<Label>Version {ApplicationState.version}</Label>
<Label>Help support us with a review on the {storeName}.</Label>
</ContentContainer>
</ButtonCell>
</TableSection>
)
}

View File

@@ -0,0 +1,20 @@
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import styled from 'styled-components/native'
export const BaseView = styled.View``
export const StyledSectionedTableCell = styled(SectionedTableCell)`
padding-top: 12px;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 16px;
font-weight: bold;
`
export const Subtitle = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
font-size: 14px;
margin-top: 4px;
`

View File

@@ -0,0 +1,99 @@
import { useIsLocked } from '@Lib/SnjsHelperHooks'
import { ApplicationContext } from '@Root/ApplicationContext'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { ContentType, StorageEncryptionPolicy } from '@standardnotes/snjs'
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { BaseView, StyledSectionedTableCell, Subtitle, Title } from './EncryptionSection.styled'
type Props = {
title: string
encryptionAvailable: boolean
}
export const EncryptionSection = (props: Props) => {
// Context
const application = useContext(ApplicationContext)
const [isLocked] = useIsLocked()
// State
const [protocolDisplayName, setProtocolDisplayName] = useState('')
useEffect(() => {
if (!props.encryptionAvailable) {
return
}
let mounted = true
const getProtocolDisplayName = async () => {
const displayName = (await application?.getProtocolEncryptionDisplayName()) ?? ''
if (mounted) {
setProtocolDisplayName(displayName)
}
}
void getProtocolDisplayName()
return () => {
mounted = false
}
}, [application, props.encryptionAvailable])
const textData = useMemo(() => {
const encryptionType = protocolDisplayName
let encryptionStatus = props.encryptionAvailable ? 'Enabled' : 'Not Enabled'
if (props.encryptionAvailable) {
encryptionStatus += ` | ${encryptionType}`
} else {
encryptionStatus += '. '
encryptionStatus +=
application?.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
? 'To enable encryption, sign in, register, or enable storage encryption.'
: 'Sign in, register, or add a local passcode to enable encryption.'
}
let sourceString
if (isLocked) {
return { title: '', text: '' }
} else {
sourceString = application?.hasAccount() ? 'Account Keys' : 'Passcode'
}
const items = application!.items.getItems([ContentType.Note, ContentType.Tag])
const itemsStatus = items.length + '/' + items.length + ' notes and tags encrypted'
return {
encryptionStatus,
sourceString,
itemsStatus,
}
}, [application, props.encryptionAvailable, isLocked, protocolDisplayName])
return (
<TableSection>
<SectionHeader title={props.title} />
<StyledSectionedTableCell last={!props.encryptionAvailable} first={true}>
<BaseView>
<Title>Encryption</Title>
<Subtitle>{textData.encryptionStatus}</Subtitle>
</BaseView>
</StyledSectionedTableCell>
{props.encryptionAvailable && (
<StyledSectionedTableCell>
<BaseView>
<Title>Encryption Source</Title>
<Subtitle>{textData.sourceString}</Subtitle>
</BaseView>
</StyledSectionedTableCell>
)}
{props.encryptionAvailable && (
<StyledSectionedTableCell last>
<BaseView>
<Title>Items Encrypted</Title>
<Subtitle>{textData.itemsStatus}</Subtitle>
</BaseView>
</StyledSectionedTableCell>
)}
</TableSection>
)
}

View File

@@ -0,0 +1,33 @@
import { SectionedTableCell } from '@Components/SectionedTableCell'
import { StyleSheet } from 'react-native'
import styled, { DefaultTheme } from 'styled-components/native'
export const useFilesInPreferencesStyles = (theme: DefaultTheme) => {
return StyleSheet.create({
progressBarContainer: {
backgroundColor: theme.stylekitSecondaryContrastBackgroundColor,
height: 8,
borderRadius: 8,
marginTop: 6,
},
progressBar: {
backgroundColor: theme.stylekitInfoColor,
borderRadius: 8,
},
})
}
export const StyledSectionedTableCell = styled(SectionedTableCell)`
padding-top: 12px;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 16px;
font-weight: bold;
`
export const SubTitle = styled.Text`
margin-top: 4px;
font-size: 14px;
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
opacity: 0.6;
`

View File

@@ -0,0 +1,56 @@
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import {
StyledSectionedTableCell,
SubTitle,
Title,
useFilesInPreferencesStyles,
} from '@Screens/Settings/Sections/FilesSection.styled'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { SubscriptionSettingName } from '@standardnotes/snjs'
import React, { FC, useContext, useEffect, useState } from 'react'
import { View } from 'react-native'
import { ThemeContext } from 'styled-components'
export const FilesSection: FC = () => {
const application = useSafeApplicationContext()
const theme = useContext(ThemeContext)
const styles = useFilesInPreferencesStyles(theme)
const [filesUsedQuota, setFilesUsedQuota] = useState(0)
const [filesTotalQuota, setFilesTotalQuota] = useState(0)
useEffect(() => {
const getQuota = async () => {
const { FileUploadBytesUsed, FileUploadBytesLimit } = SubscriptionSettingName
const usedQuota = await application.settings.getSubscriptionSetting(FileUploadBytesUsed)
const totalQuota = await application.settings.getSubscriptionSetting(FileUploadBytesLimit)
setFilesUsedQuota(usedQuota ? parseFloat(usedQuota) : 0)
setFilesTotalQuota(totalQuota ? parseFloat(totalQuota) : 0)
}
getQuota().catch(console.error)
}, [application.settings])
const usedQuotaRatioPercent = Math.round((filesUsedQuota * 100) / filesTotalQuota)
return (
<TableSection>
<SectionHeader title={'Files'} />
<StyledSectionedTableCell first>
<View>
<Title>Storage Quota</Title>
<SubTitle>
{formatSizeToReadableString(filesUsedQuota)} of {formatSizeToReadableString(filesTotalQuota)} used
</SubTitle>
<View style={styles.progressBarContainer}>
<View style={{ ...styles.progressBar, width: `${usedQuotaRatioPercent}%` }} />
</View>
</View>
</StyledSectionedTableCell>
</TableSection>
)
}

View File

@@ -0,0 +1,239 @@
import { useSignedIn } from '@Lib/SnjsHelperHooks'
import { useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
import { SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS } from '@Root/Screens/screens'
import { ButtonType, PrefKey } from '@standardnotes/snjs'
import moment from 'moment'
import React, { useCallback, useContext, useMemo, useState } from 'react'
import { Platform } from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import RNFS from 'react-native-fs'
type Props = {
title: string
encryptionAvailable: boolean
}
export const OptionsSection = ({ title, encryptionAvailable }: Props) => {
// Context
const application = useContext(ApplicationContext)
const [signedIn] = useSignedIn()
const navigation = useNavigation<ModalStackNavigationProp<typeof SCREEN_SETTINGS>['navigation']>()
// State
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState(false)
const [lastExportDate, setLastExportDate] = useState<Date | undefined>(() =>
application?.getLocalPreferences().getValue(PrefKey.MobileLastExportDate, undefined),
)
const lastExportData = useMemo(() => {
if (lastExportDate) {
const formattedDate = moment(lastExportDate).format('lll')
const lastExportString = `Last exported on ${formattedDate}`
// Date is stale if more than 7 days ago
const staleThreshold = 7 * 86400
const stale =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore date type issue
(new Date() - new Date(lastExportDate)) / 1000 > staleThreshold
return {
lastExportString,
stale,
}
}
return {
lastExportString: 'Your data has not yet been backed up.',
stale: false,
}
}, [lastExportDate])
const email = useMemo(() => {
if (signedIn) {
const user = application?.getUser()
return user?.email
}
return
}, [application, signedIn])
const exportOptions = useMemo(() => {
return [
{
title: 'Encrypted',
key: 'encrypted',
selected: encryptionAvailable,
},
{ title: 'Decrypted', key: 'decrypted', selected: true },
]
}, [encryptionAvailable])
const destroyLocalData = async () => {
if (
await application?.alertService?.confirm(
'Signing out will remove all data from this device, including notes and tags. Make sure your data is synced before proceeding.',
'Sign Out?',
'Sign Out',
ButtonType.Danger,
)
) {
await application!.user.signOut()
}
}
const exportData = useCallback(
async (encrypted: boolean) => {
setExporting(true)
const result = await application?.getBackupsService().export(encrypted)
if (result) {
const exportDate = new Date()
setLastExportDate(exportDate)
void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileLastExportDate, exportDate)
}
setExporting(false)
},
[application],
)
const readImportFile = async (fileUri: string): Promise<any> => {
return RNFS.readFile(fileUri)
.then(result => JSON.parse(result))
.catch(() => {
void application!.alertService!.alert('Unable to open file. Ensure it is a proper JSON file and try again.')
})
}
const performImport = async (data: any) => {
const result = await application!.mutator.importData(data)
if (!result) {
return
} else if ('error' in result) {
void application!.alertService!.alert(result.error.text)
} else if (result.errorCount) {
void application!.alertService!.alert(
`Import complete. ${result.errorCount} items were not imported because ` +
'there was an error decrypting them. Make sure the password is correct and try again.',
)
} else {
void application!.alertService!.alert('Your data has been successfully imported.')
}
}
const onImportPress = async () => {
try {
const selectedFiles = await DocumentPicker.pick({
type: [DocumentPicker.types.plainText],
})
const selectedFile = selectedFiles[0]
const selectedFileURI = Platform.OS === 'ios' ? decodeURIComponent(selectedFile.uri) : selectedFile.uri
const data = await readImportFile(selectedFileURI)
if (!data) {
return
}
setImporting(true)
if (data.version || data.auth_params || data.keyParams) {
const version = data.version || data.keyParams?.version || data.auth_params?.version
if (application!.protocolService.supportedVersions().includes(version)) {
await performImport(data)
} else {
void application!.alertService.alert(
'This backup file was created using an unsupported version of the application ' +
'and cannot be imported here. Please update your application and try again.',
)
}
} else {
await performImport(data)
}
} finally {
setImporting(false)
}
}
const onExportPress = useCallback(
async (option: { key: string }) => {
const encrypted = option.key === 'encrypted'
if (encrypted && !encryptionAvailable) {
void application?.alertService!.alert(
'You must be signed in, or have a local passcode set, to generate an encrypted export file.',
'Not Available',
'OK',
)
return
}
void exportData(encrypted)
},
[application?.alertService, encryptionAvailable, exportData],
)
const openManageSessions = useCallback(() => {
navigation.push(SCREEN_MANAGE_SESSIONS)
}, [navigation])
const showDataBackupAlert = useCallback(() => {
void application?.alertService.alert(
'Because you are using the app offline without a sync account, it is your responsibility to keep your data safe and backed up. It is recommended you export a backup of your data at least once a week, or, to sign up for a sync account so that your data is backed up automatically.',
'No Backups Created',
'OK',
)
}, [application?.alertService])
return (
<TableSection>
<SectionHeader title={title} />
{signedIn && (
<>
<ButtonCell
testID="manageSessionsButton"
leftAligned={true}
first={true}
title={'Manage Sessions'}
onPress={openManageSessions}
/>
<ButtonCell
testID="signOutButton"
leftAligned={true}
first={false}
title={`Sign out (${email})`}
onPress={destroyLocalData}
/>
</>
)}
<ButtonCell
testID="importData"
first={!signedIn}
leftAligned
title={importing ? 'Processing...' : 'Import Data'}
onPress={onImportPress}
/>
<SectionedOptionsTableCell
testID="exportData"
leftAligned
options={exportOptions}
title={exporting ? 'Processing...' : 'Export Data'}
onPress={onExportPress}
/>
{!signedIn && (
<SectionedAccessoryTableCell
testID="lastExportDate"
onPress={() => {
if (!lastExportDate || lastExportData.stale) {
showDataBackupAlert()
}
}}
tinted={!lastExportDate || lastExportData.stale}
text={lastExportData.lastExportString}
/>
)}
</TableSection>
)
}

View File

@@ -0,0 +1,108 @@
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
import React, { useMemo, useState } from 'react'
export const PreferencesSection = () => {
// Context
const application = useSafeApplicationContext()
// State
const [sortBy, setSortBy] = useState<CollectionSortProperty>(() =>
application.getPreference(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 [hideEditorIcon, setHideEditorIcon] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false),
)
const [hidePreviews, setHidePreviews] = useState<boolean>(() =>
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false),
)
const sortOptions = useMemo(() => {
return [
{ key: CollectionSort.CreatedAt, label: 'Date Added' },
{ key: CollectionSort.UpdatedAt, label: 'Date Modified' },
{ key: CollectionSort.Title, label: 'Title' },
]
}, [])
const toggleReverseSort = () => {
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSortNotesReverse, !sortReverse)
setSortReverse(value => !value)
}
const changeSortOption = (key: CollectionSortProperty) => {
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSortNotesBy, key)
setSortBy(key)
}
const toggleNotesPreviewHidden = () => {
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideNotePreview, !hidePreviews)
setHidePreviews(value => !value)
}
const toggleNotesDateHidden = () => {
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideDate, !hideDates)
setHideDates(value => !value)
}
const toggleNotesEditorIconHidden = () => {
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideEditorIcon, !hideEditorIcon)
setHideEditorIcon(value => !value)
}
return (
<>
<TableSection>
<SectionHeader
title={'Sort Notes By'}
buttonText={sortReverse ? 'Disable Reverse Sort' : 'Enable Reverse Sort'}
buttonAction={toggleReverseSort}
/>
{sortOptions.map((option, i) => {
return (
<SectionedAccessoryTableCell
onPress={() => {
changeSortOption(option.key)
}}
text={option.label}
key={option.key}
first={i === 0}
last={i === sortOptions.length - 1}
selected={() => option.key === sortBy}
/>
)
})}
</TableSection>
<TableSection>
<SectionHeader title={'Note List Options'} />
<SectionedAccessoryTableCell
onPress={toggleNotesPreviewHidden}
text={'Hide note previews'}
first
selected={() => hidePreviews}
/>
<SectionedAccessoryTableCell
onPress={toggleNotesDateHidden}
text={'Hide note dates'}
selected={() => hideDates}
/>
<SectionedAccessoryTableCell
onPress={toggleNotesEditorIconHidden}
text={'Hide editor icons'}
last
selected={() => hideEditorIcon}
/>
</TableSection>
</>
)
}

View File

@@ -0,0 +1,26 @@
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
import styled from 'styled-components/native'
export const BaseView = styled.View``
export const StyledSectionedTableCell = styled(SectionedTableCell)`
padding-top: 12px;
`
export const SubText = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
font-size: 14px;
padding: 12px 14px;
`
export const Subtitle = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
font-size: 14px;
margin-top: 4px;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
font-size: 16px;
font-weight: bold;
`

View File

@@ -0,0 +1,44 @@
import { useProtectionSessionExpiry } from '@Lib/SnjsHelperHooks'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import React from 'react'
import { BaseView, StyledSectionedTableCell, SubText, Subtitle, Title } from './ProtectionsSection.styled'
type Props = {
title: string
protectionsAvailable?: boolean
}
export const ProtectionsSection = (props: Props) => {
// Context
const application = useSafeApplicationContext()
// State
const [protectionsDisabledUntil] = useProtectionSessionExpiry()
const protectionsEnabledSubtitle = protectionsDisabledUntil ? `Disabled until ${protectionsDisabledUntil}` : 'Enabled'
const protectionsEnabledSubtext =
'Actions like viewing protected notes, exporting decrypted backups, or revoking an active session, require additional authentication like entering your account password or application passcode.'
const enableProtections = () => {
void application?.clearProtectionSession()
}
return (
<TableSection>
<SectionHeader title={props.title} />
<StyledSectionedTableCell first>
<BaseView>
<Title>Status</Title>
<Subtitle>{props.protectionsAvailable ? protectionsEnabledSubtitle : 'Disabled'}</Subtitle>
</BaseView>
</StyledSectionedTableCell>
{props.protectionsAvailable && protectionsDisabledUntil && (
<ButtonCell leftAligned title={'Enable Protections'} onPress={enableProtections} />
)}
<SubText>{protectionsEnabledSubtext}</SubText>
</TableSection>
)
}

View File

@@ -0,0 +1,6 @@
import styled from 'styled-components/native'
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitNeutralColor};
margin-top: 2px;
`

View File

@@ -0,0 +1,248 @@
import { UnlockTiming } from '@Lib/ApplicationState'
import { MobileDeviceInterface } from '@Lib/Interface'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
import { Option, SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_INPUT_MODAL_PASSCODE, SCREEN_SETTINGS } from '@Root/Screens/screens'
import { StorageEncryptionPolicy } from '@standardnotes/snjs'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { Platform } from 'react-native'
import { Title } from './SecuritySection.styled'
type Props = {
title: string
hasPasscode: boolean
encryptionAvailable: boolean
updateProtectionsAvailable: (...args: unknown[]) => unknown
}
export const SecuritySection = (props: Props) => {
const navigation = useNavigation<ModalStackNavigationProp<typeof SCREEN_SETTINGS>['navigation']>()
// Context
const application = useContext(ApplicationContext)
// State
const [encryptionPolicy, setEncryptionPolicy] = useState(() => application?.getStorageEncryptionPolicy())
const [encryptionPolictChangeInProgress, setEncryptionPolictChangeInProgress] = useState(false)
const [hasScreenshotPrivacy, setHasScreenshotPrivacy] = useState<boolean | undefined>(false)
const [hasBiometrics, setHasBiometrics] = useState(false)
const [supportsBiometrics, setSupportsBiometrics] = useState(false)
const [biometricsTimingOptions, setBiometricsTimingOptions] = useState(() =>
application!.getAppState().getBiometricsTimingOptions(),
)
const [passcodeTimingOptions, setPasscodeTimingOptions] = useState(() =>
application!.getAppState().getPasscodeTimingOptions(),
)
useEffect(() => {
let mounted = true
const getHasScreenshotPrivacy = async () => {
const hasScreenshotPrivacyEnabled = await application?.getAppState().screenshotPrivacyEnabled
if (mounted) {
setHasScreenshotPrivacy(hasScreenshotPrivacyEnabled)
}
}
void getHasScreenshotPrivacy()
const getHasBiometrics = async () => {
const appHasBiometrics = await application!.hasBiometrics()
if (mounted) {
setHasBiometrics(appHasBiometrics)
}
}
void getHasBiometrics()
const hasBiometricsSupport = async () => {
const hasBiometricsAvailable = await (
application?.deviceInterface as MobileDeviceInterface
).getDeviceBiometricsAvailability()
if (mounted) {
setSupportsBiometrics(hasBiometricsAvailable)
}
}
void hasBiometricsSupport()
return () => {
mounted = false
}
}, [application])
useFocusEffect(
useCallback(() => {
if (props.hasPasscode) {
setPasscodeTimingOptions(() => application!.getAppState().getPasscodeTimingOptions())
}
}, [application, props.hasPasscode]),
)
const toggleEncryptionPolicy = async () => {
if (!props.encryptionAvailable) {
return
}
if (encryptionPolicy === StorageEncryptionPolicy.Default) {
setEncryptionPolictChangeInProgress(true)
setEncryptionPolicy(StorageEncryptionPolicy.Disabled)
await application?.setStorageEncryptionPolicy(StorageEncryptionPolicy.Disabled)
setEncryptionPolictChangeInProgress(false)
} else if (encryptionPolicy === StorageEncryptionPolicy.Disabled) {
setEncryptionPolictChangeInProgress(true)
setEncryptionPolicy(StorageEncryptionPolicy.Default)
await application?.setStorageEncryptionPolicy(StorageEncryptionPolicy.Default)
setEncryptionPolictChangeInProgress(false)
}
}
// State
const storageEncryptionTitle = props.encryptionAvailable
? encryptionPolicy === StorageEncryptionPolicy.Default
? 'Disable Storage Encryption'
: 'Enable Storage Encryption'
: 'Storage Encryption'
let storageSubText = "Encrypts your data before saving to your device's local storage."
if (props.encryptionAvailable) {
storageSubText +=
encryptionPolicy === StorageEncryptionPolicy.Default
? ' Disable to improve app start-up speed.'
: ' May decrease app start-up speed.'
} else {
storageSubText += ' Sign in, register, or add a local passcode to enable this option.'
}
if (encryptionPolictChangeInProgress) {
storageSubText = 'Applying changes...'
}
const screenshotPrivacyFeatureText =
Platform.OS === 'ios' ? 'Multitasking Privacy' : 'Multitasking/Screenshot Privacy'
const screenshotPrivacyTitle = hasScreenshotPrivacy
? `Disable ${screenshotPrivacyFeatureText}`
: `Enable ${screenshotPrivacyFeatureText}`
const passcodeTitle = props.hasPasscode ? 'Disable Passcode Lock' : 'Enable Passcode Lock'
const biometricTitle = hasBiometrics ? 'Disable Biometrics Lock' : 'Enable Biometrics Lock'
const setBiometricsTiming = async (timing: UnlockTiming) => {
await application?.getAppState().setBiometricsTiming(timing)
setBiometricsTimingOptions(() => application!.getAppState().getBiometricsTimingOptions())
}
const setPasscodeTiming = async (timing: UnlockTiming) => {
await application?.getAppState().setPasscodeTiming(timing)
setPasscodeTimingOptions(() => application!.getAppState().getPasscodeTimingOptions())
}
const onScreenshotPrivacyPress = async () => {
const enable = !hasScreenshotPrivacy
setHasScreenshotPrivacy(enable)
await application?.getAppState().setScreenshotPrivacyEnabled(enable)
}
const onPasscodePress = async () => {
if (props.hasPasscode) {
void disableAuthentication('passcode')
} else {
navigation.push(SCREEN_INPUT_MODAL_PASSCODE)
}
}
const onBiometricsPress = async () => {
if (hasBiometrics) {
void disableAuthentication('biometrics')
} else {
setHasBiometrics(true)
await application?.enableBiometrics()
await setBiometricsTiming(UnlockTiming.OnQuit)
props.updateProtectionsAvailable()
}
}
const disableBiometrics = useCallback(async () => {
if (await application?.disableBiometrics()) {
setHasBiometrics(false)
props.updateProtectionsAvailable()
}
}, [application, props])
const disablePasscode = useCallback(async () => {
const hasAccount = Boolean(application?.hasAccount())
let message
if (hasAccount) {
message =
'Are you sure you want to disable your local passcode? This will not affect your encryption status, as your data is currently being encrypted through your sync account keys.'
} else {
message = 'Are you sure you want to disable your local passcode? This will disable encryption on your data.'
}
const confirmed = await application?.alertService?.confirm(
message,
'Disable Passcode',
'Disable Passcode',
undefined,
)
if (confirmed) {
await application?.removePasscode()
}
}, [application])
const disableAuthentication = useCallback(
async (authenticationMethod: 'passcode' | 'biometrics') => {
switch (authenticationMethod) {
case 'biometrics': {
void disableBiometrics()
break
}
case 'passcode': {
void disablePasscode()
break
}
}
},
[disableBiometrics, disablePasscode],
)
return (
<TableSection>
<SectionHeader title={props.title} />
<ButtonCell first leftAligned title={storageEncryptionTitle} onPress={toggleEncryptionPolicy}>
<Title>{storageSubText}</Title>
</ButtonCell>
<ButtonCell leftAligned title={screenshotPrivacyTitle} onPress={onScreenshotPrivacyPress} />
<ButtonCell leftAligned title={passcodeTitle} onPress={onPasscodePress} />
<ButtonCell
last={!hasBiometrics && !props.hasPasscode}
disabled={!supportsBiometrics}
leftAligned
title={biometricTitle}
onPress={onBiometricsPress}
/>
{props.hasPasscode && (
<SectionedOptionsTableCell
leftAligned
title={'Require Passcode'}
options={passcodeTimingOptions}
onPress={(option: Option) => setPasscodeTiming(option.key as UnlockTiming)}
/>
)}
{hasBiometrics && (
<SectionedOptionsTableCell
leftAligned
title={'Require Biometrics'}
options={biometricsTimingOptions}
onPress={(option: Option) => setBiometricsTiming(option.key as UnlockTiming)}
/>
)}
</TableSection>
)
}

View File

@@ -0,0 +1,5 @@
import styled from 'styled-components/native'
export const Container = styled.ScrollView`
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`

View File

@@ -0,0 +1,70 @@
import { useSignedIn } from '@Lib/SnjsHelperHooks'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_SETTINGS } from '@Root/Screens/screens'
import { FilesSection } from '@Screens/Settings/Sections/FilesSection'
import { FeatureIdentifier } from '@standardnotes/features'
import { ApplicationEvent, FeatureStatus } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useState } from 'react'
import { AuthSection } from './Sections/AuthSection'
import { CompanySection } from './Sections/CompanySection'
import { EncryptionSection } from './Sections/EncryptionSection'
import { OptionsSection } from './Sections/OptionsSection'
import { PreferencesSection } from './Sections/PreferencesSection'
import { ProtectionsSection } from './Sections/ProtectionsSection'
import { SecuritySection } from './Sections/SecuritySection'
import { Container } from './Settings.styled'
type Props = ModalStackNavigationProp<typeof SCREEN_SETTINGS>
export const Settings = (props: Props) => {
// Context
const application = useSafeApplicationContext()
// State
const [hasPasscode, setHasPasscode] = useState(() => Boolean(application.hasPasscode()))
const [protectionsAvailable, setProtectionsAvailable] = useState(application.hasProtectionSources())
const [encryptionAvailable, setEncryptionAvailable] = useState(() => application.isEncryptionAvailable())
const updateProtectionsAvailable = useCallback(() => {
setProtectionsAvailable(application.hasProtectionSources())
}, [application])
useEffect(() => {
const removeApplicationEventSubscriber = application.addEventObserver(async event => {
if (event === ApplicationEvent.KeyStatusChanged) {
setHasPasscode(Boolean(application.hasPasscode()))
updateProtectionsAvailable()
setEncryptionAvailable(() => application.isEncryptionAvailable())
}
})
return () => {
removeApplicationEventSubscriber && removeApplicationEventSubscriber()
}
}, [application, updateProtectionsAvailable])
const goBack = useCallback(() => {
props.navigation.goBack()
}, [props.navigation])
const [signedIn] = useSignedIn(goBack)
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
return (
<Container keyboardShouldPersistTaps={'always'} keyboardDismissMode={'interactive'}>
<AuthSection title="Account" signedIn={signedIn} />
<OptionsSection encryptionAvailable={!!encryptionAvailable} title="Options" />
<PreferencesSection />
{application.hasAccount() && isEntitledToFiles && <FilesSection />}
<SecuritySection
encryptionAvailable={!!encryptionAvailable}
hasPasscode={hasPasscode}
updateProtectionsAvailable={updateProtectionsAvailable}
title="Security"
/>
<ProtectionsSection title="Protections" protectionsAvailable={protectionsAvailable} />
<EncryptionSection encryptionAvailable={!!encryptionAvailable} title={'Encryption Status'} />
<CompanySection title="Standard Notes" />
</Container>
)
}

View File

@@ -0,0 +1,35 @@
import { SnIcon } from '@Root/Components/SnIcon'
import { SideMenuCell } from '@Root/Screens/SideMenu/SideMenuCell'
import { Platform, StyleSheet } from 'react-native'
import styled from 'styled-components/native'
export const styles = StyleSheet.create({
cellContentStyle: {
flexShrink: 0,
},
learnMoreIcon: {
marginTop: Platform.OS === 'ios' ? -6 : -3,
},
})
export const SNIconStyled = styled(SnIcon)`
margin-left: 8px;
`
export const FilesContainer = styled.View`
margin-top: 10px;
`
export const FileItemContainer = styled.View`
margin-bottom: 18px;
`
export const IconsContainer = styled.View`
flex-direction: row;
margin-top: ${() => (Platform.OS === 'ios' ? 0 : '5px')};
`
export const SideMenuCellStyled = styled(SideMenuCell)`
min-height: 22px;
`
export const SideMenuCellAttachNewFile = styled(SideMenuCellStyled)`
margin-bottom: 14px;
`
export const SideMenuCellShowAllFiles = styled(SideMenuCellStyled)`
margin-bottom: 8px;
`

View File

@@ -0,0 +1,95 @@
import { SnIcon } from '@Components/SnIcon'
import { useNavigation } from '@react-navigation/native'
import { AppStackNavigationProp } from '@Root/AppStack'
import { useFiles } from '@Root/Hooks/useFiles'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_COMPOSE, SCREEN_UPLOADED_FILES_LIST } from '@Root/Screens/screens'
import {
FileItemContainer,
FilesContainer,
IconsContainer,
SideMenuCellAttachNewFile,
SideMenuCellShowAllFiles,
SideMenuCellStyled,
SNIconStyled,
styles,
} from '@Root/Screens/SideMenu/Files.styled'
import { SideMenuOptionIconDescriptionType } from '@Root/Screens/SideMenu/SideMenuSection'
import { SideMenuCell } from '@Screens/SideMenu/SideMenuCell'
import { UploadedFileItemActionType } from '@Screens/UploadedFilesList/UploadedFileItemAction'
import { FeatureIdentifier } from '@standardnotes/features'
import { FeatureStatus, SNNote } from '@standardnotes/snjs'
import React, { FC } from 'react'
type Props = {
note: SNNote
}
export const Files: FC<Props> = ({ note }) => {
const application = useSafeApplicationContext()
const filesService = application.getFilesService()
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']>()
const { showActionsMenu, handlePressAttachFile, attachedFiles, handleFileAction } = useFiles({ note })
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
const openFilesScreen = () => {
navigation.navigate(SCREEN_UPLOADED_FILES_LIST, { note })
}
if (!isEntitledToFiles) {
return (
<SideMenuCell
text={'Learn more'}
onSelect={() => application.deviceInterface.openUrl('https://standardnotes.com/plans')}
iconDesc={{
side: 'left',
type: SideMenuOptionIconDescriptionType.CustomComponent,
value: <SnIcon type={'open-in'} style={styles.learnMoreIcon} />,
}}
/>
)
}
return (
<FilesContainer>
{attachedFiles.sort(filesService.sortByName).map(file => {
const iconType = application.iconsController.getIconForFileType(file.mimeType)
return (
<FileItemContainer key={file.uuid}>
<SideMenuCellStyled
text={file.name}
key={file.uuid}
onSelect={() => {
void handleFileAction({
type: UploadedFileItemActionType.PreviewFile,
payload: file,
})
}}
onLongPress={() => showActionsMenu(file)}
iconDesc={{
side: 'right',
type: SideMenuOptionIconDescriptionType.CustomComponent,
value: (
<IconsContainer>
{file.protected && <SNIconStyled type={'lock-filled'} width={16} height={16} />}
<SNIconStyled type={iconType} width={16} height={16} />
</IconsContainer>
),
}}
cellContentStyle={styles.cellContentStyle}
/>
</FileItemContainer>
)
})}
<SideMenuCellAttachNewFile text={'Upload new file'} onSelect={() => handlePressAttachFile()} />
<SideMenuCellShowAllFiles
text={'Show all files'}
onSelect={() => openFilesScreen()}
cellContentStyle={styles.cellContentStyle}
/>
</FilesContainer>
)
}

View File

@@ -0,0 +1,35 @@
import { Platform, StyleSheet } from 'react-native'
import styled from 'styled-components/native'
export const styles = StyleSheet.create({
blogItemIcon: {
marginTop: Platform.OS === 'ios' ? -6 : -3,
},
loadingIndicator: {
alignSelf: 'flex-start',
},
blogActionInProgressIndicator: {
marginTop: -5,
marginLeft: 6,
transform: [
{
scale: 0.8,
},
],
},
})
export const CreateBlogContainer = styled.View`
margin-bottom: 8px;
`
export const CantLoadActionsText = styled.Text`
font-size: 12px;
margin-top: -12px;
margin-bottom: 10px;
opacity: 0.7;
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
`
export const ListedItemRow = styled.View`
display: flex;
flex-direction: row;
align-items: center;
`

Some files were not shown because too many files have changed in this diff Show More