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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,257 @@
import { AppStateEventType, AppStateType } from '@Lib/ApplicationState'
import { useSignedIn } from '@Lib/SnjsHelperHooks'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { AppStackNavigationProp } from '@Root/AppStack'
import { Chip } from '@Root/Components/Chip'
import { SearchBar } from '@Root/Components/SearchBar'
import { SCREEN_NOTES } from '@Root/Screens/screens'
import { CollectionSortProperty, SNNote } from '@standardnotes/snjs'
import React, { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Animated, FlatList, ListRenderItem, RefreshControl } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import IosSearchBar from 'react-native-search-bar'
import AndroidSearchBar from 'react-native-search-box'
import { ThemeContext } from 'styled-components'
import { NoteCell } from './NoteCell'
import {
Container,
HeaderContainer,
LoadingContainer,
LoadingText,
SearchBarContainer,
SearchOptionsContainer,
styles,
} from './NoteList.styled'
import { OfflineBanner } from './OfflineBanner'
type Props = {
onSearchChange: (text: string) => void
onSearchCancel: () => void
searchText: string
searchOptions: {
selected: boolean
onPress: () => void
label: string
}[]
onPressItem: (noteUuid: SNNote['uuid']) => void
selectedNoteId: string | undefined
sortType: CollectionSortProperty
hideDates: boolean
hidePreviews: boolean
hideEditorIcon: boolean
decrypting: boolean
loading: boolean
hasRefreshControl: boolean
notes: SNNote[]
refreshing: boolean
onRefresh: () => void
shouldFocusSearch: boolean
setShouldFocusSearch: Dispatch<SetStateAction<boolean>>
}
export const NoteList = (props: Props) => {
// Context
const [signedIn] = useSignedIn()
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
const insets = useSafeAreaInsets()
const [collapseSearchBarOnBlur, setCollapseSearchBarOnBlur] = useState(true)
const [noteListScrolled, setNoteListScrolled] = useState(false)
// Ref
const opacityAnimationValue = useRef(new Animated.Value(0)).current
const marginTopAnimationValue = useRef(new Animated.Value(-40)).current
const iosSearchBarInputRef = useRef<IosSearchBar>(null)
const androidSearchBarInputRef = useRef<typeof AndroidSearchBar>(null)
const noteListRef = useRef<FlatList>(null)
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
const dismissKeyboard = () => {
iosSearchBarInputRef.current?.blur()
}
useEffect(() => {
const removeBlurScreenListener = navigation.addListener('blur', () => {
setCollapseSearchBarOnBlur(false)
})
return removeBlurScreenListener
})
useEffect(() => {
const unsubscribeStateEventObserver = application?.getAppState().addStateEventObserver(state => {
if (state === AppStateEventType.DrawerOpen) {
dismissKeyboard()
}
})
return unsubscribeStateEventObserver
}, [application])
const scrollListToTop = useCallback(() => {
if (noteListScrolled && props.notes && props.notes.length > 0) {
noteListRef.current?.scrollToIndex({ animated: false, index: 0 })
setNoteListScrolled(false)
}
}, [noteListScrolled, props.notes])
useEffect(() => {
const unsubscribeTagChangedEventObserver = application?.getAppState().addStateChangeObserver(event => {
if (event === AppStateType.TagChanged) {
scrollListToTop()
}
})
return unsubscribeTagChangedEventObserver
}, [application, scrollListToTop])
const { shouldFocusSearch, searchText } = props
const focusSearch = useCallback(() => {
setCollapseSearchBarOnBlur(true)
if (shouldFocusSearch) {
iosSearchBarInputRef.current?.focus()
androidSearchBarInputRef.current?.focus(searchText)
}
}, [shouldFocusSearch, searchText])
useFocusEffect(focusSearch)
useFocusEffect(
useCallback(() => {
return dismissKeyboard
}, []),
)
const onChangeSearchText = (text: string) => {
props.onSearchChange(text)
scrollListToTop()
}
const toggleSearchOptions = (showOptions: boolean) => {
Animated.parallel([
Animated.timing(opacityAnimationValue, {
toValue: showOptions ? 1 : 0,
duration: 200,
useNativeDriver: false,
}),
Animated.timing(marginTopAnimationValue, {
toValue: showOptions ? 0 : -40,
duration: 200,
useNativeDriver: false,
}),
]).start()
}
const onSearchFocus = () => {
toggleSearchOptions(true)
props.setShouldFocusSearch(false)
}
const onSearchBlur = () => {
toggleSearchOptions(false)
}
const onScroll = () => {
setNoteListScrolled(true)
}
const renderItem: ListRenderItem<SNNote> | null | undefined = ({ item }) => {
if (!item) {
return null
}
return (
<NoteCell
note={item}
onPressItem={props.onPressItem}
sortType={props.sortType}
hideDates={props.hideDates}
hidePreviews={props.hidePreviews}
hideEditorIcon={props.hideEditorIcon}
highlighted={item.uuid === props.selectedNoteId}
/>
)
}
let placeholderText = ''
if (props.decrypting) {
placeholderText = 'Decrypting notes...'
} else if (props.loading) {
placeholderText = 'Loading notes...'
} else if (props.notes.length === 0) {
placeholderText = 'No notes.'
}
return (
<Container>
<HeaderContainer>
<SearchBarContainer>
<SearchBar
onChangeText={onChangeSearchText}
onSearchCancel={props.onSearchCancel}
onSearchFocusCallback={onSearchFocus}
onSearchBlurCallback={onSearchBlur}
iosSearchBarInputRef={iosSearchBarInputRef}
androidSearchBarInputRef={androidSearchBarInputRef}
collapseSearchBarOnBlur={collapseSearchBarOnBlur}
/>
</SearchBarContainer>
<SearchOptionsContainer
as={Animated.ScrollView}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps={'always'}
style={{
opacity: opacityAnimationValue,
marginTop: marginTopAnimationValue,
}}
>
{props.searchOptions.map(({ selected, onPress, label }, index) => (
<Chip
key={label}
selected={selected}
onPress={onPress}
label={label}
last={index === props.searchOptions.length - 1}
/>
))}
</SearchOptionsContainer>
</HeaderContainer>
<FlatList
ref={noteListRef}
style={styles.list}
keyExtractor={item => item?.uuid}
contentContainerStyle={[{ paddingBottom: insets.bottom }, props.notes.length > 0 ? {} : { height: '100%' }]}
initialNumToRender={6}
windowSize={6}
maxToRenderPerBatch={6}
ListEmptyComponent={() => {
return placeholderText.length > 0 ? (
<LoadingContainer>
<LoadingText>{placeholderText}</LoadingText>
</LoadingContainer>
) : null
}}
keyboardDismissMode={'interactive'}
keyboardShouldPersistTaps={'never'}
refreshControl={
!props.hasRefreshControl ? undefined : (
<RefreshControl
tintColor={theme.stylekitContrastForegroundColor}
refreshing={props.refreshing}
onRefresh={props.onRefresh}
/>
)
}
data={props.notes}
renderItem={renderItem}
extraData={signedIn}
ListHeaderComponent={() => <HeaderContainer>{!signedIn && <OfflineBanner />}</HeaderContainer>}
onScroll={onScroll}
/>
</Container>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,190 @@
import { SnIcon } from '@Root/Components/SnIcon'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { CantLoadActionsText, CreateBlogContainer, ListedItemRow, styles } from '@Root/Screens/SideMenu/Listed.styled'
import { SideMenuCell } from '@Root/Screens/SideMenu/SideMenuCell'
import { SideMenuOptionIconDescriptionType } from '@Root/Screens/SideMenu/SideMenuSection'
import { Action, ButtonType, ListedAccount, ListedAccountInfo, SNNote } from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import React, { FC, useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, View } from 'react-native'
type TProps = {
note: SNNote
}
type TListedAccountItem = ListedAccountInfo | Pick<ListedAccountInfo, 'display_name'>
export const Listed: FC<TProps> = ({ note }) => {
const application = useSafeApplicationContext()
const [isLoading, setIsLoading] = useState(false)
const [isActionInProgress, setIsActionInProgress] = useState(false)
const [isRequestingAccount, setIsRequestingAccount] = useState(false)
const [listedAccounts, setListedAccounts] = useState<ListedAccount[]>([])
const [listedAccountDetails, setListedAccountDetails] = useState<TListedAccountItem[]>([])
const [authorUrlWithInProgressAction, setAuthorUrlWithInProgressAction] = useState<string | null>(null)
const { showActionSheet } = useCustomActionSheet()
const getListedAccountsDetails = useCallback(
async (accounts: ListedAccount[]) => {
const listedAccountsArray: TListedAccountItem[] = []
for (const listedAccountItem of accounts) {
const listedItemInfo = await application.getListedAccountInfo(listedAccountItem, note?.uuid)
listedAccountsArray.push(listedItemInfo ? listedItemInfo : { display_name: listedAccountItem.authorId })
}
return listedAccountsArray
},
[application, note?.uuid],
)
const reloadListedAccounts = useCallback(async () => {
setIsLoading(true)
const accounts = await application.getListedAccounts()
setListedAccounts(accounts)
setListedAccountDetails((await getListedAccountsDetails(accounts)) || [])
setIsLoading(false)
}, [application, getListedAccountsDetails])
const registerNewAccount = useCallback(() => {
if (isRequestingAccount) {
return
}
const requestAccount = async () => {
setIsRequestingAccount(true)
const account = await application.requestNewListedAccount()
if (account) {
const openSettings = await application.alertService.confirm(
'Your new Listed blog has been successfully created!' +
' You can publish a new post to your blog from Standard Notes via the' +
' Actions menu in the editor pane. Open your blog settings to begin setting it up.',
undefined,
'Open Settings',
ButtonType.Info,
'Later',
)
void reloadListedAccounts()
if (openSettings) {
const info = await application.getListedAccountInfo(account)
if (info) {
application.deviceInterface.openUrl(info?.settings_url)
}
}
}
setIsRequestingAccount(false)
}
void requestAccount()
}, [application, isRequestingAccount, reloadListedAccounts])
useEffect(() => {
const loadListedData = async () => {
await reloadListedAccounts()
}
void loadListedData()
}, [reloadListedAccounts])
const doesListedItemHaveActions = (item: TListedAccountItem): item is ListedAccountInfo => {
return (item as ListedAccountInfo).author_url !== undefined
}
const showActionsMenu = (item: TListedAccountItem, index: number) => {
if (!doesListedItemHaveActions(item)) {
void application.alertService.alert('Unable to load actions.')
return
}
showActionSheet({
title: item.display_name,
options: item.actions.map(action => ({
text: (action as Action).label,
callback: async () => {
setIsActionInProgress(true)
setAuthorUrlWithInProgressAction(item.author_url)
const response = await application.actionsManager.runAction(action as Action, note)
if (!response || response.error) {
setIsActionInProgress(false)
setAuthorUrlWithInProgressAction(null)
return
}
const listedDetails = (await getListedAccountsDetails(listedAccounts)) as TListedAccountItem[]
setListedAccountDetails(listedDetails)
showActionsMenu(listedDetails[index], index)
setIsActionInProgress(false)
setAuthorUrlWithInProgressAction(null)
},
})),
})
}
return (
<View>
{isLoading && <ActivityIndicator style={styles.loadingIndicator} />}
{listedAccountDetails.length > 0 && (
<FlatList
data={listedAccountDetails}
renderItem={({ item, index }) => {
if (!item) {
return null
}
return (
<View>
<ListedItemRow>
<SideMenuCell
text={item.display_name}
onSelect={() => showActionsMenu(item, index)}
iconDesc={{
side: 'left',
type: SideMenuOptionIconDescriptionType.CustomComponent,
value: <SnIcon type={'notes'} style={styles.blogItemIcon} />,
}}
/>
{isActionInProgress && (item as ListedAccountInfo).author_url === authorUrlWithInProgressAction && (
<ActivityIndicator style={styles.blogActionInProgressIndicator} />
)}
</ListedItemRow>
{!isLoading && !doesListedItemHaveActions(item) && (
<CantLoadActionsText>Unable to load actions</CantLoadActionsText>
)}
</View>
)
}}
/>
)}
<CreateBlogContainer>
<ListedItemRow>
<SideMenuCell
text={isRequestingAccount ? 'Creating account...' : 'Create New Author'}
onSelect={registerNewAccount}
iconDesc={{
side: 'left',
type: SideMenuOptionIconDescriptionType.CustomComponent,
value: <SnIcon type={'user-add'} style={styles.blogItemIcon} />,
}}
/>
{isRequestingAccount && <ActivityIndicator style={styles.blogActionInProgressIndicator} />}
</ListedItemRow>
<ListedItemRow>
<SideMenuCell
text={'Learn more'}
onSelect={() => application.deviceInterface.openUrl('https://listed.to')}
iconDesc={{
side: 'left',
type: SideMenuOptionIconDescriptionType.CustomComponent,
value: <SnIcon type={'open-in'} style={styles.blogItemIcon} />,
}}
/>
</ListedItemRow>
</CreateBlogContainer>
</View>
)
}

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react'
import { Platform, SafeAreaView, StatusBar, StyleSheet } from 'react-native'
import styled, { css, DefaultTheme } from 'styled-components/native'
// We want top color to be different from bottom color of safe area.
// See https://stackoverflow.com/questions/47725607/react-native-safeareaview-background-color-how-to-assign-two-different-backgro
export const FirstSafeAreaView = styled(SafeAreaView)`
flex: 0;
background-color: ${({ theme }) => theme.stylekitContrastBackgroundColor};
${Platform.OS === 'android' &&
css`
margin-top: ${StatusBar.currentHeight}px;
`};
`
export const MainSafeAreaView = styled(SafeAreaView)`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
color: ${({ theme }) => theme.stylekitForegroundColor};
`
/** Styled doesn't support FlatList types */
export const useStyles = (theme: DefaultTheme) => {
return useMemo(
() =>
StyleSheet.create({
sections: {
padding: 15,
flex: 1,
backgroundColor: theme.stylekitBackgroundColor,
},
}),
[theme.stylekitBackgroundColor],
)
}

View File

@@ -0,0 +1,295 @@
import { AppStateType } from '@Lib/ApplicationState'
import { useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { SCREEN_SETTINGS } from '@Root/Screens/screens'
import { MobileTheme } from '@Root/Style/MobileTheme'
import { ContentType, SmartView, SNTag, SNTheme } from '@standardnotes/snjs'
import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet'
import { ICON_BRUSH, ICON_SETTINGS } from '@Style/Icons'
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import React, { Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Platform } from 'react-native'
import FAB from 'react-native-fab'
import { FlatList } from 'react-native-gesture-handler'
import DrawerLayout from 'react-native-gesture-handler/DrawerLayout'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import { FirstSafeAreaView, MainSafeAreaView, useStyles } from './MainSideMenu.styled'
import { SideMenuHero } from './SideMenuHero'
import { SideMenuOption, SideMenuOptionIconDescriptionType, SideMenuSection } from './SideMenuSection'
import { TagSelectionList } from './TagSelectionList'
type Props = {
drawerRef: DrawerLayout | null
}
export const MainSideMenu = React.memo(({ drawerRef }: Props) => {
// Context
const theme = useContext(ThemeContext)
const themeService = useContext(ThemeServiceContext)
const application = useContext(ApplicationContext)
const navigation = useNavigation()
const { showActionSheet } = useCustomActionSheet()
// State
const [selectedTag, setSelectedTag] = useState(() => application!.getAppState().getSelectedTag())
const [themes, setThemes] = useState<SNTheme[]>([])
const styles = useStyles(theme)
useEffect(() => {
const removeTagChangeObserver = application!.getAppState().addStateChangeObserver(state => {
if (state === AppStateType.TagChanged) {
setSelectedTag(application!.getAppState().getSelectedTag())
}
})
return removeTagChangeObserver
})
const onSystemThemeSelect = useCallback(
async (selectedTheme: MobileTheme) => {
themeService?.activateSystemTheme(selectedTheme.uuid)
},
[themeService],
)
const onThemeSelect = useCallback(
async (selectedTheme: SNTheme) => {
void themeService?.activateExternalTheme(selectedTheme)
},
[themeService],
)
const onThemeLongPress = useCallback(
async (themeId: string, name: string, snTheme?: SNTheme) => {
const options: CustomActionSheetOption[] = []
/**
* If this theme is a mobile theme, allow it to be set as the preferred
* option for light/dark mode.
*/
if ((snTheme && !snTheme.getNotAvailOnMobile()) || !snTheme) {
const activeLightTheme = await themeService?.getThemeForMode('light')
const lightThemeAction = activeLightTheme === themeId ? 'Current' : 'Set as'
const lightName = ThemeService.doesDeviceSupportDarkMode() ? 'Light' : 'Active'
const text = `${lightThemeAction} ${lightName} Theme`
options.push({
text,
callback: () => {
if (snTheme) {
void themeService?.assignExternalThemeForMode(snTheme, 'light')
} else {
void themeService?.assignThemeForMode(themeId, 'light')
}
},
})
}
/**
* Only display a dark mode option if this device supports dark mode.
*/
if (ThemeService.doesDeviceSupportDarkMode()) {
const activeDarkTheme = await themeService?.getThemeForMode('dark')
const darkThemeAction = activeDarkTheme === themeId ? 'Current' : 'Set as'
const text = `${darkThemeAction} Dark Theme`
options.push({
text,
callback: () => {
if (snTheme) {
void themeService?.assignExternalThemeForMode(snTheme, 'dark')
} else {
void themeService?.assignThemeForMode(themeId, 'dark')
}
},
})
}
/**
* System themes cannot be redownloaded.
*/
if (snTheme) {
options.push({
text: 'Redownload',
callback: async () => {
const confirmed = await application?.alertService.confirm(
'Themes are cached when downloaded. To retrieve the latest version, press Redownload.',
'Redownload Theme',
'Redownload',
)
if (confirmed) {
void themeService?.downloadThemeAndReload(snTheme)
}
},
})
}
showActionSheet({
title: name,
options,
})
},
[application?.alertService, showActionSheet, themeService],
)
useEffect(() => {
const unsubscribeStreamThemes = application?.streamItems(ContentType.Theme, () => {
const newItems = application.items.getItems(ContentType.Theme)
setThemes(newItems as SNTheme[])
})
return unsubscribeStreamThemes
}, [application])
const iconDescriptorForTheme = (currentTheme: SNTheme | MobileTheme) => {
const desc = {
type: SideMenuOptionIconDescriptionType.Circle,
side: 'right' as const,
}
const dockIcon = currentTheme.package_info && currentTheme.package_info.dock_icon
if (dockIcon && dockIcon.type === 'circle') {
Object.assign(desc, {
backgroundColor: dockIcon.background_color,
borderColor: dockIcon.border_color,
})
} else {
Object.assign(desc, {
backgroundColor: theme.stylekitInfoColor,
borderColor: theme.stylekitInfoColor,
})
}
return desc
}
const themeOptions = useMemo(() => {
const options: SideMenuOption[] = themeService!
.systemThemes()
.map(systemTheme => ({
text: systemTheme?.name,
key: systemTheme?.uuid,
iconDesc: iconDescriptorForTheme(systemTheme),
dimmed: false,
onSelect: () => onSystemThemeSelect(systemTheme),
onLongPress: () => onThemeLongPress(systemTheme?.uuid, systemTheme?.name),
selected: themeService!.activeThemeId === systemTheme?.uuid,
}))
.concat(
themes
.sort((a, b) => a.name.localeCompare(b.name))
.map(mapTheme => ({
text: mapTheme.name,
key: mapTheme.uuid,
iconDesc: iconDescriptorForTheme(mapTheme),
dimmed: !!mapTheme.getNotAvailOnMobile(),
onSelect: () => onThemeSelect(mapTheme),
onLongPress: () => onThemeLongPress(mapTheme?.uuid, mapTheme?.name, mapTheme),
selected: themeService!.activeThemeId === mapTheme.uuid,
})),
)
if (options.length === themeService!.systemThemes().length) {
options.push({
text: 'Get More Themes',
key: 'get-theme',
iconDesc: {
type: SideMenuOptionIconDescriptionType.Icon,
name: ThemeService.nameForIcon(ICON_BRUSH),
side: 'right',
size: 17,
},
onSelect: () => {
application?.deviceInterface?.openUrl('https://standardnotes.com/plans')
},
})
}
return options
// We want to also track activeThemeId
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeService, themeService?.activeThemeId, themes, onSystemThemeSelect, onThemeSelect])
const onTagSelect = useCallback(
async (tag: SNTag | SmartView) => {
if (tag.conflictOf) {
void application!.mutator.changeAndSaveItem(tag, mutator => {
mutator.conflictOf = undefined
})
}
application?.getAppState().setSelectedTag(tag, true)
drawerRef?.closeDrawer()
},
[application, drawerRef],
)
const openSettings = () => {
drawerRef?.closeDrawer()
navigation?.navigate(SCREEN_SETTINGS as never)
}
const outOfSyncPressed = async () => {
const confirmed = await application!.alertService!.confirm(
"We've detected that the data in the current application session may " +
'not match the data on the server. This can happen due to poor ' +
'network conditions, or if a large note fails to download on your ' +
'device. To resolve this issue, we recommend first creating a backup ' +
'of your data in the Settings screen, then signing out of your account ' +
'and signing back in.',
'Potentially Out of Sync',
'Open Settings',
undefined,
)
if (confirmed) {
openSettings()
}
}
const selectedTags: SNTag[] | SmartView[] = useMemo(
() => (selectedTag ? ([selectedTag] as SNTag[] | SmartView[]) : []),
[selectedTag],
)
return (
<Fragment>
<FirstSafeAreaView />
<MainSafeAreaView>
<SideMenuHero testID="settingsButton" onPress={openSettings} onOutOfSyncPress={outOfSyncPressed} />
<FlatList
style={styles.sections}
data={['themes-section', 'views-section', 'tags-section'].map(key => ({
key,
themeOptions,
onTagSelect,
selectedTags,
}))}
renderItem={({ item, index }) => {
return index === 0 ? (
<SideMenuSection title="Themes" options={item.themeOptions} collapsed={true} />
) : index === 1 ? (
<SideMenuSection title="Views">
<TagSelectionList
contentType={ContentType.SmartView}
onTagSelect={item.onTagSelect}
selectedTags={item.selectedTags}
/>
</SideMenuSection>
) : index === 2 ? (
<SideMenuSection title="Tags">
<TagSelectionList
hasBottomPadding={Platform.OS === 'android'}
emptyPlaceholder={'No tags. Create one from the note composer.'}
contentType={ContentType.Tag}
onTagSelect={item.onTagSelect}
selectedTags={item.selectedTags}
/>
</SideMenuSection>
) : null
}}
/>
<FAB
buttonColor={theme.stylekitInfoColor}
iconTextColor={theme.stylekitInfoContrastColor}
onClickAction={openSettings}
visible={true}
size={29}
iconTextComponent={<Icon name={ThemeService.nameForIcon(ICON_SETTINGS)} />}
/>
</MainSafeAreaView>
</Fragment>
)
})

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react'
import { StyleSheet } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { SafeAreaView } from 'react-native-safe-area-context'
import styled, { DefaultTheme } from 'styled-components/native'
export const SafeAreaContainer = styled(SafeAreaView)`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
color: ${({ theme }) => theme.stylekitForegroundColor};
`
export const StyledList = styled(ScrollView)`
padding: 15px;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
`
export const useStyles = (theme: DefaultTheme) => {
return useMemo(
() =>
StyleSheet.create({
sections: {
padding: 15,
backgroundColor: theme.stylekitBackgroundColor,
},
}),
[theme.stylekitBackgroundColor],
)
}

View File

@@ -0,0 +1,674 @@
import { associateComponentWithNote } from '@Lib/ComponentManager'
import { useChangeNote, useDeleteNoteWithPrivileges, useProtectOrUnprotectNote } 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_INPUT_MODAL_TAG, SCREEN_NOTE_HISTORY } from '@Root/Screens/screens'
import { Files } from '@Root/Screens/SideMenu/Files'
import { Listed } from '@Root/Screens/SideMenu/Listed'
import { FeatureIdentifier, FindNativeFeature } from '@standardnotes/features'
import {
ApplicationEvent,
ButtonType,
ComponentArea,
ComponentMutator,
ContentType,
FeatureStatus,
NoteMutator,
NoteViewController,
PayloadEmitSource,
PrefKey,
SmartView,
SNComponent,
SNNote,
SNTag,
} from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import {
ICON_ARCHIVE,
ICON_BOOKMARK,
ICON_FINGER_PRINT,
ICON_HISTORY,
ICON_LOCK,
ICON_MEDICAL,
ICON_PRICE_TAG,
ICON_SHARE,
ICON_TRASH,
} from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Platform, Share } from 'react-native'
import FAB from 'react-native-fab'
import { FlatList } from 'react-native-gesture-handler'
import DrawerLayout from 'react-native-gesture-handler/DrawerLayout'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import { SafeAreaContainer, useStyles } from './NoteSideMenu.styled'
import { SideMenuOption, SideMenuOptionIconDescriptionType, SideMenuSection } from './SideMenuSection'
import { TagSelectionList } from './TagSelectionList'
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
return array.sort((a, b) => {
const aName = FindNativeFeature(a.identifier)?.name || a.name
const bName = FindNativeFeature(b.identifier)?.name || b.name
return aName.toLowerCase() < bName.toLowerCase() ? -1 : 1
})
}
type Props = {
drawerRef: DrawerLayout | null
drawerOpen: boolean
}
function useEditorComponents(): SNComponent[] {
const application = useSafeApplicationContext()
const [components, setComponents] = useState<SNComponent[]>([])
useEffect(() => {
const removeComponentsObserver = application.streamItems(ContentType.Component, () => {
const displayComponents = sortAlphabetically(application.componentManager.componentsForArea(ComponentArea.Editor))
setComponents(displayComponents)
})
return () => {
if (application) {
removeComponentsObserver()
}
}
}, [application])
return components
}
export const NoteSideMenu = React.memo((props: Props) => {
// Context
const theme = useContext(ThemeContext)
const application = useSafeApplicationContext()
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']>()
const { showActionSheet } = useCustomActionSheet()
const styles = useStyles(theme)
// State
const [editor, setEditor] = useState<NoteViewController | undefined>(undefined)
const [note, setNote] = useState<SNNote | undefined>(undefined)
const [selectedTags, setSelectedTags] = useState<SNTag[]>([])
const [attachedFilesLength, setAttachedFilesLength] = useState(0)
const [shouldAddTagHierarchy, setShouldAddTagHierachy] = useState(() =>
application.getPreference(PrefKey.NoteAddToParentFolders, true),
)
useEffect(() => {
const removeEventObserver = application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setShouldAddTagHierachy(application.getPreference(PrefKey.NoteAddToParentFolders, true))
})
return () => {
removeEventObserver()
}
}, [application])
const components = useEditorComponents()
const [changeNote] = useChangeNote(note, editor)
const [protectOrUnprotectNote] = useProtectOrUnprotectNote(note, editor)
const [deleteNote] = useDeleteNoteWithPrivileges(
note!,
async () => {
await application.mutator.deleteItem(note!)
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
},
() => {
void changeNote(mutator => {
mutator.trashed = true
}, false)
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
},
editor,
)
useEffect(() => {
if (!note) {
setAttachedFilesLength(0)
return
}
setAttachedFilesLength(application.items.getFilesForNote(note).length)
}, [application, note])
useEffect(() => {
if (!note) {
return
}
const removeFilesObserver = application.streamItems(ContentType.File, () => {
setAttachedFilesLength(application.items.getFilesForNote(note).length)
})
return () => {
removeFilesObserver()
}
}, [application, note])
useEffect(() => {
let mounted = true
if ((!editor || props.drawerOpen) && mounted) {
const initialEditor = application.editorGroup.activeNoteViewController
const tempNote = initialEditor?.note
setEditor(initialEditor)
setNote(tempNote)
}
return () => {
mounted = false
}
}, [application, editor, props.drawerOpen])
useEffect(() => {
let mounted = true
const removeEditorObserver = application.editorGroup.addActiveControllerChangeObserver(() => {
if (mounted) {
const activeController = application.editorGroup.activeNoteViewController
setNote(activeController?.note)
setEditor(activeController)
}
})
return () => {
mounted = false
removeEditorObserver && removeEditorObserver()
}
}, [application])
const reloadTags = useCallback(() => {
if (note) {
const tags = application.getAppState().getNoteTags(note)
setSelectedTags(tags)
}
}, [application, note])
useEffect(() => {
let mounted = true
const removeObserver = editor?.addNoteInnerValueChangeObserver((newNote, source) => {
if (mounted && props.drawerOpen) {
if (source !== PayloadEmitSource.ComponentRetrieved) {
setNote(newNote)
}
}
})
return () => {
if (removeObserver) {
removeObserver()
}
mounted = false
}
}, [editor, note?.uuid, props.drawerOpen, reloadTags])
useEffect(() => {
let isMounted = true
const removeTagsObserver = application.streamItems(ContentType.Tag, () => {
if (!note) {
return
}
if (isMounted && props.drawerOpen) {
reloadTags()
}
return () => {
isMounted = false
removeTagsObserver && removeTagsObserver()
}
})
}, [application, note, props.drawerOpen, reloadTags])
const disassociateComponentWithCurrentNote = useCallback(
async (component: SNComponent) => {
if (note) {
return application.mutator.changeItem(component, m => {
const mutator = m as ComponentMutator
mutator.removeAssociatedItemId(note.uuid)
mutator.disassociateWithItem(note.uuid)
})
}
return
},
[application, note],
)
const onEditorPress = useCallback(
async (selectedComponent?: SNComponent) => {
if (!note) {
return
}
if (note?.locked) {
void application.alertService.alert(
"This note has editing disabled. If you'd like to edit its options, enable editing on it, and try again.",
)
return
}
if (editor?.isTemplateNote) {
await editor?.insertTemplatedNote()
}
const activeEditorComponent = application.componentManager.editorForNote(note)
props.drawerRef?.closeDrawer()
if (!selectedComponent) {
if (!note?.prefersPlainEditor) {
await application.mutator.changeItem(
note,
mutator => {
const noteMutator = mutator as NoteMutator
noteMutator.prefersPlainEditor = true
},
false,
)
}
if (activeEditorComponent?.isExplicitlyEnabledForItem(note.uuid) || activeEditorComponent?.isMobileDefault) {
await disassociateComponentWithCurrentNote(activeEditorComponent)
}
} else if (selectedComponent.area === ComponentArea.Editor) {
const currentEditor = activeEditorComponent
if (currentEditor && selectedComponent !== currentEditor) {
await disassociateComponentWithCurrentNote(currentEditor)
}
const prefersPlain = note.prefersPlainEditor
if (prefersPlain) {
await application.mutator.changeItem(
note,
mutator => {
const noteMutator = mutator as NoteMutator
noteMutator.prefersPlainEditor = false
},
false,
)
}
await associateComponentWithNote(application, selectedComponent, note)
}
/** Dirtying can happen above */
void application.sync.sync()
},
[application, disassociateComponentWithCurrentNote, editor, note, props.drawerRef],
)
const onEdtiorLongPress = useCallback(
async (component?: SNComponent) => {
const currentDefault = application.componentManager
.componentsForArea(ComponentArea.Editor)
.filter(e => e.isMobileDefault)[0]
let isDefault = false
if (!component) {
// System editor
if (currentDefault) {
isDefault = false
}
} else {
isDefault = component.isMobileDefault
}
let action = isDefault ? 'Remove as Mobile Default' : 'Set as Mobile Default'
if (!component && !currentDefault) {
// Long pressing on plain editor while it is default, no actions available
action = 'Is Mobile Default'
}
const setAsDefault = () => {
if (currentDefault) {
void application.mutator.changeItem(currentDefault, m => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = false
})
}
if (component) {
void application.mutator.changeAndSaveItem(component, m => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = true
})
}
}
const removeAsDefault = () => {
void application.mutator.changeItem(currentDefault, m => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = false
})
}
showActionSheet({
title: component?.name ?? 'Plain text',
options: [
{
text: action,
callback: () => {
if (!component) {
setAsDefault()
} else {
if (isDefault) {
removeAsDefault()
} else {
setAsDefault()
}
}
},
},
],
})
},
[application, showActionSheet],
)
const editors = useMemo(() => {
if (!note) {
return []
}
const componentEditor = application.componentManager.editorForNote(note)
const options: SideMenuOption[] = [
{
text: 'Plain text',
key: 'plain-editor',
selected: !componentEditor,
onSelect: () => {
void onEditorPress(undefined)
},
onLongPress: () => {
void onEdtiorLongPress(undefined)
},
},
]
components.map(component => {
options.push({
text: FindNativeFeature(component.identifier)?.name || component.name,
subtext: component.isMobileDefault ? 'Mobile Default' : undefined,
key: component.uuid || component.name,
selected: component.uuid === componentEditor?.uuid,
onSelect: () => {
void onEditorPress(component)
},
onLongPress: () => {
void onEdtiorLongPress(component)
},
})
})
if (options.length === 1) {
options.push({
text: 'Unlock More Types',
key: 'get-editors',
iconDesc: {
type: SideMenuOptionIconDescriptionType.Icon,
name: ThemeService.nameForIcon(ICON_MEDICAL),
side: 'right',
size: 17,
},
onSelect: () => {
application.deviceInterface?.openUrl('https://standardnotes.com/plans')
},
})
}
return options
}, [note, application, components, onEditorPress, onEdtiorLongPress])
useFocusEffect(
useCallback(() => {
let mounted = true
if (mounted) {
reloadTags()
}
return () => {
mounted = false
}
}, [reloadTags]),
)
const leaveEditor = useCallback(() => {
props.drawerRef?.closeDrawer()
navigation.goBack()
}, [props.drawerRef, navigation])
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
const noteOptions = useMemo(() => {
if (!note) {
return
}
const pinOption = note.pinned ? 'Unpin' : 'Pin'
const pinEvent = () =>
changeNote(mutator => {
mutator.pinned = !note.pinned
}, false)
const archiveOption = note.archived ? 'Unarchive' : 'Archive'
const archiveEvent = () => {
if (note.locked) {
void application.alertService.alert(
`This note has editing disabled. If you'd like to ${archiveOption.toLowerCase()} it, enable editing on it, and try again.`,
)
return
}
void changeNote(mutator => {
mutator.archived = !note.archived
}, false)
leaveEditor()
}
const lockOption = note.locked ? 'Enable editing' : 'Prevent editing'
const lockEvent = () =>
changeNote(mutator => {
mutator.locked = !note.locked
}, false)
const protectOption = note.protected ? 'Remove password protection' : 'Password protect'
const protectEvent = async () => await protectOrUnprotectNote()
const openSessionHistory = () => {
if (!editor?.isTemplateNote) {
props.drawerRef?.closeDrawer()
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
navigation.navigate('HistoryStack', {
screen: SCREEN_NOTE_HISTORY,
params: { noteUuid: note.uuid },
})
}
}
const shareNote = () => {
if (note) {
void application.getAppState().performActionWithoutStateChangeImpact(() => {
void Share.share({
title: note.title,
message: note.text,
})
})
}
}
const rawOptions = [
{ text: pinOption, onSelect: pinEvent, icon: ICON_BOOKMARK },
{ text: archiveOption, onSelect: archiveEvent, icon: ICON_ARCHIVE },
{ text: lockOption, onSelect: lockEvent, icon: ICON_LOCK },
{ text: protectOption, onSelect: protectEvent, icon: ICON_FINGER_PRINT },
{
text: 'History',
onSelect: openSessionHistory,
icon: ICON_HISTORY,
},
{ text: 'Share', onSelect: shareNote, icon: ICON_SHARE },
]
if (!note.trashed) {
rawOptions.push({
text: 'Move to Trash',
onSelect: async () => deleteNote(false),
icon: ICON_TRASH,
})
}
let options: SideMenuOption[] = rawOptions.map(rawOption => ({
text: rawOption.text,
key: rawOption.icon,
iconDesc: {
type: SideMenuOptionIconDescriptionType.Icon,
side: 'right' as const,
name: ThemeService.nameForIcon(rawOption.icon),
},
onSelect: rawOption.onSelect,
}))
if (note.trashed) {
options = options.concat([
{
text: 'Restore',
key: 'restore-note',
onSelect: () => {
void changeNote(mutator => {
mutator.trashed = false
}, false)
},
},
{
text: 'Delete permanently',
textClass: 'danger' as const,
key: 'delete-forever',
onSelect: async () => deleteNote(true),
},
{
text: 'Empty Trash',
textClass: 'danger' as const,
key: 'empty trash',
onSelect: async () => {
const count = application.items.trashedItems.length
const confirmed = await application.alertService?.confirm(
`Are you sure you want to permanently delete ${count} notes?`,
'Empty Trash',
'Delete',
ButtonType.Danger,
)
if (confirmed) {
await application.mutator.emptyTrash()
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
void application.sync.sync()
}
},
},
])
}
return options
}, [
application,
changeNote,
deleteNote,
editor?.isTemplateNote,
leaveEditor,
navigation,
note,
props.drawerRef,
protectOrUnprotectNote,
])
const onTagSelect = useCallback(
async (tag: SNTag | SmartView, addTagHierachy: boolean) => {
const isSelected = selectedTags.findIndex(selectedTag => selectedTag.uuid === tag.uuid) > -1
if (note) {
if (isSelected) {
await application.mutator.changeItem(tag, mutator => {
mutator.removeItemAsRelationship(note)
})
} else {
await application.items.addTagToNote(note, tag as SNTag, addTagHierachy)
}
}
reloadTags()
void application.sync.sync()
},
[application, note, reloadTags, selectedTags],
)
if (!editor || !note) {
return null
}
enum MenuSections {
FilesSection = 'files-section',
OptionsSection = 'options-section',
EditorsSection = 'editors-section',
ListedSection = 'listed-section',
TagsSection = 'tags-section',
}
return (
<SafeAreaContainer edges={['top', 'bottom', 'right']}>
<FlatList
style={styles.sections}
data={Object.values(MenuSections).map(key => ({
key,
noteOptions,
editorComponents: editors,
onTagSelect,
selectedTags,
}))}
renderItem={({ item }) => {
const { OptionsSection, EditorsSection, ListedSection, TagsSection, FilesSection } = MenuSections
if (item.key === FilesSection) {
let collapsedLabel = 'Tap to expand'
if (isEntitledToFiles) {
collapsedLabel = `${attachedFilesLength ? `${attachedFilesLength}` : 'No'} attached file${
attachedFilesLength === 1 ? '' : 's'
}`
}
return (
<SideMenuSection title={'Files'} customCollapsedLabel={collapsedLabel} collapsed={false}>
<Files note={note} />
</SideMenuSection>
)
}
if (item.key === OptionsSection) {
return <SideMenuSection title="Options" options={item.noteOptions} />
}
if (item.key === EditorsSection) {
return <SideMenuSection title="Note Type" options={item.editorComponents} collapsed={true} />
}
if (item.key === ListedSection) {
return (
<SideMenuSection title="Listed" collapsed={true}>
<Listed note={note} />
</SideMenuSection>
)
}
if (item.key === TagsSection) {
return (
<SideMenuSection title="Tags">
<TagSelectionList
hasBottomPadding={Platform.OS === 'android'}
contentType={ContentType.Tag}
onTagSelect={tag => item.onTagSelect(tag, shouldAddTagHierarchy)}
selectedTags={item.selectedTags}
emptyPlaceholder={'Create a new tag using the tag button in the bottom right corner.'}
/>
</SideMenuSection>
)
}
return null
}}
/>
<FAB
buttonColor={theme.stylekitInfoColor}
iconTextColor={theme.stylekitInfoContrastColor}
onClickAction={() => navigation.navigate(SCREEN_INPUT_MODAL_TAG, { noteUuid: note.uuid })}
visible={true}
size={30}
iconTextComponent={<Icon name={ThemeService.nameForIcon(ICON_PRICE_TAG)} />}
/>
</SafeAreaContainer>
)
})

