feat: mobile app package (#1075)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
export const StyledKeyboardAvoidingView = styled.KeyboardAvoidingView`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
export const BaseView = styled.View``
|
||||
|
||||
export const StyledSectionedTableCell = styled(SectionedTableCell)`
|
||||
padding-top: 4px;
|
||||
`
|
||||
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
export const Subtitle = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
`
|
||||
|
||||
export const Input = styled.TextInput.attrs(({ theme }) => ({
|
||||
placeholderTextColor: theme.stylekitNeutralColor,
|
||||
}))`
|
||||
font-size: ${({ theme }) => theme.mainTextFontSize}px;
|
||||
padding: 0px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export const SectionContainer = styled.View``
|
||||
|
||||
export const SourceContainer = styled.View``
|
||||
|
||||
export const SessionLengthContainer = styled.View``
|
||||
|
||||
export const StyledTableSection = styled(TableSection)<{ last?: boolean }>`
|
||||
${({ last }) =>
|
||||
last &&
|
||||
css`
|
||||
margin-bottom: 0px;
|
||||
`};
|
||||
`
|
||||
678
packages/mobile/src/Screens/Authenticate/Authenticate.tsx
Normal file
678
packages/mobile/src/Screens/Authenticate/Authenticate.tsx
Normal file
@@ -0,0 +1,678 @@
|
||||
import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState'
|
||||
import { MobileDeviceInterface } from '@Lib/Interface'
|
||||
import { HeaderHeightContext } from '@react-navigation/elements'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
|
||||
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_AUTHENTICATE } from '@Root/Screens/screens'
|
||||
import { ChallengeReason, ChallengeValidation, ChallengeValue, ProtectionSessionDurations } from '@standardnotes/snjs'
|
||||
import { ICON_CLOSE } from '@Style/Icons'
|
||||
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { Alert, BackHandler, Keyboard, Platform, ScrollView, TextInput } from 'react-native'
|
||||
import FingerprintScanner from 'react-native-fingerprint-scanner'
|
||||
import { hide } from 'react-native-privacy-snapshot'
|
||||
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
|
||||
import styled, { ThemeContext } from 'styled-components'
|
||||
import {
|
||||
BaseView,
|
||||
Input,
|
||||
SectionContainer,
|
||||
SessionLengthContainer,
|
||||
SourceContainer,
|
||||
StyledKeyboardAvoidingView,
|
||||
StyledSectionedTableCell,
|
||||
StyledTableSection,
|
||||
Subtitle,
|
||||
Title,
|
||||
} from './Authenticate.styled'
|
||||
import {
|
||||
authenticationReducer,
|
||||
AuthenticationValueStateType,
|
||||
findIndexInObject,
|
||||
getChallengePromptTitle,
|
||||
getLabelForStateAndType,
|
||||
isInActiveState,
|
||||
} from './helpers'
|
||||
|
||||
type Props = ModalStackNavigationProp<typeof SCREEN_AUTHENTICATE>
|
||||
|
||||
function isValidChallengeValue(challengeValue: ChallengeValue): boolean {
|
||||
switch (challengeValue.prompt.validation) {
|
||||
case ChallengeValidation.ProtectionSessionDuration:
|
||||
return typeof challengeValue.value === 'number'
|
||||
default:
|
||||
return !!challengeValue.value
|
||||
}
|
||||
}
|
||||
|
||||
const ItemStyled = styled(Item)`
|
||||
width: 100px;
|
||||
`
|
||||
|
||||
export const Authenticate = ({
|
||||
route: {
|
||||
params: { challenge },
|
||||
},
|
||||
navigation,
|
||||
}: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
// State
|
||||
const [supportsBiometrics, setSupportsBiometrics] = useState<boolean | undefined>(undefined)
|
||||
const [passcodeKeyboardType, setPasscodeKeyboardType] = useState<PasscodeKeyboardType | undefined>(
|
||||
PasscodeKeyboardType.Default,
|
||||
)
|
||||
const [singleValidation] = useState(() => !(challenge.prompts.filter(prompt => prompt.validates).length > 0))
|
||||
const [showSwitchKeyboard, setShowSwitchKeyboard] = useState<boolean>(false)
|
||||
|
||||
const [{ challengeValues, challengeValueStates }, dispatch] = useReducer(
|
||||
authenticationReducer,
|
||||
{
|
||||
challengeValues: challenge.prompts.reduce((map, current) => {
|
||||
map[current.id] = {
|
||||
prompt: current,
|
||||
value: current.initialValue ?? null,
|
||||
} as ChallengeValue
|
||||
return map
|
||||
}, {} as Record<string, ChallengeValue>),
|
||||
challengeValueStates: challenge.prompts.reduce((map, current, index) => {
|
||||
if (index === 0) {
|
||||
map[current.id] = AuthenticationValueStateType.WaitingInput
|
||||
} else {
|
||||
map[current.id] = AuthenticationValueStateType.WaitingTurn
|
||||
}
|
||||
return map
|
||||
}, {} as Record<string, AuthenticationValueStateType>),
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
// Refs
|
||||
const isAuthenticating = useRef(false)
|
||||
const firstInputRef = useRef<TextInput>(null)
|
||||
const secondInputRef = useRef<TextInput>(null)
|
||||
const thirdInputRef = useRef<TextInput>(null)
|
||||
const fourthInputRef = useRef<TextInput>(null)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (challenge.cancelable) {
|
||||
navigation.setOptions({
|
||||
headerLeft: ({ disabled }) => (
|
||||
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
|
||||
<ItemStyled
|
||||
testID="headerButton"
|
||||
disabled={disabled || pending}
|
||||
title={Platform.OS === 'ios' ? 'Cancel' : ''}
|
||||
iconName={Platform.OS === 'ios' ? undefined : ThemeService.nameForIcon(ICON_CLOSE)}
|
||||
onPress={() => {
|
||||
if (!pending) {
|
||||
application?.cancelChallenge(challenge)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HeaderButtons>
|
||||
),
|
||||
})
|
||||
}
|
||||
}, [navigation, challenge, application, pending])
|
||||
|
||||
const validateChallengeValue = useCallback(
|
||||
async (challengeValue: ChallengeValue) => {
|
||||
if (singleValidation) {
|
||||
setPending(true)
|
||||
return application?.submitValuesForChallenge(challenge, Object.values(challengeValues))
|
||||
} else {
|
||||
const state = challengeValueStates[challengeValue.prompt.id]
|
||||
|
||||
if (
|
||||
state === AuthenticationValueStateType.Locked ||
|
||||
state === AuthenticationValueStateType.Success ||
|
||||
!isValidChallengeValue(challengeValue)
|
||||
) {
|
||||
return
|
||||
}
|
||||
return application?.submitValuesForChallenge(challenge, [challengeValue])
|
||||
}
|
||||
},
|
||||
[challengeValueStates, singleValidation, challengeValues, application, challenge],
|
||||
)
|
||||
|
||||
const onValueLocked = useCallback((challengeValue: ChallengeValue) => {
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Locked,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.WaitingTurn,
|
||||
})
|
||||
}, 30 * 1000)
|
||||
}, [])
|
||||
|
||||
const checkForBiometrics = useCallback(
|
||||
async () => (application?.deviceInterface as MobileDeviceInterface).getDeviceBiometricsAvailability(),
|
||||
[application],
|
||||
)
|
||||
|
||||
const checkPasscodeKeyboardType = useCallback(
|
||||
async () => application?.getAppState().getPasscodeKeyboardType(),
|
||||
[application],
|
||||
)
|
||||
|
||||
const authenticateBiometrics = useCallback(
|
||||
async (challengeValue: ChallengeValue) => {
|
||||
let hasBiometrics = supportsBiometrics
|
||||
if (supportsBiometrics === undefined) {
|
||||
hasBiometrics = await checkForBiometrics()
|
||||
setSupportsBiometrics(hasBiometrics)
|
||||
}
|
||||
if (!hasBiometrics) {
|
||||
FingerprintScanner.release()
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Fail,
|
||||
})
|
||||
Alert.alert('Unsuccessful', 'This device either does not have a biometric sensor or it may not configured.')
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Pending,
|
||||
})
|
||||
|
||||
if (application?.getAppState().screenshotPrivacyEnabled) {
|
||||
hide()
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
await application?.getAppState().performActionWithoutStateChangeImpact(async () => {
|
||||
isAuthenticating.current = true
|
||||
FingerprintScanner.authenticate({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts type does not exist for deviceCredentialAllowed
|
||||
deviceCredentialAllowed: true,
|
||||
description: 'Biometrics are required to access your notes.',
|
||||
})
|
||||
.then(() => {
|
||||
FingerprintScanner.release()
|
||||
const newChallengeValue = { ...challengeValue, value: true }
|
||||
|
||||
onValueChange(newChallengeValue)
|
||||
return validateChallengeValue(newChallengeValue)
|
||||
})
|
||||
.catch(error => {
|
||||
FingerprintScanner.release()
|
||||
if (error.name === 'DeviceLocked') {
|
||||
onValueLocked(challengeValue)
|
||||
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Fail,
|
||||
})
|
||||
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
isAuthenticating.current = false
|
||||
})
|
||||
}, true)
|
||||
} else {
|
||||
// iOS
|
||||
await application?.getAppState().performActionWithoutStateChangeImpact(async () => {
|
||||
isAuthenticating.current = true
|
||||
FingerprintScanner.authenticate({
|
||||
fallbackEnabled: true,
|
||||
description: 'This is required to access your notes.',
|
||||
})
|
||||
.then(() => {
|
||||
FingerprintScanner.release()
|
||||
|
||||
const newChallengeValue = { ...challengeValue, value: true }
|
||||
onValueChange(newChallengeValue)
|
||||
return validateChallengeValue(newChallengeValue)
|
||||
})
|
||||
.catch(error_1 => {
|
||||
onValueChange({ ...challengeValue, value: false })
|
||||
FingerprintScanner.release()
|
||||
if (error_1.name !== 'SystemCancel') {
|
||||
if (error_1.name !== 'UserCancel') {
|
||||
Alert.alert('Unsuccessful')
|
||||
} else {
|
||||
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
|
||||
}
|
||||
}
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Fail,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
isAuthenticating.current = false
|
||||
})
|
||||
}, true)
|
||||
}
|
||||
},
|
||||
[application, checkForBiometrics, onValueLocked, supportsBiometrics, validateChallengeValue],
|
||||
)
|
||||
|
||||
const firstNotSuccessful = useMemo(() => {
|
||||
for (const id in challengeValueStates) {
|
||||
if (challengeValueStates[id] !== AuthenticationValueStateType.Success) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [challengeValueStates])
|
||||
|
||||
const beginAuthenticatingForNextChallengeReason = useCallback(
|
||||
(completedChallengeValue?: ChallengeValue) => {
|
||||
let challengeValue
|
||||
if (completedChallengeValue === undefined) {
|
||||
challengeValue = challengeValues[firstNotSuccessful!]
|
||||
} else {
|
||||
const index = findIndexInObject(challengeValues, completedChallengeValue.prompt.id.toString())
|
||||
|
||||
const hasNextItem = Object.prototype.hasOwnProperty.call(Object.keys(challengeValues), index + 1)
|
||||
if (!hasNextItem) {
|
||||
return
|
||||
}
|
||||
const nextItemId = Object.keys(challengeValues)[index + 1]
|
||||
challengeValue = challengeValues[nextItemId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication modal may be displayed on lose focus just before the app
|
||||
* is closing. In this state however, we don't want to begin auth. We'll
|
||||
* wait until the app gains focus.
|
||||
*/
|
||||
const isLosingFocusOrInBackground =
|
||||
application?.getAppState().getMostRecentState() === AppStateType.LosingFocus ||
|
||||
application?.getAppState().getMostRecentState() === AppStateType.EnteringBackground
|
||||
|
||||
if (challengeValue.prompt.validation === ChallengeValidation.Biometric && !isLosingFocusOrInBackground) {
|
||||
/** Begin authentication right away, we're not waiting for any input */
|
||||
void authenticateBiometrics(challengeValue)
|
||||
} else {
|
||||
const index = findIndexInObject(challengeValues, challengeValue.prompt.id.toString())
|
||||
switch (index) {
|
||||
case 0:
|
||||
firstInputRef.current?.focus()
|
||||
break
|
||||
case 1:
|
||||
secondInputRef.current?.focus()
|
||||
break
|
||||
case 2:
|
||||
thirdInputRef.current?.focus()
|
||||
break
|
||||
case 3:
|
||||
fourthInputRef.current?.focus()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: challengeValue.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.WaitingInput,
|
||||
})
|
||||
},
|
||||
[application, authenticateBiometrics, challengeValues, firstNotSuccessful],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const remove = application?.getAppState().addStateChangeObserver(state => {
|
||||
if (state === AppStateType.ResumingFromBackground) {
|
||||
if (!isAuthenticating.current) {
|
||||
beginAuthenticatingForNextChallengeReason()
|
||||
}
|
||||
} else if (state === AppStateType.EnteringBackground) {
|
||||
FingerprintScanner.release()
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: firstNotSuccessful!,
|
||||
state: AuthenticationValueStateType.WaitingInput,
|
||||
})
|
||||
}
|
||||
})
|
||||
return remove
|
||||
}, [application, beginAuthenticatingForNextChallengeReason, challengeValueStates, firstNotSuccessful])
|
||||
|
||||
const onValidValue = useCallback(
|
||||
(value: ChallengeValue) => {
|
||||
setPending(false)
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: value.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Success,
|
||||
})
|
||||
beginAuthenticatingForNextChallengeReason(value)
|
||||
},
|
||||
[beginAuthenticatingForNextChallengeReason],
|
||||
)
|
||||
|
||||
const onInvalidValue = (value: ChallengeValue) => {
|
||||
setPending(false)
|
||||
dispatch({
|
||||
type: 'setState',
|
||||
id: value.prompt.id.toString(),
|
||||
state: AuthenticationValueStateType.Fail,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
let removeObserver: () => void = () => {}
|
||||
if (application?.addChallengeObserver) {
|
||||
removeObserver = application?.addChallengeObserver(challenge, {
|
||||
onValidValue,
|
||||
onInvalidValue,
|
||||
onComplete: () => {
|
||||
navigation.goBack()
|
||||
},
|
||||
onCancel: () => {
|
||||
navigation.goBack()
|
||||
},
|
||||
})
|
||||
}
|
||||
return removeObserver
|
||||
}, [application, challenge, navigation, onValidValue])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const setBiometricsAsync = async () => {
|
||||
if (challenge.reason === ChallengeReason.ApplicationUnlock) {
|
||||
const hasBiometrics = await checkForBiometrics()
|
||||
if (mounted) {
|
||||
setSupportsBiometrics(hasBiometrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
void setBiometricsAsync()
|
||||
const setInitialPasscodeKeyboardType = async () => {
|
||||
const initialPasscodeKeyboardType = await checkPasscodeKeyboardType()
|
||||
if (mounted) {
|
||||
setPasscodeKeyboardType(initialPasscodeKeyboardType)
|
||||
}
|
||||
}
|
||||
void setInitialPasscodeKeyboardType()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [challenge.reason, checkForBiometrics, checkPasscodeKeyboardType])
|
||||
|
||||
/**
|
||||
* Authenticate for challenge reasons like biometrics as soon as possible,
|
||||
* unless a prompt has a prefilled control value, in which case give the
|
||||
* option to adjust them first.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
challenge.prompts &&
|
||||
challenge.prompts.length > 0 &&
|
||||
challenge.prompts[0].validation !== ChallengeValidation.ProtectionSessionDuration
|
||||
) {
|
||||
beginAuthenticatingForNextChallengeReason()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onBiometricDirectPress = () => {
|
||||
Keyboard.dismiss()
|
||||
|
||||
const biometricChallengeValue = Object.values(challengeValues).find(
|
||||
value => value.prompt.validation === ChallengeValidation.Biometric,
|
||||
)
|
||||
const state = challengeValueStates[biometricChallengeValue?.prompt.id as number]
|
||||
if (state === AuthenticationValueStateType.Locked || state === AuthenticationValueStateType.Success) {
|
||||
return
|
||||
}
|
||||
|
||||
beginAuthenticatingForNextChallengeReason()
|
||||
}
|
||||
|
||||
const onValueChange = (newValue: ChallengeValue, dismissKeyboard = false) => {
|
||||
if (dismissKeyboard) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setValue',
|
||||
id: newValue.prompt.id.toString(),
|
||||
value: newValue.value,
|
||||
})
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
// Always block back button on Android
|
||||
return true
|
||||
}
|
||||
|
||||
BackHandler.addEventListener('hardwareBackPress', onBackPress)
|
||||
|
||||
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress)
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onSubmitPress = () => {
|
||||
const challengeValue = challengeValues[firstNotSuccessful!]
|
||||
if (!isValidChallengeValue(challengeValue)) {
|
||||
return
|
||||
}
|
||||
if (singleValidation) {
|
||||
void validateChallengeValue(challengeValue)
|
||||
} else {
|
||||
const state = challengeValueStates[firstNotSuccessful!]
|
||||
if (
|
||||
challengeValue.prompt.validation === ChallengeValidation.Biometric &&
|
||||
(state === AuthenticationValueStateType.Locked || state === AuthenticationValueStateType.Fail)
|
||||
) {
|
||||
beginAuthenticatingForNextChallengeReason()
|
||||
return
|
||||
}
|
||||
void validateChallengeValue(challengeValue)
|
||||
}
|
||||
}
|
||||
|
||||
const switchKeyboard = () => {
|
||||
if (passcodeKeyboardType === PasscodeKeyboardType.Numeric) {
|
||||
setPasscodeKeyboardType(PasscodeKeyboardType.Default)
|
||||
} else {
|
||||
setPasscodeKeyboardType(PasscodeKeyboardType.Numeric)
|
||||
}
|
||||
}
|
||||
|
||||
const readyToSubmit = useMemo(
|
||||
() =>
|
||||
Object.values(challengeValues)
|
||||
.map(challengeValue => challengeValue.value)
|
||||
.filter(value => !value).length === 0,
|
||||
[challengeValues],
|
||||
)
|
||||
|
||||
const renderAuthenticationSource = (challengeValue: ChallengeValue, index: number) => {
|
||||
const last = index === Object.keys(challengeValues).length - 1
|
||||
const state = challengeValueStates[challengeValue.prompt.id]
|
||||
const active = isInActiveState(state)
|
||||
const isBiometric = challengeValue.prompt.validation === ChallengeValidation.Biometric
|
||||
const isProtectionSessionDuration =
|
||||
challengeValue.prompt.validation === ChallengeValidation.ProtectionSessionDuration
|
||||
const isInput = !isBiometric && !isProtectionSessionDuration
|
||||
const stateLabel = getLabelForStateAndType(challengeValue.prompt.validation, state)
|
||||
|
||||
const stateTitle = getChallengePromptTitle(challengeValue.prompt, state)
|
||||
|
||||
const keyboardType =
|
||||
challengeValue.prompt.keyboardType ??
|
||||
(challengeValue.prompt.validation === ChallengeValidation.LocalPasscode ? passcodeKeyboardType : 'default')
|
||||
|
||||
return (
|
||||
<SourceContainer key={challengeValue.prompt.id}>
|
||||
<StyledTableSection last={last}>
|
||||
<SectionHeader
|
||||
title={stateTitle}
|
||||
subtitle={isInput ? stateLabel : undefined}
|
||||
tinted={active}
|
||||
buttonText={
|
||||
challengeValue.prompt.validation === ChallengeValidation.LocalPasscode && showSwitchKeyboard
|
||||
? 'Change Keyboard'
|
||||
: undefined
|
||||
}
|
||||
buttonAction={switchKeyboard}
|
||||
buttonStyles={
|
||||
challengeValue.prompt.validation === ChallengeValidation.LocalPasscode
|
||||
? {
|
||||
color: theme.stylekitNeutralColor,
|
||||
fontSize: theme.mainTextFontSize - 5,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{isInput && (
|
||||
<SectionContainer>
|
||||
<SectionedTableCell textInputCell={true} first={true}>
|
||||
<Input
|
||||
key={Platform.OS === 'android' ? keyboardType : undefined}
|
||||
ref={Array.of(firstInputRef, secondInputRef, thirdInputRef, fourthInputRef)[index] as any}
|
||||
placeholder={challengeValue.prompt.placeholder}
|
||||
onChangeText={text => {
|
||||
onValueChange({ ...challengeValue, value: text })
|
||||
}}
|
||||
value={(challengeValue.value || '') as string}
|
||||
autoCorrect={false}
|
||||
autoFocus={false}
|
||||
autoCapitalize={'none'}
|
||||
secureTextEntry={challengeValue.prompt.secureTextEntry}
|
||||
keyboardType={keyboardType}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
underlineColorAndroid={'transparent'}
|
||||
onSubmitEditing={
|
||||
!singleValidation
|
||||
? () => {
|
||||
void validateChallengeValue(challengeValue)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onFocus={() => setShowSwitchKeyboard(true)}
|
||||
onBlur={() => setShowSwitchKeyboard(false)}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
</SectionContainer>
|
||||
)}
|
||||
{isBiometric && (
|
||||
<SectionContainer>
|
||||
<SectionedAccessoryTableCell
|
||||
first={true}
|
||||
dimmed={active}
|
||||
tinted={active}
|
||||
text={stateLabel}
|
||||
onPress={onBiometricDirectPress}
|
||||
/>
|
||||
</SectionContainer>
|
||||
)}
|
||||
{isProtectionSessionDuration && (
|
||||
<SessionLengthContainer>
|
||||
{ProtectionSessionDurations.map((duration, i) => (
|
||||
<SectionedAccessoryTableCell
|
||||
text={duration.label}
|
||||
key={duration.valueInSeconds}
|
||||
first={i === 0}
|
||||
last={i === ProtectionSessionDurations.length - 1}
|
||||
selected={() => {
|
||||
return duration.valueInSeconds === challengeValue.value
|
||||
}}
|
||||
onPress={() => {
|
||||
onValueChange(
|
||||
{
|
||||
...challengeValue,
|
||||
value: duration.valueInSeconds,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SessionLengthContainer>
|
||||
)}
|
||||
</StyledTableSection>
|
||||
</SourceContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const isPending = useMemo(
|
||||
() => Object.values(challengeValueStates).findIndex(state => state === AuthenticationValueStateType.Pending) >= 0,
|
||||
[challengeValueStates],
|
||||
)
|
||||
|
||||
let submitButtonTitle: 'Submit' | 'Next'
|
||||
if (singleValidation) {
|
||||
submitButtonTitle = 'Submit'
|
||||
} else if (!firstNotSuccessful) {
|
||||
submitButtonTitle = 'Next'
|
||||
} else {
|
||||
const stateKeys = Object.keys(challengeValueStates)
|
||||
submitButtonTitle = 'Submit'
|
||||
/** Check the next values; if one of them is not successful, show 'Next' */
|
||||
for (let i = stateKeys.indexOf(firstNotSuccessful) + 1; i < stateKeys.length; i++) {
|
||||
const nextValueState = challengeValueStates[stateKeys[i]]
|
||||
if (nextValueState !== AuthenticationValueStateType.Success) {
|
||||
submitButtonTitle = 'Next'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderHeightContext.Consumer>
|
||||
{headerHeight => (
|
||||
<StyledKeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={headerHeight}
|
||||
>
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{(challenge.heading || challenge.subheading) && (
|
||||
<StyledTableSection>
|
||||
<StyledSectionedTableCell>
|
||||
<BaseView>
|
||||
{challenge.heading && <Title>{challenge.heading}</Title>}
|
||||
{challenge.subheading && <Subtitle>{challenge.subheading}</Subtitle>}
|
||||
</BaseView>
|
||||
</StyledSectionedTableCell>
|
||||
</StyledTableSection>
|
||||
)}
|
||||
{Object.values(challengeValues).map((challengeValue, index) =>
|
||||
renderAuthenticationSource(challengeValue, index),
|
||||
)}
|
||||
<ButtonCell
|
||||
maxHeight={45}
|
||||
disabled={singleValidation ? !readyToSubmit || pending : isPending}
|
||||
title={submitButtonTitle}
|
||||
bold={true}
|
||||
onPress={onSubmitPress}
|
||||
/>
|
||||
</ScrollView>
|
||||
</StyledKeyboardAvoidingView>
|
||||
)}
|
||||
</HeaderHeightContext.Consumer>
|
||||
)
|
||||
}
|
||||
114
packages/mobile/src/Screens/Authenticate/helpers.ts
Normal file
114
packages/mobile/src/Screens/Authenticate/helpers.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ChallengePrompt, ChallengeValidation, ChallengeValue } from '@standardnotes/snjs'
|
||||
|
||||
export const isInActiveState = (state: AuthenticationValueStateType) =>
|
||||
state !== AuthenticationValueStateType.WaitingInput && state !== AuthenticationValueStateType.Success
|
||||
|
||||
export enum AuthenticationValueStateType {
|
||||
WaitingTurn = 0,
|
||||
WaitingInput = 1,
|
||||
Success = 2,
|
||||
Fail = 3,
|
||||
Pending = 4,
|
||||
Locked = 5,
|
||||
}
|
||||
|
||||
type ChallengeValueState = {
|
||||
challengeValues: Record<string, ChallengeValue>
|
||||
challengeValueStates: Record<string, AuthenticationValueStateType>
|
||||
}
|
||||
type SetChallengeValueState = {
|
||||
type: 'setState'
|
||||
id: string
|
||||
state: AuthenticationValueStateType
|
||||
}
|
||||
type SetChallengeValue = {
|
||||
type: 'setValue'
|
||||
id: string
|
||||
value: ChallengeValue['value']
|
||||
}
|
||||
|
||||
type Action = SetChallengeValueState | SetChallengeValue
|
||||
export const authenticationReducer = (state: ChallengeValueState, action: Action): ChallengeValueState => {
|
||||
switch (action.type) {
|
||||
case 'setState': {
|
||||
return {
|
||||
...state,
|
||||
challengeValueStates: {
|
||||
...state.challengeValueStates,
|
||||
[action.id]: action.state,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'setValue': {
|
||||
const updatedChallengeValue = state.challengeValues[action.id]
|
||||
return {
|
||||
...state,
|
||||
challengeValues: {
|
||||
...state.challengeValues,
|
||||
[action.id]: {
|
||||
...updatedChallengeValue,
|
||||
value: action.value,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export const findIndexInObject = (
|
||||
map: ChallengeValueState['challengeValues'] | ChallengeValueState['challengeValueStates'],
|
||||
id: string,
|
||||
) => {
|
||||
return Object.keys(map).indexOf(id)
|
||||
}
|
||||
|
||||
export const getChallengePromptTitle = (prompt: ChallengePrompt, state: AuthenticationValueStateType) => {
|
||||
const title = prompt.title
|
||||
switch (state) {
|
||||
case AuthenticationValueStateType.WaitingTurn:
|
||||
return title ?? 'Waiting'
|
||||
case AuthenticationValueStateType.Locked:
|
||||
return title ?? 'Locked'
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
export const getLabelForStateAndType = (validation: ChallengeValidation, state: AuthenticationValueStateType) => {
|
||||
switch (validation) {
|
||||
case ChallengeValidation.Biometric: {
|
||||
switch (state) {
|
||||
case AuthenticationValueStateType.WaitingTurn:
|
||||
return 'Waiting for passcode'
|
||||
case AuthenticationValueStateType.WaitingInput:
|
||||
return 'Press here to begin biometrics scan'
|
||||
case AuthenticationValueStateType.Pending:
|
||||
return 'Waiting for unlock'
|
||||
case AuthenticationValueStateType.Success:
|
||||
return 'Success | Biometrics'
|
||||
case AuthenticationValueStateType.Fail:
|
||||
return 'Biometrics failed. Tap to try again.'
|
||||
case AuthenticationValueStateType.Locked:
|
||||
return 'Biometrics locked. Try again in 30 seconds.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
default:
|
||||
switch (state) {
|
||||
case AuthenticationValueStateType.WaitingTurn:
|
||||
case AuthenticationValueStateType.WaitingInput:
|
||||
return 'Waiting'
|
||||
case AuthenticationValueStateType.Pending:
|
||||
return 'Verifying keys...'
|
||||
case AuthenticationValueStateType.Success:
|
||||
return 'Success'
|
||||
case AuthenticationValueStateType.Fail:
|
||||
return 'Invalid value. Please try again.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal file
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import WebView from 'react-native-webview'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
export const FlexContainer = styled(SafeAreaView).attrs(() => ({
|
||||
edges: ['bottom'],
|
||||
}))`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
export const LockedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: ${({ theme }) => theme.stylekitWarningColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
export const LockedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
`
|
||||
|
||||
export const StyledWebview = styled(WebView)<{ showWebView: boolean }>`
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
opacity: 0.99;
|
||||
min-height: 1px;
|
||||
${({ showWebView }) =>
|
||||
!showWebView &&
|
||||
css`
|
||||
display: none;
|
||||
`};
|
||||
`
|
||||
|
||||
export const StyledIcon = styled(Icon).attrs(({ theme }) => ({
|
||||
color: theme.stylekitBackgroundColor,
|
||||
size: 16,
|
||||
name: ThemeService.nameForIcon(ICON_LOCK),
|
||||
}))``
|
||||
|
||||
export const DeprecatedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: ${({ theme }) => theme.stylekitWarningColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
|
||||
export const DeprecatedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
`
|
||||
|
||||
export const DeprecatedIcon = styled(Icon).attrs(({ theme }) => ({
|
||||
color: theme.stylekitBackgroundColor,
|
||||
size: 16,
|
||||
name: ThemeService.nameForIcon(ICON_ALERT),
|
||||
}))``
|
||||
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal file
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { ComponentLoadingError } from '@Lib/ComponentManager'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import { ButtonType, ComponentViewer, PrefKey } from '@standardnotes/snjs'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import {
|
||||
OnShouldStartLoadWithRequest,
|
||||
WebViewErrorEvent,
|
||||
WebViewMessageEvent,
|
||||
} from 'react-native-webview/lib/WebViewTypes'
|
||||
import {
|
||||
DeprecatedContainer,
|
||||
DeprecatedIcon,
|
||||
DeprecatedText,
|
||||
FlexContainer,
|
||||
LockedContainer,
|
||||
LockedText,
|
||||
StyledIcon,
|
||||
StyledWebview,
|
||||
} from './ComponentView.styled'
|
||||
|
||||
type Props = {
|
||||
componentViewer: ComponentViewer
|
||||
onLoadEnd: () => void
|
||||
onLoadStart: () => void
|
||||
onLoadError: (error: ComponentLoadingError, desc?: string) => void
|
||||
onDownloadEditorStart: () => void
|
||||
onDownloadEditorEnd: () => void
|
||||
}
|
||||
|
||||
const log = (message?: any, ...optionalParams: any[]) => {
|
||||
const LOGGING_ENABLED = false
|
||||
if (LOGGING_ENABLED) {
|
||||
console.log(message, optionalParams, '\n\n')
|
||||
console.log('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
/** On Android, webview.onShouldStartLoadWithRequest is not called by react-native-webview*/
|
||||
const SupportsShouldLoadRequestHandler = Platform.OS === 'ios'
|
||||
|
||||
export const ComponentView = ({
|
||||
onLoadEnd,
|
||||
onLoadError,
|
||||
onLoadStart,
|
||||
onDownloadEditorStart,
|
||||
onDownloadEditorEnd,
|
||||
componentViewer,
|
||||
}: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [showWebView, setShowWebView] = useState<boolean>(true)
|
||||
const [requiresLocalEditor, setRequiresLocalEditor] = useState<boolean>(false)
|
||||
const [localEditorReady, setLocalEditorReady] = useState<boolean>(false)
|
||||
|
||||
// Ref
|
||||
const didLoadRootUrl = useRef<boolean>(false)
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
||||
|
||||
useEffect(() => {
|
||||
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
||||
setShowWebView(false)
|
||||
})
|
||||
|
||||
return removeBlurScreenListener
|
||||
}, [navigation])
|
||||
|
||||
useFocusEffect(() => {
|
||||
setShowWebView(true)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const warnIfUnsupportedEditors = async () => {
|
||||
let platformVersionRequirements
|
||||
|
||||
switch (Platform.OS) {
|
||||
case 'ios':
|
||||
if (parseInt(Platform.Version.toString(), 10) < 11) {
|
||||
// WKWebView has issues on iOS < 11
|
||||
platformVersionRequirements = 'iOS 11 or greater'
|
||||
}
|
||||
break
|
||||
case 'android':
|
||||
if (Platform.Version <= 23) {
|
||||
/**
|
||||
* postMessage doesn't work on Android <= 6 (API version 23)
|
||||
* https://github.com/facebook/react-native/issues/11594
|
||||
*/
|
||||
platformVersionRequirements = 'Android 7.0 or greater'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!platformVersionRequirements) {
|
||||
return
|
||||
}
|
||||
|
||||
const doNotShowAgainUnsupportedEditors = application
|
||||
?.getLocalPreferences()
|
||||
.getValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, false)
|
||||
|
||||
if (!doNotShowAgainUnsupportedEditors) {
|
||||
const alertText =
|
||||
`Web editors require ${platformVersionRequirements}. ` +
|
||||
'Your version does not support web editors. ' +
|
||||
'Changes you make may not be properly saved. Please switch to the Plain Editor for the best experience.'
|
||||
|
||||
const confirmed = await application?.alertService?.confirm(
|
||||
alertText,
|
||||
'Editors Not Supported',
|
||||
"Don't show again",
|
||||
ButtonType.Info,
|
||||
'OK',
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void warnIfUnsupportedEditors()
|
||||
}, [application])
|
||||
|
||||
const onLoadErrorHandler = useCallback(
|
||||
(error?: WebViewErrorEvent) => {
|
||||
log('On load error', error)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
onLoadError(ComponentLoadingError.Unknown, error?.nativeEvent?.description)
|
||||
},
|
||||
[onLoadError, timeoutRef],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const componentManager = application!.mobileComponentManager
|
||||
const component = componentViewer.component
|
||||
const isDownloadable = componentManager.isComponentDownloadable(component)
|
||||
setRequiresLocalEditor(isDownloadable)
|
||||
|
||||
if (isDownloadable) {
|
||||
const asyncFunc = async () => {
|
||||
if (await componentManager.doesComponentNeedDownload(component)) {
|
||||
onDownloadEditorStart()
|
||||
const error = await componentManager.downloadComponentOffline(component)
|
||||
log('Download component error', error)
|
||||
onDownloadEditorEnd()
|
||||
if (error) {
|
||||
onLoadError(error)
|
||||
}
|
||||
}
|
||||
setLocalEditorReady(true)
|
||||
}
|
||||
void asyncFunc()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onMessage = (event: WebViewMessageEvent) => {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data)
|
||||
} catch (e) {
|
||||
log('Message is not valid JSON, returning')
|
||||
return
|
||||
}
|
||||
componentViewer?.handleMessage(data)
|
||||
}
|
||||
|
||||
const onFrameLoad = useCallback(() => {
|
||||
log('Iframe did load', webViewRef.current?.props.source)
|
||||
|
||||
/**
|
||||
* We have no way of knowing if the webview load is successful or not. We
|
||||
* have to wait to see if the error event is fired. Looking at the code,
|
||||
* the error event is fired right after this, so we can wait just a few ms
|
||||
* to see if the error event is fired before registering the component
|
||||
* window. Otherwise, on error, this component will be dealloced, and a
|
||||
* pending postMessage will cause a memory leak crash on Android in the
|
||||
* form of "react native attempt to invoke virtual method
|
||||
* double java.lang.double.doublevalue() on a null object reference"
|
||||
*/
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
if (didLoadRootUrl.current === true || !SupportsShouldLoadRequestHandler) {
|
||||
log('Setting component viewer webview')
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
componentViewer?.setWindow(webViewRef.current as unknown as Window)
|
||||
}, 1)
|
||||
/**
|
||||
* The parent will remove their loading screen on load end. We want to
|
||||
* delay this to avoid flicker that may result if using a dark theme.
|
||||
* This delay will allow editor to load its theme.
|
||||
*/
|
||||
const isDarkTheme = themeService?.isLikelyUsingDarkColorTheme()
|
||||
const delayToAvoidFlicker = isDarkTheme ? 50 : 0
|
||||
setTimeout(() => {
|
||||
onLoadEnd()
|
||||
}, delayToAvoidFlicker)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onLoadStartHandler = () => {
|
||||
onLoadStart()
|
||||
}
|
||||
|
||||
const onShouldStartLoadWithRequest: OnShouldStartLoadWithRequest = request => {
|
||||
log('Setting last iframe URL to', request.url)
|
||||
/** The first request can typically be 'about:blank', which we want to ignore */
|
||||
if (!didLoadRootUrl.current) {
|
||||
didLoadRootUrl.current = request.url === componentViewer.url!
|
||||
}
|
||||
/**
|
||||
* We want to handle link clicks within an editor by opening the browser
|
||||
* instead of loading inline. On iOS, onShouldStartLoadWithRequest is
|
||||
* called for all requests including the initial request to load the editor.
|
||||
* On iOS, clicks in the editors have a navigationType of 'click', but on
|
||||
* Android, this is not the case (no navigationType).
|
||||
* However, on Android, this function is not called for the initial request.
|
||||
* So that might be one way to determine if this request is a click or the
|
||||
* actual editor load request. But I don't think it's safe to rely on this
|
||||
* being the case in the future. So on Android, we'll handle url loads only
|
||||
* if the url isn't equal to the editor url.
|
||||
*/
|
||||
|
||||
if (
|
||||
(Platform.OS === 'ios' && request.navigationType === 'click') ||
|
||||
(Platform.OS === 'android' && request.url !== componentViewer.url!)
|
||||
) {
|
||||
application!.deviceInterface!.openUrl(request.url)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultInjectedJavaScript = () => {
|
||||
return `(function() {
|
||||
window.parent.postMessage = function(data) {
|
||||
window.parent.ReactNativeWebView.postMessage(data);
|
||||
};
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('content', 'width=device-width, initial-scale=1, user-scalable=no');
|
||||
meta.setAttribute('name', 'viewport');
|
||||
document.getElementsByTagName('head')[0].appendChild(meta);
|
||||
return true;
|
||||
})()`
|
||||
}
|
||||
|
||||
const deprecationMessage = componentViewer.component.deprecationMessage
|
||||
|
||||
const renderWebview = !requiresLocalEditor || localEditorReady
|
||||
|
||||
return (
|
||||
<FlexContainer>
|
||||
{componentViewer.component.isExpired && (
|
||||
<LockedContainer>
|
||||
<StyledIcon />
|
||||
<LockedText>
|
||||
Subscription expired. Editors are in a read-only state. To edit immediately, please switch to the Plain
|
||||
Editor.
|
||||
</LockedText>
|
||||
</LockedContainer>
|
||||
)}
|
||||
|
||||
{componentViewer.component.isDeprecated && (
|
||||
<DeprecatedContainer>
|
||||
<DeprecatedIcon />
|
||||
<DeprecatedText>{deprecationMessage || 'This extension is deprecated.'}</DeprecatedText>
|
||||
</DeprecatedContainer>
|
||||
)}
|
||||
|
||||
{renderWebview && (
|
||||
<StyledWebview
|
||||
showWebView={showWebView}
|
||||
source={{ uri: componentViewer.url! }}
|
||||
key={componentViewer.component.uuid}
|
||||
ref={webViewRef}
|
||||
/**
|
||||
* onLoad and onLoadEnd seem to be the same exact thing, except
|
||||
* that when an error occurs, onLoadEnd is called twice, whereas
|
||||
* onLoad is called once (what we want)
|
||||
*/
|
||||
onLoad={onFrameLoad}
|
||||
onLoadStart={onLoadStartHandler}
|
||||
onError={onLoadErrorHandler}
|
||||
onHttpError={() => onLoadErrorHandler()}
|
||||
onMessage={onMessage}
|
||||
hideKeyboardAccessoryView={true}
|
||||
setSupportMultipleWindows={false}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
cacheEnabled={true}
|
||||
autoManageStatusBarEnabled={false /* To prevent StatusBar from changing colors when focusing */}
|
||||
injectedJavaScript={defaultInjectedJavaScript()}
|
||||
onContentProcessDidTerminate={() => onLoadErrorHandler()}
|
||||
/>
|
||||
)}
|
||||
</FlexContainer>
|
||||
)
|
||||
}
|
||||
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal file
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import SNTextView from '@standardnotes/react-native-textview'
|
||||
import React, { ComponentProps } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
const PADDING = 14
|
||||
const NOTE_TITLE_HEIGHT = 50
|
||||
|
||||
export const Container = styled.View`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
export const LockedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${PADDING}px;
|
||||
padding: 8px;
|
||||
background-color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
export const LockedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
padding-right: 100px;
|
||||
`
|
||||
export const WebViewReloadButton = styled.TouchableOpacity`
|
||||
position: absolute;
|
||||
right: ${PADDING}px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
export const WebViewReloadButtonText = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
`
|
||||
export const NoteTitleInput = styled.TextInput`
|
||||
font-weight: ${Platform.OS === 'ios' ? 600 : 'bold'};
|
||||
font-size: ${Platform.OS === 'ios' ? 17 : 18}px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
height: ${NOTE_TITLE_HEIGHT}px;
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
padding-top: ${Platform.OS === 'ios' ? 5 : 12}px;
|
||||
padding-left: ${PADDING}px;
|
||||
padding-right: ${PADDING}px;
|
||||
`
|
||||
export const LoadingWebViewContainer = styled.View<{ locked?: boolean }>`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: ${({ locked }) => (locked ? NOTE_TITLE_HEIGHT + 26 : NOTE_TITLE_HEIGHT)}px;
|
||||
bottom: 0px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
export const LoadingText = styled.Text`
|
||||
padding-left: 0px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
opacity: 0.7;
|
||||
margin-top: 5px;
|
||||
`
|
||||
export const ContentContainer = styled.View`
|
||||
flex-grow: 1;
|
||||
`
|
||||
export const TextContainer = styled.View`
|
||||
flex: 1;
|
||||
`
|
||||
export const StyledKeyboardAvoidngView = styled.KeyboardAvoidingView`
|
||||
flex: 1;
|
||||
${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
const StyledTextViewComponent = styled(SNTextView)<{ errorState: boolean }>`
|
||||
padding-top: 10px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
padding-left: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
padding-right: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
padding-bottom: ${({ errorState }) => (errorState ? 36 : 10)}px;
|
||||
${Platform.OS === 'ios' &&
|
||||
css`
|
||||
height: 96%;
|
||||
`}
|
||||
${Platform.OS === 'android' &&
|
||||
css`
|
||||
flex: 1;
|
||||
`}
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
/* ${Platform.OS === 'ios' && 'padding-bottom: 10px'}; */
|
||||
`
|
||||
|
||||
export const StyledTextView = React.memo(
|
||||
StyledTextViewComponent,
|
||||
(newProps: ComponentProps<typeof SNTextView>, prevProps: ComponentProps<typeof SNTextView>) => {
|
||||
if (
|
||||
newProps.value !== prevProps.value ||
|
||||
newProps.selectionColor !== prevProps.selectionColor ||
|
||||
newProps.handlesColor !== prevProps.handlesColor ||
|
||||
newProps.autoFocus !== prevProps.autoFocus ||
|
||||
newProps.editable !== prevProps.editable ||
|
||||
newProps.keyboardDismissMode !== prevProps.keyboardDismissMode ||
|
||||
newProps.keyboardAppearance !== prevProps.keyboardAppearance ||
|
||||
newProps.testID !== prevProps.testID ||
|
||||
newProps.multiline !== prevProps.multiline
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
)
|
||||
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal file
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import { AppStateEventType } from '@Lib/ApplicationState'
|
||||
import { ComponentLoadingError, ComponentManager } from '@Lib/ComponentManager'
|
||||
import { isNullOrUndefined } from '@Lib/Utils'
|
||||
import { ApplicationContext, SafeApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { SCREEN_COMPOSE } from '@Root/Screens/screens'
|
||||
import SNTextView from '@standardnotes/react-native-textview'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentMutator,
|
||||
ComponentViewer,
|
||||
ContentType,
|
||||
isPayloadSourceInternalChange,
|
||||
isPayloadSourceRetrieved,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
NoteViewController,
|
||||
PayloadEmitSource,
|
||||
SNComponent,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
|
||||
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
|
||||
import { lighten } from '@Style/Utils'
|
||||
import React, { createRef } from 'react'
|
||||
import { Keyboard, Platform, View } from 'react-native'
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { ComponentView } from './ComponentView'
|
||||
import {
|
||||
Container,
|
||||
LoadingText,
|
||||
LoadingWebViewContainer,
|
||||
LockedContainer,
|
||||
LockedText,
|
||||
NoteTitleInput,
|
||||
StyledTextView,
|
||||
TextContainer,
|
||||
WebViewReloadButton,
|
||||
WebViewReloadButtonText,
|
||||
} from './Compose.styled'
|
||||
|
||||
const NOTE_PREVIEW_CHAR_LIMIT = 80
|
||||
const MINIMUM_STATUS_DURATION = 400
|
||||
const SAVE_TIMEOUT_DEBOUNCE = 250
|
||||
const SAVE_TIMEOUT_NO_DEBOUNCE = 100
|
||||
|
||||
type State = {
|
||||
title: string
|
||||
text: string
|
||||
saveError: boolean
|
||||
webViewError?: ComponentLoadingError
|
||||
webViewErrorDesc?: string
|
||||
loadingWebview: boolean
|
||||
downloadingEditor: boolean
|
||||
componentViewer?: ComponentViewer
|
||||
}
|
||||
|
||||
type PropsWhenNavigating = AppStackNavigationProp<typeof SCREEN_COMPOSE>
|
||||
|
||||
type PropsWhenRenderingDirectly = {
|
||||
noteUuid: UuidString
|
||||
}
|
||||
|
||||
const EditingIsDisabledText = 'This note has editing disabled. Please enable editing on this note to make changes.'
|
||||
|
||||
export class Compose extends React.Component<PropsWhenNavigating | PropsWhenRenderingDirectly, State> {
|
||||
static override contextType = ApplicationContext
|
||||
override context: React.ContextType<typeof ApplicationContext>
|
||||
editor: NoteViewController
|
||||
editorViewRef: React.RefObject<SNTextView> = createRef()
|
||||
saveTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
alreadySaved = false
|
||||
statusTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
downloadingMessageTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
removeNoteInnerValueObserver?: () => void
|
||||
removeComponentsObserver?: () => void
|
||||
removeStreamComponents?: () => void
|
||||
removeStateEventObserver?: () => void
|
||||
removeAppEventObserver?: () => void
|
||||
removeComponentHandler?: () => void
|
||||
|
||||
constructor(
|
||||
props: PropsWhenNavigating | PropsWhenRenderingDirectly,
|
||||
context: React.ContextType<typeof SafeApplicationContext>,
|
||||
) {
|
||||
super(props)
|
||||
this.context = context
|
||||
|
||||
const noteUuid = 'noteUuid' in props ? props.noteUuid : props.route.params.noteUuid
|
||||
const editor = this.context.editorGroup.noteControllers.find(c => c.note.uuid === noteUuid)
|
||||
if (!editor) {
|
||||
throw 'Unable to to find note controller'
|
||||
}
|
||||
|
||||
this.editor = editor
|
||||
|
||||
this.state = {
|
||||
title: this.editor.note.title,
|
||||
text: this.editor.note.text,
|
||||
componentViewer: undefined,
|
||||
saveError: false,
|
||||
webViewError: undefined,
|
||||
loadingWebview: false,
|
||||
downloadingEditor: false,
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
this.removeNoteInnerValueObserver = this.editor.addNoteInnerValueChangeObserver((note, source) => {
|
||||
if (isPayloadSourceRetrieved(source)) {
|
||||
this.setState({
|
||||
title: note.title,
|
||||
text: note.text,
|
||||
})
|
||||
}
|
||||
|
||||
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadEmitSource.LocalInserted && note.dirty
|
||||
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
if (note.lastSyncEnd) {
|
||||
if (note.dirty || (note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime())) {
|
||||
this.showSavingStatus()
|
||||
} else if (
|
||||
this.context?.getStatusManager().hasMessage(SCREEN_COMPOSE) &&
|
||||
note.lastSyncBegan &&
|
||||
note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
|
||||
) {
|
||||
this.showAllChangesSavedStatus()
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.removeStreamComponents = this.context?.streamItems(ContentType.Component, async ({ source }) => {
|
||||
if (isPayloadSourceInternalChange(source)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.note) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.reloadComponentEditorState()
|
||||
})
|
||||
|
||||
this.removeAppEventObserver = this.context?.addEventObserver(async eventName => {
|
||||
if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
||||
if (!this.note.dirty && this.state.saveError) {
|
||||
this.showAllChangesSavedStatus()
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.FailedSync) {
|
||||
/**
|
||||
* Only show error status in editor if the note is dirty.
|
||||
* Otherwise, it means the originating sync came from somewhere else
|
||||
* and we don't want to display an error here.
|
||||
*/
|
||||
if (this.note.dirty) {
|
||||
this.showErrorStatus('Sync Unavailable (changes saved offline)')
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
this.showErrorStatus('Offline Saving Issue (changes not saved)')
|
||||
}
|
||||
})
|
||||
|
||||
this.removeStateEventObserver = this.context?.getAppState().addStateEventObserver(state => {
|
||||
if (state === AppStateEventType.DrawerOpen) {
|
||||
this.dismissKeyboard()
|
||||
/**
|
||||
* Saves latest note state before any change might happen in the drawer
|
||||
*/
|
||||
}
|
||||
})
|
||||
|
||||
if (this.editor.isTemplateNote && Platform.OS === 'ios') {
|
||||
setTimeout(() => {
|
||||
this.editorViewRef?.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount() {
|
||||
this.dismissKeyboard()
|
||||
this.removeNoteInnerValueObserver && this.removeNoteInnerValueObserver()
|
||||
this.removeAppEventObserver && this.removeAppEventObserver()
|
||||
this.removeStreamComponents && this.removeStreamComponents()
|
||||
this.removeStateEventObserver && this.removeStateEventObserver()
|
||||
this.removeComponentHandler && this.removeComponentHandler()
|
||||
this.removeStateEventObserver = undefined
|
||||
this.removeNoteInnerValueObserver = undefined
|
||||
this.removeComponentHandler = undefined
|
||||
this.removeStreamComponents = undefined
|
||||
this.removeAppEventObserver = undefined
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, '')
|
||||
if (this.state.componentViewer && this.componentManager) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
if (this.statusTimeout) {
|
||||
clearTimeout(this.statusTimeout)
|
||||
}
|
||||
if (this.downloadingMessageTimeout) {
|
||||
clearTimeout(this.downloadingMessageTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Because note.locked accesses note.content.appData,
|
||||
* we do not want to expose the template to direct access to note.locked,
|
||||
* otherwise an exception will occur when trying to access note.locked if the note
|
||||
* is deleted. There is potential for race conditions to occur with setState, where a
|
||||
* previous setState call may have queued a digest cycle, and the digest cycle triggers
|
||||
* on a deleted note.
|
||||
*/
|
||||
get noteLocked() {
|
||||
if (!this.note) {
|
||||
return false
|
||||
}
|
||||
return this.note.locked
|
||||
}
|
||||
|
||||
setStatus = (status: string, color?: string, wait = true) => {
|
||||
if (this.statusTimeout) {
|
||||
clearTimeout(this.statusTimeout)
|
||||
}
|
||||
if (wait) {
|
||||
this.statusTimeout = setTimeout(() => {
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
|
||||
}, MINIMUM_STATUS_DURATION)
|
||||
} else {
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
|
||||
}
|
||||
}
|
||||
|
||||
showSavingStatus = () => {
|
||||
this.setStatus('Saving...', undefined, false)
|
||||
}
|
||||
|
||||
showAllChangesSavedStatus = () => {
|
||||
this.setState({
|
||||
saveError: false,
|
||||
})
|
||||
const offlineStatus = this.context?.hasAccount() ? '' : ' (offline)'
|
||||
this.setStatus('All changes saved' + offlineStatus)
|
||||
}
|
||||
|
||||
showErrorStatus = (message: string) => {
|
||||
this.setState({
|
||||
saveError: true,
|
||||
})
|
||||
this.setStatus(message)
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.editor.note
|
||||
}
|
||||
|
||||
dismissKeyboard = () => {
|
||||
Keyboard.dismiss()
|
||||
this.editorViewRef.current?.blur()
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.context?.mobileComponentManager as ComponentManager
|
||||
}
|
||||
|
||||
async associateComponentWithCurrentNote(component: SNComponent) {
|
||||
const note = this.note
|
||||
if (!note) {
|
||||
return
|
||||
}
|
||||
return this.context?.mutator.changeItem(component, (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.removeDisassociatedItemId(note.uuid)
|
||||
mutator.associateWithItem(note.uuid)
|
||||
})
|
||||
}
|
||||
|
||||
reloadComponentEditorState = async () => {
|
||||
this.setState({
|
||||
downloadingEditor: false,
|
||||
loadingWebview: false,
|
||||
webViewError: undefined,
|
||||
})
|
||||
|
||||
const associatedEditor = this.componentManager.editorForNote(this.note)
|
||||
|
||||
/** Editors cannot interact with template notes so the note must be inserted */
|
||||
if (associatedEditor && this.editor.isTemplateNote) {
|
||||
await this.editor.insertTemplatedNote()
|
||||
void this.associateComponentWithCurrentNote(associatedEditor)
|
||||
}
|
||||
|
||||
if (!associatedEditor) {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
this.setState({ componentViewer: undefined })
|
||||
}
|
||||
} else if (associatedEditor.uuid !== this.state.componentViewer?.component.uuid) {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
if (this.componentManager.isComponentThirdParty(associatedEditor.identifier)) {
|
||||
await this.componentManager.preloadThirdPartyIndexPathFromDisk(associatedEditor.identifier)
|
||||
}
|
||||
this.loadComponentViewer(associatedEditor)
|
||||
}
|
||||
}
|
||||
|
||||
loadComponentViewer(component: SNComponent) {
|
||||
this.setState({
|
||||
componentViewer: this.componentManager.createComponentViewer(component, this.note.uuid),
|
||||
})
|
||||
}
|
||||
|
||||
async forceReloadExistingEditor() {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
componentViewer: undefined,
|
||||
loadingWebview: false,
|
||||
webViewError: undefined,
|
||||
})
|
||||
|
||||
const associatedEditor = this.componentManager.editorForNote(this.note)
|
||||
if (associatedEditor) {
|
||||
this.loadComponentViewer(associatedEditor)
|
||||
}
|
||||
}
|
||||
|
||||
saveNote = async (params: { newTitle?: string; newText?: string }) => {
|
||||
if (this.editor.isTemplateNote) {
|
||||
await this.editor.insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (!this.context?.items.findItem(this.note.uuid)) {
|
||||
void this.context?.alertService.alert('Attempting to save this note has failed. The note cannot be found.')
|
||||
return
|
||||
}
|
||||
|
||||
const { newTitle, newText } = params
|
||||
|
||||
await this.context.mutator.changeItem(
|
||||
this.note,
|
||||
mutator => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
|
||||
if (newTitle != null) {
|
||||
noteMutator.title = newTitle
|
||||
}
|
||||
|
||||
if (newText != null) {
|
||||
noteMutator.text = newText
|
||||
|
||||
const substring = newText.substring(0, NOTE_PREVIEW_CHAR_LIMIT)
|
||||
const shouldTruncate = newText.length > NOTE_PREVIEW_CHAR_LIMIT
|
||||
const previewPlain = substring + (shouldTruncate ? '...' : '')
|
||||
noteMutator.preview_plain = previewPlain
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
const noDebounce = this.context?.noAccount()
|
||||
const syncDebouceMs = noDebounce ? SAVE_TIMEOUT_NO_DEBOUNCE : SAVE_TIMEOUT_DEBOUNCE
|
||||
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.context?.sync.sync()
|
||||
}, syncDebouceMs)
|
||||
}
|
||||
|
||||
onTitleChange = (newTitle: string) => {
|
||||
if (this.note.locked) {
|
||||
void this.context?.alertService?.alert(EditingIsDisabledText)
|
||||
return
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
title: newTitle,
|
||||
},
|
||||
() => this.saveNote({ newTitle: newTitle }),
|
||||
)
|
||||
}
|
||||
|
||||
onContentChange = (text: string) => {
|
||||
if (this.note.locked) {
|
||||
void this.context?.alertService?.alert(EditingIsDisabledText)
|
||||
return
|
||||
}
|
||||
|
||||
void this.saveNote({ newText: text })
|
||||
}
|
||||
|
||||
onLoadWebViewStart = () => {
|
||||
this.setState({
|
||||
loadingWebview: true,
|
||||
webViewError: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
onLoadWebViewEnd = () => {
|
||||
this.setState({
|
||||
loadingWebview: false,
|
||||
})
|
||||
}
|
||||
|
||||
onLoadWebViewError = (error: ComponentLoadingError, desc?: string) => {
|
||||
this.setState({
|
||||
loadingWebview: false,
|
||||
webViewError: error,
|
||||
webViewErrorDesc: desc,
|
||||
})
|
||||
}
|
||||
|
||||
onDownloadEditorStart = () => {
|
||||
this.setState({
|
||||
downloadingEditor: true,
|
||||
})
|
||||
}
|
||||
|
||||
onDownloadEditorEnd = () => {
|
||||
if (this.downloadingMessageTimeout) {
|
||||
clearTimeout(this.downloadingMessageTimeout)
|
||||
}
|
||||
|
||||
this.downloadingMessageTimeout = setTimeout(
|
||||
() =>
|
||||
this.setState({
|
||||
downloadingEditor: false,
|
||||
}),
|
||||
this.state.webViewError ? 0 : 200,
|
||||
)
|
||||
}
|
||||
|
||||
getErrorText(): string {
|
||||
let text = ''
|
||||
switch (this.state.webViewError) {
|
||||
case ComponentLoadingError.ChecksumMismatch:
|
||||
text = 'The remote editor signature differs from the expected value.'
|
||||
break
|
||||
case ComponentLoadingError.DoesntExist:
|
||||
text = 'The local editor files do not exist.'
|
||||
break
|
||||
case ComponentLoadingError.FailedDownload:
|
||||
text = 'The editor failed to download.'
|
||||
break
|
||||
case ComponentLoadingError.LocalServerFailure:
|
||||
text = 'The local component server has an error.'
|
||||
break
|
||||
case ComponentLoadingError.Unknown:
|
||||
text = 'An unknown error occurred.'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (this.state.webViewErrorDesc) {
|
||||
text += `Webview Error: ${this.state.webViewErrorDesc}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
override render() {
|
||||
const shouldDisplayEditor =
|
||||
this.state.componentViewer && Boolean(this.note) && !this.note.prefersPlainEditor && !this.state.webViewError
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ThemeContext.Consumer>
|
||||
{theme => (
|
||||
<>
|
||||
{this.noteLocked && (
|
||||
<LockedContainer>
|
||||
<Icon name={ThemeService.nameForIcon(ICON_LOCK)} size={16} color={theme.stylekitBackgroundColor} />
|
||||
<LockedText>Note Editing Disabled</LockedText>
|
||||
</LockedContainer>
|
||||
)}
|
||||
{this.state.webViewError && (
|
||||
<LockedContainer>
|
||||
<Icon name={ThemeService.nameForIcon(ICON_ALERT)} size={16} color={theme.stylekitBackgroundColor} />
|
||||
<LockedText>
|
||||
Unable to load {this.state.componentViewer?.component.name} — {this.getErrorText()}
|
||||
</LockedText>
|
||||
<WebViewReloadButton
|
||||
onPress={() => {
|
||||
void this.forceReloadExistingEditor()
|
||||
}}
|
||||
>
|
||||
<WebViewReloadButtonText>Reload</WebViewReloadButtonText>
|
||||
</WebViewReloadButton>
|
||||
</LockedContainer>
|
||||
)}
|
||||
<ThemeServiceContext.Consumer>
|
||||
{themeService => (
|
||||
<>
|
||||
<NoteTitleInput
|
||||
testID="noteTitleField"
|
||||
onChangeText={this.onTitleChange}
|
||||
value={this.state.title}
|
||||
placeholder={'Add Title'}
|
||||
selectionColor={theme.stylekitInfoColor}
|
||||
underlineColorAndroid={'transparent'}
|
||||
placeholderTextColor={theme.stylekitNeutralColor}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
autoCorrect={true}
|
||||
autoCapitalize={'sentences'}
|
||||
/>
|
||||
{(this.state.downloadingEditor ||
|
||||
(this.state.loadingWebview && themeService?.isLikelyUsingDarkColorTheme())) && (
|
||||
<LoadingWebViewContainer locked={this.noteLocked}>
|
||||
<LoadingText>
|
||||
{'Loading '}
|
||||
{this.state.componentViewer?.component.name}...
|
||||
</LoadingText>
|
||||
</LoadingWebViewContainer>
|
||||
)}
|
||||
{/* setting webViewError to false on onLoadEnd will cause an infinite loop on Android upon webview error, so, don't do that. */}
|
||||
{shouldDisplayEditor && this.state.componentViewer && (
|
||||
<ComponentView
|
||||
key={this.state.componentViewer?.identifier}
|
||||
componentViewer={this.state.componentViewer}
|
||||
onLoadStart={this.onLoadWebViewStart}
|
||||
onLoadEnd={this.onLoadWebViewEnd}
|
||||
onLoadError={this.onLoadWebViewError}
|
||||
onDownloadEditorStart={this.onDownloadEditorStart}
|
||||
onDownloadEditorEnd={this.onDownloadEditorEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldDisplayEditor && !isNullOrUndefined(this.note) && Platform.OS === 'android' && (
|
||||
<TextContainer>
|
||||
<StyledTextView
|
||||
testID="noteContentField"
|
||||
ref={this.editorViewRef}
|
||||
autoFocus={false}
|
||||
value={this.state.text}
|
||||
selectionColor={lighten(theme.stylekitInfoColor, 0.35)}
|
||||
handlesColor={theme.stylekitInfoColor}
|
||||
onChangeText={this.onContentChange}
|
||||
errorState={false}
|
||||
/>
|
||||
</TextContainer>
|
||||
)}
|
||||
{/* Empty wrapping view fixes native textview crashing */}
|
||||
{!shouldDisplayEditor && Platform.OS === 'ios' && (
|
||||
<View key={this.note.uuid}>
|
||||
<StyledTextView
|
||||
testID="noteContentField"
|
||||
ref={this.editorViewRef}
|
||||
autoFocus={false}
|
||||
multiline
|
||||
value={this.state.text}
|
||||
keyboardDismissMode={'interactive'}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
selectionColor={lighten(theme.stylekitInfoColor)}
|
||||
onChangeText={this.onContentChange}
|
||||
editable={!this.noteLocked}
|
||||
errorState={!!this.state.webViewError}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ThemeServiceContext.Consumer>
|
||||
</>
|
||||
)}
|
||||
</ThemeContext.Consumer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
}
|
||||
61
packages/mobile/src/Screens/InputModal/FileInputModal.tsx
Normal file
61
packages/mobile/src/Screens/InputModal/FileInputModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_INPUT_MODAL_FILE_NAME } from '@Root/Screens/screens'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { FC, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { TextInput } from 'react-native'
|
||||
import { Container, Input } from './InputModal.styled'
|
||||
|
||||
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_FILE_NAME>
|
||||
|
||||
export const FileInputModal: FC<Props> = props => {
|
||||
const { file, renameFile } = props.route.params
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
const fileNameInputRef = useRef<TextInput>(null)
|
||||
|
||||
const [fileName, setFileName] = useState(file.name)
|
||||
|
||||
const onSubmit = async () => {
|
||||
const trimmedFileName = fileName.trim()
|
||||
if (trimmedFileName === '') {
|
||||
setFileName(file.name)
|
||||
await application?.alertService.alert('File name cannot be empty')
|
||||
fileNameInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
await renameFile(file, trimmedFileName)
|
||||
void application.sync.sync()
|
||||
props.navigation.goBack()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fileNameInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TableSection>
|
||||
<SectionedTableCell textInputCell first={true}>
|
||||
<Input
|
||||
ref={fileNameInputRef as any}
|
||||
placeholder={'File name'}
|
||||
onChangeText={setFileName}
|
||||
value={fileName}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
underlineColorAndroid={'transparent'}
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<ButtonCell maxHeight={45} disabled={fileName.length === 0} title={'Save'} bold onPress={onSubmit} />
|
||||
</TableSection>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
15
packages/mobile/src/Screens/InputModal/InputModal.styled.ts
Normal file
15
packages/mobile/src/Screens/InputModal/InputModal.styled.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Container = styled.View`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
export const Input = styled.TextInput.attrs(({ theme }) => ({
|
||||
placeholderTextColor: theme.stylekitNeutralColor,
|
||||
}))`
|
||||
font-size: ${({ theme }) => theme.mainTextFontSize}px;
|
||||
padding: 0px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
height: 100%;
|
||||
`
|
||||
134
packages/mobile/src/Screens/InputModal/PasscodeInputModal.tsx
Normal file
134
packages/mobile/src/Screens/InputModal/PasscodeInputModal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { PasscodeKeyboardType, UnlockTiming } from '@Lib/ApplicationState'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { Option, SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_INPUT_MODAL_PASSCODE } from '@Root/Screens/screens'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useContext, useMemo, useRef, useState } from 'react'
|
||||
import { Keyboard, KeyboardType, Platform, TextInput } from 'react-native'
|
||||
import { Container, Input } from './InputModal.styled'
|
||||
|
||||
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_PASSCODE>
|
||||
export const PasscodeInputModal = (props: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [settingPassocode, setSettingPassocode] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [keyboardType, setKeyboardType] = useState<KeyboardType>('default')
|
||||
|
||||
// Refs
|
||||
const textRef = useRef<TextInput>(null)
|
||||
const confirmTextRef = useRef<TextInput>(null)
|
||||
|
||||
const onTextSubmit = () => {
|
||||
if (!confirmText) {
|
||||
confirmTextRef.current?.focus()
|
||||
} else {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (settingPassocode) {
|
||||
return
|
||||
}
|
||||
setSettingPassocode(true)
|
||||
if (text !== confirmText) {
|
||||
void application?.alertService?.alert(
|
||||
'The two values you entered do not match. Please try again.',
|
||||
'Invalid Confirmation',
|
||||
'OK',
|
||||
)
|
||||
setSettingPassocode(false)
|
||||
} else {
|
||||
await application?.addPasscode(text)
|
||||
await application?.getAppState().setPasscodeKeyboardType(keyboardType as PasscodeKeyboardType)
|
||||
await application?.getAppState().setPasscodeTiming(UnlockTiming.OnQuit)
|
||||
setSettingPassocode(false)
|
||||
props.navigation.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
const keyboardOptions: Option[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'General',
|
||||
key: 'default' as PasscodeKeyboardType,
|
||||
selected: keyboardType === 'default',
|
||||
},
|
||||
{
|
||||
title: 'Numeric',
|
||||
key: 'numeric' as PasscodeKeyboardType,
|
||||
selected: keyboardType === 'numeric',
|
||||
},
|
||||
],
|
||||
[keyboardType],
|
||||
)
|
||||
|
||||
const onKeyboardTypeSelect = (option: Option) => {
|
||||
setKeyboardType(option.key as KeyboardType)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TableSection>
|
||||
<SectionedTableCell textInputCell first={true}>
|
||||
<Input
|
||||
ref={textRef as any}
|
||||
key={Platform.OS === 'android' ? keyboardType + '1' : undefined}
|
||||
placeholder="Enter a passcode"
|
||||
onChangeText={setText}
|
||||
value={text}
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardType={keyboardType}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
autoFocus={true}
|
||||
underlineColorAndroid={'transparent'}
|
||||
onSubmitEditing={onTextSubmit}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<SectionedTableCell textInputCell first={false}>
|
||||
<Input
|
||||
ref={confirmTextRef as any}
|
||||
key={Platform.OS === 'android' ? keyboardType + '2' : undefined}
|
||||
placeholder="Confirm passcode"
|
||||
onChangeText={setConfirmText}
|
||||
value={confirmText}
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardType={keyboardType}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
underlineColorAndroid={'transparent'}
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<SectionedOptionsTableCell
|
||||
title={'Keyboard Type'}
|
||||
leftAligned
|
||||
options={keyboardOptions}
|
||||
onPress={onKeyboardTypeSelect}
|
||||
/>
|
||||
|
||||
<ButtonCell
|
||||
maxHeight={45}
|
||||
disabled={settingPassocode || text.length === 0}
|
||||
title={'Save'}
|
||||
bold
|
||||
onPress={onSubmit}
|
||||
/>
|
||||
</TableSection>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
92
packages/mobile/src/Screens/InputModal/TagInputModal.tsx
Normal file
92
packages/mobile/src/Screens/InputModal/TagInputModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_INPUT_MODAL_TAG } from '@Root/Screens/screens'
|
||||
import { SNNote, SNTag, TagMutator } from '@standardnotes/snjs'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { TextInput } from 'react-native'
|
||||
import { Container, Input } from './InputModal.styled'
|
||||
|
||||
type Props = ModalStackNavigationProp<typeof SCREEN_INPUT_MODAL_TAG>
|
||||
export const TagInputModal = (props: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [text, setText] = useState('')
|
||||
|
||||
// Refs
|
||||
const textRef = useRef<TextInput>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.route.params.tagUuid) {
|
||||
const tag = application?.items.findItem(props.route.params.tagUuid) as SNTag
|
||||
setText(tag.title)
|
||||
}
|
||||
}, [application, props.route.params.tagUuid])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setTimeout(() => {
|
||||
textRef.current?.focus()
|
||||
}, 1)
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (props.route.params.tagUuid) {
|
||||
const tag = application?.items.findItem(props.route.params.tagUuid) as SNTag
|
||||
await application?.mutator.changeItem(tag, mutator => {
|
||||
const tagMutator = mutator as TagMutator
|
||||
tagMutator.title = text
|
||||
if (props.route.params.noteUuid) {
|
||||
const note = application.items.findItem(props.route.params.noteUuid)
|
||||
if (note) {
|
||||
tagMutator.addNote(note as SNNote)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const tag = await application!.mutator.findOrCreateTag(text)
|
||||
if (props.route.params.noteUuid) {
|
||||
await application?.mutator.changeItem(tag, mutator => {
|
||||
const tagMutator = mutator as TagMutator
|
||||
const note = application.items.findItem(props.route.params.noteUuid!)
|
||||
if (note) {
|
||||
tagMutator.addNote(note as SNNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void application?.sync.sync()
|
||||
props.navigation.goBack()
|
||||
}, [application, props.navigation, props.route.params.noteUuid, props.route.params.tagUuid, text])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TableSection>
|
||||
<SectionedTableCell textInputCell first={true}>
|
||||
<Input
|
||||
ref={textRef as any}
|
||||
placeholder={props.route.params.tagUuid ? 'Tag name' : 'New tag name'}
|
||||
onChangeText={setText}
|
||||
value={text}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
underlineColorAndroid={'transparent'}
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<ButtonCell maxHeight={45} disabled={text.length === 0} title={'Save'} bold onPress={onSubmit} />
|
||||
</TableSection>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
154
packages/mobile/src/Screens/ManageSessions/ManageSessions.tsx
Normal file
154
packages/mobile/src/Screens/ManageSessions/ManageSessions.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { LoadingContainer, LoadingText } from '@Root/Screens/Notes/NoteList.styled'
|
||||
import { ButtonType, RemoteSession, SessionStrings, UuidString } from '@standardnotes/snjs'
|
||||
import { useCustomActionSheet } from '@Style/CustomActionSheet'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { FlatList, ListRenderItem, RefreshControl } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { SessionCell } from './SessionCell'
|
||||
|
||||
const useSessions = (): [
|
||||
RemoteSession[],
|
||||
() => void,
|
||||
() => void,
|
||||
boolean,
|
||||
(uuid: UuidString) => Promise<void>,
|
||||
string,
|
||||
] => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
|
||||
// State
|
||||
const [sessions, setSessions] = useState<RemoteSession[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const getSessions = useCallback(async () => {
|
||||
const response = await application?.getSessions()
|
||||
|
||||
if (!response) {
|
||||
setErrorMessage('An unknown error occurred while loading sessions.')
|
||||
return
|
||||
}
|
||||
|
||||
if ('error' in response || !response.data) {
|
||||
if (response.error?.message) {
|
||||
setErrorMessage(response.error.message)
|
||||
} else {
|
||||
setErrorMessage('An unknown error occurred while loading sessions.')
|
||||
}
|
||||
} else {
|
||||
const newSessions = response.data as RemoteSession[]
|
||||
setSessions(newSessions)
|
||||
setErrorMessage('')
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await getSessions()
|
||||
setRefreshing(false)
|
||||
}, [getSessions])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSessions()
|
||||
}, [application, refreshSessions])
|
||||
|
||||
async function revokeSession(uuid: UuidString) {
|
||||
const response = await application?.revokeSession(uuid)
|
||||
if (response && 'error' in response) {
|
||||
if (response.error?.message) {
|
||||
setErrorMessage(response.error?.message)
|
||||
} else {
|
||||
setErrorMessage('An unknown error occurred while revoking the session.')
|
||||
}
|
||||
} else {
|
||||
setSessions(sessions.filter(session => session.uuid !== uuid))
|
||||
}
|
||||
}
|
||||
|
||||
return [sessions, getSessions, refreshSessions, refreshing, revokeSession, errorMessage]
|
||||
}
|
||||
|
||||
export const ManageSessions: React.FC = () => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const { showActionSheet } = useCustomActionSheet()
|
||||
const theme = useContext(ThemeContext)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [sessions, getSessions, refreshSessions, refreshing, revokeSession, errorMessage] = useSessions()
|
||||
|
||||
const onItemPress = (item: RemoteSession) => {
|
||||
showActionSheet({
|
||||
title: item.device_info,
|
||||
options: [
|
||||
{
|
||||
text: 'Revoke',
|
||||
destructive: true,
|
||||
callback: () => showRevokeSessionAlert(item),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showRevokeSessionAlert = useCallback(
|
||||
async (item: RemoteSession) => {
|
||||
const confirmed = await application?.alertService.confirm(
|
||||
SessionStrings.RevokeText,
|
||||
SessionStrings.RevokeTitle,
|
||||
SessionStrings.RevokeConfirmButton,
|
||||
ButtonType.Danger,
|
||||
SessionStrings.RevokeCancelButton,
|
||||
)
|
||||
if (confirmed) {
|
||||
try {
|
||||
await revokeSession(item.uuid)
|
||||
getSessions()
|
||||
} catch (e) {
|
||||
void application?.alertService.alert('Action failed. Please try again.')
|
||||
}
|
||||
}
|
||||
},
|
||||
[application?.alertService, getSessions, revokeSession],
|
||||
)
|
||||
|
||||
const RenderItem: ListRenderItem<RemoteSession> | null | undefined = ({ item }) => {
|
||||
return (
|
||||
<SessionCell
|
||||
onPress={() => onItemPress(item)}
|
||||
title={item.device_info}
|
||||
subTitle={item.updated_at.toLocaleDateString()}
|
||||
currentSession={item.current}
|
||||
disabled={item.current}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingText>{errorMessage}</LoadingText>
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList<RemoteSession>
|
||||
keyExtractor={item => item.uuid}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
initialNumToRender={7}
|
||||
windowSize={7}
|
||||
data={sessions}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
tintColor={theme.stylekitContrastForegroundColor}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refreshSessions}
|
||||
/>
|
||||
}
|
||||
renderItem={RenderItem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
58
packages/mobile/src/Screens/ManageSessions/SessionCell.tsx
Normal file
58
packages/mobile/src/Screens/ManageSessions/SessionCell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Props as TableCellProps, SectionedTableCellTouchableHighlight } from '@Root/Components/SectionedTableCell'
|
||||
import React from 'react'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
type Props = {
|
||||
testID?: string
|
||||
disabled?: boolean
|
||||
onPress: () => void
|
||||
title: string
|
||||
subTitle: string
|
||||
currentSession: boolean
|
||||
}
|
||||
|
||||
const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
|
||||
underlayColor: props.theme.stylekitBorderColor,
|
||||
}))<TableCellProps>`
|
||||
padding-top: ${12}px;
|
||||
justify-content: center;
|
||||
`
|
||||
const ButtonContainer = styled.View``
|
||||
|
||||
type ButtonLabelProps = Pick<Props, 'disabled'>
|
||||
const ButtonLabel = styled.Text<ButtonLabelProps>`
|
||||
color: ${props => {
|
||||
let color = props.theme.stylekitForegroundColor
|
||||
if (props.disabled) {
|
||||
color = 'gray'
|
||||
}
|
||||
return color
|
||||
}};
|
||||
font-weight: bold;
|
||||
font-size: ${props => props.theme.mainTextFontSize}px;
|
||||
${({ disabled }) =>
|
||||
disabled &&
|
||||
css`
|
||||
opacity: 0.6;
|
||||
`}
|
||||
`
|
||||
export const SubTitleText = styled.Text<{ current: boolean }>`
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
color: ${({ theme, current }) => {
|
||||
return current ? theme.stylekitInfoColor : theme.stylekitForegroundColor
|
||||
}};
|
||||
opacity: 0.8;
|
||||
line-height: 21px;
|
||||
`
|
||||
|
||||
export const SessionCell: React.FC<Props> = props => (
|
||||
<Container testID={props.testID} disabled={props.disabled} onPress={props.onPress}>
|
||||
<ButtonContainer>
|
||||
<ButtonLabel disabled={props.disabled}>{props.title}</ButtonLabel>
|
||||
<SubTitleText current={props.currentSession}>
|
||||
{props.currentSession ? 'Current session' : 'Signed in on ' + props.subTitle}
|
||||
</SubTitleText>
|
||||
</ButtonContainer>
|
||||
</Container>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Container = styled.View``
|
||||
|
||||
export const DateText = styled.Text`
|
||||
font-size: 15px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.8;
|
||||
line-height: 21px;
|
||||
`
|
||||
|
||||
export const IosTabBarContainer = styled.View`
|
||||
padding-top: 10px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
`
|
||||
95
packages/mobile/src/Screens/NoteHistory/NoteHistory.tsx
Normal file
95
packages/mobile/src/Screens/NoteHistory/NoteHistory.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import SegmentedControl from '@react-native-community/segmented-control'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { HistoryStackNavigationProp } from '@Root/HistoryStack'
|
||||
import { SCREEN_NOTE_HISTORY, SCREEN_NOTE_HISTORY_PREVIEW } from '@Root/Screens/screens'
|
||||
import { NoteHistoryEntry, SNNote } from '@standardnotes/snjs'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useContext, useState } from 'react'
|
||||
import { Dimensions, Platform } from 'react-native'
|
||||
import { NavigationState, Route, SceneRendererProps, TabBar, TabView } from 'react-native-tab-view'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { IosTabBarContainer } from './NoteHistory.styled'
|
||||
import { RemoteHistory } from './RemoteHistory'
|
||||
import { SessionHistory } from './SessionHistory'
|
||||
|
||||
const initialLayout = { width: Dimensions.get('window').width }
|
||||
|
||||
type Props = HistoryStackNavigationProp<typeof SCREEN_NOTE_HISTORY>
|
||||
export const NoteHistory = (props: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [note] = useState<SNNote>(() => application?.items.findItem(props.route.params.noteUuid) as SNNote)
|
||||
const [routes] = React.useState([
|
||||
{ key: 'session', title: 'Session' },
|
||||
{ key: 'remote', title: 'Remote' },
|
||||
])
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
const openPreview = (_uuid: string, revision: NoteHistoryEntry, title: string) => {
|
||||
props.navigation.navigate(SCREEN_NOTE_HISTORY_PREVIEW, {
|
||||
title,
|
||||
revision,
|
||||
originalNoteUuid: note.uuid,
|
||||
})
|
||||
}
|
||||
|
||||
const renderScene = ({ route }: { route: { key: string; title: string } }) => {
|
||||
switch (route.key) {
|
||||
case 'session':
|
||||
return <SessionHistory onPress={openPreview} note={note} />
|
||||
case 'remote':
|
||||
return <RemoteHistory onPress={openPreview} note={note} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabBar = (
|
||||
tabBarProps: SceneRendererProps & {
|
||||
navigationState: NavigationState<Route>
|
||||
},
|
||||
) => {
|
||||
return Platform.OS === 'ios' && parseInt(Platform.Version as string, 10) >= 13 ? (
|
||||
<IosTabBarContainer>
|
||||
<SegmentedControl
|
||||
backgroundColor={theme.stylekitContrastBackgroundColor}
|
||||
appearance={themeService?.keyboardColorForActiveTheme()}
|
||||
fontStyle={{
|
||||
color: theme.stylekitForegroundColor,
|
||||
}}
|
||||
values={routes.map(route => route.title)}
|
||||
selectedIndex={tabBarProps.navigationState.index}
|
||||
onChange={event => {
|
||||
setIndex(event.nativeEvent.selectedSegmentIndex)
|
||||
}}
|
||||
/>
|
||||
</IosTabBarContainer>
|
||||
) : (
|
||||
<TabBar
|
||||
{...tabBarProps}
|
||||
indicatorStyle={{ backgroundColor: theme.stylekitInfoColor }}
|
||||
inactiveColor={theme.stylekitBorderColor}
|
||||
activeColor={theme.stylekitInfoColor}
|
||||
style={{
|
||||
backgroundColor: theme.stylekitBackgroundColor,
|
||||
shadowColor: theme.stylekitShadowColor,
|
||||
}}
|
||||
labelStyle={{ color: theme.stylekitInfoColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabView
|
||||
renderTabBar={renderTabBar}
|
||||
navigationState={{ index, routes }}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={initialLayout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
packages/mobile/src/Screens/NoteHistory/NoteHistoryCell.tsx
Normal file
61
packages/mobile/src/Screens/NoteHistory/NoteHistoryCell.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Props as TableCellProps, SectionedTableCellTouchableHighlight } from '@Root/Components/SectionedTableCell'
|
||||
import React from 'react'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
type Props = {
|
||||
testID?: string
|
||||
disabled?: boolean
|
||||
onPress: () => void
|
||||
first?: boolean
|
||||
last?: boolean
|
||||
title: string
|
||||
subTitle?: string
|
||||
}
|
||||
|
||||
const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({
|
||||
underlayColor: props.theme.stylekitBorderColor,
|
||||
}))<TableCellProps>`
|
||||
padding-top: ${12}px;
|
||||
justify-content: center;
|
||||
`
|
||||
const ButtonContainer = styled.View``
|
||||
|
||||
type ButtonLabelProps = Pick<Props, 'disabled'>
|
||||
const ButtonLabel = styled.Text<ButtonLabelProps>`
|
||||
color: ${props => {
|
||||
let color = props.theme.stylekitForegroundColor
|
||||
if (props.disabled) {
|
||||
color = 'gray'
|
||||
}
|
||||
return color
|
||||
}};
|
||||
font-weight: bold;
|
||||
font-size: ${props => props.theme.mainTextFontSize}px;
|
||||
${({ disabled }) =>
|
||||
disabled &&
|
||||
css`
|
||||
opacity: 0.6;
|
||||
`}
|
||||
`
|
||||
export const SubTitleText = styled.Text`
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
opacity: 0.8;
|
||||
line-height: 21px;
|
||||
`
|
||||
|
||||
export const NoteHistoryCell: React.FC<Props> = props => (
|
||||
<Container
|
||||
first={props.first}
|
||||
last={props.last}
|
||||
testID={props.testID}
|
||||
disabled={props.disabled}
|
||||
onPress={props.onPress}
|
||||
>
|
||||
<ButtonContainer>
|
||||
<ButtonLabel disabled={props.disabled}>{props.title}</ButtonLabel>
|
||||
{props.subTitle && <SubTitleText>{props.subTitle}</SubTitleText>}
|
||||
</ButtonContainer>
|
||||
</Container>
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Platform } from 'react-native'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
const PADDING = 14
|
||||
const NOTE_TITLE_HEIGHT = 50
|
||||
|
||||
export const Container = styled.View`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
export const StyledTextView = styled.Text`
|
||||
padding-top: 10px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
padding-left: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
padding-right: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
/* padding-bottom: 10px; */
|
||||
${Platform.OS === 'ios' &&
|
||||
css`
|
||||
font-size: 17px;
|
||||
`}
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
/* ${Platform.OS === 'ios' && 'padding-bottom: 10px'}; */
|
||||
`
|
||||
|
||||
export const TextContainer = styled.ScrollView``
|
||||
|
||||
export const TitleContainer = styled.View`
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
height: ${NOTE_TITLE_HEIGHT}px;
|
||||
padding-top: ${Platform.OS === 'ios' ? 5 : 12}px;
|
||||
padding-left: ${PADDING}px;
|
||||
padding-right: ${PADDING}px;
|
||||
`
|
||||
|
||||
export const Title = styled.Text`
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
`
|
||||
131
packages/mobile/src/Screens/NoteHistory/NoteHistoryPreview.tsx
Normal file
131
packages/mobile/src/Screens/NoteHistory/NoteHistoryPreview.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
|
||||
import { HistoryStackNavigationProp } from '@Root/HistoryStack'
|
||||
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_NOTE_HISTORY_PREVIEW } from '@Root/Screens/screens'
|
||||
import { ButtonType, PayloadEmitSource, SNNote } from '@standardnotes/snjs'
|
||||
import { useCustomActionSheet } from '@Style/CustomActionSheet'
|
||||
import { ELIPSIS } from '@Style/Icons'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useLayoutEffect } from 'react'
|
||||
import { LogBox } from 'react-native'
|
||||
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
|
||||
import { Container, StyledTextView, TextContainer, Title, TitleContainer } from './NoteHistoryPreview.styled'
|
||||
|
||||
LogBox.ignoreLogs(['Non-serializable values were found in the navigation state'])
|
||||
|
||||
type Props = HistoryStackNavigationProp<typeof SCREEN_NOTE_HISTORY_PREVIEW>
|
||||
export const NoteHistoryPreview = ({
|
||||
navigation,
|
||||
route: {
|
||||
params: { revision, title, originalNoteUuid },
|
||||
},
|
||||
}: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const { showActionSheet } = useCustomActionSheet()
|
||||
|
||||
// State
|
||||
|
||||
const restore = useCallback(
|
||||
async (asCopy: boolean) => {
|
||||
const originalNote = application!.items.findSureItem<SNNote>(originalNoteUuid)
|
||||
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await application?.mutator.duplicateItem(originalNote!, {
|
||||
...revision.payload.content,
|
||||
title: revision.payload.content.title ? revision.payload.content.title + ' (copy)' : undefined,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
navigation.navigate(SCREEN_NOTES)
|
||||
} else {
|
||||
await application?.mutator.changeAndSaveItem(
|
||||
originalNote,
|
||||
mutator => {
|
||||
mutator.setCustomContent(revision.payload.content)
|
||||
},
|
||||
true,
|
||||
PayloadEmitSource.RemoteRetrieved,
|
||||
)
|
||||
if (application?.getAppState().isTabletDevice) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
navigation.navigate(SCREEN_NOTES)
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
navigation.navigate(SCREEN_COMPOSE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!asCopy) {
|
||||
if (originalNote.locked) {
|
||||
void application?.alertService.alert(
|
||||
"This note has editing disabled. If you'd like to restore it to a previous revision, enable editing and try again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
const confirmed = await application?.alertService?.confirm(
|
||||
"Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
'Restore note',
|
||||
'Restore',
|
||||
ButtonType.Info,
|
||||
)
|
||||
if (confirmed) {
|
||||
void run()
|
||||
}
|
||||
} else {
|
||||
void run()
|
||||
}
|
||||
},
|
||||
[application, navigation, originalNoteUuid, revision.payload.content],
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
showActionSheet({
|
||||
title: title!,
|
||||
options: [
|
||||
{
|
||||
text: 'Restore',
|
||||
callback: () => restore(false),
|
||||
},
|
||||
{
|
||||
text: 'Restore as copy',
|
||||
callback: async () => restore(true),
|
||||
},
|
||||
],
|
||||
})
|
||||
}, [showActionSheet, title, restore])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<HeaderButtons HeaderButtonComponent={IoniconsHeaderButton}>
|
||||
<Item
|
||||
testID="notePreviewOptions"
|
||||
disabled={false}
|
||||
iconSize={25}
|
||||
title={''}
|
||||
iconName={ThemeService.nameForIcon(ELIPSIS)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</HeaderButtons>
|
||||
),
|
||||
})
|
||||
}, [navigation, onPress])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TitleContainer>
|
||||
<Title testID="notePreviewTitleField">{revision.payload.content.title}</Title>
|
||||
</TitleContainer>
|
||||
|
||||
<TextContainer>
|
||||
<StyledTextView testID="notePreviewText">{revision.payload.content.text}</StyledTextView>
|
||||
</TextContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
82
packages/mobile/src/Screens/NoteHistory/RemoteHistory.tsx
Normal file
82
packages/mobile/src/Screens/NoteHistory/RemoteHistory.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { LoadingContainer, LoadingText } from '@Root/Screens/Notes/NoteList.styled'
|
||||
import { NoteHistoryEntry, RevisionListEntry, SNNote } from '@standardnotes/snjs'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { FlatList, ListRenderItem } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { NoteHistoryCell } from './NoteHistoryCell'
|
||||
|
||||
type Props = {
|
||||
note: SNNote
|
||||
onPress: (uuid: string, revision: NoteHistoryEntry, title: string) => void
|
||||
}
|
||||
export const RemoteHistory: React.FC<Props> = ({ note, onPress }) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
// State
|
||||
const [remoteHistoryList, setRemoteHistoryList] = useState<RevisionListEntry[]>()
|
||||
const [fetchingRemoteHistory, setFetchingRemoteHistory] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const fetchRemoteHistoryList = async () => {
|
||||
if (note) {
|
||||
setFetchingRemoteHistory(true)
|
||||
const newRemoteHistory = await application?.historyManager?.remoteHistoryForItem(note)
|
||||
if (isMounted) {
|
||||
setFetchingRemoteHistory(false)
|
||||
setRemoteHistoryList(newRemoteHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchRemoteHistoryList()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [application?.historyManager, note])
|
||||
|
||||
const onItemPress = useCallback(
|
||||
async (item: RevisionListEntry) => {
|
||||
const remoteRevision = await application?.historyManager!.fetchRemoteRevision(note, item)
|
||||
if (remoteRevision) {
|
||||
onPress(item.uuid, remoteRevision as NoteHistoryEntry, new Date(item.updated_at).toLocaleString())
|
||||
} else {
|
||||
void application?.alertService!.alert(
|
||||
'The remote revision could not be loaded. Please try again later.',
|
||||
'Error',
|
||||
)
|
||||
return
|
||||
}
|
||||
},
|
||||
[application?.alertService, application?.historyManager, note, onPress],
|
||||
)
|
||||
|
||||
const renderItem: ListRenderItem<RevisionListEntry> | null | undefined = ({ item }) => {
|
||||
return <NoteHistoryCell onPress={() => onItemPress(item)} title={new Date(item.updated_at).toLocaleString()} />
|
||||
}
|
||||
|
||||
if (fetchingRemoteHistory || !remoteHistoryList || (remoteHistoryList && remoteHistoryList.length === 0)) {
|
||||
const placeholderText = fetchingRemoteHistory ? 'Loading entries...' : 'No entries.'
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingText>{placeholderText}</LoadingText>
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList<RevisionListEntry>
|
||||
keyExtractor={item => item.uuid}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
initialNumToRender={10}
|
||||
windowSize={10}
|
||||
keyboardShouldPersistTaps={'never'}
|
||||
data={remoteHistoryList}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
54
packages/mobile/src/Screens/NoteHistory/SessionHistory.tsx
Normal file
54
packages/mobile/src/Screens/NoteHistory/SessionHistory.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { HistoryEntry, NoteHistoryEntry, SNNote } from '@standardnotes/snjs'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { FlatList, ListRenderItem } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { NoteHistoryCell } from './NoteHistoryCell'
|
||||
|
||||
type Props = {
|
||||
note: SNNote
|
||||
onPress: (uuid: string, revision: NoteHistoryEntry, title: string) => void
|
||||
}
|
||||
export const SessionHistory: React.FC<Props> = ({ note, onPress }) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
// State
|
||||
const [sessionHistory, setSessionHistory] = useState<HistoryEntry[]>()
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setSessionHistory(application?.historyManager?.sessionHistoryForItem(note))
|
||||
}
|
||||
}, [application?.historyManager, note])
|
||||
|
||||
const onItemPress = useCallback(
|
||||
(item: NoteHistoryEntry) => {
|
||||
onPress(item.payload.uuid, item, item.previewTitle())
|
||||
},
|
||||
[onPress],
|
||||
)
|
||||
|
||||
const RenderItem: ListRenderItem<NoteHistoryEntry> | null | undefined = ({ item }) => {
|
||||
return (
|
||||
<NoteHistoryCell
|
||||
onPress={() => onItemPress(item)}
|
||||
title={item.previewTitle()}
|
||||
subTitle={item.previewSubTitle()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList<NoteHistoryEntry>
|
||||
keyExtractor={item => item.previewTitle()}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
initialNumToRender={10}
|
||||
windowSize={10}
|
||||
keyboardShouldPersistTaps={'never'}
|
||||
data={sessionHistory as NoteHistoryEntry[]}
|
||||
renderItem={RenderItem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
79
packages/mobile/src/Screens/Notes/NoteCell.styled.ts
Normal file
79
packages/mobile/src/Screens/Notes/NoteCell.styled.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { hexToRGBA } from '@Style/Utils'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const TouchableContainer = styled.TouchableWithoutFeedback``
|
||||
export const Container = styled.View<{ selected: boolean; distance: number }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: ${props => props.distance}px 0 0 ${props => props.distance}px;
|
||||
background-color: ${({ theme, selected }) => {
|
||||
return selected ? theme.stylekitInfoColor : theme.stylekitBackgroundColor
|
||||
}};
|
||||
`
|
||||
export const NoteDataContainer = styled.View<{ distance: number }>`
|
||||
border-bottom-color: ${({ theme }) => hexToRGBA(theme.stylekitBorderColor, 0.75)};
|
||||
border-bottom-width: 1px;
|
||||
padding-bottom: ${props => props.distance}px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
padding-right: ${props => props.distance}px;
|
||||
`
|
||||
export const DeletedText = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitInfoColor};
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
export const NoteText = styled.Text<{ selected: boolean }>`
|
||||
font-size: 15px;
|
||||
color: ${({ theme, selected }) => {
|
||||
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
|
||||
}};
|
||||
opacity: 0.8;
|
||||
line-height: 19px;
|
||||
`
|
||||
export const TitleText = styled.Text<{ selected: boolean }>`
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: ${({ theme, selected }) => {
|
||||
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
|
||||
}};
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
margin-bottom: 4px;
|
||||
`
|
||||
export const TagsContainter = styled.View`
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
margin-top: 7px;
|
||||
`
|
||||
export const TagText = styled.Text<{ selected: boolean }>`
|
||||
margin-right: 2px;
|
||||
font-size: 12px;
|
||||
color: ${({ theme, selected }) => {
|
||||
return selected ? theme.stylekitInfoContrastColor : theme.stylekitForegroundColor
|
||||
}};
|
||||
opacity: ${props => (props.selected ? 0.8 : 0.5)};
|
||||
`
|
||||
export const DetailsText = styled(TagText)`
|
||||
margin-right: 0;
|
||||
margin-top: 5px;
|
||||
`
|
||||
export const FlexContainer = styled.View`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
`
|
||||
export const NoteContentsContainer = styled.View`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
`
|
||||
export const styles = StyleSheet.create({
|
||||
editorIcon: {
|
||||
marginTop: 2,
|
||||
marginRight: 10,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
})
|
||||
241
packages/mobile/src/Screens/Notes/NoteCell.tsx
Normal file
241
packages/mobile/src/Screens/Notes/NoteCell.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useChangeNote, useDeleteNoteWithPrivileges, useProtectOrUnprotectNote } from '@Lib/SnjsHelperHooks'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { SnIcon } from '@Root/Components/SnIcon'
|
||||
import { NoteCellIconFlags } from '@Root/Screens/Notes/NoteCellIconFlags'
|
||||
import { CollectionSort, CollectionSortProperty, IconType, isNullOrUndefined, SNNote } from '@standardnotes/snjs'
|
||||
import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet'
|
||||
import { getTintColorForEditor } from '@Style/Utils'
|
||||
import React, { useContext, useRef, useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import {
|
||||
Container,
|
||||
DetailsText,
|
||||
FlexContainer,
|
||||
NoteContentsContainer,
|
||||
NoteDataContainer,
|
||||
NoteText,
|
||||
styles,
|
||||
TitleText,
|
||||
TouchableContainer,
|
||||
} from './NoteCell.styled'
|
||||
import { NoteCellFlags } from './NoteCellFlags'
|
||||
|
||||
type Props = {
|
||||
note: SNNote
|
||||
highlighted?: boolean
|
||||
onPressItem: (noteUuid: SNNote['uuid']) => void
|
||||
hideDates: boolean
|
||||
hidePreviews: boolean
|
||||
hideEditorIcon: boolean
|
||||
sortType: CollectionSortProperty
|
||||
}
|
||||
|
||||
export const NoteCell = ({
|
||||
note,
|
||||
onPressItem,
|
||||
highlighted,
|
||||
sortType,
|
||||
hideDates,
|
||||
hidePreviews,
|
||||
hideEditorIcon,
|
||||
}: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
const [changeNote] = useChangeNote(note)
|
||||
const [protectOrUnprotectNote] = useProtectOrUnprotectNote(note)
|
||||
|
||||
// State
|
||||
const [selected, setSelected] = useState(false)
|
||||
|
||||
// Ref
|
||||
const selectionTimeout = useRef<ReturnType<typeof setTimeout>>()
|
||||
const elementRef = useRef<View>(null)
|
||||
|
||||
const { showActionSheet } = useCustomActionSheet()
|
||||
|
||||
const [deleteNote] = useDeleteNoteWithPrivileges(
|
||||
note,
|
||||
async () => {
|
||||
await application?.mutator.deleteItem(note)
|
||||
},
|
||||
() => {
|
||||
void changeNote(mutator => {
|
||||
mutator.trashed = true
|
||||
}, false)
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
|
||||
const highlight = Boolean(selected || highlighted)
|
||||
|
||||
const _onPress = () => {
|
||||
setSelected(true)
|
||||
selectionTimeout.current = setTimeout(() => {
|
||||
setSelected(false)
|
||||
onPressItem(note.uuid)
|
||||
}, 25)
|
||||
}
|
||||
|
||||
const _onPressIn = () => {
|
||||
setSelected(true)
|
||||
}
|
||||
|
||||
const _onPressOut = () => {
|
||||
setSelected(false)
|
||||
}
|
||||
|
||||
const onLongPress = () => {
|
||||
if (note.protected) {
|
||||
showActionSheet({
|
||||
title: note.title,
|
||||
options: [
|
||||
{
|
||||
text: 'Note Protected',
|
||||
},
|
||||
],
|
||||
anchor: elementRef.current ?? undefined,
|
||||
})
|
||||
} else {
|
||||
let options: CustomActionSheetOption[] = []
|
||||
|
||||
options.push({
|
||||
text: note.pinned ? 'Unpin' : 'Pin',
|
||||
key: 'pin',
|
||||
callback: () =>
|
||||
changeNote(mutator => {
|
||||
mutator.pinned = !note.pinned
|
||||
}, false),
|
||||
})
|
||||
|
||||
options.push({
|
||||
text: note.archived ? 'Unarchive' : 'Archive',
|
||||
key: 'archive',
|
||||
callback: () => {
|
||||
if (note.locked) {
|
||||
void application?.alertService.alert(
|
||||
`This note has editing disabled. If you'd like to ${
|
||||
note.archived ? 'unarchive' : 'archive'
|
||||
} it, enable editing on it, and try again.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
void changeNote(mutator => {
|
||||
mutator.archived = !note.archived
|
||||
}, false)
|
||||
},
|
||||
})
|
||||
|
||||
options.push({
|
||||
text: note.locked ? 'Enable editing' : 'Prevent editing',
|
||||
key: 'lock',
|
||||
callback: () =>
|
||||
changeNote(mutator => {
|
||||
mutator.locked = !note.locked
|
||||
}, false),
|
||||
})
|
||||
|
||||
options.push({
|
||||
text: note.protected ? 'Unprotect' : 'Protect',
|
||||
key: 'protect',
|
||||
callback: async () => await protectOrUnprotectNote(),
|
||||
})
|
||||
|
||||
if (!note.trashed) {
|
||||
options.push({
|
||||
text: 'Move to Trash',
|
||||
key: 'trash',
|
||||
destructive: true,
|
||||
callback: async () => deleteNote(false),
|
||||
})
|
||||
} else {
|
||||
options = options.concat([
|
||||
{
|
||||
text: 'Restore',
|
||||
key: 'restore-note',
|
||||
callback: () => {
|
||||
void changeNote(mutator => {
|
||||
mutator.trashed = false
|
||||
}, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Delete permanently',
|
||||
key: 'delete-forever',
|
||||
destructive: true,
|
||||
callback: async () => deleteNote(true),
|
||||
},
|
||||
])
|
||||
}
|
||||
showActionSheet({
|
||||
title: note.title,
|
||||
options,
|
||||
anchor: elementRef.current ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const padding = 14
|
||||
const showPreview = !hidePreviews && !note.protected && !note.hidePreview
|
||||
const hasPlainPreview = !isNullOrUndefined(note.preview_plain) && note.preview_plain.length > 0
|
||||
const showDetails = !hideDates || note.protected
|
||||
|
||||
const editorForNote = application?.componentManager.editorForNote(note)
|
||||
const [icon, tint] = application?.iconsController.getIconAndTintForNoteType(
|
||||
editorForNote?.package_info.note_type,
|
||||
) as [IconType, number]
|
||||
|
||||
return (
|
||||
<TouchableContainer
|
||||
onPress={_onPress}
|
||||
onPressIn={_onPressIn}
|
||||
onPressOut={_onPressOut}
|
||||
onLongPress={onLongPress}
|
||||
delayPressIn={150}
|
||||
>
|
||||
<Container ref={elementRef as any} selected={highlight} distance={padding}>
|
||||
{!hideEditorIcon && <SnIcon type={icon} fill={getTintColorForEditor(theme, tint)} style={styles.editorIcon} />}
|
||||
<NoteDataContainer distance={padding}>
|
||||
<NoteCellFlags note={note} highlight={highlight} />
|
||||
|
||||
<FlexContainer>
|
||||
<NoteContentsContainer>
|
||||
{note.title.length > 0 ? <TitleText selected={highlight}>{note.title}</TitleText> : <View />}
|
||||
{hasPlainPreview && showPreview && (
|
||||
<NoteText selected={highlight} numberOfLines={2}>
|
||||
{note.preview_plain}
|
||||
</NoteText>
|
||||
)}
|
||||
|
||||
{!hasPlainPreview && showPreview && note.text.length > 0 && (
|
||||
<NoteText selected={highlight} numberOfLines={2}>
|
||||
{note.text}
|
||||
</NoteText>
|
||||
)}
|
||||
</NoteContentsContainer>
|
||||
<NoteCellIconFlags note={note} />
|
||||
</FlexContainer>
|
||||
|
||||
{showDetails && (
|
||||
<DetailsText numberOfLines={1} selected={highlight}>
|
||||
{note.protected && (
|
||||
<Text>
|
||||
Protected
|
||||
{!hideDates && ' • '}
|
||||
</Text>
|
||||
)}
|
||||
{!hideDates && (
|
||||
<Text>
|
||||
{sortType === CollectionSort.UpdatedAt ? 'Modified ' + note.updatedAtString : note.createdAtString}
|
||||
</Text>
|
||||
)}
|
||||
</DetailsText>
|
||||
)}
|
||||
</NoteDataContainer>
|
||||
</Container>
|
||||
</TouchableContainer>
|
||||
)
|
||||
}
|
||||
56
packages/mobile/src/Screens/Notes/NoteCellFlags.tsx
Normal file
56
packages/mobile/src/Screens/Notes/NoteCellFlags.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import React, { useContext } from 'react'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
type NoteFlag = {
|
||||
text: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const FlagsContainer = styled.View`
|
||||
flex-direction: row;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
const FlagContainer = styled.View<{ color: string; selected: boolean }>`
|
||||
background-color: ${({ theme, selected, color }) => {
|
||||
return selected ? theme.stylekitInfoContrastColor : color
|
||||
}};
|
||||
padding: 4px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
`
|
||||
const FlagLabel = styled.Text<{ selected: boolean }>`
|
||||
color: ${({ theme, selected }) => {
|
||||
return selected ? theme.stylekitInfoColor : theme.stylekitInfoContrastColor
|
||||
}};
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
export const NoteCellFlags = ({ note, highlight }: { note: SNNote; highlight: boolean }) => {
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
const flags: NoteFlag[] = []
|
||||
|
||||
if (note.conflictOf) {
|
||||
flags.push({
|
||||
text: 'Conflicted Copy',
|
||||
color: theme.stylekitDangerColor,
|
||||
})
|
||||
}
|
||||
|
||||
return flags.length > 0 ? (
|
||||
<FlagsContainer>
|
||||
{flags.map(flag => (
|
||||
<FlagContainer key={flag.text.concat(flag.color)} color={flag.color} selected={highlight}>
|
||||
<FlagLabel selected={highlight}>{flag.text}</FlagLabel>
|
||||
</FlagContainer>
|
||||
))}
|
||||
</FlagsContainer>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
59
packages/mobile/src/Screens/Notes/NoteCellIconFlags.tsx
Normal file
59
packages/mobile/src/Screens/Notes/NoteCellIconFlags.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { SnIcon } from '@Root/Components/SnIcon'
|
||||
import { IconType, SNNote } from '@standardnotes/snjs'
|
||||
import React, { useContext } from 'react'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
const FlagIconsContainer = styled.View`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
margin-top: 2px;
|
||||
`
|
||||
type Props = {
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
type TFlagIcon = {
|
||||
icon: IconType
|
||||
fillColor?: string
|
||||
}
|
||||
|
||||
export const NoteCellIconFlags = ({ note }: Props) => {
|
||||
const theme = useContext(ThemeContext)
|
||||
const { stylekitCorn, stylekitDangerColor, stylekitInfoColor } = theme
|
||||
|
||||
const flagIcons = [] as TFlagIcon[]
|
||||
|
||||
if (note.archived) {
|
||||
flagIcons.push({
|
||||
icon: 'archive',
|
||||
fillColor: stylekitCorn,
|
||||
})
|
||||
}
|
||||
if (note.locked) {
|
||||
flagIcons.push({
|
||||
icon: 'pencil-off',
|
||||
fillColor: stylekitInfoColor,
|
||||
})
|
||||
}
|
||||
if (note.trashed) {
|
||||
flagIcons.push({
|
||||
icon: 'trash-filled',
|
||||
fillColor: stylekitDangerColor,
|
||||
})
|
||||
}
|
||||
if (note.pinned) {
|
||||
flagIcons.push({
|
||||
icon: 'pin-filled',
|
||||
fillColor: stylekitInfoColor,
|
||||
})
|
||||
}
|
||||
return flagIcons.length ? (
|
||||
<FlagIconsContainer>
|
||||
{flagIcons.map((flagIcon, index) => (
|
||||
<SnIcon key={index} type={flagIcon.icon} fill={flagIcon.fillColor} />
|
||||
))}
|
||||
</FlagIconsContainer>
|
||||
) : null
|
||||
}
|
||||
60
packages/mobile/src/Screens/Notes/NoteList.styled.ts
Normal file
60
packages/mobile/src/Screens/Notes/NoteList.styled.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
// no support for generic types in Flatlist
|
||||
export const styles = StyleSheet.create({
|
||||
list: {
|
||||
height: '100%',
|
||||
},
|
||||
inputStyle: {
|
||||
height: 30,
|
||||
},
|
||||
androidSearch: {
|
||||
height: 30,
|
||||
},
|
||||
})
|
||||
|
||||
export const Container = styled.View`
|
||||
background-color: ${props => props.theme.stylekitBackgroundColor};
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
export const LoadingContainer = styled.View`
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
interface LoadingTextProps {
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify'
|
||||
}
|
||||
|
||||
export const LoadingText = styled.Text<LoadingTextProps>`
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
color: ${props => props.theme.stylekitForegroundColor};
|
||||
text-align: ${props => props.textAlign ?? 'left'};
|
||||
`
|
||||
|
||||
export const HeaderContainer = styled.View`
|
||||
padding-top: 3px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
`
|
||||
|
||||
export const SearchBarContainer = styled.View`
|
||||
background-color: ${props => props.theme.stylekitBackgroundColor};
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
export const SearchOptionsContainer = styled.ScrollView`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 8px;
|
||||
margin-bottom: 12px;
|
||||
${() =>
|
||||
Platform.OS === 'android' &&
|
||||
css`
|
||||
padding-top: 4px;
|
||||
`}
|
||||
`
|
||||
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
257
packages/mobile/src/Screens/Notes/NoteList.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { AppStateEventType, AppStateType } from '@Lib/ApplicationState'
|
||||
import { useSignedIn } from '@Lib/SnjsHelperHooks'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { Chip } from '@Root/Components/Chip'
|
||||
import { SearchBar } from '@Root/Components/SearchBar'
|
||||
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import { CollectionSortProperty, SNNote } from '@standardnotes/snjs'
|
||||
import React, { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Animated, FlatList, ListRenderItem, RefreshControl } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import IosSearchBar from 'react-native-search-bar'
|
||||
import AndroidSearchBar from 'react-native-search-box'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { NoteCell } from './NoteCell'
|
||||
import {
|
||||
Container,
|
||||
HeaderContainer,
|
||||
LoadingContainer,
|
||||
LoadingText,
|
||||
SearchBarContainer,
|
||||
SearchOptionsContainer,
|
||||
styles,
|
||||
} from './NoteList.styled'
|
||||
import { OfflineBanner } from './OfflineBanner'
|
||||
|
||||
type Props = {
|
||||
onSearchChange: (text: string) => void
|
||||
onSearchCancel: () => void
|
||||
searchText: string
|
||||
searchOptions: {
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
label: string
|
||||
}[]
|
||||
onPressItem: (noteUuid: SNNote['uuid']) => void
|
||||
selectedNoteId: string | undefined
|
||||
sortType: CollectionSortProperty
|
||||
hideDates: boolean
|
||||
hidePreviews: boolean
|
||||
hideEditorIcon: boolean
|
||||
decrypting: boolean
|
||||
loading: boolean
|
||||
hasRefreshControl: boolean
|
||||
notes: SNNote[]
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
shouldFocusSearch: boolean
|
||||
setShouldFocusSearch: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const NoteList = (props: Props) => {
|
||||
// Context
|
||||
const [signedIn] = useSignedIn()
|
||||
const application = useContext(ApplicationContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [collapseSearchBarOnBlur, setCollapseSearchBarOnBlur] = useState(true)
|
||||
const [noteListScrolled, setNoteListScrolled] = useState(false)
|
||||
|
||||
// Ref
|
||||
const opacityAnimationValue = useRef(new Animated.Value(0)).current
|
||||
const marginTopAnimationValue = useRef(new Animated.Value(-40)).current
|
||||
const iosSearchBarInputRef = useRef<IosSearchBar>(null)
|
||||
const androidSearchBarInputRef = useRef<typeof AndroidSearchBar>(null)
|
||||
const noteListRef = useRef<FlatList>(null)
|
||||
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
||||
|
||||
const dismissKeyboard = () => {
|
||||
iosSearchBarInputRef.current?.blur()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
||||
setCollapseSearchBarOnBlur(false)
|
||||
})
|
||||
|
||||
return removeBlurScreenListener
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeStateEventObserver = application?.getAppState().addStateEventObserver(state => {
|
||||
if (state === AppStateEventType.DrawerOpen) {
|
||||
dismissKeyboard()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeStateEventObserver
|
||||
}, [application])
|
||||
|
||||
const scrollListToTop = useCallback(() => {
|
||||
if (noteListScrolled && props.notes && props.notes.length > 0) {
|
||||
noteListRef.current?.scrollToIndex({ animated: false, index: 0 })
|
||||
setNoteListScrolled(false)
|
||||
}
|
||||
}, [noteListScrolled, props.notes])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeTagChangedEventObserver = application?.getAppState().addStateChangeObserver(event => {
|
||||
if (event === AppStateType.TagChanged) {
|
||||
scrollListToTop()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeTagChangedEventObserver
|
||||
}, [application, scrollListToTop])
|
||||
|
||||
const { shouldFocusSearch, searchText } = props
|
||||
|
||||
const focusSearch = useCallback(() => {
|
||||
setCollapseSearchBarOnBlur(true)
|
||||
|
||||
if (shouldFocusSearch) {
|
||||
iosSearchBarInputRef.current?.focus()
|
||||
androidSearchBarInputRef.current?.focus(searchText)
|
||||
}
|
||||
}, [shouldFocusSearch, searchText])
|
||||
|
||||
useFocusEffect(focusSearch)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return dismissKeyboard
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onChangeSearchText = (text: string) => {
|
||||
props.onSearchChange(text)
|
||||
scrollListToTop()
|
||||
}
|
||||
|
||||
const toggleSearchOptions = (showOptions: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnimationValue, {
|
||||
toValue: showOptions ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(marginTopAnimationValue, {
|
||||
toValue: showOptions ? 0 : -40,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]).start()
|
||||
}
|
||||
|
||||
const onSearchFocus = () => {
|
||||
toggleSearchOptions(true)
|
||||
props.setShouldFocusSearch(false)
|
||||
}
|
||||
|
||||
const onSearchBlur = () => {
|
||||
toggleSearchOptions(false)
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
setNoteListScrolled(true)
|
||||
}
|
||||
|
||||
const renderItem: ListRenderItem<SNNote> | null | undefined = ({ item }) => {
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteCell
|
||||
note={item}
|
||||
onPressItem={props.onPressItem}
|
||||
sortType={props.sortType}
|
||||
hideDates={props.hideDates}
|
||||
hidePreviews={props.hidePreviews}
|
||||
hideEditorIcon={props.hideEditorIcon}
|
||||
highlighted={item.uuid === props.selectedNoteId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
let placeholderText = ''
|
||||
if (props.decrypting) {
|
||||
placeholderText = 'Decrypting notes...'
|
||||
} else if (props.loading) {
|
||||
placeholderText = 'Loading notes...'
|
||||
} else if (props.notes.length === 0) {
|
||||
placeholderText = 'No notes.'
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<HeaderContainer>
|
||||
<SearchBarContainer>
|
||||
<SearchBar
|
||||
onChangeText={onChangeSearchText}
|
||||
onSearchCancel={props.onSearchCancel}
|
||||
onSearchFocusCallback={onSearchFocus}
|
||||
onSearchBlurCallback={onSearchBlur}
|
||||
iosSearchBarInputRef={iosSearchBarInputRef}
|
||||
androidSearchBarInputRef={androidSearchBarInputRef}
|
||||
collapseSearchBarOnBlur={collapseSearchBarOnBlur}
|
||||
/>
|
||||
</SearchBarContainer>
|
||||
<SearchOptionsContainer
|
||||
as={Animated.ScrollView}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
style={{
|
||||
opacity: opacityAnimationValue,
|
||||
marginTop: marginTopAnimationValue,
|
||||
}}
|
||||
>
|
||||
{props.searchOptions.map(({ selected, onPress, label }, index) => (
|
||||
<Chip
|
||||
key={label}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
label={label}
|
||||
last={index === props.searchOptions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SearchOptionsContainer>
|
||||
</HeaderContainer>
|
||||
<FlatList
|
||||
ref={noteListRef}
|
||||
style={styles.list}
|
||||
keyExtractor={item => item?.uuid}
|
||||
contentContainerStyle={[{ paddingBottom: insets.bottom }, props.notes.length > 0 ? {} : { height: '100%' }]}
|
||||
initialNumToRender={6}
|
||||
windowSize={6}
|
||||
maxToRenderPerBatch={6}
|
||||
ListEmptyComponent={() => {
|
||||
return placeholderText.length > 0 ? (
|
||||
<LoadingContainer>
|
||||
<LoadingText>{placeholderText}</LoadingText>
|
||||
</LoadingContainer>
|
||||
) : null
|
||||
}}
|
||||
keyboardDismissMode={'interactive'}
|
||||
keyboardShouldPersistTaps={'never'}
|
||||
refreshControl={
|
||||
!props.hasRefreshControl ? undefined : (
|
||||
<RefreshControl
|
||||
tintColor={theme.stylekitContrastForegroundColor}
|
||||
refreshing={props.refreshing}
|
||||
onRefresh={props.onRefresh}
|
||||
/>
|
||||
)
|
||||
}
|
||||
data={props.notes}
|
||||
renderItem={renderItem}
|
||||
extraData={signedIn}
|
||||
ListHeaderComponent={() => <HeaderContainer>{!signedIn && <OfflineBanner />}</HeaderContainer>}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
7
packages/mobile/src/Screens/Notes/Notes.styled.ts
Normal file
7
packages/mobile/src/Screens/Notes/Notes.styled.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const StyledIcon = styled(Icon)`
|
||||
text-align-vertical: center;
|
||||
margin-left: 2px;
|
||||
`
|
||||
543
packages/mobile/src/Screens/Notes/Notes.tsx
Normal file
543
packages/mobile/src/Screens/Notes/Notes.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import { AppStateType } from '@Lib/ApplicationState'
|
||||
import { useSignedIn, useSyncStatus } from '@Lib/SnjsHelperHooks'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
CollectionSort,
|
||||
CollectionSortProperty,
|
||||
ContentType,
|
||||
PrefKey,
|
||||
SmartView,
|
||||
SNNote,
|
||||
SNTag,
|
||||
SystemViewId,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ICON_ADD } from '@Style/Icons'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import FAB from 'react-native-fab'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { NoteList } from './NoteList'
|
||||
import { StyledIcon } from './Notes.styled'
|
||||
|
||||
type SearchOptions = {
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
label: string
|
||||
}[]
|
||||
|
||||
export const Notes = React.memo(
|
||||
({ isInTabletMode, keyboardHeight }: { isInTabletMode: boolean | undefined; keyboardHeight: number | undefined }) => {
|
||||
const application = useSafeApplicationContext()
|
||||
const theme = useContext(ThemeContext)
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
||||
|
||||
const [loading, decrypting, refreshing, startRefreshing] = useSyncStatus()
|
||||
const [signedIn] = useSignedIn()
|
||||
|
||||
const [sortBy, setSortBy] = useState<CollectionSortProperty>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileSortNotesBy, CollectionSort.CreatedAt),
|
||||
)
|
||||
const [sortReverse, setSortReverse] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileSortNotesReverse, false),
|
||||
)
|
||||
const [hideDates, setHideDates] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideDate, false),
|
||||
)
|
||||
const [hidePreviews, setHidePreviews] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false),
|
||||
)
|
||||
const [hideEditorIcon, setHideEditorIcon] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false),
|
||||
)
|
||||
const [notes, setNotes] = useState<SNNote[]>([])
|
||||
const [selectedNoteId, setSelectedNoteId] = useState<SNNote['uuid']>()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOptions>([])
|
||||
const [includeProtectedNoteText, setIncludeProtectedNoteText] = useState<boolean>(
|
||||
() => !(application.hasProtectionSources() && !application.hasUnprotectedAccessSession()),
|
||||
)
|
||||
const [includeArchivedNotes, setIncludeArchivedNotes] = useState<boolean>(false)
|
||||
const [includeTrashedNotes, setIncludeTrashedNotes] = useState<boolean>(false)
|
||||
const [includeProtectedStarted, setIncludeProtectedStarted] = useState<boolean>(false)
|
||||
const [shouldFocusSearch, setShouldFocusSearch] = useState<boolean>(false)
|
||||
|
||||
const haveDisplayOptions = useRef(false)
|
||||
const protectionsEnabled = useRef(application.hasProtectionSources() && !application.hasUnprotectedAccessSession())
|
||||
|
||||
const reloadTitle = useCallback(
|
||||
(newNotes?: SNNote[], newFilter?: string) => {
|
||||
let title = ''
|
||||
let subTitle: string | undefined
|
||||
|
||||
const selectedTag = application.getAppState().selectedTag
|
||||
|
||||
if (newNotes && (newFilter ?? searchText).length > 0) {
|
||||
const resultCount = newNotes.length
|
||||
title = resultCount === 1 ? `${resultCount} search result` : `${resultCount} search results`
|
||||
} else if (selectedTag) {
|
||||
title = selectedTag.title
|
||||
if (selectedTag instanceof SNTag && selectedTag.parentId) {
|
||||
const parents = application.items.getTagParentChain(selectedTag)
|
||||
const hierarchy = parents.map(tag => tag.title).join(' ⫽ ')
|
||||
subTitle = hierarchy.length > 0 ? `in ${hierarchy}` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
navigation.setParams({
|
||||
title,
|
||||
subTitle,
|
||||
})
|
||||
},
|
||||
[application, navigation, searchText],
|
||||
)
|
||||
|
||||
const openCompose = useCallback(
|
||||
(newNote: boolean, noteUuid: UuidString, replaceScreen = false) => {
|
||||
if (!isInTabletMode) {
|
||||
if (replaceScreen) {
|
||||
navigation.replace(SCREEN_COMPOSE, {
|
||||
title: newNote ? 'Compose' : 'Note',
|
||||
noteUuid,
|
||||
})
|
||||
} else {
|
||||
navigation.navigate(SCREEN_COMPOSE, {
|
||||
title: newNote ? 'Compose' : 'Note',
|
||||
noteUuid,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[navigation, isInTabletMode],
|
||||
)
|
||||
|
||||
const openNote = useCallback(
|
||||
async (noteUuid: SNNote['uuid'], replaceScreen = false) => {
|
||||
await application.getAppState().openEditor(noteUuid)
|
||||
openCompose(false, noteUuid, replaceScreen)
|
||||
},
|
||||
[application, openCompose],
|
||||
)
|
||||
|
||||
const onNoteSelect = useCallback(
|
||||
async (noteUuid: SNNote['uuid']) => {
|
||||
const note = application.items.findItem<SNNote>(noteUuid)
|
||||
if (note) {
|
||||
if (note.protected && !application.hasProtectionSources()) {
|
||||
return navigation.navigate(SCREEN_VIEW_PROTECTED_NOTE, {
|
||||
onPressView: () => openNote(noteUuid, true),
|
||||
})
|
||||
}
|
||||
if (await application.authorizeNoteAccess(note)) {
|
||||
if (!isInTabletMode) {
|
||||
await openNote(noteUuid)
|
||||
} else {
|
||||
/**
|
||||
* @TODO: remove setTimeout after SNJS navigation feature
|
||||
* https://app.asana.com/0/1201653402817596/1202360754617865
|
||||
*/
|
||||
setTimeout(async () => {
|
||||
await openNote(noteUuid)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[application, isInTabletMode, navigation, openNote],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
||||
if (includeProtectedStarted) {
|
||||
setIncludeProtectedStarted(false)
|
||||
setShouldFocusSearch(true)
|
||||
}
|
||||
})
|
||||
|
||||
return removeBlurScreenListener
|
||||
}, [navigation, includeProtectedStarted])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const removeEditorObserver = application.editorGroup.addActiveControllerChangeObserver(activeEditor => {
|
||||
if (mounted) {
|
||||
setSelectedNoteId(activeEditor?.note?.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
removeEditorObserver && removeEditorObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
/**
|
||||
* Note that reloading display options destroys the current index and rebuilds it,
|
||||
* so call sparingly. The runtime complexity of destroying and building
|
||||
* an index is roughly O(n^2).
|
||||
* There are optional parameters to force using the new values,
|
||||
* use when React is too slow when updating the state.
|
||||
*/
|
||||
const reloadNotesDisplayOptions = useCallback(
|
||||
(
|
||||
searchFilter?: string,
|
||||
sortOptions?: {
|
||||
sortBy?: CollectionSortProperty
|
||||
sortReverse: boolean
|
||||
},
|
||||
includeProtected?: boolean,
|
||||
includeArchived?: boolean,
|
||||
includeTrashed?: boolean,
|
||||
) => {
|
||||
const tag = application.getAppState().selectedTag
|
||||
const searchQuery =
|
||||
searchText || searchFilter
|
||||
? {
|
||||
query: searchFilter?.toLowerCase() ?? searchText.toLowerCase(),
|
||||
includeProtectedNoteText: includeProtected ?? includeProtectedNoteText,
|
||||
}
|
||||
: undefined
|
||||
|
||||
let applyFilters = false
|
||||
if (typeof searchFilter !== 'undefined') {
|
||||
applyFilters = searchFilter !== ''
|
||||
} else if (typeof searchText !== 'undefined') {
|
||||
applyFilters = searchText !== ''
|
||||
}
|
||||
|
||||
application.items.setPrimaryItemDisplayOptions({
|
||||
sortBy: sortOptions?.sortBy ?? sortBy,
|
||||
sortDirection: sortOptions?.sortReverse ?? sortReverse ? 'asc' : 'dsc',
|
||||
tags: tag instanceof SNTag ? [tag] : [],
|
||||
views: tag instanceof SmartView ? [tag] : [],
|
||||
searchQuery: searchQuery,
|
||||
includeArchived: applyFilters && (includeArchived ?? includeArchivedNotes),
|
||||
includeTrashed: applyFilters && (includeTrashed ?? includeTrashedNotes),
|
||||
})
|
||||
},
|
||||
[
|
||||
application,
|
||||
includeArchivedNotes,
|
||||
includeProtectedNoteText,
|
||||
includeTrashedNotes,
|
||||
sortBy,
|
||||
sortReverse,
|
||||
searchText,
|
||||
],
|
||||
)
|
||||
|
||||
const toggleIncludeProtected = useCallback(async () => {
|
||||
const includeProtected = !includeProtectedNoteText
|
||||
let allowToggling: boolean | undefined = true
|
||||
|
||||
if (includeProtected) {
|
||||
setIncludeProtectedStarted(true)
|
||||
allowToggling = await application.authorizeSearchingProtectedNotesText()
|
||||
}
|
||||
|
||||
setIncludeProtectedStarted(false)
|
||||
|
||||
if (allowToggling) {
|
||||
reloadNotesDisplayOptions(undefined, undefined, includeProtected)
|
||||
setIncludeProtectedNoteText(includeProtected)
|
||||
}
|
||||
}, [application, includeProtectedNoteText, reloadNotesDisplayOptions])
|
||||
|
||||
const toggleIncludeArchived = useCallback(() => {
|
||||
const includeArchived = !includeArchivedNotes
|
||||
reloadNotesDisplayOptions(undefined, undefined, undefined, includeArchived)
|
||||
setIncludeArchivedNotes(includeArchived)
|
||||
}, [includeArchivedNotes, reloadNotesDisplayOptions])
|
||||
|
||||
const toggleIncludeTrashed = useCallback(() => {
|
||||
const includeTrashed = !includeTrashedNotes
|
||||
reloadNotesDisplayOptions(undefined, undefined, undefined, undefined, includeTrashed)
|
||||
setIncludeTrashedNotes(includeTrashed)
|
||||
}, [includeTrashedNotes, reloadNotesDisplayOptions])
|
||||
|
||||
const reloadSearchOptions = useCallback(() => {
|
||||
const protections = application.hasProtectionSources() && !application.hasUnprotectedAccessSession()
|
||||
|
||||
if (protections !== protectionsEnabled.current) {
|
||||
protectionsEnabled.current = !!protections
|
||||
setIncludeProtectedNoteText(!protections)
|
||||
}
|
||||
|
||||
const selectedTag = application.getAppState().selectedTag
|
||||
const options = [
|
||||
{
|
||||
label: 'Include Protected Contents',
|
||||
selected: includeProtectedNoteText,
|
||||
onPress: toggleIncludeProtected,
|
||||
},
|
||||
]
|
||||
|
||||
const isArchiveView = selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.ArchivedNotes
|
||||
const isTrashView = selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.TrashedNotes
|
||||
if (!isArchiveView && !isTrashView) {
|
||||
setSearchOptions([
|
||||
...options,
|
||||
{
|
||||
label: 'Archived',
|
||||
selected: includeArchivedNotes,
|
||||
onPress: toggleIncludeArchived,
|
||||
},
|
||||
{
|
||||
label: 'Trashed',
|
||||
selected: includeTrashedNotes,
|
||||
onPress: toggleIncludeTrashed,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
setSearchOptions(options)
|
||||
}
|
||||
}, [
|
||||
application,
|
||||
includeProtectedNoteText,
|
||||
includeArchivedNotes,
|
||||
includeTrashedNotes,
|
||||
toggleIncludeProtected,
|
||||
toggleIncludeArchived,
|
||||
toggleIncludeTrashed,
|
||||
])
|
||||
|
||||
const getFirstSelectableNote = useCallback((newNotes: SNNote[]) => newNotes.find(note => !note.protected), [])
|
||||
|
||||
const selectFirstNote = useCallback(
|
||||
(newNotes: SNNote[]) => {
|
||||
const note = getFirstSelectableNote(newNotes)
|
||||
if (note && !loading && !decrypting) {
|
||||
void onNoteSelect(note.uuid)
|
||||
}
|
||||
},
|
||||
[decrypting, getFirstSelectableNote, loading, onNoteSelect],
|
||||
)
|
||||
|
||||
const selectNextOrCreateNew = useCallback(
|
||||
(newNotes: SNNote[]) => {
|
||||
const note = getFirstSelectableNote(newNotes)
|
||||
if (note) {
|
||||
void onNoteSelect(note.uuid)
|
||||
} else {
|
||||
application.getAppState().closeActiveEditor()
|
||||
}
|
||||
},
|
||||
[application, getFirstSelectableNote, onNoteSelect],
|
||||
)
|
||||
|
||||
const reloadNotes = useCallback(
|
||||
(reselectNote?: boolean, tagChanged?: boolean, searchFilter?: string) => {
|
||||
const tag = application.getAppState().selectedTag
|
||||
|
||||
if (!tag) {
|
||||
return
|
||||
}
|
||||
|
||||
reloadSearchOptions()
|
||||
|
||||
if (!haveDisplayOptions.current) {
|
||||
haveDisplayOptions.current = true
|
||||
reloadNotesDisplayOptions()
|
||||
}
|
||||
|
||||
const newNotes = application.items.getDisplayableNotes()
|
||||
const renderedNotes: SNNote[] = newNotes
|
||||
|
||||
setNotes(renderedNotes)
|
||||
reloadTitle(renderedNotes, searchFilter)
|
||||
|
||||
if (!application.getAppState().isTabletDevice || !reselectNote) {
|
||||
return
|
||||
}
|
||||
|
||||
if (tagChanged) {
|
||||
if (renderedNotes.length > 0) {
|
||||
selectFirstNote(renderedNotes)
|
||||
} else {
|
||||
application.getAppState().closeActiveEditor()
|
||||
}
|
||||
} else {
|
||||
const activeNote = application.getAppState().getActiveNoteController()?.note
|
||||
|
||||
if (activeNote) {
|
||||
const isTrashView =
|
||||
application.getAppState().selectedTag instanceof SmartView &&
|
||||
application.getAppState().selectedTag.uuid === SystemViewId.TrashedNotes
|
||||
|
||||
if (activeNote.trashed && !isTrashView) {
|
||||
selectNextOrCreateNew(renderedNotes)
|
||||
}
|
||||
} else {
|
||||
selectFirstNote(renderedNotes)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
application,
|
||||
reloadNotesDisplayOptions,
|
||||
reloadSearchOptions,
|
||||
reloadTitle,
|
||||
selectFirstNote,
|
||||
selectNextOrCreateNew,
|
||||
],
|
||||
)
|
||||
|
||||
const onNoteCreate = useCallback(async () => {
|
||||
const title = application.getAppState().isTabletDevice ? `Note ${notes.length + 1}` : undefined
|
||||
const noteView = await application.getAppState().createEditor(title)
|
||||
openCompose(true, noteView.note.uuid)
|
||||
reloadNotes(true)
|
||||
}, [application, notes.length, openCompose, reloadNotes])
|
||||
|
||||
const reloadPreferences = useCallback(async () => {
|
||||
let newSortBy = application.getLocalPreferences().getValue(PrefKey.MobileSortNotesBy, CollectionSort.CreatedAt)
|
||||
|
||||
if (newSortBy === CollectionSort.UpdatedAt || (newSortBy as string) === 'client_updated_at') {
|
||||
newSortBy = CollectionSort.UpdatedAt
|
||||
}
|
||||
let displayOptionsChanged = false
|
||||
const newSortReverse = application.getLocalPreferences().getValue(PrefKey.MobileSortNotesReverse, false)
|
||||
const newHidePreview = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false)
|
||||
const newHideDate = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideDate, false)
|
||||
const newHideEditorIcon = application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false)
|
||||
|
||||
if (sortBy !== newSortBy) {
|
||||
setSortBy(newSortBy)
|
||||
displayOptionsChanged = true
|
||||
}
|
||||
if (sortReverse !== newSortReverse) {
|
||||
setSortReverse(newSortReverse)
|
||||
displayOptionsChanged = true
|
||||
}
|
||||
if (hidePreviews !== newHidePreview) {
|
||||
setHidePreviews(newHidePreview)
|
||||
displayOptionsChanged = true
|
||||
}
|
||||
if (hideDates !== newHideDate) {
|
||||
setHideDates(newHideDate)
|
||||
displayOptionsChanged = true
|
||||
}
|
||||
if (hideEditorIcon !== newHideEditorIcon) {
|
||||
setHideEditorIcon(newHideEditorIcon)
|
||||
displayOptionsChanged = true
|
||||
}
|
||||
|
||||
if (displayOptionsChanged) {
|
||||
reloadNotesDisplayOptions(undefined, {
|
||||
sortBy: newSortBy,
|
||||
sortReverse: newSortReverse,
|
||||
})
|
||||
}
|
||||
reloadNotes()
|
||||
}, [
|
||||
application,
|
||||
sortBy,
|
||||
sortReverse,
|
||||
hidePreviews,
|
||||
hideDates,
|
||||
hideEditorIcon,
|
||||
reloadNotes,
|
||||
reloadNotesDisplayOptions,
|
||||
])
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
startRefreshing()
|
||||
void application.sync.sync()
|
||||
}, [application, startRefreshing])
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(filter: string) => {
|
||||
reloadNotesDisplayOptions(filter)
|
||||
setSearchText(filter)
|
||||
reloadNotes(undefined, undefined, filter)
|
||||
},
|
||||
[reloadNotes, reloadNotesDisplayOptions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const removeEventObserver = application?.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
|
||||
await reloadPreferences()
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeEventObserver?.()
|
||||
}
|
||||
}, [application, reloadPreferences])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void reloadPreferences()
|
||||
}, [reloadPreferences]),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const removeAppStateChangeHandler = application.getAppState().addStateChangeObserver(state => {
|
||||
if (state === AppStateType.TagChanged) {
|
||||
reloadNotesDisplayOptions()
|
||||
reloadNotes(true, true)
|
||||
}
|
||||
if (state === AppStateType.PreferencesChanged) {
|
||||
void reloadPreferences()
|
||||
}
|
||||
})
|
||||
|
||||
const removeStreamNotes = application.streamItems([ContentType.Note], async () => {
|
||||
/** If a note changes, it will be queried against the existing filter;
|
||||
* we dont need to reload display options */
|
||||
reloadNotes(true)
|
||||
})
|
||||
|
||||
const removeStreamTags = application.streamItems([ContentType.Tag], async () => {
|
||||
/** A tag could have changed its relationships, so we need to reload the filter */
|
||||
reloadNotesDisplayOptions()
|
||||
reloadNotes()
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeStreamNotes()
|
||||
removeStreamTags()
|
||||
removeAppStateChangeHandler()
|
||||
}
|
||||
}, [application, reloadNotes, reloadNotesDisplayOptions, reloadPreferences])
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoteList
|
||||
onRefresh={onRefresh}
|
||||
hasRefreshControl={signedIn}
|
||||
onPressItem={onNoteSelect}
|
||||
refreshing={refreshing}
|
||||
searchText={searchText}
|
||||
onSearchChange={onSearchChange}
|
||||
onSearchCancel={() => onSearchChange('')}
|
||||
notes={notes}
|
||||
sortType={sortBy}
|
||||
decrypting={decrypting}
|
||||
loading={loading}
|
||||
hidePreviews={hidePreviews}
|
||||
hideDates={hideDates}
|
||||
hideEditorIcon={hideEditorIcon}
|
||||
selectedNoteId={application.getAppState().isInTabletMode ? selectedNoteId : undefined}
|
||||
searchOptions={searchOptions}
|
||||
shouldFocusSearch={shouldFocusSearch}
|
||||
setShouldFocusSearch={setShouldFocusSearch}
|
||||
/>
|
||||
<FAB
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore style prop does not exist in types
|
||||
style={application.getAppState().isInTabletMode ? { bottom: keyboardHeight } : undefined}
|
||||
buttonColor={theme.stylekitInfoColor}
|
||||
iconTextColor={theme.stylekitInfoContrastColor}
|
||||
onClickAction={onNoteCreate}
|
||||
visible={true}
|
||||
size={30}
|
||||
iconTextComponent={<StyledIcon testID="newNoteButton" name={ThemeService.nameForIcon(ICON_ADD)} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
41
packages/mobile/src/Screens/Notes/OfflineBanner.styled.ts
Normal file
41
packages/mobile/src/Screens/Notes/OfflineBanner.styled.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
const MARGIN = 4
|
||||
const PADDING = 12
|
||||
|
||||
const Touchable = styled.TouchableWithoutFeedback``
|
||||
const Container = styled.View`
|
||||
flex-direction: row;
|
||||
margin: ${MARGIN}px;
|
||||
padding: ${PADDING}px;
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
border-color: ${props => props.theme.stylekitBorderColor};
|
||||
`
|
||||
const CenterContainer = styled.View`
|
||||
justify-content: center;
|
||||
`
|
||||
const UserIcon = styled(Icon)`
|
||||
font-size: 24px;
|
||||
color: ${props => props.theme.stylekitInfoColor};
|
||||
`
|
||||
const ForwardIcon = styled(UserIcon)`
|
||||
color: ${props => props.theme.stylekitNeutralColor};
|
||||
`
|
||||
const TextContainer = styled.View`
|
||||
flex: 1;
|
||||
padding-left: ${PADDING}px;
|
||||
`
|
||||
const BoldText = styled.Text`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.stylekitForegroundColor};
|
||||
`
|
||||
const SubText = styled.Text`
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: ${props => props.theme.stylekitNeutralColor};
|
||||
`
|
||||
|
||||
export { Touchable, Container, CenterContainer, UserIcon, ForwardIcon, TextContainer, BoldText, SubText }
|
||||
42
packages/mobile/src/Screens/Notes/OfflineBanner.tsx
Normal file
42
packages/mobile/src/Screens/Notes/OfflineBanner.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { SCREEN_SETTINGS } from '@Root/Screens/screens'
|
||||
import { ICON_FORWARD, ICON_USER } from '@Style/Icons'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import React from 'react'
|
||||
import {
|
||||
BoldText,
|
||||
CenterContainer,
|
||||
Container,
|
||||
ForwardIcon,
|
||||
SubText,
|
||||
TextContainer,
|
||||
Touchable,
|
||||
UserIcon,
|
||||
} from './OfflineBanner.styled'
|
||||
|
||||
const NOT_BACKED_UP_TEXT = 'Data not backed up'
|
||||
const SIGN_IN_TEXT = 'Sign in or register to backup your notes'
|
||||
|
||||
export const OfflineBanner: React.FC = () => {
|
||||
const navigation = useNavigation()
|
||||
const onPress = () => {
|
||||
navigation.navigate(SCREEN_SETTINGS as never)
|
||||
}
|
||||
|
||||
return (
|
||||
<Touchable onPress={onPress}>
|
||||
<Container>
|
||||
<CenterContainer>
|
||||
<UserIcon name={ThemeService.nameForIcon(ICON_USER)} />
|
||||
</CenterContainer>
|
||||
<TextContainer>
|
||||
<BoldText>{NOT_BACKED_UP_TEXT}</BoldText>
|
||||
<SubText>{SIGN_IN_TEXT}</SubText>
|
||||
</TextContainer>
|
||||
<CenterContainer>
|
||||
<ForwardIcon name={ThemeService.nameForIcon(ICON_FORWARD)} />
|
||||
</CenterContainer>
|
||||
</Container>
|
||||
</Touchable>
|
||||
)
|
||||
}
|
||||
42
packages/mobile/src/Screens/Root.styled.ts
Normal file
42
packages/mobile/src/Screens/Root.styled.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
export const Container = styled.View`
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
`
|
||||
export const NotesContainer = styled.View<{
|
||||
isInTabletMode?: boolean
|
||||
notesListCollapsed?: boolean
|
||||
}>`
|
||||
${({ isInTabletMode, notesListCollapsed, theme }) => {
|
||||
return isInTabletMode
|
||||
? css`
|
||||
border-right-color: ${theme.stylekitBorderColor};
|
||||
border-right-width: ${notesListCollapsed ? 0 : 1}px;
|
||||
width: ${notesListCollapsed ? 0 : '40%'};
|
||||
`
|
||||
: css`
|
||||
flex: 1;
|
||||
`
|
||||
}}
|
||||
`
|
||||
export const ComposeContainer = styled.View`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
export const ExpandTouchable = styled.TouchableHighlight.attrs(({ theme }) => ({
|
||||
underlayColor: theme.stylekitBackgroundColor,
|
||||
}))`
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
padding: 7px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
margin-top: -12px;
|
||||
`
|
||||
|
||||
export const iconNames = {
|
||||
md: ['arrow-dropright', 'arrow-dropleft'],
|
||||
ios: ['arrow-forward', 'arrow-back'],
|
||||
}
|
||||
123
packages/mobile/src/Screens/Root.tsx
Normal file
123
packages/mobile/src/Screens/Root.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState'
|
||||
import { useIsLocked } from '@Lib/SnjsHelperHooks'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { NoteViewController } from '@standardnotes/snjs'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import { hexToRGBA } from '@Style/Utils'
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { LayoutChangeEvent } from 'react-native'
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { Compose } from './Compose/Compose'
|
||||
import { Notes } from './Notes/Notes'
|
||||
import { ComposeContainer, Container, ExpandTouchable, iconNames, NotesContainer } from './Root.styled'
|
||||
|
||||
export const Root = () => {
|
||||
const application = useContext(ApplicationContext)
|
||||
const theme = useContext(ThemeContext)
|
||||
const [isLocked] = useIsLocked()
|
||||
|
||||
const [, setWidth] = useState<number | undefined>(undefined)
|
||||
const [height, setHeight] = useState<number | undefined>(undefined)
|
||||
const [, setX] = useState<number | undefined>(undefined)
|
||||
const [noteListCollapsed, setNoteListCollapsed] = useState<boolean>(false)
|
||||
const [activeNoteView, setActiveNoteView] = useState<NoteViewController | undefined>()
|
||||
const [isInTabletMode, setIsInTabletMode] = useState<boolean | undefined>(application?.getAppState().isInTabletMode)
|
||||
const [keyboardHeight, setKeyboardHeight] = useState<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const removeStateObserver = application?.getAppState().addStateChangeObserver(state => {
|
||||
if (state === AppStateType.GainingFocus) {
|
||||
void application.sync.sync()
|
||||
}
|
||||
})
|
||||
const removeApplicationStateEventHandler = application
|
||||
?.getAppState()
|
||||
.addStateEventObserver((event: AppStateEventType, data: TabletModeChangeData | undefined) => {
|
||||
if (event === AppStateEventType.TabletModeChange) {
|
||||
const eventData = data as TabletModeChangeData
|
||||
if (eventData.new_isInTabletMode && !eventData.old_isInTabletMode) {
|
||||
setIsInTabletMode(true)
|
||||
} else if (!eventData.new_isInTabletMode && eventData.old_isInTabletMode) {
|
||||
setIsInTabletMode(false)
|
||||
}
|
||||
}
|
||||
if (event === AppStateEventType.KeyboardChangeEvent) {
|
||||
// need to refresh the height of the keyboard when it opens so that we can change the position
|
||||
// of the sidebar collapse icon
|
||||
if (application?.getAppState().isInTabletMode) {
|
||||
setKeyboardHeight(application?.getAppState().getKeyboardHeight())
|
||||
}
|
||||
}
|
||||
})
|
||||
const removeNoteObserver = application?.editorGroup.addActiveControllerChangeObserver(activeController => {
|
||||
setActiveNoteView(activeController)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (removeApplicationStateEventHandler) {
|
||||
removeApplicationStateEventHandler()
|
||||
}
|
||||
if (removeStateObserver) {
|
||||
removeStateObserver()
|
||||
}
|
||||
if (removeNoteObserver) {
|
||||
removeNoteObserver()
|
||||
}
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const collapseIconName = useMemo(() => {
|
||||
const collapseIconPrefix = ThemeService.platformIconPrefix()
|
||||
|
||||
return collapseIconPrefix + '-' + iconNames[collapseIconPrefix][noteListCollapsed ? 0 : 1]
|
||||
}, [noteListCollapsed])
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const tempWidth = e.nativeEvent.layout.width
|
||||
/**
|
||||
If you're in tablet mode, but on an iPad where this app is running side by
|
||||
side by another app, we only want to show the Compose window and not the
|
||||
list, because there isn't enough space.
|
||||
*/
|
||||
const MinWidthToSplit = 450
|
||||
if (application?.getAppState().isTabletDevice) {
|
||||
if (tempWidth < MinWidthToSplit) {
|
||||
application?.getAppState().setTabletModeEnabled(false)
|
||||
} else {
|
||||
application?.getAppState().setTabletModeEnabled(true)
|
||||
}
|
||||
}
|
||||
setWidth(tempWidth)
|
||||
setHeight(e.nativeEvent.layout.height)
|
||||
setX(e.nativeEvent.layout.x)
|
||||
setIsInTabletMode(application?.getAppState().isInTabletMode)
|
||||
setKeyboardHeight(application?.getAppState().getKeyboardHeight())
|
||||
}
|
||||
|
||||
const toggleNoteList = () => {
|
||||
setNoteListCollapsed(value => !value)
|
||||
}
|
||||
|
||||
const collapseIconBottomPosition = (keyboardHeight ?? 0) > (height ?? 0) / 2 ? (keyboardHeight ?? 0) + 40 : '50%'
|
||||
|
||||
if (isLocked) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container testID="rootView" onLayout={onLayout}>
|
||||
<NotesContainer notesListCollapsed={noteListCollapsed} isInTabletMode={isInTabletMode}>
|
||||
<Notes keyboardHeight={keyboardHeight} isInTabletMode={isInTabletMode} />
|
||||
</NotesContainer>
|
||||
{activeNoteView && !activeNoteView.dealloced && isInTabletMode && (
|
||||
<ComposeContainer>
|
||||
<Compose noteUuid={activeNoteView.note.uuid} />
|
||||
<ExpandTouchable style={{ bottom: collapseIconBottomPosition }} onPress={toggleNoteList}>
|
||||
<Icon name={collapseIconName} size={24} color={hexToRGBA(theme.stylekitInfoColor, 0.85)} />
|
||||
</ExpandTouchable>
|
||||
</ComposeContainer>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
const PADDING = 14
|
||||
|
||||
export const RegistrationDescription = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
font-size: ${({ theme }) => theme.mainTextFontSize}px;
|
||||
padding-left: ${PADDING}px;
|
||||
padding-right: ${PADDING}px;
|
||||
margin-bottom: ${PADDING}px;
|
||||
`
|
||||
|
||||
export const RegistrationInput = styled.TextInput.attrs(({ theme }) => ({
|
||||
underlineColorAndroid: 'transparent',
|
||||
placeholderTextColor: theme.stylekitNeutralColor,
|
||||
}))`
|
||||
font-size: ${({ theme }) => theme.mainTextFontSize}px;
|
||||
padding: 0px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export const RegularView = styled.View``
|
||||
254
packages/mobile/src/Screens/Settings/Sections/AuthSection.tsx
Normal file
254
packages/mobile/src/Screens/Settings/Sections/AuthSection.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { Keyboard } from 'react-native'
|
||||
import { RegistrationDescription, RegistrationInput, RegularView } from './AuthSection.styled'
|
||||
|
||||
const DEFAULT_SIGN_IN_TEXT = 'Sign In'
|
||||
const DEFAULT_REGISTER_TEXT = 'Register'
|
||||
const SIGNIN_IN = 'Generating Keys...'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
signedIn: boolean
|
||||
}
|
||||
|
||||
export const AuthSection = (props: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [strictSignIn, setStrictSignIn] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [server, setServer] = useState('')
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||
const [confirmRegistration, setConfirmRegistration] = useState(false)
|
||||
|
||||
// set initial server
|
||||
useEffect(() => {
|
||||
const getServer = async () => {
|
||||
const host = await application?.getHost()
|
||||
setServer(host!)
|
||||
}
|
||||
void getServer()
|
||||
}, [application])
|
||||
|
||||
const updateServer = useCallback(
|
||||
async (host: string) => {
|
||||
setServer(host)
|
||||
await application?.setCustomHost(host)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
if (props.signedIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (!email) {
|
||||
void application?.alertService?.alert('Please enter a valid email address.', 'Missing Email', 'OK')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
void application?.alertService?.alert('Please enter your password.', 'Missing Password', 'OK')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const signIn = async () => {
|
||||
setSigningIn(true)
|
||||
if (!validate()) {
|
||||
setSigningIn(false)
|
||||
return
|
||||
}
|
||||
Keyboard.dismiss()
|
||||
const result = await application!.signIn(email, password, strictSignIn, undefined, true, false)
|
||||
|
||||
if (result?.error) {
|
||||
if (result?.error.message) {
|
||||
void application?.alertService?.alert(result?.error.message)
|
||||
}
|
||||
setSigningIn(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSigningIn(false)
|
||||
setPassword('')
|
||||
setPasswordConfirmation('')
|
||||
}
|
||||
|
||||
const onRegisterPress = () => {
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
setConfirmRegistration(true)
|
||||
}
|
||||
|
||||
const register = async () => {
|
||||
setRegistering(true)
|
||||
if (password !== passwordConfirmation) {
|
||||
void application?.alertService?.alert(
|
||||
'The passwords you entered do not match. Please try again.',
|
||||
"Passwords Don't Match",
|
||||
'OK',
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
Keyboard.dismiss()
|
||||
await application!.register(email, password, undefined, true)
|
||||
} catch (error) {
|
||||
void application?.alertService?.alert((error as Error).message)
|
||||
}
|
||||
}
|
||||
setRegistering(false)
|
||||
}
|
||||
|
||||
const _renderRegistrationConfirm = () => {
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={'Confirm Password'} />
|
||||
|
||||
<RegistrationDescription>
|
||||
Due to the nature of our encryption, Standard Notes cannot offer password reset functionality. If you forget
|
||||
your password, you will permanently lose access to your data.
|
||||
</RegistrationDescription>
|
||||
|
||||
<SectionedTableCell first textInputCell>
|
||||
<RegistrationInput
|
||||
testID="passwordConfirmationField"
|
||||
placeholder={'Password confirmation'}
|
||||
onChangeText={setPasswordConfirmation}
|
||||
value={passwordConfirmation}
|
||||
secureTextEntry
|
||||
autoFocus
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<ButtonCell
|
||||
testID="registerConfirmButton"
|
||||
disabled={registering}
|
||||
title={registering ? 'Generating Keys...' : 'Register'}
|
||||
bold
|
||||
onPress={register}
|
||||
/>
|
||||
|
||||
<ButtonCell
|
||||
title="Cancel"
|
||||
onPress={() => {
|
||||
setConfirmRegistration(false)
|
||||
setPasswordConfirmation('')
|
||||
setPassword('')
|
||||
}}
|
||||
/>
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
|
||||
const _renderDefaultContent = () => {
|
||||
const keyboardApperance = themeService?.keyboardColorForActiveTheme()
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
{props.title && <SectionHeader title={props.title} />}
|
||||
|
||||
<>
|
||||
<RegularView>
|
||||
<SectionedTableCell textInputCell first>
|
||||
<RegistrationInput
|
||||
testID="emailField"
|
||||
placeholder={'Email'}
|
||||
onChangeText={setEmail}
|
||||
value={email ?? undefined}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardType={'email-address'}
|
||||
textContentType={'emailAddress'}
|
||||
keyboardAppearance={keyboardApperance}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<SectionedTableCell textInputCell>
|
||||
<RegistrationInput
|
||||
testID="passwordField"
|
||||
placeholder={'Password'}
|
||||
onChangeText={setPassword}
|
||||
value={password ?? undefined}
|
||||
textContentType={'password'}
|
||||
secureTextEntry
|
||||
keyboardAppearance={keyboardApperance}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
</RegularView>
|
||||
|
||||
{(showAdvanced || !server) && (
|
||||
<RegularView>
|
||||
<SectionHeader title={'Advanced'} />
|
||||
<SectionedTableCell textInputCell first>
|
||||
<RegistrationInput
|
||||
testID="syncServerField"
|
||||
placeholder={'Sync Server'}
|
||||
onChangeText={updateServer}
|
||||
value={server}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
keyboardType={'url'}
|
||||
keyboardAppearance={keyboardApperance}
|
||||
/>
|
||||
</SectionedTableCell>
|
||||
|
||||
<SectionedAccessoryTableCell
|
||||
onPress={() => setStrictSignIn(!strictSignIn)}
|
||||
text={'Use strict sign in'}
|
||||
selected={() => {
|
||||
return strictSignIn
|
||||
}}
|
||||
/>
|
||||
</RegularView>
|
||||
)}
|
||||
</>
|
||||
|
||||
<ButtonCell
|
||||
testID="signInButton"
|
||||
title={signingIn ? SIGNIN_IN : DEFAULT_SIGN_IN_TEXT}
|
||||
disabled={signingIn}
|
||||
bold={true}
|
||||
onPress={signIn}
|
||||
/>
|
||||
|
||||
<ButtonCell
|
||||
testID="registerButton"
|
||||
title={DEFAULT_REGISTER_TEXT}
|
||||
disabled={registering}
|
||||
bold
|
||||
onPress={onRegisterPress}
|
||||
/>
|
||||
|
||||
{!showAdvanced && (
|
||||
<ButtonCell testID="advancedOptionsButton" title="Advanced Options" onPress={() => setShowAdvanced(true)} />
|
||||
)}
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RegularView>
|
||||
{confirmRegistration && _renderRegistrationConfirm()}
|
||||
|
||||
{!confirmRegistration && _renderDefaultContent()}
|
||||
</RegularView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Label = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
margin-top: 3px;
|
||||
`
|
||||
export const ContentContainer = styled.View`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ApplicationState } from '@Lib/ApplicationState'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import React, { useContext } from 'react'
|
||||
import { Platform, Share } from 'react-native'
|
||||
import { ContentContainer, Label } from './CompanySection.styled'
|
||||
|
||||
const URLS = {
|
||||
feedback: `mailto:help@standardnotes.com?subject=${Platform.OS === 'android' ? 'Android' : 'iOS'} app feedback (v${
|
||||
ApplicationState.version
|
||||
})`,
|
||||
learn_more: 'https://standardnotes.com',
|
||||
privacy: 'https://standardnotes.com/privacy',
|
||||
help: 'https://standardnotes.com/help',
|
||||
rate: Platform.select({
|
||||
ios: 'https://itunes.apple.com/us/app/standard-notes/id1285392450?ls=1&mt=8',
|
||||
android: 'market://details?id=com.standardnotes',
|
||||
}) as string,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
}
|
||||
|
||||
export const CompanySection = (props: Props) => {
|
||||
const application = useContext(ApplicationContext)
|
||||
const storeName = Platform.OS === 'android' ? 'Play Store' : 'App Store'
|
||||
|
||||
const openUrl = (action: keyof typeof URLS) => {
|
||||
application?.deviceInterface!.openUrl(URLS[action])
|
||||
}
|
||||
|
||||
const shareEncryption = () => {
|
||||
const title = 'The Unexpected Benefits of Encrypted Writing'
|
||||
let message = Platform.OS === 'ios' ? title : ''
|
||||
const url = 'https://standardnotes.com/why-encrypted'
|
||||
// Android ignores url. iOS ignores title.
|
||||
if (Platform.OS === 'android') {
|
||||
message += '\n\nhttps://standardnotes.com/why-encrypted'
|
||||
}
|
||||
void application?.getAppState().performActionWithoutStateChangeImpact(() => {
|
||||
void Share.share({ title: title, message: message, url: url })
|
||||
})
|
||||
}
|
||||
|
||||
const shareWithFriend = () => {
|
||||
const title = 'Standard Notes'
|
||||
let message = 'Check out Standard Notes, a free, open-source, and completely encrypted notes app.'
|
||||
const url = 'https://standardnotes.com'
|
||||
// Android ignores url. iOS ignores title.
|
||||
if (Platform.OS === 'android') {
|
||||
message += '\n\nhttps://standardnotes.com'
|
||||
}
|
||||
void application?.getAppState().performActionWithoutStateChangeImpact(() => {
|
||||
void Share.share({ title: title, message: message, url: url })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={props.title} />
|
||||
|
||||
<ButtonCell first leftAligned={true} title="Help" onPress={() => openUrl('help')}>
|
||||
<Label>https://standardnotes.com/help</Label>
|
||||
</ButtonCell>
|
||||
<ButtonCell leftAligned={true} title="Contact Support" onPress={() => openUrl('feedback')}>
|
||||
<ContentContainer>
|
||||
<Label>help@standardnotes.com</Label>
|
||||
</ContentContainer>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned={true} title="Spread Encryption" onPress={shareEncryption}>
|
||||
<Label>Share the unexpected benefits of encrypted writing.</Label>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned={true} title="Tell a Friend" onPress={shareWithFriend}>
|
||||
<Label>Share Standard Notes with a friend.</Label>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned={true} title="Learn About Standard Notes" onPress={() => openUrl('learn_more')}>
|
||||
<Label>https://standardnotes.com</Label>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned={true} title="Our Privacy Manifesto" onPress={() => openUrl('privacy')}>
|
||||
<Label>https://standardnotes.com/privacy</Label>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned={true} title="Rate Standard Notes" onPress={() => openUrl('rate')}>
|
||||
<ContentContainer>
|
||||
<Label>Version {ApplicationState.version}</Label>
|
||||
<Label>Help support us with a review on the {storeName}.</Label>
|
||||
</ContentContainer>
|
||||
</ButtonCell>
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const BaseView = styled.View``
|
||||
|
||||
export const StyledSectionedTableCell = styled(SectionedTableCell)`
|
||||
padding-top: 12px;
|
||||
`
|
||||
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
export const Subtitle = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
`
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useIsLocked } from '@Lib/SnjsHelperHooks'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ContentType, StorageEncryptionPolicy } from '@standardnotes/snjs'
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { BaseView, StyledSectionedTableCell, Subtitle, Title } from './EncryptionSection.styled'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export const EncryptionSection = (props: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const [isLocked] = useIsLocked()
|
||||
|
||||
// State
|
||||
const [protocolDisplayName, setProtocolDisplayName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.encryptionAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
let mounted = true
|
||||
const getProtocolDisplayName = async () => {
|
||||
const displayName = (await application?.getProtocolEncryptionDisplayName()) ?? ''
|
||||
if (mounted) {
|
||||
setProtocolDisplayName(displayName)
|
||||
}
|
||||
}
|
||||
void getProtocolDisplayName()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [application, props.encryptionAvailable])
|
||||
|
||||
const textData = useMemo(() => {
|
||||
const encryptionType = protocolDisplayName
|
||||
let encryptionStatus = props.encryptionAvailable ? 'Enabled' : 'Not Enabled'
|
||||
if (props.encryptionAvailable) {
|
||||
encryptionStatus += ` | ${encryptionType}`
|
||||
} else {
|
||||
encryptionStatus += '. '
|
||||
encryptionStatus +=
|
||||
application?.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
|
||||
? 'To enable encryption, sign in, register, or enable storage encryption.'
|
||||
: 'Sign in, register, or add a local passcode to enable encryption.'
|
||||
}
|
||||
let sourceString
|
||||
if (isLocked) {
|
||||
return { title: '', text: '' }
|
||||
} else {
|
||||
sourceString = application?.hasAccount() ? 'Account Keys' : 'Passcode'
|
||||
}
|
||||
|
||||
const items = application!.items.getItems([ContentType.Note, ContentType.Tag])
|
||||
const itemsStatus = items.length + '/' + items.length + ' notes and tags encrypted'
|
||||
|
||||
return {
|
||||
encryptionStatus,
|
||||
sourceString,
|
||||
itemsStatus,
|
||||
}
|
||||
}, [application, props.encryptionAvailable, isLocked, protocolDisplayName])
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={props.title} />
|
||||
|
||||
<StyledSectionedTableCell last={!props.encryptionAvailable} first={true}>
|
||||
<BaseView>
|
||||
<Title>Encryption</Title>
|
||||
<Subtitle>{textData.encryptionStatus}</Subtitle>
|
||||
</BaseView>
|
||||
</StyledSectionedTableCell>
|
||||
|
||||
{props.encryptionAvailable && (
|
||||
<StyledSectionedTableCell>
|
||||
<BaseView>
|
||||
<Title>Encryption Source</Title>
|
||||
<Subtitle>{textData.sourceString}</Subtitle>
|
||||
</BaseView>
|
||||
</StyledSectionedTableCell>
|
||||
)}
|
||||
|
||||
{props.encryptionAvailable && (
|
||||
<StyledSectionedTableCell last>
|
||||
<BaseView>
|
||||
<Title>Items Encrypted</Title>
|
||||
<Subtitle>{textData.itemsStatus}</Subtitle>
|
||||
</BaseView>
|
||||
</StyledSectionedTableCell>
|
||||
)}
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SectionedTableCell } from '@Components/SectionedTableCell'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import styled, { DefaultTheme } from 'styled-components/native'
|
||||
|
||||
export const useFilesInPreferencesStyles = (theme: DefaultTheme) => {
|
||||
return StyleSheet.create({
|
||||
progressBarContainer: {
|
||||
backgroundColor: theme.stylekitSecondaryContrastBackgroundColor,
|
||||
height: 8,
|
||||
borderRadius: 8,
|
||||
marginTop: 6,
|
||||
},
|
||||
progressBar: {
|
||||
backgroundColor: theme.stylekitInfoColor,
|
||||
borderRadius: 8,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const StyledSectionedTableCell = styled(SectionedTableCell)`
|
||||
padding-top: 12px;
|
||||
`
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
`
|
||||
export const SubTitle = styled.Text`
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
|
||||
opacity: 0.6;
|
||||
`
|
||||
@@ -0,0 +1,56 @@
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import {
|
||||
StyledSectionedTableCell,
|
||||
SubTitle,
|
||||
Title,
|
||||
useFilesInPreferencesStyles,
|
||||
} from '@Screens/Settings/Sections/FilesSection.styled'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { SubscriptionSettingName } from '@standardnotes/snjs'
|
||||
import React, { FC, useContext, useEffect, useState } from 'react'
|
||||
import { View } from 'react-native'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
|
||||
export const FilesSection: FC = () => {
|
||||
const application = useSafeApplicationContext()
|
||||
const theme = useContext(ThemeContext)
|
||||
const styles = useFilesInPreferencesStyles(theme)
|
||||
|
||||
const [filesUsedQuota, setFilesUsedQuota] = useState(0)
|
||||
const [filesTotalQuota, setFilesTotalQuota] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const getQuota = async () => {
|
||||
const { FileUploadBytesUsed, FileUploadBytesLimit } = SubscriptionSettingName
|
||||
|
||||
const usedQuota = await application.settings.getSubscriptionSetting(FileUploadBytesUsed)
|
||||
const totalQuota = await application.settings.getSubscriptionSetting(FileUploadBytesLimit)
|
||||
|
||||
setFilesUsedQuota(usedQuota ? parseFloat(usedQuota) : 0)
|
||||
setFilesTotalQuota(totalQuota ? parseFloat(totalQuota) : 0)
|
||||
}
|
||||
|
||||
getQuota().catch(console.error)
|
||||
}, [application.settings])
|
||||
|
||||
const usedQuotaRatioPercent = Math.round((filesUsedQuota * 100) / filesTotalQuota)
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={'Files'} />
|
||||
<StyledSectionedTableCell first>
|
||||
<View>
|
||||
<Title>Storage Quota</Title>
|
||||
<SubTitle>
|
||||
{formatSizeToReadableString(filesUsedQuota)} of {formatSizeToReadableString(filesTotalQuota)} used
|
||||
</SubTitle>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={{ ...styles.progressBar, width: `${usedQuotaRatioPercent}%` }} />
|
||||
</View>
|
||||
</View>
|
||||
</StyledSectionedTableCell>
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
239
packages/mobile/src/Screens/Settings/Sections/OptionsSection.tsx
Normal file
239
packages/mobile/src/Screens/Settings/Sections/OptionsSection.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useSignedIn } from '@Lib/SnjsHelperHooks'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
|
||||
import { SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS } from '@Root/Screens/screens'
|
||||
import { ButtonType, PrefKey } from '@standardnotes/snjs'
|
||||
import moment from 'moment'
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import DocumentPicker from 'react-native-document-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export const OptionsSection = ({ title, encryptionAvailable }: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const [signedIn] = useSignedIn()
|
||||
const navigation = useNavigation<ModalStackNavigationProp<typeof SCREEN_SETTINGS>['navigation']>()
|
||||
|
||||
// State
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [lastExportDate, setLastExportDate] = useState<Date | undefined>(() =>
|
||||
application?.getLocalPreferences().getValue(PrefKey.MobileLastExportDate, undefined),
|
||||
)
|
||||
|
||||
const lastExportData = useMemo(() => {
|
||||
if (lastExportDate) {
|
||||
const formattedDate = moment(lastExportDate).format('lll')
|
||||
const lastExportString = `Last exported on ${formattedDate}`
|
||||
|
||||
// Date is stale if more than 7 days ago
|
||||
const staleThreshold = 7 * 86400
|
||||
const stale =
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore date type issue
|
||||
(new Date() - new Date(lastExportDate)) / 1000 > staleThreshold
|
||||
return {
|
||||
lastExportString,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
return {
|
||||
lastExportString: 'Your data has not yet been backed up.',
|
||||
stale: false,
|
||||
}
|
||||
}, [lastExportDate])
|
||||
|
||||
const email = useMemo(() => {
|
||||
if (signedIn) {
|
||||
const user = application?.getUser()
|
||||
return user?.email
|
||||
}
|
||||
return
|
||||
}, [application, signedIn])
|
||||
|
||||
const exportOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'Encrypted',
|
||||
key: 'encrypted',
|
||||
selected: encryptionAvailable,
|
||||
},
|
||||
{ title: 'Decrypted', key: 'decrypted', selected: true },
|
||||
]
|
||||
}, [encryptionAvailable])
|
||||
|
||||
const destroyLocalData = async () => {
|
||||
if (
|
||||
await application?.alertService?.confirm(
|
||||
'Signing out will remove all data from this device, including notes and tags. Make sure your data is synced before proceeding.',
|
||||
'Sign Out?',
|
||||
'Sign Out',
|
||||
ButtonType.Danger,
|
||||
)
|
||||
) {
|
||||
await application!.user.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
const exportData = useCallback(
|
||||
async (encrypted: boolean) => {
|
||||
setExporting(true)
|
||||
const result = await application?.getBackupsService().export(encrypted)
|
||||
if (result) {
|
||||
const exportDate = new Date()
|
||||
setLastExportDate(exportDate)
|
||||
void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileLastExportDate, exportDate)
|
||||
}
|
||||
setExporting(false)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const readImportFile = async (fileUri: string): Promise<any> => {
|
||||
return RNFS.readFile(fileUri)
|
||||
.then(result => JSON.parse(result))
|
||||
.catch(() => {
|
||||
void application!.alertService!.alert('Unable to open file. Ensure it is a proper JSON file and try again.')
|
||||
})
|
||||
}
|
||||
|
||||
const performImport = async (data: any) => {
|
||||
const result = await application!.mutator.importData(data)
|
||||
if (!result) {
|
||||
return
|
||||
} else if ('error' in result) {
|
||||
void application!.alertService!.alert(result.error.text)
|
||||
} else if (result.errorCount) {
|
||||
void application!.alertService!.alert(
|
||||
`Import complete. ${result.errorCount} items were not imported because ` +
|
||||
'there was an error decrypting them. Make sure the password is correct and try again.',
|
||||
)
|
||||
} else {
|
||||
void application!.alertService!.alert('Your data has been successfully imported.')
|
||||
}
|
||||
}
|
||||
|
||||
const onImportPress = async () => {
|
||||
try {
|
||||
const selectedFiles = await DocumentPicker.pick({
|
||||
type: [DocumentPicker.types.plainText],
|
||||
})
|
||||
const selectedFile = selectedFiles[0]
|
||||
const selectedFileURI = Platform.OS === 'ios' ? decodeURIComponent(selectedFile.uri) : selectedFile.uri
|
||||
const data = await readImportFile(selectedFileURI)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
if (data.version || data.auth_params || data.keyParams) {
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version
|
||||
if (application!.protocolService.supportedVersions().includes(version)) {
|
||||
await performImport(data)
|
||||
} else {
|
||||
void application!.alertService.alert(
|
||||
'This backup file was created using an unsupported version of the application ' +
|
||||
'and cannot be imported here. Please update your application and try again.',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await performImport(data)
|
||||
}
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onExportPress = useCallback(
|
||||
async (option: { key: string }) => {
|
||||
const encrypted = option.key === 'encrypted'
|
||||
if (encrypted && !encryptionAvailable) {
|
||||
void application?.alertService!.alert(
|
||||
'You must be signed in, or have a local passcode set, to generate an encrypted export file.',
|
||||
'Not Available',
|
||||
'OK',
|
||||
)
|
||||
return
|
||||
}
|
||||
void exportData(encrypted)
|
||||
},
|
||||
[application?.alertService, encryptionAvailable, exportData],
|
||||
)
|
||||
|
||||
const openManageSessions = useCallback(() => {
|
||||
navigation.push(SCREEN_MANAGE_SESSIONS)
|
||||
}, [navigation])
|
||||
|
||||
const showDataBackupAlert = useCallback(() => {
|
||||
void application?.alertService.alert(
|
||||
'Because you are using the app offline without a sync account, it is your responsibility to keep your data safe and backed up. It is recommended you export a backup of your data at least once a week, or, to sign up for a sync account so that your data is backed up automatically.',
|
||||
'No Backups Created',
|
||||
'OK',
|
||||
)
|
||||
}, [application?.alertService])
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={title} />
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ButtonCell
|
||||
testID="manageSessionsButton"
|
||||
leftAligned={true}
|
||||
first={true}
|
||||
title={'Manage Sessions'}
|
||||
onPress={openManageSessions}
|
||||
/>
|
||||
<ButtonCell
|
||||
testID="signOutButton"
|
||||
leftAligned={true}
|
||||
first={false}
|
||||
title={`Sign out (${email})`}
|
||||
onPress={destroyLocalData}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ButtonCell
|
||||
testID="importData"
|
||||
first={!signedIn}
|
||||
leftAligned
|
||||
title={importing ? 'Processing...' : 'Import Data'}
|
||||
onPress={onImportPress}
|
||||
/>
|
||||
|
||||
<SectionedOptionsTableCell
|
||||
testID="exportData"
|
||||
leftAligned
|
||||
options={exportOptions}
|
||||
title={exporting ? 'Processing...' : 'Export Data'}
|
||||
onPress={onExportPress}
|
||||
/>
|
||||
|
||||
{!signedIn && (
|
||||
<SectionedAccessoryTableCell
|
||||
testID="lastExportDate"
|
||||
onPress={() => {
|
||||
if (!lastExportDate || lastExportData.stale) {
|
||||
showDataBackupAlert()
|
||||
}
|
||||
}}
|
||||
tinted={!lastExportDate || lastExportData.stale}
|
||||
text={lastExportData.lastExportString}
|
||||
/>
|
||||
)}
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { SectionedAccessoryTableCell } from '@Root/Components/SectionedAccessoryTableCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
export const PreferencesSection = () => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
// State
|
||||
const [sortBy, setSortBy] = useState<CollectionSortProperty>(() =>
|
||||
application.getPreference(PrefKey.MobileSortNotesBy, CollectionSort.CreatedAt),
|
||||
)
|
||||
const [sortReverse, setSortReverse] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileSortNotesReverse, false),
|
||||
)
|
||||
const [hideDates, setHideDates] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideDate, false),
|
||||
)
|
||||
const [hideEditorIcon, setHideEditorIcon] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideEditorIcon, false),
|
||||
)
|
||||
const [hidePreviews, setHidePreviews] = useState<boolean>(() =>
|
||||
application.getLocalPreferences().getValue(PrefKey.MobileNotesHideNotePreview, false),
|
||||
)
|
||||
|
||||
const sortOptions = useMemo(() => {
|
||||
return [
|
||||
{ key: CollectionSort.CreatedAt, label: 'Date Added' },
|
||||
{ key: CollectionSort.UpdatedAt, label: 'Date Modified' },
|
||||
{ key: CollectionSort.Title, label: 'Title' },
|
||||
]
|
||||
}, [])
|
||||
|
||||
const toggleReverseSort = () => {
|
||||
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSortNotesReverse, !sortReverse)
|
||||
setSortReverse(value => !value)
|
||||
}
|
||||
|
||||
const changeSortOption = (key: CollectionSortProperty) => {
|
||||
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSortNotesBy, key)
|
||||
setSortBy(key)
|
||||
}
|
||||
const toggleNotesPreviewHidden = () => {
|
||||
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideNotePreview, !hidePreviews)
|
||||
setHidePreviews(value => !value)
|
||||
}
|
||||
const toggleNotesDateHidden = () => {
|
||||
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideDate, !hideDates)
|
||||
setHideDates(value => !value)
|
||||
}
|
||||
const toggleNotesEditorIconHidden = () => {
|
||||
void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileNotesHideEditorIcon, !hideEditorIcon)
|
||||
setHideEditorIcon(value => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableSection>
|
||||
<SectionHeader
|
||||
title={'Sort Notes By'}
|
||||
buttonText={sortReverse ? 'Disable Reverse Sort' : 'Enable Reverse Sort'}
|
||||
buttonAction={toggleReverseSort}
|
||||
/>
|
||||
{sortOptions.map((option, i) => {
|
||||
return (
|
||||
<SectionedAccessoryTableCell
|
||||
onPress={() => {
|
||||
changeSortOption(option.key)
|
||||
}}
|
||||
text={option.label}
|
||||
key={option.key}
|
||||
first={i === 0}
|
||||
last={i === sortOptions.length - 1}
|
||||
selected={() => option.key === sortBy}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</TableSection>
|
||||
|
||||
<TableSection>
|
||||
<SectionHeader title={'Note List Options'} />
|
||||
|
||||
<SectionedAccessoryTableCell
|
||||
onPress={toggleNotesPreviewHidden}
|
||||
text={'Hide note previews'}
|
||||
first
|
||||
selected={() => hidePreviews}
|
||||
/>
|
||||
|
||||
<SectionedAccessoryTableCell
|
||||
onPress={toggleNotesDateHidden}
|
||||
text={'Hide note dates'}
|
||||
selected={() => hideDates}
|
||||
/>
|
||||
|
||||
<SectionedAccessoryTableCell
|
||||
onPress={toggleNotesEditorIconHidden}
|
||||
text={'Hide editor icons'}
|
||||
last
|
||||
selected={() => hideEditorIcon}
|
||||
/>
|
||||
</TableSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SectionedTableCell } from '@Root/Components/SectionedTableCell'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const BaseView = styled.View``
|
||||
|
||||
export const StyledSectionedTableCell = styled(SectionedTableCell)`
|
||||
padding-top: 12px;
|
||||
`
|
||||
|
||||
export const SubText = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
`
|
||||
|
||||
export const Subtitle = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
`
|
||||
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
`
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useProtectionSessionExpiry } from '@Lib/SnjsHelperHooks'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import React from 'react'
|
||||
import { BaseView, StyledSectionedTableCell, SubText, Subtitle, Title } from './ProtectionsSection.styled'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
protectionsAvailable?: boolean
|
||||
}
|
||||
|
||||
export const ProtectionsSection = (props: Props) => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
// State
|
||||
const [protectionsDisabledUntil] = useProtectionSessionExpiry()
|
||||
|
||||
const protectionsEnabledSubtitle = protectionsDisabledUntil ? `Disabled until ${protectionsDisabledUntil}` : 'Enabled'
|
||||
|
||||
const protectionsEnabledSubtext =
|
||||
'Actions like viewing protected notes, exporting decrypted backups, or revoking an active session, require additional authentication like entering your account password or application passcode.'
|
||||
|
||||
const enableProtections = () => {
|
||||
void application?.clearProtectionSession()
|
||||
}
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={props.title} />
|
||||
<StyledSectionedTableCell first>
|
||||
<BaseView>
|
||||
<Title>Status</Title>
|
||||
<Subtitle>{props.protectionsAvailable ? protectionsEnabledSubtitle : 'Disabled'}</Subtitle>
|
||||
</BaseView>
|
||||
</StyledSectionedTableCell>
|
||||
{props.protectionsAvailable && protectionsDisabledUntil && (
|
||||
<ButtonCell leftAligned title={'Enable Protections'} onPress={enableProtections} />
|
||||
)}
|
||||
<SubText>{protectionsEnabledSubtext}</SubText>
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Title = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
margin-top: 2px;
|
||||
`
|
||||
@@ -0,0 +1,248 @@
|
||||
import { UnlockTiming } from '@Lib/ApplicationState'
|
||||
import { MobileDeviceInterface } from '@Lib/Interface'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { ButtonCell } from '@Root/Components/ButtonCell'
|
||||
import { Option, SectionedOptionsTableCell } from '@Root/Components/SectionedOptionsTableCell'
|
||||
import { SectionHeader } from '@Root/Components/SectionHeader'
|
||||
import { TableSection } from '@Root/Components/TableSection'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_INPUT_MODAL_PASSCODE, SCREEN_SETTINGS } from '@Root/Screens/screens'
|
||||
import { StorageEncryptionPolicy } from '@standardnotes/snjs'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { Title } from './SecuritySection.styled'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
hasPasscode: boolean
|
||||
encryptionAvailable: boolean
|
||||
updateProtectionsAvailable: (...args: unknown[]) => unknown
|
||||
}
|
||||
|
||||
export const SecuritySection = (props: Props) => {
|
||||
const navigation = useNavigation<ModalStackNavigationProp<typeof SCREEN_SETTINGS>['navigation']>()
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
|
||||
// State
|
||||
const [encryptionPolicy, setEncryptionPolicy] = useState(() => application?.getStorageEncryptionPolicy())
|
||||
const [encryptionPolictChangeInProgress, setEncryptionPolictChangeInProgress] = useState(false)
|
||||
const [hasScreenshotPrivacy, setHasScreenshotPrivacy] = useState<boolean | undefined>(false)
|
||||
const [hasBiometrics, setHasBiometrics] = useState(false)
|
||||
const [supportsBiometrics, setSupportsBiometrics] = useState(false)
|
||||
const [biometricsTimingOptions, setBiometricsTimingOptions] = useState(() =>
|
||||
application!.getAppState().getBiometricsTimingOptions(),
|
||||
)
|
||||
const [passcodeTimingOptions, setPasscodeTimingOptions] = useState(() =>
|
||||
application!.getAppState().getPasscodeTimingOptions(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const getHasScreenshotPrivacy = async () => {
|
||||
const hasScreenshotPrivacyEnabled = await application?.getAppState().screenshotPrivacyEnabled
|
||||
if (mounted) {
|
||||
setHasScreenshotPrivacy(hasScreenshotPrivacyEnabled)
|
||||
}
|
||||
}
|
||||
void getHasScreenshotPrivacy()
|
||||
const getHasBiometrics = async () => {
|
||||
const appHasBiometrics = await application!.hasBiometrics()
|
||||
if (mounted) {
|
||||
setHasBiometrics(appHasBiometrics)
|
||||
}
|
||||
}
|
||||
void getHasBiometrics()
|
||||
const hasBiometricsSupport = async () => {
|
||||
const hasBiometricsAvailable = await (
|
||||
application?.deviceInterface as MobileDeviceInterface
|
||||
).getDeviceBiometricsAvailability()
|
||||
if (mounted) {
|
||||
setSupportsBiometrics(hasBiometricsAvailable)
|
||||
}
|
||||
}
|
||||
void hasBiometricsSupport()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [application])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (props.hasPasscode) {
|
||||
setPasscodeTimingOptions(() => application!.getAppState().getPasscodeTimingOptions())
|
||||
}
|
||||
}, [application, props.hasPasscode]),
|
||||
)
|
||||
|
||||
const toggleEncryptionPolicy = async () => {
|
||||
if (!props.encryptionAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (encryptionPolicy === StorageEncryptionPolicy.Default) {
|
||||
setEncryptionPolictChangeInProgress(true)
|
||||
setEncryptionPolicy(StorageEncryptionPolicy.Disabled)
|
||||
await application?.setStorageEncryptionPolicy(StorageEncryptionPolicy.Disabled)
|
||||
setEncryptionPolictChangeInProgress(false)
|
||||
} else if (encryptionPolicy === StorageEncryptionPolicy.Disabled) {
|
||||
setEncryptionPolictChangeInProgress(true)
|
||||
setEncryptionPolicy(StorageEncryptionPolicy.Default)
|
||||
await application?.setStorageEncryptionPolicy(StorageEncryptionPolicy.Default)
|
||||
setEncryptionPolictChangeInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
const storageEncryptionTitle = props.encryptionAvailable
|
||||
? encryptionPolicy === StorageEncryptionPolicy.Default
|
||||
? 'Disable Storage Encryption'
|
||||
: 'Enable Storage Encryption'
|
||||
: 'Storage Encryption'
|
||||
|
||||
let storageSubText = "Encrypts your data before saving to your device's local storage."
|
||||
|
||||
if (props.encryptionAvailable) {
|
||||
storageSubText +=
|
||||
encryptionPolicy === StorageEncryptionPolicy.Default
|
||||
? ' Disable to improve app start-up speed.'
|
||||
: ' May decrease app start-up speed.'
|
||||
} else {
|
||||
storageSubText += ' Sign in, register, or add a local passcode to enable this option.'
|
||||
}
|
||||
|
||||
if (encryptionPolictChangeInProgress) {
|
||||
storageSubText = 'Applying changes...'
|
||||
}
|
||||
|
||||
const screenshotPrivacyFeatureText =
|
||||
Platform.OS === 'ios' ? 'Multitasking Privacy' : 'Multitasking/Screenshot Privacy'
|
||||
|
||||
const screenshotPrivacyTitle = hasScreenshotPrivacy
|
||||
? `Disable ${screenshotPrivacyFeatureText}`
|
||||
: `Enable ${screenshotPrivacyFeatureText}`
|
||||
|
||||
const passcodeTitle = props.hasPasscode ? 'Disable Passcode Lock' : 'Enable Passcode Lock'
|
||||
|
||||
const biometricTitle = hasBiometrics ? 'Disable Biometrics Lock' : 'Enable Biometrics Lock'
|
||||
|
||||
const setBiometricsTiming = async (timing: UnlockTiming) => {
|
||||
await application?.getAppState().setBiometricsTiming(timing)
|
||||
setBiometricsTimingOptions(() => application!.getAppState().getBiometricsTimingOptions())
|
||||
}
|
||||
|
||||
const setPasscodeTiming = async (timing: UnlockTiming) => {
|
||||
await application?.getAppState().setPasscodeTiming(timing)
|
||||
setPasscodeTimingOptions(() => application!.getAppState().getPasscodeTimingOptions())
|
||||
}
|
||||
|
||||
const onScreenshotPrivacyPress = async () => {
|
||||
const enable = !hasScreenshotPrivacy
|
||||
setHasScreenshotPrivacy(enable)
|
||||
await application?.getAppState().setScreenshotPrivacyEnabled(enable)
|
||||
}
|
||||
|
||||
const onPasscodePress = async () => {
|
||||
if (props.hasPasscode) {
|
||||
void disableAuthentication('passcode')
|
||||
} else {
|
||||
navigation.push(SCREEN_INPUT_MODAL_PASSCODE)
|
||||
}
|
||||
}
|
||||
|
||||
const onBiometricsPress = async () => {
|
||||
if (hasBiometrics) {
|
||||
void disableAuthentication('biometrics')
|
||||
} else {
|
||||
setHasBiometrics(true)
|
||||
await application?.enableBiometrics()
|
||||
await setBiometricsTiming(UnlockTiming.OnQuit)
|
||||
props.updateProtectionsAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
const disableBiometrics = useCallback(async () => {
|
||||
if (await application?.disableBiometrics()) {
|
||||
setHasBiometrics(false)
|
||||
props.updateProtectionsAvailable()
|
||||
}
|
||||
}, [application, props])
|
||||
|
||||
const disablePasscode = useCallback(async () => {
|
||||
const hasAccount = Boolean(application?.hasAccount())
|
||||
let message
|
||||
if (hasAccount) {
|
||||
message =
|
||||
'Are you sure you want to disable your local passcode? This will not affect your encryption status, as your data is currently being encrypted through your sync account keys.'
|
||||
} else {
|
||||
message = 'Are you sure you want to disable your local passcode? This will disable encryption on your data.'
|
||||
}
|
||||
|
||||
const confirmed = await application?.alertService?.confirm(
|
||||
message,
|
||||
'Disable Passcode',
|
||||
'Disable Passcode',
|
||||
undefined,
|
||||
)
|
||||
if (confirmed) {
|
||||
await application?.removePasscode()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const disableAuthentication = useCallback(
|
||||
async (authenticationMethod: 'passcode' | 'biometrics') => {
|
||||
switch (authenticationMethod) {
|
||||
case 'biometrics': {
|
||||
void disableBiometrics()
|
||||
break
|
||||
}
|
||||
case 'passcode': {
|
||||
void disablePasscode()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[disableBiometrics, disablePasscode],
|
||||
)
|
||||
|
||||
return (
|
||||
<TableSection>
|
||||
<SectionHeader title={props.title} />
|
||||
|
||||
<ButtonCell first leftAligned title={storageEncryptionTitle} onPress={toggleEncryptionPolicy}>
|
||||
<Title>{storageSubText}</Title>
|
||||
</ButtonCell>
|
||||
|
||||
<ButtonCell leftAligned title={screenshotPrivacyTitle} onPress={onScreenshotPrivacyPress} />
|
||||
|
||||
<ButtonCell leftAligned title={passcodeTitle} onPress={onPasscodePress} />
|
||||
|
||||
<ButtonCell
|
||||
last={!hasBiometrics && !props.hasPasscode}
|
||||
disabled={!supportsBiometrics}
|
||||
leftAligned
|
||||
title={biometricTitle}
|
||||
onPress={onBiometricsPress}
|
||||
/>
|
||||
|
||||
{props.hasPasscode && (
|
||||
<SectionedOptionsTableCell
|
||||
leftAligned
|
||||
title={'Require Passcode'}
|
||||
options={passcodeTimingOptions}
|
||||
onPress={(option: Option) => setPasscodeTiming(option.key as UnlockTiming)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasBiometrics && (
|
||||
<SectionedOptionsTableCell
|
||||
leftAligned
|
||||
title={'Require Biometrics'}
|
||||
options={biometricsTimingOptions}
|
||||
onPress={(option: Option) => setBiometricsTiming(option.key as UnlockTiming)}
|
||||
/>
|
||||
)}
|
||||
</TableSection>
|
||||
)
|
||||
}
|
||||
5
packages/mobile/src/Screens/Settings/Settings.styled.ts
Normal file
5
packages/mobile/src/Screens/Settings/Settings.styled.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const Container = styled.ScrollView`
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
70
packages/mobile/src/Screens/Settings/Settings.tsx
Normal file
70
packages/mobile/src/Screens/Settings/Settings.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useSignedIn } from '@Lib/SnjsHelperHooks'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { ModalStackNavigationProp } from '@Root/ModalStack'
|
||||
import { SCREEN_SETTINGS } from '@Root/Screens/screens'
|
||||
import { FilesSection } from '@Screens/Settings/Sections/FilesSection'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { ApplicationEvent, FeatureStatus } from '@standardnotes/snjs'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { AuthSection } from './Sections/AuthSection'
|
||||
import { CompanySection } from './Sections/CompanySection'
|
||||
import { EncryptionSection } from './Sections/EncryptionSection'
|
||||
import { OptionsSection } from './Sections/OptionsSection'
|
||||
import { PreferencesSection } from './Sections/PreferencesSection'
|
||||
import { ProtectionsSection } from './Sections/ProtectionsSection'
|
||||
import { SecuritySection } from './Sections/SecuritySection'
|
||||
import { Container } from './Settings.styled'
|
||||
|
||||
type Props = ModalStackNavigationProp<typeof SCREEN_SETTINGS>
|
||||
export const Settings = (props: Props) => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
// State
|
||||
const [hasPasscode, setHasPasscode] = useState(() => Boolean(application.hasPasscode()))
|
||||
const [protectionsAvailable, setProtectionsAvailable] = useState(application.hasProtectionSources())
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState(() => application.isEncryptionAvailable())
|
||||
|
||||
const updateProtectionsAvailable = useCallback(() => {
|
||||
setProtectionsAvailable(application.hasProtectionSources())
|
||||
}, [application])
|
||||
|
||||
useEffect(() => {
|
||||
const removeApplicationEventSubscriber = application.addEventObserver(async event => {
|
||||
if (event === ApplicationEvent.KeyStatusChanged) {
|
||||
setHasPasscode(Boolean(application.hasPasscode()))
|
||||
updateProtectionsAvailable()
|
||||
setEncryptionAvailable(() => application.isEncryptionAvailable())
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
removeApplicationEventSubscriber && removeApplicationEventSubscriber()
|
||||
}
|
||||
}, [application, updateProtectionsAvailable])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
props.navigation.goBack()
|
||||
}, [props.navigation])
|
||||
|
||||
const [signedIn] = useSignedIn(goBack)
|
||||
|
||||
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
|
||||
|
||||
return (
|
||||
<Container keyboardShouldPersistTaps={'always'} keyboardDismissMode={'interactive'}>
|
||||
<AuthSection title="Account" signedIn={signedIn} />
|
||||
<OptionsSection encryptionAvailable={!!encryptionAvailable} title="Options" />
|
||||
<PreferencesSection />
|
||||
{application.hasAccount() && isEntitledToFiles && <FilesSection />}
|
||||
<SecuritySection
|
||||
encryptionAvailable={!!encryptionAvailable}
|
||||
hasPasscode={hasPasscode}
|
||||
updateProtectionsAvailable={updateProtectionsAvailable}
|
||||
title="Security"
|
||||
/>
|
||||
<ProtectionsSection title="Protections" protectionsAvailable={protectionsAvailable} />
|
||||
<EncryptionSection encryptionAvailable={!!encryptionAvailable} title={'Encryption Status'} />
|
||||
<CompanySection title="Standard Notes" />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
35
packages/mobile/src/Screens/SideMenu/Files.styled.ts
Normal file
35
packages/mobile/src/Screens/SideMenu/Files.styled.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SnIcon } from '@Root/Components/SnIcon'
|
||||
import { SideMenuCell } from '@Root/Screens/SideMenu/SideMenuCell'
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
cellContentStyle: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
learnMoreIcon: {
|
||||
marginTop: Platform.OS === 'ios' ? -6 : -3,
|
||||
},
|
||||
})
|
||||
export const SNIconStyled = styled(SnIcon)`
|
||||
margin-left: 8px;
|
||||
`
|
||||
export const FilesContainer = styled.View`
|
||||
margin-top: 10px;
|
||||
`
|
||||
export const FileItemContainer = styled.View`
|
||||
margin-bottom: 18px;
|
||||
`
|
||||
export const IconsContainer = styled.View`
|
||||
flex-direction: row;
|
||||
margin-top: ${() => (Platform.OS === 'ios' ? 0 : '5px')};
|
||||
`
|
||||
export const SideMenuCellStyled = styled(SideMenuCell)`
|
||||
min-height: 22px;
|
||||
`
|
||||
export const SideMenuCellAttachNewFile = styled(SideMenuCellStyled)`
|
||||
margin-bottom: 14px;
|
||||
`
|
||||
export const SideMenuCellShowAllFiles = styled(SideMenuCellStyled)`
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
95
packages/mobile/src/Screens/SideMenu/Files.tsx
Normal file
95
packages/mobile/src/Screens/SideMenu/Files.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { SnIcon } from '@Components/SnIcon'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { useFiles } from '@Root/Hooks/useFiles'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { SCREEN_COMPOSE, SCREEN_UPLOADED_FILES_LIST } from '@Root/Screens/screens'
|
||||
import {
|
||||
FileItemContainer,
|
||||
FilesContainer,
|
||||
IconsContainer,
|
||||
SideMenuCellAttachNewFile,
|
||||
SideMenuCellShowAllFiles,
|
||||
SideMenuCellStyled,
|
||||
SNIconStyled,
|
||||
styles,
|
||||
} from '@Root/Screens/SideMenu/Files.styled'
|
||||
import { SideMenuOptionIconDescriptionType } from '@Root/Screens/SideMenu/SideMenuSection'
|
||||
import { SideMenuCell } from '@Screens/SideMenu/SideMenuCell'
|
||||
import { UploadedFileItemActionType } from '@Screens/UploadedFilesList/UploadedFileItemAction'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { FeatureStatus, SNNote } from '@standardnotes/snjs'
|
||||
import React, { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
export const Files: FC<Props> = ({ note }) => {
|
||||
const application = useSafeApplicationContext()
|
||||
const filesService = application.getFilesService()
|
||||
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']>()
|
||||
const { showActionsMenu, handlePressAttachFile, attachedFiles, handleFileAction } = useFiles({ note })
|
||||
|
||||
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
|
||||
|
||||
const openFilesScreen = () => {
|
||||
navigation.navigate(SCREEN_UPLOADED_FILES_LIST, { note })
|
||||
}
|
||||
|
||||
if (!isEntitledToFiles) {
|
||||
return (
|
||||
<SideMenuCell
|
||||
text={'Learn more'}
|
||||
onSelect={() => application.deviceInterface.openUrl('https://standardnotes.com/plans')}
|
||||
iconDesc={{
|
||||
side: 'left',
|
||||
type: SideMenuOptionIconDescriptionType.CustomComponent,
|
||||
value: <SnIcon type={'open-in'} style={styles.learnMoreIcon} />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesContainer>
|
||||
{attachedFiles.sort(filesService.sortByName).map(file => {
|
||||
const iconType = application.iconsController.getIconForFileType(file.mimeType)
|
||||
|
||||
return (
|
||||
<FileItemContainer key={file.uuid}>
|
||||
<SideMenuCellStyled
|
||||
text={file.name}
|
||||
key={file.uuid}
|
||||
onSelect={() => {
|
||||
void handleFileAction({
|
||||
type: UploadedFileItemActionType.PreviewFile,
|
||||
payload: file,
|
||||
})
|
||||
}}
|
||||
onLongPress={() => showActionsMenu(file)}
|
||||
iconDesc={{
|
||||
side: 'right',
|
||||
type: SideMenuOptionIconDescriptionType.CustomComponent,
|
||||
value: (
|
||||
<IconsContainer>
|
||||
{file.protected && <SNIconStyled type={'lock-filled'} width={16} height={16} />}
|
||||
<SNIconStyled type={iconType} width={16} height={16} />
|
||||
</IconsContainer>
|
||||
),
|
||||
}}
|
||||
cellContentStyle={styles.cellContentStyle}
|
||||
/>
|
||||
</FileItemContainer>
|
||||
)
|
||||
})}
|
||||
<SideMenuCellAttachNewFile text={'Upload new file'} onSelect={() => handlePressAttachFile()} />
|
||||
<SideMenuCellShowAllFiles
|
||||
text={'Show all files'}
|
||||
onSelect={() => openFilesScreen()}
|
||||
cellContentStyle={styles.cellContentStyle}
|
||||
/>
|
||||
</FilesContainer>
|
||||
)
|
||||
}
|
||||
35
packages/mobile/src/Screens/SideMenu/Listed.styled.ts
Normal file
35
packages/mobile/src/Screens/SideMenu/Listed.styled.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
import styled from 'styled-components/native'
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
blogItemIcon: {
|
||||
marginTop: Platform.OS === 'ios' ? -6 : -3,
|
||||
},
|
||||
loadingIndicator: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
blogActionInProgressIndicator: {
|
||||
marginTop: -5,
|
||||
marginLeft: 6,
|
||||
transform: [
|
||||
{
|
||||
scale: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
export const CreateBlogContainer = styled.View`
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
export const CantLoadActionsText = styled.Text`
|
||||
font-size: 12px;
|
||||
margin-top: -12px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.7;
|
||||
color: ${({ theme }) => theme.stylekitContrastForegroundColor};
|
||||
`
|
||||
export const ListedItemRow = styled.View`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`
|
||||
190
packages/mobile/src/Screens/SideMenu/Listed.tsx
Normal file
190
packages/mobile/src/Screens/SideMenu/Listed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
packages/mobile/src/Screens/SideMenu/MainSideMenu.styled.ts
Normal file
34
packages/mobile/src/Screens/SideMenu/MainSideMenu.styled.ts
Normal 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],
|
||||
)
|
||||
}
|
||||
295
packages/mobile/src/Screens/SideMenu/MainSideMenu.tsx
Normal file
295
packages/mobile/src/Screens/SideMenu/MainSideMenu.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
29
packages/mobile/src/Screens/SideMenu/NoteSideMenu.styled.ts
Normal file
29
packages/mobile/src/Screens/SideMenu/NoteSideMenu.styled.ts
Normal 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],
|
||||
)
|
||||
}
|
||||
674
packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx
Normal file
674
packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
83
packages/mobile/src/Screens/SideMenu/SideMenuCell.styled.ts
Normal file
83
packages/mobile/src/Screens/SideMenu/SideMenuCell.styled.ts
Normal 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``
|
||||
94
packages/mobile/src/Screens/SideMenu/SideMenuCell.tsx
Normal file
94
packages/mobile/src/Screens/SideMenu/SideMenuCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
packages/mobile/src/Screens/SideMenu/SideMenuHero.styled.ts
Normal file
41
packages/mobile/src/Screens/SideMenu/SideMenuHero.styled.ts
Normal 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;
|
||||
`
|
||||
80
packages/mobile/src/Screens/SideMenu/SideMenuHero.tsx
Normal file
80
packages/mobile/src/Screens/SideMenu/SideMenuHero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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};
|
||||
`
|
||||
81
packages/mobile/src/Screens/SideMenu/SideMenuSection.tsx
Normal file
81
packages/mobile/src/Screens/SideMenu/SideMenuSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
@@ -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;
|
||||
`
|
||||
157
packages/mobile/src/Screens/SideMenu/TagSelectionList.tsx
Normal file
157
packages/mobile/src/Screens/SideMenu/TagSelectionList.tsx
Normal 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>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}};
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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};
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
packages/mobile/src/Screens/package.json
Normal file
3
packages/mobile/src/Screens/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "@Screens"
|
||||
}
|
||||
17
packages/mobile/src/Screens/screens.ts
Normal file
17
packages/mobile/src/Screens/screens.ts
Normal 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
|
||||
Reference in New Issue
Block a user