feat: mobile app package (#1075)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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