View File

@@ -0,0 +1,83 @@
import { Platform } from 'react-native'
import styled, { css } from 'styled-components/native'
export const Touchable = styled.TouchableOpacity<{ isSubtext: boolean }>`
min-height: ${props => (props.isSubtext ? 52 : 42)}px;
`
export const CellContent = styled.View<{
iconSide: 'right' | 'left' | null
}>`
flex: 1;
flex-direction: row;
align-items: center;
${({ iconSide }) =>
iconSide === 'right' &&
css`
justify-content: space-between;
`}
`
const IconContainer = styled.View`
justify-content: center;
`
export const IconContainerLeft = styled(IconContainer)`
margin-right: 6px;
`
export const IconContainerRight = styled(IconContainer)`
justify-content: space-between;
margin-left: 6px;
margin-right: 4px;
height: 100%;
`
export const TextContainer = styled.View<{
isSubtext: boolean
selected?: boolean
}>`
min-height: ${props => (props.isSubtext ? 38 : 24)}px;
margin-left: 6px;
flex-shrink: 1;
${({ selected, theme }) =>
selected &&
css`
border-bottom-color: ${theme.stylekitInfoColor};
border-bottom-width: 2px;
`}
`
const BaseText = styled.Text`
${() =>
Platform.OS === 'android' &&
css`
font-family: 'Roboto';
`}
`
export const Text = styled(BaseText)<{ textColor?: string }>`
color: ${({ theme, textColor }) => textColor ?? theme.stylekitContrastForegroundColor};
font-weight: bold;
font-size: 15px;
padding-bottom: 0px;
`
export const SubTextContainer = styled.View``
export const SubText = styled(BaseText)`
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
opacity: 0.75;
font-size: 12px;
margin-top: -5px;
margin-bottom: 3px;
`
export const IconGraphicContainer = styled.View`
margin-top: -3px;
width: 20px;
flex: 1;
justify-content: center;
align-items: center;
`
export const IconCircleContainer = styled(IconGraphicContainer)`
margin-top: -5px;
`
export const IconAscii = styled.Text`
font-size: 15px;
font-weight: bold;
color: ${({ theme }) => theme.stylekitNeutralColor};
opacity: 0.6;
margin-top: -4px;
`
export const RegularText = styled.Text``

