diff --git a/src/App.tsx b/src/App.tsx index 7df53060..90273ddc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { MobileApplication } from '@Lib/Application' import { ApplicationGroup } from '@Lib/ApplicationGroup' import { navigationRef } from '@Lib/NavigationService' import { DefaultTheme, NavigationContainer } from '@react-navigation/native' +import { ApplicationGroupContext } from '@Root/ApplicationGroupContext' import { MobileThemeVariables } from '@Root/Style/Themes/styled-components' import { ApplicationGroupEvent, DeinitMode, DeinitSource } from '@standardnotes/snjs' import { ThemeService, ThemeServiceContext } from '@Style/ThemeService' @@ -58,7 +59,7 @@ const AppComponent: React.FC<{ void application.launch() } }, - [application] + [application], ) useEffect(() => { @@ -145,15 +146,17 @@ export const App = (props: { env: TEnvironment }) => { } }) return removeAppChangeObserver - }, [appGroup, appGroup.primaryApplication, setAppGroup, createNewAppGroup]) + }, [appGroup, appGroup.primaryApplication, createNewAppGroup]) if (!application) { return null } return ( - - - + + + + + ) } diff --git a/src/ApplicationGroupContext.tsx b/src/ApplicationGroupContext.tsx new file mode 100644 index 00000000..cc15f8db --- /dev/null +++ b/src/ApplicationGroupContext.tsx @@ -0,0 +1,4 @@ +import { ApplicationGroup } from '@Lib/ApplicationGroup' +import React from 'react' + +export const ApplicationGroupContext = React.createContext(undefined) diff --git a/src/Hooks/useSafeApplicationGroupContext.ts b/src/Hooks/useSafeApplicationGroupContext.ts new file mode 100644 index 00000000..7919c55a --- /dev/null +++ b/src/Hooks/useSafeApplicationGroupContext.ts @@ -0,0 +1,8 @@ +import { ApplicationGroup } from '@Lib/ApplicationGroup' +import { ApplicationGroupContext } from '@Root/ApplicationGroupContext' +import { useContext } from 'react' + +export const useSafeApplicationGroupContext = () => { + const applicationGroupContext = useContext(ApplicationGroupContext) as ApplicationGroup + return applicationGroupContext +} diff --git a/src/Lib/Interface.ts b/src/Lib/Interface.ts index 165fd8c0..ea434dc7 100644 --- a/src/Lib/Interface.ts +++ b/src/Lib/Interface.ts @@ -80,7 +80,7 @@ export class MobileDeviceInterface implements DeviceInterface { private async getAllDatabaseKeys(identifier: ApplicationIdentifier) { const keys = await AsyncStorage.getAllKeys() const filtered = keys.filter(key => { - return key.includes(this.getDatabaseKeyPrefix(identifier)) + return key.startsWith(this.getDatabaseKeyPrefix(identifier)) }) return filtered } diff --git a/src/ModalStack.tsx b/src/ModalStack.tsx index da8557c1..b9e2e2bd 100644 --- a/src/ModalStack.tsx +++ b/src/ModalStack.tsx @@ -14,13 +14,15 @@ import { SCREEN_INPUT_MODAL_FILE_NAME, SCREEN_INPUT_MODAL_PASSCODE, SCREEN_INPUT_MODAL_TAG, + SCREEN_INPUT_MODAL_WORKSPACE_NAME, SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS, SCREEN_UPLOADED_FILES_LIST, } from '@Root/Screens/screens' import { Settings } from '@Root/Screens/Settings/Settings' import { UploadedFilesList } from '@Root/Screens/UploadedFilesList/UploadedFilesList' -import { Challenge, DeinitMode, DeinitSource, FileItem, SNNote } from '@standardnotes/snjs' +import { WorkspaceInputModal } from '@Screens/InputModal/WorkspaceInputModal' +import { ApplicationDescriptor, Challenge, DeinitMode, DeinitSource, FileItem, SNNote } from '@standardnotes/snjs' import { ICON_CHECKMARK, ICON_CLOSE } from '@Style/Icons' import { ThemeService } from '@Style/ThemeService' import React, { memo, useContext } from 'react' @@ -48,6 +50,10 @@ export type ModalStackNavigatorParamList = { [SCREEN_UPLOADED_FILES_LIST]: HeaderTitleParams & { note: SNNote } + [SCREEN_INPUT_MODAL_WORKSPACE_NAME]: HeaderTitleParams & { + descriptor: ApplicationDescriptor + renameWorkspace: (descriptor: ApplicationDescriptor, workspaceName: string) => Promise + } [SCREEN_INPUT_MODAL_PASSCODE]: undefined [SCREEN_AUTHENTICATE]: { challenge: Challenge @@ -275,6 +281,28 @@ export const MainStackComponent = ({ env }: { env: TEnvironment }) => { })} component={BlockingModal} /> + ({ + title: 'Workspace', + gestureEnabled: false, + headerTitle: ({ children }) => { + return + }, + headerLeft: ({ disabled, onPress }) => ( + + + + ), + })} + component={WorkspaceInputModal} + /> ) } diff --git a/src/Screens/InputModal/WorkspaceInputModal.tsx b/src/Screens/InputModal/WorkspaceInputModal.tsx new file mode 100644 index 00000000..8c4ce466 --- /dev/null +++ b/src/Screens/InputModal/WorkspaceInputModal.tsx @@ -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_WORKSPACE_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 + +export const WorkspaceInputModal: FC = props => { + const { descriptor, renameWorkspace } = props.route.params + const themeService = useContext(ThemeServiceContext) + const application = useSafeApplicationContext() + + const workspaceNameInputRef = useRef(null) + + const [workspaceName, setWorkspaceName] = useState(descriptor.label) + + const onSubmit = async () => { + const trimmedWorkspaceName = workspaceName.trim() + if (trimmedWorkspaceName === '') { + setWorkspaceName(descriptor.label) + await application?.alertService.alert('Workspace name cannot be empty') + workspaceNameInputRef.current?.focus() + return + } + await renameWorkspace(descriptor, trimmedWorkspaceName) + void application.sync.sync() + props.navigation.goBack() + } + + useEffect(() => { + workspaceNameInputRef.current?.focus() + }, []) + + return ( + + + + + + + + + + ) +} diff --git a/src/Screens/Notes/Notes.tsx b/src/Screens/Notes/Notes.tsx index db45bf28..693b528d 100644 --- a/src/Screens/Notes/Notes.tsx +++ b/src/Screens/Notes/Notes.tsx @@ -344,7 +344,7 @@ export const Notes = React.memo( reloadNotesDisplayOptions() } - const newNotes = application.items.getDisplayableNotes() + const newNotes = application.items.getDisplayableNotes() // TODO: returns notes from all workspaces when in Main workspace const renderedNotes: SNNote[] = newNotes setNotes(renderedNotes) diff --git a/src/Screens/Settings/Sections/OptionsSection.tsx b/src/Screens/Settings/Sections/OptionsSection.tsx index d544842a..4250ea26 100644 --- a/src/Screens/Settings/Sections/OptionsSection.tsx +++ b/src/Screens/Settings/Sections/OptionsSection.tsx @@ -1,16 +1,19 @@ 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 { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext' +import { useSafeApplicationGroupContext } from '@Root/Hooks/useSafeApplicationGroupContext' import { ModalStackNavigationProp } from '@Root/ModalStack' -import { SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS } from '@Root/Screens/screens' -import { ButtonType, PrefKey } from '@standardnotes/snjs' +import { SCREEN_INPUT_MODAL_WORKSPACE_NAME, SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS } from '@Root/Screens/screens' +import SNReactNative from '@standardnotes/react-native-utils' +import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType, PrefKey } from '@standardnotes/snjs' +import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet' import moment from 'moment' -import React, { useCallback, useContext, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Platform } from 'react-native' import DocumentPicker from 'react-native-document-picker' import RNFS from 'react-native-fs' @@ -22,7 +25,9 @@ type Props = { export const OptionsSection = ({ title, encryptionAvailable }: Props) => { // Context - const application = useContext(ApplicationContext) + const application = useSafeApplicationContext() + const appGroup = useSafeApplicationGroupContext() + const [signedIn] = useSignedIn() const navigation = useNavigation['navigation']>() @@ -30,9 +35,29 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { const [importing, setImporting] = useState(false) const [exporting, setExporting] = useState(false) const [lastExportDate, setLastExportDate] = useState(() => - application?.getLocalPreferences().getValue(PrefKey.MobileLastExportDate, undefined) + application.getLocalPreferences().getValue(PrefKey.MobileLastExportDate, undefined), ) + const { showActionSheet } = useCustomActionSheet() + + const [applicationDescriptors, setApplicationDescriptors] = useState([]) + + useEffect(() => { + let descriptors = appGroup.getDescriptors() + setApplicationDescriptors(descriptors) + + const removeAppGroupObserver = appGroup.addEventObserver(event => { + if (event === ApplicationGroupEvent.DescriptorsDataChanged) { + descriptors = appGroup.getDescriptors() + setApplicationDescriptors(descriptors) + } + }) + + return () => { + removeAppGroupObserver() + } + }, [appGroup]) + const lastExportData = useMemo(() => { if (lastExportDate) { const formattedDate = moment(lastExportDate).format('lll') @@ -57,7 +82,7 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { const email = useMemo(() => { if (signedIn) { - const user = application?.getUser() + const user = application.getUser() return user?.email } return @@ -76,52 +101,52 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { const destroyLocalData = async () => { if ( - await application?.alertService?.confirm( + 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 + ButtonType.Danger, ) ) { - await application!.user.signOut() + await application.user.signOut() } } const exportData = useCallback( async (encrypted: boolean) => { setExporting(true) - const result = await application?.getBackupsService().export(encrypted) + const result = await application.getBackupsService().export(encrypted) if (result) { const exportDate = new Date() setLastExportDate(exportDate) - void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileLastExportDate, exportDate) + void application.getLocalPreferences().setUserPrefValue(PrefKey.MobileLastExportDate, exportDate) } setExporting(false) }, - [application] + [application], ) const readImportFile = async (fileUri: string): Promise => { 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.') + 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) + const result = await application.mutator.importData(data) if (!result) { return } else if ('error' in result) { - void application!.alertService!.alert(result.error.text) + void application.alertService.alert(result.error.text) } else if (result.errorCount) { - void application!.alertService!.alert( + 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.' + '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.') + void application.alertService.alert('Your data has been successfully imported.') } } @@ -139,12 +164,12 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { 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)) { + if (application.protocolService.supportedVersions().includes(version)) { await performImport(data) } else { - void application!.alertService.alert( + 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.' + 'and cannot be imported here. Please update your application and try again.', ) } } else { @@ -159,40 +184,219 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => { async (option: { key: string }) => { const encrypted = option.key === 'encrypted' if (encrypted && !encryptionAvailable) { - void application?.alertService!.alert( + void application.alertService.alert( 'You must be signed in, or have a local passcode set, to generate an encrypted export file.', 'Not Available', - 'OK' + 'OK', ) return } void exportData(encrypted) }, - [application?.alertService, encryptionAvailable, exportData] + [application.alertService, encryptionAvailable, exportData], ) const openManageSessions = useCallback(() => { navigation.push(SCREEN_MANAGE_SESSIONS) }, [navigation]) + enum WorkspaceAction { + AddAnother = 'Add another workspace', + Open = 'Open', + Rename = 'Rename', + Remove = 'Remove', + SignOutAll = 'Sign out all workspaces', + } + + const { AddAnother, Open, Rename, Remove, SignOutAll } = WorkspaceAction + + const getWorkspaceActionConfirmation = useCallback( + async (action: WorkspaceAction): Promise => { + const { Info, Danger } = ButtonType + let message = '' + let buttonText = '' + let buttonType = Info + + switch (action) { + case Open: + case AddAnother: + message = 'Your new workspace will be ready for you when you come back.' + buttonText = 'Quit App' + break + case SignOutAll: + message = 'Are you sure you want to sign out of all workspaces on this device?' + buttonText = 'Sign Out All' + break + case Remove: + message = + 'This action will remove this workspace and its related data from this device. Your synced data will not be affected.' + buttonText = 'Delete Workspace' + buttonType = Danger + break + default: + break + } + return application.alertService.confirm(message, undefined, buttonText, buttonType) + }, + [AddAnother, Open, Remove, SignOutAll, application.alertService], + ) + + const renameWorkspace = useCallback( + async (descriptor: ApplicationDescriptor, newName: string) => { + appGroup.renameDescriptor(descriptor, newName) + }, + [appGroup], + ) + + const signOutWorkspace = useCallback(async () => { + const confirmed = await getWorkspaceActionConfirmation(Remove) + + if (!confirmed) { + return + } + + try { + await application.user.signOut() // TODO: do I need to call `SNReactNative.exitApp()` here as well? + } catch (error) { + console.error(error) + } + }, [Remove, application.user, getWorkspaceActionConfirmation]) + + const openWorkspace = useCallback( + async (descriptor: ApplicationDescriptor) => { + const confirmed = await getWorkspaceActionConfirmation(Open) + if (!confirmed) { + return + } + + // await application.getInstallationService().customWipeData() + await appGroup.unloadCurrentAndActivateDescriptor(descriptor) + // TODO: find a way to check if there are memory leaks *without* the below call. + SNReactNative.exitApp() + }, + [Open, appGroup, getWorkspaceActionConfirmation], + ) + + const getSingleWorkspaceItemOptions = useCallback( + (descriptor: ApplicationDescriptor) => { + const worskspaceItemOptions: CustomActionSheetOption[] = [] + if (descriptor.primary) { + worskspaceItemOptions.push( + { + text: Rename, + callback: () => { + navigation.navigate(SCREEN_INPUT_MODAL_WORKSPACE_NAME, { + descriptor, + renameWorkspace, + }) + }, + }, + { + text: Remove, + destructive: true, + callback: signOutWorkspace, + }, + ) + } else { + worskspaceItemOptions.push({ + text: Open, + callback: () => openWorkspace(descriptor), + }) + } + + return worskspaceItemOptions + }, + [Open, Remove, Rename, navigation, openWorkspace, renameWorkspace, signOutWorkspace], + ) + + const getActiveWorkspaceItems = useCallback(() => { + const descriptorItemOptions: CustomActionSheetOption[] = [] + + applicationDescriptors.forEach(descriptor => { + descriptorItemOptions.push({ + text: `${descriptor.label}${descriptor.primary ? ' *' : ''}`, + + callback: async () => { + const singleItemOptions = getSingleWorkspaceItemOptions(descriptor) + + showActionSheet({ + title: '', + options: singleItemOptions, + }) + }, + }) + }) + + return descriptorItemOptions + }, [applicationDescriptors, getSingleWorkspaceItemOptions, showActionSheet]) + + const addAnotherWorkspace = useCallback(async () => { + const confirmed = await getWorkspaceActionConfirmation(AddAnother) + if (!confirmed) { + return + } + // await application.getInstallationService().wipeData() + // await application.getInstallationService().customWipeData() + await appGroup.unloadCurrentAndCreateNewDescriptor() + SNReactNative.exitApp() + }, [AddAnother, appGroup, getWorkspaceActionConfirmation]) + + const signOutAllWorkspaces = useCallback(async () => { + try { + const confirmed = await getWorkspaceActionConfirmation(SignOutAll) + if (!confirmed) { + return + } + await appGroup.signOutAllWorkspaces() + + // TODO: do we need `exitApp` here as well? + SNReactNative.exitApp() + } catch (error) { + console.error(error) + } + }, [SignOutAll, appGroup, getWorkspaceActionConfirmation]) + + const handleSwitchWorkspaceClick = useCallback(() => { + const activeDescriptors = getActiveWorkspaceItems() + const options: CustomActionSheetOption[] = [ + ...activeDescriptors, + { + text: AddAnother, + callback: addAnotherWorkspace, + }, + { + text: SignOutAll, + callback: signOutAllWorkspaces, + }, + ] + showActionSheet({ title: '', options }) + }, [AddAnother, SignOutAll, addAnotherWorkspace, getActiveWorkspaceItems, showActionSheet, signOutAllWorkspaces]) + const showDataBackupAlert = useCallback(() => { - void application?.alertService.alert( + 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' + 'OK', ) - }, [application?.alertService]) + }, [application.alertService]) return ( + {signedIn && ( <> diff --git a/src/Screens/screens.ts b/src/Screens/screens.ts index 3f74c8ea..27320e52 100644 --- a/src/Screens/screens.ts +++ b/src/Screens/screens.ts @@ -8,6 +8,7 @@ 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_INPUT_MODAL_WORKSPACE_NAME = 'InputModalWorkspaceName' as const export const SCREEN_SETTINGS = 'Settings' export const SCREEN_MANAGE_SESSIONS = 'ManageSessions' as const