feat: mobile app package (#1075)
This commit is contained in:
159
packages/mobile/src/App.tsx
Normal file
159
packages/mobile/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
packages/mobile/src/AppStack.tsx
Normal file
242
packages/mobile/src/AppStack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
packages/mobile/src/ApplicationContext.tsx
Normal file
6
packages/mobile/src/ApplicationContext.tsx
Normal 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>
|
||||
29
packages/mobile/src/Components/BlockingModal.styled.ts
Normal file
29
packages/mobile/src/Components/BlockingModal.styled.ts
Normal 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;
|
||||
`
|
||||
17
packages/mobile/src/Components/BlockingModal.tsx
Normal file
17
packages/mobile/src/Components/BlockingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
packages/mobile/src/Components/Button.tsx
Normal file
75
packages/mobile/src/Components/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
80
packages/mobile/src/Components/ButtonCell.tsx
Normal file
80
packages/mobile/src/Components/ButtonCell.tsx
Normal 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>
|
||||
)
|
||||
112
packages/mobile/src/Components/Chip.tsx
Normal file
112
packages/mobile/src/Components/Chip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
packages/mobile/src/Components/Circle.ts
Normal file
16
packages/mobile/src/Components/Circle.ts
Normal 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;
|
||||
`
|
||||
41
packages/mobile/src/Components/HeaderTitleView.tsx
Normal file
41
packages/mobile/src/Components/HeaderTitleView.tsx
Normal 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>
|
||||
)
|
||||
8
packages/mobile/src/Components/Icon.styled.ts
Normal file
8
packages/mobile/src/Components/Icon.styled.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
export const iconStyles = StyleSheet.create({
|
||||
icon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
})
|
||||
19
packages/mobile/src/Components/IoniconsHeaderButton.tsx
Normal file
19
packages/mobile/src/Components/IoniconsHeaderButton.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
7
packages/mobile/src/Components/SearchBar.styled.ts
Normal file
7
packages/mobile/src/Components/SearchBar.styled.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
export const searchBarStyles = StyleSheet.create({
|
||||
androidSearch: {
|
||||
height: 30,
|
||||
},
|
||||
})
|
||||
94
packages/mobile/src/Components/SearchBar.tsx
Normal file
94
packages/mobile/src/Components/SearchBar.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
76
packages/mobile/src/Components/SectionHeader.tsx
Normal file
76
packages/mobile/src/Components/SectionHeader.tsx
Normal 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>
|
||||
)
|
||||
132
packages/mobile/src/Components/SectionedAccessoryTableCell.tsx
Normal file
132
packages/mobile/src/Components/SectionedAccessoryTableCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
packages/mobile/src/Components/SectionedOptionsTableCell.tsx
Normal file
88
packages/mobile/src/Components/SectionedOptionsTableCell.tsx
Normal 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>
|
||||
)
|
||||
59
packages/mobile/src/Components/SectionedTableCell.ts
Normal file
59
packages/mobile/src/Components/SectionedTableCell.ts
Normal 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;
|
||||
`};
|
||||
`
|
||||
101
packages/mobile/src/Components/SnIcon.tsx
Normal file
101
packages/mobile/src/Components/SnIcon.tsx
Normal 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]} />
|
||||
}
|
||||
7
packages/mobile/src/Components/TableSection.ts
Normal file
7
packages/mobile/src/Components/TableSection.ts
Normal 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};
|
||||
`
|
||||
31
packages/mobile/src/Components/ToastWrapper.styled.ts
Normal file
31
packages/mobile/src/Components/ToastWrapper.styled.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
53
packages/mobile/src/Components/ToastWrapper.tsx
Normal file
53
packages/mobile/src/Components/ToastWrapper.tsx
Normal 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} />
|
||||
}
|
||||
3
packages/mobile/src/Components/package.json
Normal file
3
packages/mobile/src/Components/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "@Components"
|
||||
}
|
||||
91
packages/mobile/src/HistoryStack.tsx
Normal file
91
packages/mobile/src/HistoryStack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
809
packages/mobile/src/Hooks/useFiles.ts
Normal file
809
packages/mobile/src/Hooks/useFiles.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
28
packages/mobile/src/Hooks/useProgessBar.ts
Normal file
28
packages/mobile/src/Hooks/useProgessBar.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
packages/mobile/src/Hooks/useSafeApplicationContext.ts
Normal file
8
packages/mobile/src/Hooks/useSafeApplicationContext.ts
Normal 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
|
||||
}
|
||||
62
packages/mobile/src/Lib/AlertService.ts
Normal file
62
packages/mobile/src/Lib/AlertService.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
164
packages/mobile/src/Lib/Application.ts
Normal file
164
packages/mobile/src/Lib/Application.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
packages/mobile/src/Lib/ApplicationGroup.ts
Normal file
45
packages/mobile/src/Lib/ApplicationGroup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
683
packages/mobile/src/Lib/ApplicationState.ts
Normal file
683
packages/mobile/src/Lib/ApplicationState.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
162
packages/mobile/src/Lib/BackupsService.ts
Normal file
162
packages/mobile/src/Lib/BackupsService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
339
packages/mobile/src/Lib/ComponentManager.ts
Normal file
339
packages/mobile/src/Lib/ComponentManager.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
98
packages/mobile/src/Lib/FilesService.ts
Normal file
98
packages/mobile/src/Lib/FilesService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
68
packages/mobile/src/Lib/InstallationService.ts
Normal file
68
packages/mobile/src/Lib/InstallationService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
325
packages/mobile/src/Lib/Interface.ts
Normal file
325
packages/mobile/src/Lib/Interface.ts
Normal 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
|
||||
}
|
||||
}
|
||||
31
packages/mobile/src/Lib/Keychain.ts
Normal file
31
packages/mobile/src/Lib/Keychain.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
21
packages/mobile/src/Lib/NavigationService.ts
Normal file
21
packages/mobile/src/Lib/NavigationService.ts
Normal 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()
|
||||
}
|
||||
71
packages/mobile/src/Lib/PreferencesManager.ts
Normal file
71
packages/mobile/src/Lib/PreferencesManager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
179
packages/mobile/src/Lib/ReactNativeCrypto.ts
Normal file
179
packages/mobile/src/Lib/ReactNativeCrypto.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
25
packages/mobile/src/Lib/ReviewService.ts
Normal file
25
packages/mobile/src/Lib/ReviewService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
426
packages/mobile/src/Lib/SnjsHelperHooks.ts
Normal file
426
packages/mobile/src/Lib/SnjsHelperHooks.ts
Normal 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]
|
||||
}
|
||||
73
packages/mobile/src/Lib/StatusManager.ts
Normal file
73
packages/mobile/src/Lib/StatusManager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/mobile/src/Lib/Types.ts
Normal file
5
packages/mobile/src/Lib/Types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ToastType {
|
||||
Success = 'success',
|
||||
Info = 'info',
|
||||
Error = 'error',
|
||||
}
|
||||
42
packages/mobile/src/Lib/Utils.ts
Normal file
42
packages/mobile/src/Lib/Utils.ts
Normal 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__
|
||||
}
|
||||
3
packages/mobile/src/Lib/constants.ts
Normal file
3
packages/mobile/src/Lib/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum ErrorMessage {
|
||||
GeneralText = 'An error occurred. Please try again later.',
|
||||
}
|
||||
12
packages/mobile/src/Lib/moment.ts
Normal file
12
packages/mobile/src/Lib/moment.ts
Normal 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
|
||||
3
packages/mobile/src/Lib/package.json
Normal file
3
packages/mobile/src/Lib/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "@Lib"
|
||||
}
|
||||
280
packages/mobile/src/ModalStack.tsx
Normal file
280
packages/mobile/src/ModalStack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`};
|
||||
`
|
||||
678
packages/mobile/src/Screens/Authenticate/Authenticate.tsx
Normal file
678
packages/mobile/src/Screens/Authenticate/Authenticate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
packages/mobile/src/Screens/Authenticate/helpers.ts
Normal file
114
packages/mobile/src/Screens/Authenticate/helpers.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
}
|
||||
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal file
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal 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),
|
||||
}))``
|
||||
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal file
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal file
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal 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
|
||||
},
|
||||
)
|
||||
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal file
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
61
packages/mobile/src/Screens/InputModal/FileInputModal.tsx
Normal file
61
packages/mobile/src/Screens/InputModal/FileInputModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
packages/mobile/src/Screens/InputModal/InputModal.styled.ts
Normal file
15
packages/mobile/src/Screens/InputModal/InputModal.styled.ts
Normal 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%;
|
||||
`
|
||||
134
packages/mobile/src/Screens/InputModal/PasscodeInputModal.tsx
Normal file
134
packages/mobile/src/Screens/InputModal/PasscodeInputModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
packages/mobile/src/Screens/InputModal/TagInputModal.tsx
Normal file
92
packages/mobile/src/Screens/InputModal/TagInputModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
packages/mobile/src/Screens/ManageSessions/ManageSessions.tsx
Normal file
154
packages/mobile/src/Screens/ManageSessions/ManageSessions.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
58
packages/mobile/src/Screens/ManageSessions/SessionCell.tsx
Normal file
58
packages/mobile/src/Screens/ManageSessions/SessionCell.tsx
Normal 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>
|
||||
)
|
||||
@@ -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;
|
||||
`
|
||||
95
packages/mobile/src/Screens/NoteHistory/NoteHistory.tsx
Normal file
95
packages/mobile/src/Screens/NoteHistory/NoteHistory.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
packages/mobile/src/Screens/NoteHistory/NoteHistoryCell.tsx
Normal file
61
packages/mobile/src/Screens/NoteHistory/NoteHistoryCell.tsx
Normal 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>
|
||||
)
|
||||
@@ -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};
|
||||
`
|
||||
131
packages/mobile/src/Screens/NoteHistory/NoteHistoryPreview.tsx
Normal file
131
packages/mobile/src/Screens/NoteHistory/NoteHistoryPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
packages/mobile/src/Screens/NoteHistory/RemoteHistory.tsx
Normal file
82
packages/mobile/src/Screens/NoteHistory/RemoteHistory.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
54
packages/mobile/src/Screens/NoteHistory/SessionHistory.tsx
Normal file
54
packages/mobile/src/Screens/NoteHistory/SessionHistory.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
79
packages/mobile/src/Screens/Notes/NoteCell.styled.ts
Normal file
79
packages/mobile/src/Screens/Notes/NoteCell.styled.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
241
packages/mobile/src/Screens/Notes/NoteCell.tsx
Normal file
241
packages/mobile/src/Screens/Notes/NoteCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
packages/mobile/src/Screens/Notes/NoteCellFlags.tsx
Normal file
56
packages/mobile/src/Screens/Notes/NoteCellFlags.tsx
Normal 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>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
59
packages/mobile/src/Screens/Notes/NoteCellIconFlags.tsx
Normal file
59
packages/mobile/src/Screens/Notes/NoteCellIconFlags.tsx
Normal 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
|
||||
}
|
||||
60
packages/mobile/src/Screens/Notes/NoteList.styled.ts
Normal file
60
packages/mobile/src/Screens/Notes/NoteList.styled.ts
Normal 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;
|
||||
`}
|
||||
`
|
||||
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { AppStateEventType, AppStateType } from '@Lib/ApplicationState'
|
||||
import { useSignedIn } from '@Lib/SnjsHelperHooks'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { Chip } from '@Root/Components/Chip'
|
||||
import { SearchBar } from '@Root/Components/SearchBar'
|
||||
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import { CollectionSortProperty, SNNote } from '@standardnotes/snjs'
|
||||
import React, { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Animated, FlatList, ListRenderItem, RefreshControl } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import IosSearchBar from 'react-native-search-bar'
|
||||
import AndroidSearchBar from 'react-native-search-box'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { NoteCell } from './NoteCell'
|
||||
import {
|
||||
Container,
|
||||
HeaderContainer,
|
||||
LoadingContainer,
|
||||
LoadingText,
|
||||
SearchBarContainer,
|
||||
SearchOptionsContainer,
|
||||
styles,
|
||||
} from './NoteList.styled'
|
||||
import { OfflineBanner } from './OfflineBanner'
|
||||
|
||||
type Props = {
|
||||
onSearchChange: (text: string) => void
|
||||
onSearchCancel: () => void
|
||||
searchText: string
|
||||
searchOptions: {
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
label: string
|
||||
}[]
|
||||
onPressItem: (noteUuid: SNNote['uuid']) => void
|
||||
selectedNoteId: string | undefined
|
||||
sortType: CollectionSortProperty
|
||||
hideDates: boolean
|
||||
hidePreviews: boolean
|
||||
hideEditorIcon: boolean
|
||||
decrypting: boolean
|
||||
loading: boolean
|
||||
hasRefreshControl: boolean
|
||||
notes: SNNote[]
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
shouldFocusSearch: boolean
|
||||
setShouldFocusSearch: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const NoteList = (props: Props) => {
|
||||
// Context
|
||||
const [signedIn] = useSignedIn()
|
||||
const application = useContext(ApplicationContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [collapseSearchBarOnBlur, setCollapseSearchBarOnBlur] = useState(true)
|
||||
const [noteListScrolled, setNoteListScrolled] = useState(false)
|
||||
|
||||
// Ref
|
||||
const opacityAnimationValue = useRef(new Animated.Value(0)).current
|
||||
const marginTopAnimationValue = useRef(new Animated.Value(-40)).current
|
||||
const iosSearchBarInputRef = useRef<IosSearchBar>(null)
|
||||
const androidSearchBarInputRef = useRef<typeof AndroidSearchBar>(null)
|
||||
const noteListRef = useRef<FlatList>(null)
|
||||
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
||||
|
||||
const dismissKeyboard = () => {
|
||||
iosSearchBarInputRef.current?.blur()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
||||
setCollapseSearchBarOnBlur(false)
|
||||
})
|
||||
|
||||
return removeBlurScreenListener
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeStateEventObserver = application?.getAppState().addStateEventObserver(state => {
|
||||
if (state === AppStateEventType.DrawerOpen) {
|
||||
dismissKeyboard()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeStateEventObserver
|
||||
}, [application])
|
||||
|
||||
const scrollListToTop = useCallback(() => {
|
||||
if (noteListScrolled && props.notes && props.notes.length > 0) {
|
||||
noteListRef.current?.scrollToIndex({ animated: false, index: 0 })
|
||||
setNoteListScrolled(false)
|
||||
}
|
||||
}, [noteListScrolled, props.notes])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTagChangedEventObserver = application?.getAppState().addStateChangeObserver(event => {
|
||||
if (event === AppStateType.TagChanged) {
|
||||
scrollListToTop()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeTagChangedEventObserver
|
||||
}, [application, scrollListToTop])
|
||||
|
||||
const { shouldFocusSearch, searchText } = props
|
||||
|
||||
const focusSearch = useCallback(() => {
|
||||
setCollapseSearchBarOnBlur(true)
|
||||
|
||||
if (shouldFocusSearch) {
|
||||
iosSearchBarInputRef.current?.focus()
|
||||
androidSearchBarInputRef.current?.focus(searchText)
|
||||
}
|
||||
}, [shouldFocusSearch, searchText])
|
||||
|
||||
useFocusEffect(focusSearch)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return dismissKeyboard
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onChangeSearchText = (text: string) => {
|
||||
props.onSearchChange(text)
|
||||
scrollListToTop()
|
||||
}
|
||||
|
||||
const toggleSearchOptions = (showOptions: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnimationValue, {
|
||||
toValue: showOptions ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(marginTopAnimationValue, {
|
||||
toValue: showOptions ? 0 : -40,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]).start()
|
||||
}
|
||||
|
||||
const onSearchFocus = () => {
|
||||
toggleSearchOptions(true)
|
||||
props.setShouldFocusSearch(false)
|
||||
}
|
||||
|
||||
const onSearchBlur = () => {
|
||||
toggleSearchOptions(false)
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
setNoteListScrolled(true)
|
||||
}
|
||||
|
||||
const renderItem: ListRenderItem<SNNote> | null | undefined = ({ item }) => {
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteCell
|
||||
note={item}
|
||||
onPressItem={props.onPressItem}
|
||||
sortType={props.sortType}
|
||||
hideDates={props.hideDates}
|
||||
hidePreviews={props.hidePreviews}
|
||||
hideEditorIcon={props.hideEditorIcon}
|
||||
highlighted={item.uuid === props.selectedNoteId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
let placeholderText = ''
|
||||
if (props.decrypting) {
|
||||
placeholderText = 'Decrypting notes...'
|
||||
} else if (props.loading) {
|
||||
placeholderText = 'Loading notes...'
|
||||
} else if (props.notes.length === 0) {
|
||||
placeholderText = 'No notes.'
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<HeaderContainer>
|
||||
<SearchBarContainer>
|
||||
<SearchBar
|
||||
onChangeText={onChangeSearchText}
|
||||
onSearchCancel={props.onSearchCancel}
|
||||
onSearchFocusCallback={onSearchFocus}
|
||||
onSearchBlurCallback={onSearchBlur}
|
||||
iosSearchBarInputRef={iosSearchBarInputRef}
|
||||
androidSearchBarInputRef={androidSearchBarInputRef}
|
||||
collapseSearchBarOnBlur={collapseSearchBarOnBlur}
|
||||
/>
|
||||
</SearchBarContainer>
|
||||
<SearchOptionsContainer
|
||||
as={Animated.ScrollView}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
style={{
|
||||
opacity: opacityAnimationValue,
|
||||
marginTop: marginTopAnimationValue,
|
||||
}}
|
||||
>
|
||||
{props.searchOptions.map(({ selected, onPress, label }, index) => (
|
||||
<Chip
|
||||
key={label}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
label={label}
|
||||
last={index === props.searchOptions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SearchOptionsContainer>
|
||||
</HeaderContainer>
|
||||
<FlatList
|
||||
ref={noteListRef}
|
||||
style={styles.list}
|
||||
keyExtractor={item => item?.uuid}
|
||||
contentContainerStyle={[{ paddingBottom: insets.bottom }, props.notes.length > 0 ? {} : { height: '100%' }]}
|
||||
initialNumToRender={6}
|
||||
windowSize={6}
|
||||
maxToRenderPerBatch={6}
|
||||
ListEmptyComponent={() => {
|
||||
return placeholderText.length > 0 ? (
|
||||
<LoadingContainer>
|
||||
<LoadingText>{placeholderText}</LoadingText>
|
||||
</LoadingContainer>
|
||||
) : null
|
||||
}}
|
||||
keyboardDismissMode={'interactive'}
|
||||
keyboardShouldPersistTaps={'never'}
|
||||
refreshControl={
|
||||
!props.hasRefreshControl ? undefined : (
|
||||
<RefreshControl
|
||||
tintColor={theme.stylekitContrastForegroundColor}
|
||||
refreshing={props.refreshing}
|
||||
onRefresh={props.onRefresh}
|
||||
/>
|
||||
)
|
||||
}
|
||||
data={props.notes}
|
||||
renderItem={renderItem}
|
||||
extraData={signedIn}
|
||||
ListHeaderComponent={() => <HeaderContainer>{!signedIn && <OfflineBanner />}</HeaderContainer>}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
7
packages/mobile/src/Screens/Notes/Notes.styled.ts
Normal file
7
packages/mobile/src/Screens/Notes/Notes.styled.ts
Normal 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;
|
||||
`
|
||||
543
packages/mobile/src/Screens/Notes/Notes.tsx
Normal file
543
packages/mobile/src/Screens/Notes/Notes.tsx
Normal 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)} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
41
packages/mobile/src/Screens/Notes/OfflineBanner.styled.ts
Normal file
41
packages/mobile/src/Screens/Notes/OfflineBanner.styled.ts
Normal 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 }
|
||||
42
packages/mobile/src/Screens/Notes/OfflineBanner.tsx
Normal file
42
packages/mobile/src/Screens/Notes/OfflineBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
packages/mobile/src/Screens/Root.styled.ts
Normal file
42
packages/mobile/src/Screens/Root.styled.ts
Normal 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'],
|
||||
}
|
||||
123
packages/mobile/src/Screens/Root.tsx
Normal file
123
packages/mobile/src/Screens/Root.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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``
|
||||
254
packages/mobile/src/Screens/Settings/Sections/AuthSection.tsx
Normal file
254
packages/mobile/src/Screens/Settings/Sections/AuthSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
239
packages/mobile/src/Screens/Settings/Sections/OptionsSection.tsx
Normal file
239
packages/mobile/src/Screens/Settings/Sections/OptionsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
margin-top: 2px;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
5
packages/mobile/src/Screens/Settings/Settings.styled.ts
Normal file
5
packages/mobile/src/Screens/Settings/Settings.styled.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Container = styled.ScrollView`
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
70
packages/mobile/src/Screens/Settings/Settings.tsx
Normal file
70
packages/mobile/src/Screens/Settings/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
packages/mobile/src/Screens/SideMenu/Files.styled.ts
Normal file
35
packages/mobile/src/Screens/SideMenu/Files.styled.ts
Normal 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;
|
||||
`
|
||||
95
packages/mobile/src/Screens/SideMenu/Files.tsx
Normal file
95
packages/mobile/src/Screens/SideMenu/Files.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
packages/mobile/src/Screens/SideMenu/Listed.styled.ts
Normal file
35
packages/mobile/src/Screens/SideMenu/Listed.styled.ts
Normal 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
Reference in New Issue
Block a user