View File

@@ -0,0 +1,94 @@
import { Circle } from '@Root/Components/Circle'
import React, { useContext } from 'react'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import {
CellContent,
IconAscii,
IconCircleContainer,
IconContainerLeft,
IconContainerRight,
IconGraphicContainer,
RegularText,
SubText,
SubTextContainer,
Text,
TextContainer,
Touchable,
} from './SideMenuCell.styled'
import { SideMenuOption, SideMenuOptionIconDescriptionType } from './SideMenuSection'
const renderIcon = (desc: SideMenuOption['iconDesc'], color: string) => {
if (!desc) {
return null
}
if (desc.type === SideMenuOptionIconDescriptionType.Icon && desc.name) {
return (
<IconGraphicContainer>
<Icon name={desc.name} size={desc.size || 20} color={color} />
</IconGraphicContainer>
)
}
if (desc.type === SideMenuOptionIconDescriptionType.Ascii) {
return <IconAscii>{desc.value}</IconAscii>
}
if (desc.type === SideMenuOptionIconDescriptionType.Circle) {
return (
<IconCircleContainer>
<Circle backgroundColor={desc.backgroundColor} borderColor={desc.borderColor} />
</IconCircleContainer>
)
}
if (desc.type === SideMenuOptionIconDescriptionType.CustomComponent) {
return desc.value
}
return <RegularText>*</RegularText>
}
export const SideMenuCell: React.FC<SideMenuOption> = props => {
const theme = useContext(ThemeContext)
const colorForTextClass = (textClass: SideMenuOption['textClass']) => {
if (!textClass) {
return undefined
}
return {
info: theme.stylekitInfoColor,
danger: theme.stylekitDangerColor,
warning: theme.stylekitWarningColor,
}[textClass]
}
const hasIcon = props.iconDesc
const iconSide = hasIcon && props.iconDesc?.side ? props.iconDesc.side : hasIcon ? 'left' : null
return (
<Touchable
isSubtext={Boolean(props.subtext)}
onPress={props.onSelect}
onLongPress={props.onLongPress}
style={[props.style || {}]}
>
<CellContent iconSide={iconSide} style={props.cellContentStyle || {}}>
{iconSide === 'left' && (
<IconContainerLeft>{renderIcon(props.iconDesc, theme.stylekitInfoColor)}</IconContainerLeft>
)}
<TextContainer selected={props.selected} isSubtext={Boolean(props.subtext)}>
{props.subtext && (
<SubTextContainer>
<SubText>{props.subtext}</SubText>
</SubTextContainer>
)}
<Text textColor={colorForTextClass(props.textClass)}>{props.text}</Text>
</TextContainer>
{props.children}
{iconSide === 'right' && (
<IconContainerRight>{renderIcon(props.iconDesc, theme.stylekitInfoColor)}</IconContainerRight>
)}
</CellContent>
</Touchable>
)
}

