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