diff --git a/src/App.tsx b/src/App.tsx index 7f723bbd..8136e438 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { import { navigationRef } from '@Lib/NavigationService'; import { useHasEditor, useIsLocked } from '@Lib/snjsHooks'; import { + CompositeNavigationProp, DefaultTheme, NavigationContainer, RouteProp, @@ -20,6 +21,7 @@ import { StackNavigationProp, } from '@react-navigation/stack'; import { Authenticate } from '@Screens/Authenticate/Authenticate'; +import { AuthenticatePrivileges } from '@Screens/Authenticate/AuthenticatePrivileges'; import { Compose } from '@Screens/Compose/Compose'; import { PasscodeInputModal } from '@Screens/InputModal/PasscodeInputModal'; import { TagInputModal } from '@Screens/InputModal/TagInputModal'; @@ -50,11 +52,12 @@ import DrawerLayout, { DrawerState, } from 'react-native-gesture-handler/DrawerLayout'; import { HeaderButtons, Item } from 'react-navigation-header-buttons'; -import { Challenge } from 'snjs'; +import { Challenge, PrivilegeCredential, ProtectedAction } from 'snjs'; import { ThemeContext, ThemeProvider } from 'styled-components/native'; import { ApplicationContext } from './ApplicationContext'; import { SCREEN_AUTHENTICATE, + SCREEN_AUTHENTICATE_PRIVILEGES, SCREEN_COMPOSE, SCREEN_INPUT_MODAL_PASSCODE, SCREEN_INPUT_MODAL_TAG, @@ -86,11 +89,20 @@ type ModalStackNavigatorParamList = { [SCREEN_AUTHENTICATE]: { challenge: Challenge; }; + [SCREEN_AUTHENTICATE_PRIVILEGES]: { + action: ProtectedAction; + privilegeCredentials: PrivilegeCredential[]; + previousScreen: string; + unlockedItemId?: string; + }; }; export type AppStackNavigationProp< T extends keyof AppStackNavigatorParamList > = { - navigation: StackNavigationProp; + navigation: CompositeNavigationProp< + ModalStackNavigationProp<'AppStack'>['navigation'], + StackNavigationProp + >; route: RouteProp; }; export type ModalStackNavigationProp< @@ -333,8 +345,7 @@ const MainStackComponent = ({ env }: { env: 'prod' | 'dev' }) => { ), headerRight: () => - env === 'dev' || - (__DEV__ && ( + (env === 'dev' || __DEV__) && ( { }} /> - )), + ), })} component={Settings} /> @@ -441,6 +452,31 @@ const MainStackComponent = ({ env }: { env: 'prod' | 'dev' }) => { })} component={Authenticate} /> + ({ + title: 'Authenticate', + headerLeft: ({ disabled, onPress }) => ( + + + + ), + headerTitle: ({ children }) => { + return ; + }, + })} + component={AuthenticatePrivileges} + /> ); }; diff --git a/src/lib/snjsHooks.ts b/src/lib/snjsHooks.ts index a4222e5f..d16ab0b8 100644 --- a/src/lib/snjsHooks.ts +++ b/src/lib/snjsHooks.ts @@ -1,9 +1,15 @@ -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { AppStackNavigationProp } from '@Root/App'; import { ApplicationContext } from '@Root/ApplicationContext'; -import { SCREEN_NOTES } from '@Screens/screens'; +import { PRIVILEGES_UNLOCK_PAYLOAD } from '@Screens/Authenticate/AuthenticatePrivileges'; +import { + SCREEN_AUTHENTICATE_PRIVILEGES, + SCREEN_COMPOSE, + SCREEN_NOTES, +} from '@Screens/screens'; import React, { useCallback, useEffect } from 'react'; -import { ApplicationEvent } from 'snjs'; +import { ApplicationEvent, ButtonType, ProtectedAction, SNNote } from 'snjs'; +import { Editor } from './Editor'; export const useSignedIn = ( signedInCallback?: () => void, @@ -226,7 +232,9 @@ export const useSyncStatus = () => { setLoading(false); updateLocalDataStatus(); } else if (eventName === ApplicationEvent.WillSync) { - setStatus('Syncing...'); + if (!completedInitialSync) { + setStatus('Syncing...'); + } } else if (eventName === ApplicationEvent.CompletedFullSync) { setStatus(); if (!completedInitialSync) { @@ -267,3 +275,140 @@ export const useSyncStatus = () => { () => void ]; }; + +export const useDeleteNoteWithPrivileges = ( + note: SNNote, + onDeleteCallback: () => void, + onTrashCallback: () => void, + editor?: Editor +) => { + // Context + const application = React.useContext(ApplicationContext); + const navigation = useNavigation< + AppStackNavigationProp['navigation'] + >(); + + // State + const [deleteAction, setDeleteAction] = React.useState<'trash' | 'delete'>(); + + const trashNote = useCallback(async () => { + const title = 'Move to Trash'; + const message = 'Are you sure you want to move this note to the trash?'; + + const confirmed = await application?.alertService?.confirm( + message, + title, + 'Confirm', + ButtonType.Danger, + 'Cancel' + ); + if (confirmed) { + onTrashCallback(); + } + }, [application?.alertService, onTrashCallback]); + + const deleteNotePermanently = useCallback(async () => { + const title = `Delete ${note!.safeTitle()}`; + const message = 'Are you sure you want to permanently delete this nite}?'; + if (editor?.isTemplateNote) { + application?.alertService!.alert( + 'This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.' + ); + return; + } + if (note!.locked) { + application?.alertService!.alert( + "This note is locked. If you'd like to delete it, unlock it, and try again." + ); + return; + } + const confirmed = await application?.alertService?.confirm( + message, + title, + 'Delete', + ButtonType.Danger, + 'Cancel' + ); + if (confirmed) { + onDeleteCallback(); + } + }, [ + application?.alertService, + editor?.isTemplateNote, + note, + onDeleteCallback, + ]); + + const deleteNote = useCallback( + async (permanently: boolean) => { + if ( + await application?.privilegesService!.actionRequiresPrivilege( + ProtectedAction.DeleteNote + ) + ) { + const privilegeCredentials = await application!.privilegesService!.netCredentialsForAction( + ProtectedAction.DeleteNote + ); + const activeScreen = application!.getAppState().isInTabletMode + ? SCREEN_NOTES + : SCREEN_COMPOSE; + setDeleteAction(permanently ? 'delete' : 'trash'); + navigation.navigate(SCREEN_AUTHENTICATE_PRIVILEGES, { + action: ProtectedAction.DeleteNote, + privilegeCredentials, + unlockedItemId: note.uuid, + previousScreen: activeScreen, + }); + } else { + if (permanently) { + deleteNotePermanently(); + } else { + trashNote(); + } + } + }, + [application, deleteNotePermanently, navigation, note?.uuid, trashNote] + ); + + /* + * After screen is focused read if a requested privilage was unlocked + */ + useFocusEffect( + useCallback(() => { + const readPrivilegesUnlockResponse = async () => { + if (deleteAction && application?.isLaunched()) { + const activeScreen = application.getAppState().isInTabletMode + ? SCREEN_NOTES + : SCREEN_COMPOSE; + const result = await application?.getValue(PRIVILEGES_UNLOCK_PAYLOAD); + if ( + result && + result.previousScreen === activeScreen && + result.unlockedAction === ProtectedAction.DeleteNote && + result.unlockedItemId === note.uuid + ) { + setDeleteAction(undefined); + application?.removeValue(PRIVILEGES_UNLOCK_PAYLOAD); + if (deleteAction === 'trash') { + trashNote(); + } else if (deleteAction === 'delete') { + deleteNotePermanently(); + } + } else { + setDeleteAction(undefined); + } + } + }; + + readPrivilegesUnlockResponse(); + }, [ + application, + deleteAction, + deleteNotePermanently, + note?.uuid, + trashNote, + ]) + ); + + return [deleteNote]; +}; diff --git a/src/screens/Authenticate/Authenticate.styled.ts b/src/screens/Authenticate/Authenticate.styled.ts index 600e5ed4..cd64c4f0 100644 --- a/src/screens/Authenticate/Authenticate.styled.ts +++ b/src/screens/Authenticate/Authenticate.styled.ts @@ -23,3 +23,7 @@ export const SectionContainer = styled.View<{ last: boolean }>` `; export const SourceContainer = styled.View``; + +export const SessionLengthContainer = styled.View` + margin-top: 10px; +`; diff --git a/src/screens/Authenticate/Authenticate.tsx b/src/screens/Authenticate/Authenticate.tsx index df95e34a..05740f80 100644 --- a/src/screens/Authenticate/Authenticate.tsx +++ b/src/screens/Authenticate/Authenticate.tsx @@ -30,7 +30,7 @@ import { } from './Authenticate.styled'; import { authenticationReducer, - ChallengeValueStateType, + AuthenticationValueStateType, findMatchingValueIndex, getLabelForStateAndType, getTitleForStateAndType, @@ -65,7 +65,7 @@ export const Authenticate = ({ type: challengeType, })), challengeValueStates: challenge.types.map( - () => ChallengeValueStateType.WaitingTurn + () => AuthenticationValueStateType.WaitingTurn ), }, undefined @@ -84,8 +84,8 @@ export const Authenticate = ({ const state = challengeValueStates[index]; if ( - state === ChallengeValueStateType.Locked || - state === ChallengeValueStateType.Success + state === AuthenticationValueStateType.Locked || + state === AuthenticationValueStateType.Success ) { return; } @@ -93,7 +93,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.Pending, + state: AuthenticationValueStateType.Pending, }); await application?.submitValuesForChallenge(challenge, [challengeValue]); @@ -105,14 +105,14 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.Locked, + state: AuthenticationValueStateType.Locked, }); setTimeout(() => { dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.WaitingTurn, + state: AuthenticationValueStateType.WaitingTurn, }); }, 30 * 1000); }, []); @@ -140,7 +140,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.Fail, + state: AuthenticationValueStateType.Fail, }); Alert.alert( 'Unsuccessful', @@ -180,7 +180,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.Fail, + state: AuthenticationValueStateType.Fail, }); Alert.alert( 'Unsuccessful', @@ -217,7 +217,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.Fail, + state: AuthenticationValueStateType.Fail, }); } } @@ -234,7 +234,7 @@ export const Authenticate = ({ const firstNotSuccessful = useMemo( () => challengeValueStates.findIndex( - state => state !== ChallengeValueStateType.Success + state => state !== AuthenticationValueStateType.Success ), [challengeValueStates] ); @@ -283,7 +283,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: challengeValue.type, - state: ChallengeValueStateType.WaitingInput, + state: AuthenticationValueStateType.WaitingInput, }); }, [application, authenticateBiometrics, challengeValues, firstNotSuccessful] @@ -294,7 +294,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: value.type, - state: ChallengeValueStateType.Success, + state: AuthenticationValueStateType.Success, }); beginAuthenticatingForNextChallengeReason(value); }, @@ -305,7 +305,7 @@ export const Authenticate = ({ dispatch({ type: 'setState', valueType: value.type, - state: ChallengeValueStateType.Fail, + state: AuthenticationValueStateType.Fail, }); }; useEffect(() => { @@ -340,7 +340,6 @@ export const Authenticate = ({ let mounted = true; const setBiometricsAsync = async () => { if (challenge.reason !== ChallengeReason.Migration) { - console.log('ssadasfsdfsdfsdfsf'); const hasBiometrics = await checkForBiometrics(); if (mounted) { setSupportsBiometrics(hasBiometrics); @@ -373,7 +372,7 @@ export const Authenticate = ({ ChallengeType.Biometric ); const state = challengeValueStates[index]; - if (state === ChallengeValueStateType.Locked) { + if (state === AuthenticationValueStateType.Locked) { return; } @@ -408,8 +407,8 @@ export const Authenticate = ({ const state = challengeValueStates[index]; if ( challengeValue.type === ChallengeType.Biometric && - (state === ChallengeValueStateType.Locked || - state === ChallengeValueStateType.Fail) + (state === AuthenticationValueStateType.Locked || + state === AuthenticationValueStateType.Fail) ) { beginAuthenticatingForNextChallengeReason(); return; @@ -447,7 +446,7 @@ export const Authenticate = ({ tinted={active} buttonText={ challengeValue.type === ChallengeType.LocalPasscode && - state === ChallengeValueStateType.WaitingInput + state === AuthenticationValueStateType.WaitingInput ? 'Change Keyboard' : undefined } @@ -512,7 +511,7 @@ export const Authenticate = ({ const isPending = useMemo( () => challengeValueStates.findIndex( - state => state === ChallengeValueStateType.Pending + state => state === AuthenticationValueStateType.Pending ) >= 0, [challengeValueStates] ); diff --git a/src/screens/Authenticate/AuthenticatePrivileges.tsx b/src/screens/Authenticate/AuthenticatePrivileges.tsx new file mode 100644 index 00000000..07a934af --- /dev/null +++ b/src/screens/Authenticate/AuthenticatePrivileges.tsx @@ -0,0 +1,422 @@ +import { ButtonCell } from '@Components/ButtonCell'; +import { SectionedAccessoryTableCell } from '@Components/SectionedAccessoryTableCell'; +import { SectionedTableCell } from '@Components/SectionedTableCell'; +import { SectionHeader } from '@Components/SectionHeader'; +import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState'; +import { ModalStackNavigationProp } from '@Root/App'; +import { ApplicationContext } from '@Root/ApplicationContext'; +import { SCREEN_AUTHENTICATE_PRIVILEGES } from '@Screens/screens'; +import { StyleKitContext } from '@Style/StyleKit'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import { Platform, TextInput } from 'react-native'; +import { + PrivilegeCredential, + PrivilegeSessionLength, + ProtectedAction, +} from 'snjs'; +import { ThemeContext } from 'styled-components/native'; +import { + Container, + Input, + SectionContainer, + SessionLengthContainer, + SourceContainer, +} from './Authenticate.styled'; +import { + AuthenticationValueStateType, + findMatchingPrivilegeValueIndex, + getLabelForPrivilegeLockStateAndType, + getTitleForPrivilegeLockStateAndType, + isInActiveState, + PrivilegeLockValue, + privilegesAuthenticationReducer, +} from './helpers'; + +type Props = ModalStackNavigationProp; + +export const PRIVILEGES_UNLOCK_PAYLOAD = 'privilegesUnlockPayload'; + +export const AuthenticatePrivileges = ({ + route: { + params: { privilegeCredentials, action, previousScreen, unlockedItemId }, + }, + navigation, +}: Props) => { + // Context + const application = useContext(ApplicationContext); + const styleKit = useContext(StyleKitContext); + const theme = useContext(ThemeContext); + + // State + const [keyboardType, setKeyboardType] = useState< + PasscodeKeyboardType | undefined + >(undefined); + const [sessionLengthOptions] = useState(() => + application!.privilegesService!.getSessionLengthOptions() + ); + const [selectedSessionLength, setSelectedSessionLength] = useState< + PrivilegeSessionLength + >(); + const [{ privilegeValues, privilegeValueStates }, dispatch] = useReducer( + privilegesAuthenticationReducer, + { + privilegeValues: privilegeCredentials.map(type => ({ + value: '', + type: type, + })), + privilegeValueStates: privilegeCredentials.map( + () => AuthenticationValueStateType.WaitingTurn + ), + }, + undefined + ); + + // Refs + const localPasscodeRef = useRef(null); + const accountPasswordRef = useRef(null); + + const firstNotSuccessful = useMemo( + () => + privilegeValueStates.findIndex( + state => state !== AuthenticationValueStateType.Success + ), + [privilegeValueStates] + ); + + useEffect(() => { + let isMounted = true; + const getSessionLength = async () => { + const length = await application?.privilegesService!.getSelectedSessionLength(); + if (isMounted) { + setSelectedSessionLength(length as PrivilegeSessionLength); + } + }; + getSessionLength(); + return () => { + isMounted = false; + }; + }, [application]); + + const beginAuthenticatingForNextAuthenticationReason = useCallback( + (completedprivilegeValue?: PrivilegeLockValue) => { + let privilegeValue; + if (completedprivilegeValue === undefined) { + privilegeValue = privilegeValues[firstNotSuccessful]; + } else { + const index = findMatchingPrivilegeValueIndex( + privilegeValues, + completedprivilegeValue.type + ); + + if (!privilegeValues.hasOwnProperty(index + 1)) { + return; + } + privilegeValue = privilegeValues[index + 1]; + } + + if (privilegeValue.type === PrivilegeCredential.LocalPasscode) { + localPasscodeRef.current?.focus(); + } else if (privilegeValue.type === PrivilegeCredential.AccountPassword) { + accountPasswordRef.current?.focus(); + } + + dispatch({ + type: 'setState', + valueType: privilegeValue.type, + state: AuthenticationValueStateType.WaitingInput, + }); + }, + [privilegeValues, firstNotSuccessful] + ); + + const onInvalidValue = (value: PrivilegeLockValue) => { + dispatch({ + type: 'setState', + valueType: value.type, + state: AuthenticationValueStateType.Fail, + }); + }; + useEffect(() => { + const removeAppStateSubscriber = application + ?.getAppState() + .addStateChangeObserver(state => { + if (state === AppStateType.ResumingFromBackground) { + beginAuthenticatingForNextAuthenticationReason(); + } + }); + + return removeAppStateSubscriber; + }, [application, beginAuthenticatingForNextAuthenticationReason]); + + const onValidValue = useCallback( + (value: PrivilegeLockValue) => { + dispatch({ + type: 'setState', + valueType: value.type, + state: AuthenticationValueStateType.Success, + }); + beginAuthenticatingForNextAuthenticationReason(value); + }, + [beginAuthenticatingForNextAuthenticationReason] + ); + + const validatePrivilegeValue = useCallback( + async (privilegeLockValue: PrivilegeLockValue) => { + const index = findMatchingPrivilegeValueIndex( + privilegeValues, + privilegeLockValue.type + ); + const state = privilegeValueStates[index]; + + if ( + state === AuthenticationValueStateType.Locked || + state === AuthenticationValueStateType.Success + ) { + return; + } + + dispatch({ + type: 'setState', + valueType: privilegeLockValue.type, + state: AuthenticationValueStateType.Pending, + }); + + const result = await application!.privilegesService!.authenticateAction( + action, + privilegeValues.reduce((accumulator, currentValue) => { + accumulator[currentValue.type] = currentValue.value; + return accumulator; + }, {} as Partial>) + ); + + if (result.success) { + await application?.privilegesService!.setSessionLength( + selectedSessionLength! + ); + if ( + action === ProtectedAction.ViewProtectedNotes || + action === ProtectedAction.DeleteNote + ) { + await application?.setValue(PRIVILEGES_UNLOCK_PAYLOAD, { + unlockedAction: action, + unlockedItemId, + previousScreen, + }); + } else { + await application?.setValue(PRIVILEGES_UNLOCK_PAYLOAD, { + unlockedAction: action, + previousScreen, + }); + } + + navigation.goBack(); + } else { + if (result.failedCredentials) { + result.failedCredentials.map(item => { + const lockValueIndex = findMatchingPrivilegeValueIndex( + privilegeValues, + item + ); + onInvalidValue(privilegeValues[lockValueIndex]); + }); + } + if (result.successfulCredentials) { + result.successfulCredentials.map(item => { + const lockValueIndex = findMatchingPrivilegeValueIndex( + privilegeValues, + item + ); + onValidValue(privilegeValues[lockValueIndex]); + }); + } + } + }, + [ + privilegeValues, + privilegeValueStates, + application, + action, + navigation, + selectedSessionLength, + unlockedItemId, + previousScreen, + onValidValue, + ] + ); + + const checkPasscodeKeyboardType = useCallback( + async () => application?.getAppState().getPasscodeKeyboardType(), + [application] + ); + + useEffect(() => { + let mounted = true; + const setInitialKeyboardType = async () => { + const initialKeyboardType = await checkPasscodeKeyboardType(); + if (mounted) { + setKeyboardType(initialKeyboardType); + } + }; + setInitialKeyboardType(); + return () => { + mounted = false; + }; + }, [checkPasscodeKeyboardType]); + + useEffect(() => { + beginAuthenticatingForNextAuthenticationReason(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onValueChange = (newValue: PrivilegeLockValue) => { + dispatch({ + type: 'setValue', + valueType: newValue.type, + value: newValue.value, + }); + }; + + const onSubmitPress = () => { + const privilegeValue = privilegeValues[firstNotSuccessful]; + validatePrivilegeValue(privilegeValue); + }; + + const switchKeyboard = () => { + if (keyboardType === PasscodeKeyboardType.Default) { + setKeyboardType(PasscodeKeyboardType.Numeric); + } else if (keyboardType === PasscodeKeyboardType.Numeric) { + setKeyboardType(PasscodeKeyboardType.Default); + } + }; + + const isPending = useMemo( + () => + privilegeValueStates.findIndex( + singleState => singleState === AuthenticationValueStateType.Pending + ) >= 0, + [privilegeValueStates] + ); + + const renderAuthenticationSource = ( + privilegeValue: PrivilegeLockValue, + index: number + ) => { + const last = index === privilegeValues.length - 1; + const state = privilegeValueStates[index]; + const active = isInActiveState(state); + const isInput = + privilegeValue.type === PrivilegeCredential.LocalPasscode || + privilegeValue.type === PrivilegeCredential.AccountPassword; + const stateLabel = getLabelForPrivilegeLockStateAndType( + privilegeValue, + state + ); + const stateTitle = getTitleForPrivilegeLockStateAndType( + privilegeValue, + state + ); + + return ( + + + {isInput && ( + + + { + onValueChange({ ...privilegeValue, value: text }); + }} + value={(privilegeValue.value || '') as string} + autoCorrect={false} + autoFocus={false} + autoCapitalize={'none'} + secureTextEntry={true} + keyboardType={keyboardType} + keyboardAppearance={styleKit?.keyboardColorForActiveTheme()} + underlineColorAndroid={'transparent'} + onSubmitEditing={() => { + validatePrivilegeValue(privilegeValue); + }} + /> + + + )} + + ); + }; + + return ( + + {privilegeValues.map((privilegeValue, index) => + renderAuthenticationSource(privilegeValue, index) + )} + + + + + {sessionLengthOptions.map((option, index) => ( + { + return option.value === selectedSessionLength; + }} + onPress={() => { + setSelectedSessionLength(option.value); + }} + /> + ))} + + + ); +}; diff --git a/src/screens/Authenticate/helpers.ts b/src/screens/Authenticate/helpers.ts index 0d7bf69d..98391693 100644 --- a/src/screens/Authenticate/helpers.ts +++ b/src/screens/Authenticate/helpers.ts @@ -1,4 +1,4 @@ -import { ChallengeType, ChallengeValue } from 'snjs'; +import { ChallengeType, ChallengeValue, PrivilegeCredential } from 'snjs'; export const findMatchingValueIndex = ( values: ChallengeValue[], @@ -7,11 +7,18 @@ export const findMatchingValueIndex = ( return values.findIndex(arrayValue => type === arrayValue.type); }; -export const isInActiveState = (state: ChallengeValueStateType) => - state !== ChallengeValueStateType.WaitingInput && - state !== ChallengeValueStateType.Success; +export const findMatchingPrivilegeValueIndex = ( + values: PrivilegeLockValue[], + type: PrivilegeCredential +) => { + return values.findIndex(arrayValue => type === arrayValue.type); +}; -export enum ChallengeValueStateType { +export const isInActiveState = (state: AuthenticationValueStateType) => + state !== AuthenticationValueStateType.WaitingInput && + state !== AuthenticationValueStateType.Success; + +export enum AuthenticationValueStateType { WaitingTurn = 0, WaitingInput = 1, Success = 2, @@ -22,18 +29,72 @@ export enum ChallengeValueStateType { type ChallengeValueState = { challengeValues: ChallengeValue[]; - challengeValueStates: ChallengeValueStateType[]; + challengeValueStates: AuthenticationValueStateType[]; }; type SetChallengeValueState = { type: 'setState'; valueType: ChallengeValue['type']; - state: ChallengeValueStateType; + state: AuthenticationValueStateType; }; type SetChallengeValue = { type: 'setValue'; valueType: ChallengeValue['type']; value: ChallengeValue['value']; }; + +export type PrivilegeLockValue = { + type: PrivilegeCredential; + value: string; +}; + +type SetPrivilegesValueState = { + type: 'setState'; + valueType: PrivilegeLockValue['type']; + state: AuthenticationValueStateType; +}; +type SetPrivilegesValue = { + type: 'setValue'; + valueType: PrivilegeLockValue['type']; + value: PrivilegeLockValue['value']; +}; + +type PrivilegeValueState = { + privilegeValues: PrivilegeLockValue[]; + privilegeValueStates: AuthenticationValueStateType[]; +}; + +type PrivilegesAction = SetPrivilegesValueState | SetPrivilegesValue; +export const privilegesAuthenticationReducer = ( + state: PrivilegeValueState, + action: PrivilegesAction +): PrivilegeValueState => { + switch (action.type) { + case 'setState': { + const tempArray = state.privilegeValueStates.slice(); + const index = findMatchingPrivilegeValueIndex( + state.privilegeValues, + action.valueType + ); + tempArray[index] = action.state; + return { ...state, privilegeValueStates: tempArray }; + } + case 'setValue': { + const tempArray = state.privilegeValues.slice(); + const index = findMatchingPrivilegeValueIndex( + state.privilegeValues, + action.valueType + ); + tempArray[index] = { + type: state.privilegeValues[index].type, + value: action.value, + }; + return { ...state, privilegeValues: tempArray }; + } + default: + return state; + } +}; + type Action = SetChallengeValueState | SetChallengeValue; export const authenticationReducer = ( state: ChallengeValueState, @@ -66,28 +127,63 @@ export const authenticationReducer = ( } }; +const mapPrivilageCredentialToChallengeType = ( + credential: PrivilegeCredential +) => { + switch (credential) { + case PrivilegeCredential.AccountPassword: + return ChallengeType.AccountPassword; + case PrivilegeCredential.LocalPasscode: + return ChallengeType.LocalPasscode; + } +}; + +export const getTitleForPrivilegeLockStateAndType = ( + privilegeValue: PrivilegeLockValue, + state: AuthenticationValueStateType +) => + getTitleForStateAndType( + { + ...privilegeValue, + type: mapPrivilageCredentialToChallengeType(privilegeValue.type), + }, + state + ); + +export const getLabelForPrivilegeLockStateAndType = ( + privilegeValue: PrivilegeLockValue, + state: AuthenticationValueStateType +) => + getLabelForStateAndType( + { + ...privilegeValue, + type: mapPrivilageCredentialToChallengeType(privilegeValue.type), + }, + state + ); + export const getTitleForStateAndType = ( challengeValue: ChallengeValue, - state: ChallengeValueStateType + state: AuthenticationValueStateType ) => { switch (challengeValue.type) { case ChallengeType.AccountPassword: { const title = 'Account Password'; switch (state) { - case ChallengeValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingTurn: return title.concat(' ', '- Waiting.'); - case ChallengeValueStateType.Locked: + case AuthenticationValueStateType.Locked: return title.concat(' ', '- Locked.'); default: return title; } } case ChallengeType.LocalPasscode: { - const title = 'Local Password'; + const title = 'Local Passcode'; switch (state) { - case ChallengeValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingTurn: return title.concat(' ', '- Waiting.'); - case ChallengeValueStateType.Locked: + case AuthenticationValueStateType.Locked: return title.concat(' ', '- Locked.'); default: return title; @@ -96,9 +192,9 @@ export const getTitleForStateAndType = ( case ChallengeType.Biometric: { const title = 'Biometrics'; switch (state) { - case ChallengeValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingTurn: return title.concat(' ', '- Waiting.'); - case ChallengeValueStateType.Locked: + case AuthenticationValueStateType.Locked: return title.concat(' ', '- Locked.'); default: return title; @@ -109,19 +205,19 @@ export const getTitleForStateAndType = ( export const getLabelForStateAndType = ( challengeValue: ChallengeValue, - state: ChallengeValueStateType + state: AuthenticationValueStateType ) => { switch (challengeValue.type) { case ChallengeType.AccountPassword: { switch (state) { - case ChallengeValueStateType.WaitingTurn: - case ChallengeValueStateType.WaitingInput: + case AuthenticationValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingInput: return 'Enter your account password'; - case ChallengeValueStateType.Pending: + case AuthenticationValueStateType.Pending: return 'Verifying keys...'; - case ChallengeValueStateType.Success: + case AuthenticationValueStateType.Success: return 'Success | Account Password'; - case ChallengeValueStateType.Fail: + case AuthenticationValueStateType.Fail: return 'Invalid account password. Please try again.'; default: return ''; @@ -129,31 +225,31 @@ export const getLabelForStateAndType = ( } case ChallengeType.LocalPasscode: { switch (state) { - case ChallengeValueStateType.WaitingTurn: - case ChallengeValueStateType.WaitingInput: + case AuthenticationValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingInput: return 'Enter your local passcode'; - case ChallengeValueStateType.Pending: + case AuthenticationValueStateType.Pending: return 'Verifying keys...'; - case ChallengeValueStateType.Success: + case AuthenticationValueStateType.Success: return 'Success | Local Passcode'; - case ChallengeValueStateType.Fail: - return 'Invalid local password. Please try again.'; + case AuthenticationValueStateType.Fail: + return 'Invalid local passcode. Please try again.'; default: return ''; } } case ChallengeType.Biometric: { switch (state) { - case ChallengeValueStateType.WaitingTurn: - case ChallengeValueStateType.WaitingInput: + case AuthenticationValueStateType.WaitingTurn: + case AuthenticationValueStateType.WaitingInput: return 'Please use biometrics to unlock.'; - case ChallengeValueStateType.Pending: + case AuthenticationValueStateType.Pending: return 'Waiting for unlock.'; - case ChallengeValueStateType.Success: + case AuthenticationValueStateType.Success: return 'Success | Biometrics.'; - case ChallengeValueStateType.Fail: + case AuthenticationValueStateType.Fail: return 'Biometrics failed. Tap to try again.'; - case ChallengeValueStateType.Locked: + case AuthenticationValueStateType.Locked: return 'Biometrics locked. Try again in 30 seconds.'; default: return ''; diff --git a/src/screens/Notes/NoteCell.tsx b/src/screens/Notes/NoteCell.tsx index e8c1a9aa..4da6d1c3 100644 --- a/src/screens/Notes/NoteCell.tsx +++ b/src/screens/Notes/NoteCell.tsx @@ -1,3 +1,4 @@ +import { useDeleteNoteWithPrivileges } from '@Lib/snjsHooks'; import { ApplicationContext } from '@Root/ApplicationContext'; import { CustomActionSheetOption, @@ -5,13 +6,7 @@ import { } from '@Style/useCustomActionSheet'; import React, { useCallback, useContext, useRef, useState } from 'react'; import { View } from 'react-native'; -import { - ButtonType, - CollectionSort, - isNullOrUndefined, - NoteMutator, - SNNote, -} from 'snjs'; +import { CollectionSort, isNullOrUndefined, NoteMutator, SNNote } from 'snjs'; import { Container, DateText, @@ -59,6 +54,19 @@ export const NoteCell = ({ const { showActionSheet } = useCustomActionSheet(); + const [deleteNote] = useDeleteNoteWithPrivileges( + note, + async () => { + await application?.deleteItem(note); + }, + () => { + changeNote(mutator => { + mutator.trashed = true; + }); + }, + undefined + ); + const highlight = Boolean(selected || highlighted); const _onPress = () => { @@ -171,24 +179,7 @@ export const NoteCell = ({ text: 'Move to Trash', key: 'trash', destructive: true, - callback: async () => { - const title = 'Move to Trash'; - const message = - 'Are you sure you want to move this note to the trash?'; - - const confirmed = await application?.alertService?.confirm( - message, - title, - 'Confirm', - ButtonType.Danger, - 'Cancel' - ); - if (confirmed) { - changeNote(mutator => { - mutator.trashed = true; - }); - } - }, + callback: async () => deleteNote(false), }); } else { options = options.concat([ @@ -205,27 +196,7 @@ export const NoteCell = ({ text: 'Delete Permanently', key: 'delete-forever', destructive: true, - callback: async () => { - const title = `Delete ${note.safeTitle()}`; - const message = - 'Are you sure you want to permanently delete this nite}?'; - if (note.locked) { - application?.alertService!.alert( - "This note is locked. If you'd like to delete it, unlock it, and try again." - ); - return; - } - const confirmed = await application?.alertService?.confirm( - message, - title, - 'Delete', - ButtonType.Danger, - 'Cancel' - ); - if (confirmed) { - await application?.deleteItem(note); - } - }, + callback: async () => deleteNote(true), }, ]); } diff --git a/src/screens/Root.tsx b/src/screens/Root.tsx index 2d5109a0..7ac5038f 100644 --- a/src/screens/Root.tsx +++ b/src/screens/Root.tsx @@ -4,16 +4,28 @@ import { TabletModeChangeData, } from '@Lib/ApplicationState'; import { useHasEditor, useIsLocked } from '@Lib/snjsHooks'; +import { useFocusEffect } from '@react-navigation/native'; import { AppStackNavigationProp } from '@Root/App'; import { ApplicationContext } from '@Root/ApplicationContext'; -import { SCREEN_COMPOSE, SCREEN_NOTES } from '@Screens/screens'; +import { + SCREEN_AUTHENTICATE_PRIVILEGES, + SCREEN_COMPOSE, + SCREEN_NOTES, +} from '@Screens/screens'; import { StyleKit } from '@Style/StyleKit'; import { hexToRGBA } from '@Style/utils'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { LayoutChangeEvent } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; -import { SNNote } from 'snjs/'; +import { ProtectedAction, SNNote } from 'snjs/'; import { ThemeContext } from 'styled-components/native'; +import { PRIVILEGES_UNLOCK_PAYLOAD } from './Authenticate/AuthenticatePrivileges'; import { Compose } from './Compose/Compose'; import { Notes } from './Notes/Notes'; import { @@ -45,6 +57,7 @@ export const Root = (props: Props): JSX.Element | null => { const [keyboardHeight, setKeyboardHeight] = useState( undefined ); + const [expectsUnlock, setExpectsUnlock] = useState(false); /** * Register observers @@ -123,18 +136,75 @@ export const Root = (props: Props): JSX.Element | null => { setKeyboardHeight(application?.getAppState().getKeyboardHeight()); }; - const openCompose = (newNote: boolean) => { - if (!shouldSplitLayout) { - props.navigation.navigate(SCREEN_COMPOSE, { - title: newNote ? 'Compose' : 'Note', - }); + const openCompose = useCallback( + (newNote: boolean) => { + if (!shouldSplitLayout) { + props.navigation.navigate(SCREEN_COMPOSE, { + title: newNote ? 'Compose' : 'Note', + }); + } + }, + [props.navigation, shouldSplitLayout] + ); + + const openNote = useCallback( + async (noteUuid: SNNote['uuid']) => { + await application!.getAppState().openEditor(noteUuid); + openCompose(false); + }, + [application, openCompose] + ); + + const onNoteSelect = async (noteUuid: SNNote['uuid']) => { + const note = application?.findItem(noteUuid) as SNNote; + if (note) { + if ( + note.safeContent.protected && + (await application?.privilegesService!.actionRequiresPrivilege( + ProtectedAction.ViewProtectedNotes + )) + ) { + const privilegeCredentials = await application!.privilegesService!.netCredentialsForAction( + ProtectedAction.ViewProtectedNotes + ); + setExpectsUnlock(true); + props.navigation.navigate(SCREEN_AUTHENTICATE_PRIVILEGES, { + action: ProtectedAction.ViewProtectedNotes, + privilegeCredentials, + unlockedItemId: noteUuid, + previousScreen: SCREEN_NOTES, + }); + } else { + openNote(noteUuid); + } } }; - const onNoteSelect = async (noteUuid: SNNote['uuid']) => { - await application!.getAppState().openEditor(noteUuid); - openCompose(false); - }; + /* + * After screen is focused read if a requested privilage was unlocked + */ + useFocusEffect( + useCallback(() => { + const readPrivilegesUnlockResponse = async () => { + if (application?.isLaunched() && expectsUnlock) { + const result = await application?.getValue(PRIVILEGES_UNLOCK_PAYLOAD); + if ( + result && + result.previousScreen === SCREEN_NOTES && + result.unlockedItemId + ) { + setExpectsUnlock(false); + application?.removeValue(PRIVILEGES_UNLOCK_PAYLOAD); + openNote(result.unlockedItemId); + } else { + setExpectsUnlock(false); + } + } + }; + + readPrivilegesUnlockResponse(); + }, [application, expectsUnlock, openNote]) + ); const onNoteCreate = async () => { await application!.getAppState().createEditor(); diff --git a/src/screens/Settings/ManagePrivileges.tsx b/src/screens/Settings/ManagePrivileges.tsx index 749c5566..a95b469a 100644 --- a/src/screens/Settings/ManagePrivileges.tsx +++ b/src/screens/Settings/ManagePrivileges.tsx @@ -1,6 +1,5 @@ import { ButtonCell } from '@Components/ButtonCell'; import { SectionedAccessoryTableCell } from '@Components/SectionedAccessoryTableCell'; -import { SectionedTableCell } from '@Components/SectionedTableCell'; import { SectionHeader } from '@Components/SectionHeader'; import { useFocusEffect } from '@react-navigation/native'; import { ApplicationContext } from '@Root/ApplicationContext'; @@ -139,11 +138,11 @@ export const ManagePrivileges = () => { {sessionExpirey && !sessionExpired && (
- + You will not be asked to authenticate until {sessionExpirey}. - + { // State const [exporting, setExporting] = useState(false); + const [awaitingUnlock, setAwaitingUnlock] = useState(false); + const [encryptedBackup, setEncryptedBackp] = useState(false); const email = useMemo(() => { if (signedIn) { @@ -59,6 +66,30 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { } }; + const openPrivilegeModal = useCallback( + async (protectedAction: ProtectedAction) => { + setAwaitingUnlock(true); + const privilegeCredentials = await application!.privilegesService!.netCredentialsForAction( + protectedAction + ); + navigation.navigate(SCREEN_AUTHENTICATE_PRIVILEGES, { + action: protectedAction, + privilegeCredentials, + previousScreen: SCREEN_SETTINGS, + }); + }, + [application, navigation] + ); + + const exportData = useCallback( + async (encrypted: boolean) => { + setExporting(true); + await application?.getBackupsService().export(encrypted); + setExporting(false); + }, + [application] + ); + const onExportPress = useCallback( async (option: { key: string }) => { let encrypted = option.key === 'encrypted'; @@ -70,19 +101,80 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { ); return; } - - setExporting(true); - - await application?.getBackupsService().export(encrypted); - - setExporting(false); + if ( + await application?.privilegesService!.actionRequiresPrivilege( + ProtectedAction.ManageBackups + ) + ) { + setEncryptedBackp(encrypted); + await openPrivilegeModal(ProtectedAction.ManageBackups); + } else { + exportData(encrypted); + } }, - [application, encryptionAvailable] + [ + application?.alertService, + application?.privilegesService, + encryptionAvailable, + exportData, + openPrivilegeModal, + ] ); - const openManagePrivileges = () => { + const openManagePrivileges = useCallback(() => { navigation.push(SCREEN_MANAGE_PRIVILEGES); - }; + }, [navigation]); + + const onManagePrivilegesPress = useCallback(async () => { + if ( + await application?.privilegesService!.actionRequiresPrivilege( + ProtectedAction.ManagePrivileges + ) + ) { + await openPrivilegeModal(ProtectedAction.ManagePrivileges); + } else { + openManagePrivileges(); + } + }, [ + application?.privilegesService, + openManagePrivileges, + openPrivilegeModal, + ]); + + /* + * After screen is focused read if a requested privilage was unlocked + */ + useFocusEffect( + useCallback(() => { + const readPrivilegesUnlockResponse = async () => { + if (application?.isLaunched() && awaitingUnlock) { + const result = await application?.getValue(PRIVILEGES_UNLOCK_PAYLOAD); + if (result && result.previousScreen === SCREEN_SETTINGS) { + setAwaitingUnlock(false); + if (result.unlockedAction === ProtectedAction.ManagePrivileges) { + openManagePrivileges(); + } else if ( + result.unlockedAction === ProtectedAction.ManageBackups + ) { + exportData(encryptedBackup); + setEncryptedBackp(false); + } + application?.removeValue(PRIVILEGES_UNLOCK_PAYLOAD); + } else { + setAwaitingUnlock(false); + } + } + }; + + readPrivilegesUnlockResponse(); + }, [ + application, + awaitingUnlock, + encryptedBackup, + exportData, + openManagePrivileges, + ]) + ); return ( @@ -92,7 +184,7 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { first={true} leftAligned={true} title={'Manage Privileges'} - onPress={openManagePrivileges} + onPress={onManagePrivilegesPress} /> {signedIn && ( diff --git a/src/screens/Settings/Sections/PasscodeSection.tsx b/src/screens/Settings/Sections/PasscodeSection.tsx index cac10e2c..681180bc 100644 --- a/src/screens/Settings/Sections/PasscodeSection.tsx +++ b/src/screens/Settings/Sections/PasscodeSection.tsx @@ -10,9 +10,14 @@ import { MobileDeviceInterface } from '@Lib/interface'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { ModalStackNavigationProp } from '@Root/App'; import { ApplicationContext } from '@Root/ApplicationContext'; -import { SCREEN_INPUT_MODAL_PASSCODE, SCREEN_SETTINGS } from '@Screens/screens'; +import { PRIVILEGES_UNLOCK_PAYLOAD } from '@Screens/Authenticate/AuthenticatePrivileges'; +import { + SCREEN_AUTHENTICATE_PRIVILEGES, + SCREEN_INPUT_MODAL_PASSCODE, + SCREEN_SETTINGS, +} from '@Screens/screens'; import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { StorageEncryptionPolicies } from 'snjs'; +import { ProtectedAction, StorageEncryptionPolicies } from 'snjs'; import { Title } from './PasscodeSection.styled'; type Props = { @@ -44,6 +49,7 @@ export const PasscodeSection = (props: Props) => { encryptionPolictChangeInProgress, setEncryptionPolictChangeInProgress, ] = useState(false); + const [lockedMethod, setLockedMethod] = useState<'passcode' | 'biometrics'>(); useEffect(() => { let mounted = true; @@ -128,26 +134,7 @@ export const PasscodeSection = (props: Props) => { const passcodeOnPress = async () => { if (props.hasPasscode) { - 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(); - await application?.getAppState().setScreenshotPrivacy(); - } + disableAuthentication('passcode'); } else { navigation.push(SCREEN_INPUT_MODAL_PASSCODE); } @@ -169,8 +156,7 @@ export const PasscodeSection = (props: Props) => { const onBiometricsPress = async () => { if (hasBiometrics) { - setHasBiometrics(false); - await application?.disableBiometrics(); + disableAuthentication('biometrics'); } else { setHasBiometrics(true); await application?.enableBiometrics(); @@ -179,6 +165,92 @@ export const PasscodeSection = (props: Props) => { await application?.getAppState().setScreenshotPrivacy(); }; + const disableBiometrics = useCallback(async () => { + setHasBiometrics(false); + await application?.disableBiometrics(); + }, [application]); + + 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(); + await application?.getAppState().setScreenshotPrivacy(); + } + }, [application]); + + const disableAuthentication = useCallback( + async (authenticationMethod: 'passcode' | 'biometrics') => { + if ( + await application?.privilegesService!.actionRequiresPrivilege( + ProtectedAction.ManagePasscode + ) + ) { + const privilegeCredentials = await application!.privilegesService!.netCredentialsForAction( + ProtectedAction.ManagePasscode + ); + setLockedMethod(authenticationMethod); + navigation.navigate(SCREEN_AUTHENTICATE_PRIVILEGES, { + action: ProtectedAction.ManagePasscode, + privilegeCredentials, + previousScreen: SCREEN_SETTINGS, + }); + } else { + if (authenticationMethod === 'biometrics') { + disableBiometrics(); + } else if (authenticationMethod === 'passcode') { + disablePasscode(); + } + } + }, + [application, disableBiometrics, disablePasscode, navigation] + ); + + /* + * After screen is focused read if a requested privilage was unlocked + */ + useFocusEffect( + useCallback(() => { + const readPrivilegesUnlockResponse = async () => { + if (application?.isLaunched() && lockedMethod) { + const result = await application?.getValue(PRIVILEGES_UNLOCK_PAYLOAD); + if ( + result && + result.previousScreen === SCREEN_SETTINGS && + result.unlockedAction === ProtectedAction.ManagePasscode + ) { + if (lockedMethod === 'biometrics') { + disableBiometrics(); + } else if (lockedMethod === 'passcode') { + disablePasscode(); + } + setLockedMethod(undefined); + application?.removeValue(PRIVILEGES_UNLOCK_PAYLOAD); + application?.removeValue(PRIVILEGES_UNLOCK_PAYLOAD); + } else { + setLockedMethod(undefined); + } + } + }; + + readPrivilegesUnlockResponse(); + }, [application, disableBiometrics, disablePasscode, lockedMethod]) + ); + let biometricTitle = hasBiometrics ? 'Disable Biometrics Lock' : 'Enable Biometrics Lock'; diff --git a/src/screens/SideMenu/NoteSideMenu.tsx b/src/screens/SideMenu/NoteSideMenu.tsx index 48e80990..96e04aaa 100644 --- a/src/screens/SideMenu/NoteSideMenu.tsx +++ b/src/screens/SideMenu/NoteSideMenu.tsx @@ -1,4 +1,5 @@ import { Editor } from '@Lib/Editor'; +import { useDeleteNoteWithPrivileges } from '@Lib/snjsHooks'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { AppStackNavigationProp } from '@Root/App'; import { ApplicationContext } from '@Root/ApplicationContext'; @@ -68,6 +69,23 @@ export const NoteSideMenu = (props: Props) => { const [selectedTags, setSelectedTags] = useState([]); const [components, setComponents] = useState([]); + const [deleteNote] = useDeleteNoteWithPrivileges( + note!, + async () => { + await application?.deleteItem(note!); + props.drawerRef?.closeDrawer(); + if (!application?.getAppState().isInTabletMode) { + navigation.popToTop(); + } + }, + () => { + changeNote(mutator => { + mutator.trashed = true; + }); + }, + editor + ); + useEffect(() => { let mounted = true; if (!editor && mounted) { @@ -459,24 +477,7 @@ export const NoteSideMenu = (props: Props) => { if (!note.safeContent.trashed) { rawOptions.push({ text: 'Move to Trash', - onSelect: async () => { - const title = 'Move to Trash'; - const message = - 'Are you sure you want to move this note to the trash?'; - - const confirmed = await application?.alertService?.confirm( - message, - title, - 'Confirm', - ButtonType.Danger, - 'Cancel' - ); - if (confirmed) { - changeNote(mutator => { - mutator.trashed = true; - }); - } - }, + onSelect: async () => deleteNote(false), icon: ICON_TRASH, }); } @@ -507,37 +508,7 @@ export const NoteSideMenu = (props: Props) => { text: 'Delete Permanently', textClass: 'danger' as 'danger', key: 'delete-forever', - onSelect: async () => { - const title = `Delete ${note.safeTitle()}`; - const message = - 'Are you sure you want to permanently delete this nite}?'; - if (editor?.isTemplateNote) { - application?.alertService!.alert( - 'This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.' - ); - return; - } - if (note.locked) { - application?.alertService!.alert( - "This note is locked. If you'd like to delete it, unlock it, and try again." - ); - return; - } - const confirmed = await application?.alertService?.confirm( - message, - title, - 'Delete', - ButtonType.Danger, - 'Cancel' - ); - if (confirmed) { - await application?.deleteItem(note); - props.drawerRef?.closeDrawer(); - if (!application?.getAppState().isInTabletMode) { - navigation.popToTop(); - } - } - }, + onSelect: async () => deleteNote(true), }, { text: 'Empty Trash', @@ -570,7 +541,7 @@ export const NoteSideMenu = (props: Props) => { changeNote, leaveEditor, application, - editor?.isTemplateNote, + deleteNote, props.drawerRef, navigation, ]); @@ -640,7 +611,6 @@ export const NoteSideMenu = (props: Props) => { buttonColor={theme.stylekitInfoColor} iconTextColor={theme.stylekitInfoContrastColor} onClickAction={() => - // @ts-expect-error navigation.navigate(SCREEN_INPUT_MODAL_TAG, { noteUuid: note.uuid }) } visible={true} diff --git a/src/screens/SideMenu/SideMenuHero.tsx b/src/screens/SideMenu/SideMenuHero.tsx index b038a24d..e6e12873 100644 --- a/src/screens/SideMenu/SideMenuHero.tsx +++ b/src/screens/SideMenu/SideMenuHero.tsx @@ -62,7 +62,8 @@ export const SideMenuHero: React.FC = props => { async event => { if ( event === ApplicationEvent.Launched || - event === ApplicationEvent.SignedIn + event === ApplicationEvent.SignedIn || + event === ApplicationEvent.WillSync ) { setIsLocked(false); } diff --git a/src/screens/SideMenu/TagSelectionList.tsx b/src/screens/SideMenu/TagSelectionList.tsx index c01dbcc8..0ea21a0b 100644 --- a/src/screens/SideMenu/TagSelectionList.tsx +++ b/src/screens/SideMenu/TagSelectionList.tsx @@ -69,7 +69,6 @@ export const TagSelectionList = (props: Props): JSX.Element => { { text: 'Rename', callback: () => - // @ts-expect-error navigation.navigate(SCREEN_INPUT_MODAL_TAG, { tagUuid: tag.uuid }), }, { diff --git a/src/screens/screens.ts b/src/screens/screens.ts index bcd091d1..584d1cd2 100644 --- a/src/screens/screens.ts +++ b/src/screens/screens.ts @@ -1,4 +1,5 @@ export const SCREEN_AUTHENTICATE = 'Authenticate'; +export const SCREEN_AUTHENTICATE_PRIVILEGES = 'AuthenticatePrivileges' as 'AuthenticatePrivileges'; export const SCREEN_HOME = 'Home'; export const SCREEN_NOTES = 'Notes' as 'Notes'; diff --git a/yarn.lock b/yarn.lock index c011aa29..6af651dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6741,6 +6741,13 @@ react-navigation-header-buttons@^5.0.2: invariant ">=2" react-native-platform-touchable "^1.1.1" +react-navigation-props-mapper@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/react-navigation-props-mapper/-/react-navigation-props-mapper-1.0.4.tgz#0c5026aeb51469f9cb9034e6dc9a4db3701e768c" + integrity sha512-uwGCzz6AYDHLEaCrACaTUrQD7e5YEUC6XFr0ooq2ZJ+tAkPQcHJqDtO3mw50FFs8KWwO37B+SBcgf9kKWyOwQQ== + dependencies: + hoist-non-react-statics "^3.3.0" + react-refresh@^0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.4.2.tgz#54a277a6caaac2803d88f1d6f13c1dcfbd81e334"