View File

@@ -0,0 +1,41 @@
import styled from 'styled-components/native'
export const Cell = styled.View`
background-color: ${({ theme }) => theme.stylekitContrastBackgroundColor};
border-bottom-color: ${({ theme }) => theme.stylekitContrastBorderColor};
border-bottom-width: 1px;
padding: 15px;
padding-top: 10px;
padding-bottom: 12px;
padding-right: 25px;
`
export const Touchable = styled.TouchableOpacity``
export const Title = styled.Text`
font-weight: bold;
font-size: 16px;
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
margin-bottom: 3px;
`
export const SubTitle = styled.Text`
font-size: 13px;
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
opacity: 0.6;
`
export const OutOfSyncContainer = styled.TouchableOpacity`
flex: 1;
margin-top: 5px;
margin-bottom: 5px;
flex-direction: row;
align-items: center;
`
export const IconCircle = styled.View`
margin-top: 10px;
width: 15px;
`
export const OutOfSyncLabel = styled.Text`
margin-top: 10px;
font-size: 13px;
height: 15px;
color: ${({ theme }) => theme.stylekitWarningColor};
font-weight: bold;
`

View File

@@ -0,0 +1,80 @@
import { useIsLocked, useOutOfSync, useSignedIn } from '@Lib/SnjsHelperHooks'
import { ApplicationContext } from '@Root/ApplicationContext'
import { Circle } from '@Root/Components/Circle'
import { ContentType } from '@standardnotes/snjs'
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { ViewProps } from 'react-native'
import { ThemeContext } from 'styled-components'
import { Cell, IconCircle, OutOfSyncContainer, OutOfSyncLabel, SubTitle, Title, Touchable } from './SideMenuHero.styled'
type Props = {
onPress: () => void
onOutOfSyncPress: () => void
testID: ViewProps['testID']
}
export const SideMenuHero: React.FC<Props> = props => {
// Context
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
// State
const [signedIn] = useSignedIn()
const [isLocked] = useIsLocked()
const [isOutOfSync] = useOutOfSync()
const [itemsCount, setItemsCount] = useState(0)
useEffect(() => {
const observedContentTypes = [ContentType.Note, ContentType.Tag]
const removeStreamItems = application?.streamItems(observedContentTypes, _items => {
const notesAndTagsCount = application?.items.getItems(observedContentTypes).length ?? 0
if (notesAndTagsCount !== itemsCount) {
setItemsCount(notesAndTagsCount)
}
})
return removeStreamItems
}, [application, itemsCount])
const textData = useMemo(() => {
const hasEncryption = application?.isEncryptionAvailable()
if (!signedIn) {
return {
title: 'Data Not Backed Up',
text: hasEncryption
? 'Sign in or register to enable sync to your other devices.'
: 'Sign in or register to add encryption and enable sync to your other devices.',
}
} else if (!isLocked) {
const user = application?.getUser()
const email = user?.email
const itemsStatus = itemsCount + '/' + itemsCount + ' notes and tags encrypted'
return {
title: email,
text: itemsStatus,
}
} else {
return { text: '', title: '' }
}
}, [application, signedIn, itemsCount, isLocked])
return (
<Cell>
<Touchable testID={props.testID} onPress={props.onPress}>
<Title>{textData.title}</Title>
</Touchable>
<Touchable onPress={props.onPress}>
<SubTitle>{textData.text}</SubTitle>
</Touchable>
{isOutOfSync && (
<OutOfSyncContainer onPress={props.onOutOfSyncPress}>
<IconCircle>
<Circle size={10} backgroundColor={theme.stylekitWarningColor} borderColor={theme.stylekitWarningColor} />
</IconCircle>
<OutOfSyncLabel>Potentially Out of Sync</OutOfSyncLabel>
</OutOfSyncContainer>
)}
</Cell>
)
}

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components/native'
export const Root = styled.View`
padding-bottom: 6px;
`
export const Header = styled.TouchableOpacity<{ collapsed: boolean }>`
height: ${props => (props.collapsed ? 50 : 22)}px;
`
export const Title = styled.Text`
color: ${({ theme }) => theme.stylekitInfoColor};
font-size: 13px;
font-weight: 700;
`
export const CollapsedLabel = styled.Text`
font-size: 12px;
opacity: 0.7;
margin-top: 3px;
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
`

View File

@@ -0,0 +1,81 @@
import React, { ReactElement, useMemo, useState } from 'react'
import { StyleProp, ViewStyle } from 'react-native'
import { SideMenuCell } from './SideMenuCell'
import { CollapsedLabel, Header, Root, Title } from './SideMenuSection.styled'
export enum SideMenuOptionIconDescriptionType {
Icon = 'icon',
Ascii = 'ascii',
Circle = 'circle',
CustomComponent = 'custom-component',
}
export type SideMenuOption = {
text: string
subtext?: string
textClass?: 'info' | 'danger' | 'warning'
key?: string
iconDesc?: {
type: SideMenuOptionIconDescriptionType
side?: 'left' | 'right'
name?: string
value?: string | ReactElement
backgroundColor?: string
borderColor?: string
size?: number
}
dimmed?: boolean
selected?: boolean
onSelect?: () => void | Promise<void>
onLongPress?: () => void
style?: StyleProp<ViewStyle>
cellContentStyle?: StyleProp<ViewStyle>
}
type Props = {
title: string
customCollapsedLabel?: string
collapsed?: boolean
options?: SideMenuOption[]
}
export const SideMenuSection: React.FC<Props> = React.memo(props => {
const [collapsed, setCollapsed] = useState(Boolean(props.collapsed))
const options = useMemo(() => {
return props.options || []
}, [props.options])
const collapsedLabel =
props.customCollapsedLabel ||
(options.length > 0 ? 'Tap to expand ' + options.length + ' options' : 'Tap to expand')
return (
<Root>
<Header collapsed={collapsed} onPress={() => setCollapsed(!collapsed)}>
<>
<Title>{props.title}</Title>
{collapsed && <CollapsedLabel>{collapsedLabel}</CollapsedLabel>}
</>
</Header>
{!collapsed && (
<>
{options.map(option => {
return (
<SideMenuCell
text={option.text}
textClass={option.textClass}
subtext={option.subtext}
key={option.text + option.subtext + option.key}
iconDesc={option.iconDesc}
dimmed={option.dimmed}
selected={option.selected}
onSelect={option.onSelect}
onLongPress={option.onLongPress}
/>
)
})}
{props.children}
</>
)}
</Root>
)
})

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components/native'
export const EmptyPlaceholder = styled.Text`
color: ${({ theme }) => theme.stylekitForegroundColor};
opacity: 0.6;
padding-right: 30px;
line-height: 18px;
`

View File

@@ -0,0 +1,157 @@
import { useNavigation } from '@react-navigation/native'
import { AppStackNavigationProp } from '@Root/AppStack'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_COMPOSE, SCREEN_INPUT_MODAL_TAG } from '@Root/Screens/screens'
import { SideMenuOptionIconDescriptionType } from '@Root/Screens/SideMenu/SideMenuSection'
import { ButtonType, ContentType, FindItem, SmartView, SNTag } from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import React, { useCallback, useEffect, useState } from 'react'
import { FlatList, ListRenderItem } from 'react-native'
import { SideMenuCell } from './SideMenuCell'
import { EmptyPlaceholder } from './TagSelectionList.styled'
type Props = {
contentType: ContentType.Tag | ContentType.SmartView
onTagSelect: (tag: SNTag | SmartView) => void
selectedTags: SNTag[] | SmartView[]
emptyPlaceholder?: string
hasBottomPadding?: boolean
}
export const TagSelectionList = React.memo(
({ contentType, onTagSelect, selectedTags, emptyPlaceholder, hasBottomPadding }: Props) => {
// Context
const application = useSafeApplicationContext()
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']>()
const { showActionSheet } = useCustomActionSheet()
// State
const [tags, setTags] = useState<SNTag[] | SmartView[]>(() => {
return contentType === ContentType.SmartView ? application.items.getSmartViews() : []
})
const reloadTags = useCallback(() => {
if (contentType === ContentType.SmartView) {
setTags(application.items.getSmartViews())
} else {
setTags(application.items.getDisplayableTags())
}
}, [application, contentType])
const streamTags = useCallback(
() =>
application.streamItems(contentType, ({ removed }) => {
reloadTags()
if (application?.getAppState().selectedTag) {
if (FindItem(removed, application.getAppState().selectedTag.uuid)) {
application.getAppState().setSelectedTag(application.items.getSmartViews()[0], true)
}
}
}),
[application, contentType, reloadTags],
)
useEffect(() => {
const removeStreamTags = streamTags()
return removeStreamTags
}, [application, contentType, streamTags])
const onTagLongPress = (tag: SNTag | SmartView) => {
showActionSheet({
title: tag.title,
options: [
{
text: 'Rename',
callback: () =>
navigation.navigate(SCREEN_INPUT_MODAL_TAG, {
tagUuid: tag.uuid,
}),
},
{
text: 'Delete',
destructive: true,
callback: async () => {
const confirmed = await application?.alertService.confirm(
'Are you sure you want to delete this tag? Deleting a tag will not delete its notes.',
undefined,
'Delete',
ButtonType.Danger,
)
if (confirmed) {
await application.mutator.deleteItem(tag)
}
},
},
],
})
}
const isRootTag = (tag: SNTag | SmartView): boolean =>
tag instanceof SmartView || !application.items.getTagParent(tag)
const showFolders = contentType === ContentType.Tag
const renderedTags = showFolders ? (tags as SNTag[]).filter(isRootTag) : tags
const renderItem: ListRenderItem<SNTag | SmartView> = ({ item }) => {
const title = item.title
let children: SNTag[] = []
if (showFolders && item instanceof SNTag) {
const rawChildren = application.items.getTagChildren(item).map(tag => tag.uuid)
children = (tags as SNTag[]).filter((tag: SNTag) => rawChildren.includes(tag.uuid))
}
const isSelected = selectedTags.some((selectedTag: SNTag | SmartView) => selectedTag.uuid === item.uuid)
return (
<>
<SideMenuCell
onSelect={() => onTagSelect(item)}
onLongPress={() => onTagLongPress(item)}
text={title}
iconDesc={{
side: 'left',
type: SideMenuOptionIconDescriptionType.Ascii,
value: '#',
}}
key={item.uuid}
selected={isSelected}
/>
{children && (
<FlatList
// eslint-disable-next-line react-native/no-inline-styles
style={{
paddingLeft: 25,
}}
initialNumToRender={10}
windowSize={10}
maxToRenderPerBatch={10}
data={children}
keyExtractor={childTag => childTag.uuid}
renderItem={renderItem}
/>
)}
</>
)
}
return (
<>
<FlatList
// eslint-disable-next-line react-native/no-inline-styles
style={{ paddingBottom: hasBottomPadding ? 30 : 0 }}
initialNumToRender={10}
windowSize={10}
maxToRenderPerBatch={10}
data={renderedTags as SNTag[]}
keyExtractor={item => item.uuid}
renderItem={renderItem}
/>
{tags.length === 0 && <EmptyPlaceholder>{emptyPlaceholder}</EmptyPlaceholder>}
</>
)
},
)

View File

@@ -0,0 +1,54 @@
import { SnIcon } from '@Root/Components/SnIcon'
import { Text } from '@Screens/SideMenu/SideMenuCell.styled'
import { hexToRGBA } from '@Style/Utils'
import { StyleSheet } from 'react-native'
import styled from 'styled-components/native'
export const uploadedFileItemStyles = StyleSheet.create({
lockIcon: {
marginLeft: 8,
},
})
export const FileDataContainer = styled.View`
align-items: flex-start;
flex-direction: row;
padding-top: 12px;
`
export const FileIconContainer = styled.View`
margin-top: 2px;
margin-right: 16px;
`
export const FileDetailsWithExtraIconsContainer = styled.View`
flex-direction: row;
flex-shrink: 1;
flex-grow: 1;
align-items: center;
border-bottom-color: ${({ theme }) => hexToRGBA(theme.stylekitBorderColor, 0.75)};
border-bottom-width: 1px;
padding-bottom: 12px;
`
export const LockIconStyled = styled(SnIcon)`
background-color: green;
display: none;
`
export const FileDetailsContainer = styled.View`
flex-shrink: 1;
`
export const FileName = styled(Text)`
font-weight: normal;
font-size: 16px;
margin-bottom: 4px;
`
export const FileDateAndSizeContainer = styled.View`
flex-direction: row;
align-items: center;
`
export const FileDateAndSize = styled.Text`
color: ${({ theme }) => {
return theme.stylekitForegroundColor
}};
opacity: 0.5;
font-weight: normal;
font-size: 12px;
`

View File

@@ -0,0 +1,83 @@
import { AppStackNavigationProp } from '@Root/AppStack'
import { SnIcon } from '@Root/Components/SnIcon'
import { useFiles } from '@Root/Hooks/useFiles'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_COMPOSE } from '@Root/Screens/screens'
import { UploadedFileItemActionType } from '@Screens/UploadedFilesList/UploadedFileItemAction'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem, SNNote } from '@standardnotes/snjs'
import React, { FC, useContext, useEffect, useState } from 'react'
import { TouchableOpacity, View } from 'react-native'
import { ThemeContext } from 'styled-components'
import {
FileDataContainer,
FileDateAndSize,
FileDateAndSizeContainer,
FileDetailsContainer,
FileDetailsWithExtraIconsContainer,
FileIconContainer,
FileName,
uploadedFileItemStyles,
} from './UploadedFileItem.styled'
export type UploadedFileItemProps = {
file: FileItem
note: SNNote
isAttachedToNote: boolean
}
export type TAppStackNavigationProp = AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']
export const UploadedFileItem: FC<UploadedFileItemProps> = ({ file, note }) => {
const application = useSafeApplicationContext()
const theme = useContext(ThemeContext)
const { showActionsMenu, handleFileAction } = useFiles({ note })
const [fileName, setFileName] = useState(file.name)
useEffect(() => {
setFileName(file.name)
}, [file.name])
const iconType = application.iconsController.getIconForFileType(file.mimeType)
return (
<TouchableOpacity
onPress={() => {
void handleFileAction({
type: UploadedFileItemActionType.PreviewFile,
payload: file,
})
}}
onLongPress={() => showActionsMenu(file)}
>
<View>
<FileDataContainer>
<FileIconContainer>
<SnIcon type={iconType} width={32} height={32} />
</FileIconContainer>
<FileDetailsWithExtraIconsContainer>
<FileDetailsContainer>
<FileName>{fileName}</FileName>
<FileDateAndSizeContainer>
<FileDateAndSize>
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
</FileDateAndSize>
{file.protected && (
<SnIcon
type={'lock-filled'}
width={12}
height={12}
fill={theme.stylekitPalSky}
style={uploadedFileItemStyles.lockIcon}
/>
)}
</FileDateAndSizeContainer>
</FileDetailsContainer>
</FileDetailsWithExtraIconsContainer>
</FileDataContainer>
</View>
</TouchableOpacity>
)
}

View File

@@ -0,0 +1,17 @@
import { FileItem } from '@standardnotes/snjs'
export enum UploadedFileItemActionType {
AttachFileToNote,
DetachFileToNote,
DeleteFile,
ShareFile,
DownloadFile,
RenameFile,
ToggleFileProtection,
PreviewFile,
}
export type UploadedFileItemAction = {
type: UploadedFileItemActionType
payload: FileItem
}

View File

@@ -0,0 +1,66 @@
import { StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import styled from 'styled-components/native'
export const useUploadedFilesListStyles = () => {
const insets = useSafeAreaInsets()
return StyleSheet.create({
centeredView: {
justifyContent: 'flex-start',
alignItems: 'center',
flexShrink: 1,
flexGrow: 1,
paddingBottom: insets.bottom,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
headerTabContainer: {
flexDirection: 'row',
},
noAttachmentsIconContainer: {
alignItems: 'center',
marginTop: 24,
},
noAttachmentsIcon: {
marginTop: 24,
marginBottom: 24,
},
})
}
export const UploadFilesListContainer = styled.View`
margin-top: 12px;
padding-right: 16px;
padding-left: 16px;
width: 100%;
height: 100%;
`
export const HeaderTabItem = styled.View<{
isActive: boolean
isLeftTab?: boolean
}>`
align-items: center;
padding: 8px;
flex-grow: 1;
background-color: ${({ theme, isActive }) => {
return isActive ? theme.stylekitInfoColor : theme.stylekitInfoContrastColor
}};
border-width: 1px;
border-color: ${({ theme }) => theme.stylekitInfoColor};
border-top-right-radius: ${({ isLeftTab }) => (isLeftTab ? 0 : '8px')};
border-bottom-right-radius: ${({ isLeftTab }) => (isLeftTab ? 0 : '8px')};
border-top-left-radius: ${({ isLeftTab }) => (isLeftTab ? '8px' : 0)};
border-bottom-left-radius: ${({ isLeftTab }) => (isLeftTab ? '8px' : 0)};
margin-left: ${({ isLeftTab }) => (isLeftTab ? 0 : '-1px')};
`
export const TabText = styled.Text<{ isActive: boolean }>`
font-weight: bold;
color: ${({ isActive, theme }) => {
return isActive ? theme.stylekitInfoContrastColor : theme.stylekitInfoColor
}};
`

View File

@@ -0,0 +1,151 @@
import { useNavigation } from '@react-navigation/native'
import { SearchBar } from '@Root/Components/SearchBar'
import { SnIcon } from '@Root/Components/SnIcon'
import { useFiles } from '@Root/Hooks/useFiles'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_UPLOADED_FILES_LIST } from '@Root/Screens/screens'
import { UploadedFileItem } from '@Root/Screens/UploadedFilesList/UploadedFileItem'
import {
HeaderTabItem,
TabText,
UploadFilesListContainer,
useUploadedFilesListStyles,
} from '@Root/Screens/UploadedFilesList/UploadedFilesList.styled'
import { FileItem } from '@standardnotes/snjs'
import { ICON_ATTACH } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { FlatList, ListRenderItem, Text, View } from 'react-native'
import FAB from 'react-native-fab'
import IosSearchBar from 'react-native-search-bar'
import AndroidSearchBar from 'react-native-search-box'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
export enum Tabs {
AttachedFiles,
AllFiles,
}
type Props = ModalStackNavigationProp<typeof SCREEN_UPLOADED_FILES_LIST>
export const UploadedFilesList: FC<Props> = props => {
const { AttachedFiles, AllFiles } = Tabs
const { note } = props.route.params
const theme = useContext(ThemeContext)
const styles = useUploadedFilesListStyles()
const navigation = useNavigation()
const [currentTab, setCurrentTab] = useState(AllFiles)
const [searchString, setSearchString] = useState('')
const [filesListScrolled, setFilesListScrolled] = useState(false)
const iosSearchBarInputRef = useRef<IosSearchBar>(null)
const androidSearchBarInputRef = useRef<typeof AndroidSearchBar>(null)
const filesListRef = useRef<FlatList>(null)
const { attachedFiles, allFiles, handlePressAttachFile } = useFiles({
note,
})
const filesList = currentTab === Tabs.AttachedFiles ? attachedFiles : allFiles
const filteredList = useMemo(() => {
return searchString
? filesList.filter(file => file.name.toLowerCase().includes(searchString.toLowerCase()))
: filesList
}, [filesList, searchString])
useEffect(() => {
let screenTitle = 'Files'
if (searchString) {
const filesCount = filteredList.length
screenTitle = `${filesCount} search result${filesCount !== 1 ? 's' : ''}`
}
navigation.setOptions({
title: screenTitle,
})
}, [filteredList.length, navigation, searchString])
const scrollListToTop = useCallback(() => {
if (filesListScrolled && filteredList.length > 0) {
filesListRef.current?.scrollToIndex({ animated: false, index: 0 })
setFilesListScrolled(false)
}
}, [filesListScrolled, filteredList.length])
const handleFilter = useCallback(
(textToSearch: string) => {
setSearchString(textToSearch)
scrollListToTop()
},
[scrollListToTop],
)
const { centeredView, header, headerTabContainer, noAttachmentsIcon, noAttachmentsIconContainer } = styles
const onScroll = () => {
if (filesListScrolled) {
return
}
setFilesListScrolled(true)
}
const renderItem: ListRenderItem<FileItem> = ({ item }) => {
return <UploadedFileItem key={item.uuid} file={item} note={note} isAttachedToNote={attachedFiles.includes(item)} />
}
return (
<View style={centeredView}>
<UploadFilesListContainer>
<View style={header}>
<View style={headerTabContainer}>
<HeaderTabItem
isActive={currentTab === AttachedFiles}
isLeftTab={true}
onTouchEnd={() => setCurrentTab(AttachedFiles)}
>
<TabText isActive={currentTab === AttachedFiles}>Attached</TabText>
</HeaderTabItem>
<HeaderTabItem isActive={currentTab === AllFiles} onTouchEnd={() => setCurrentTab(AllFiles)}>
<TabText isActive={currentTab === AllFiles}>All files</TabText>
</HeaderTabItem>
</View>
</View>
<View>
<SearchBar
onChangeText={handleFilter}
onSearchCancel={() => handleFilter('')}
iosSearchBarInputRef={iosSearchBarInputRef}
androidSearchBarInputRef={androidSearchBarInputRef}
/>
</View>
{filteredList.length > 0 ? (
<FlatList
ref={filesListRef}
data={filteredList}
renderItem={renderItem}
keyExtractor={item => item.uuid}
onScroll={onScroll}
/>
) : (
<View style={noAttachmentsIconContainer}>
<SnIcon type={'files-illustration'} style={noAttachmentsIcon} width={72} height={72} />
<Text>{searchString ? 'No files found' : 'No files attached to this note'}</Text>
</View>
)}
<FAB
buttonColor={theme.stylekitInfoColor}
iconTextColor={theme.stylekitInfoContrastColor}
onClickAction={() => handlePressAttachFile(currentTab)}
visible={true}
size={30}
iconTextComponent={<Icon name={ThemeService.nameForIcon(ICON_ATTACH)} />}
/>
</UploadFilesListContainer>
</View>
)
}

View File

@@ -0,0 +1,23 @@
import styled from 'styled-components/native'
export const Container = styled.View`
flex: 1;
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
display: flex;
justify-content: center;
padding: 20px;
`
export const Title = styled.Text`
font-size: 20px;
font-weight: bold;
text-align: center;
color: ${({ theme }) => theme.stylekitForegroundColor};
`
export const Text = styled.Text`
margin: 8px 0 24px;
font-size: 14px;
text-align: center;
color: ${({ theme }) => theme.stylekitParagraphTextColor};
`

View File

@@ -0,0 +1,40 @@
import { useFocusEffect } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { AppStackNavigationProp } from '@Root/AppStack'
import { Button } from '@Root/Components/Button'
import { SCREEN_SETTINGS, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
import React, { useCallback, useContext } from 'react'
import { Container, Text, Title } from './ViewProtectedNote.styled'
type Props = AppStackNavigationProp<typeof SCREEN_VIEW_PROTECTED_NOTE>
export const ViewProtectedNote = ({
route: {
params: { onPressView },
},
navigation,
}: Props) => {
const application = useContext(ApplicationContext)
const onPressGoToSettings = () => {
navigation.navigate(SCREEN_SETTINGS)
}
const checkProtectionSources = useCallback(() => {
const hasProtectionSources = application?.hasProtectionSources()
if (hasProtectionSources) {
onPressView()
}
}, [application, onPressView])
useFocusEffect(checkProtectionSources)
return (
<Container>
<Title>This note is protected</Title>
<Text>Add a passcode or biometrics lock, or create an account, to require authentication to view this note.</Text>
<Button label="Go to Settings" primary={true} fullWidth={true} onPress={onPressGoToSettings} />
<Button label="View" fullWidth={true} last={true} onPress={onPressView} />
</Container>
)
}

View File

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

View File

@@ -0,0 +1,17 @@
export const SCREEN_AUTHENTICATE = 'Authenticate'
export const SCREEN_NOTES = 'Notes' as const
export const SCREEN_COMPOSE = 'Compose' as const
export const SCREEN_INPUT_MODAL_PASSCODE = 'InputModalPasscode' as const
export const SCREEN_INPUT_MODAL_TAG = 'InputModalTag' as const
export const SCREEN_INPUT_MODAL_FILE_NAME = 'InputModalFileName'
export const SCREEN_NOTE_HISTORY = 'NoteSessionHistory' as const
export const SCREEN_NOTE_HISTORY_PREVIEW = 'NoteSessionHistoryPreview' as const
export const SCREEN_UPLOADED_FILES_LIST = 'UploadedFilesList' as const
export const SCREEN_SETTINGS = 'Settings'
export const SCREEN_MANAGE_SESSIONS = 'ManageSessions' as const
export const MODAL_BLOCKING_ALERT = 'ModalBlockingAlert' as const
export const SCREEN_VIEW_PROTECTED_NOTE = 'ViewProtectedNote' as const