From 6bb52d5ad66898b23d9f22a47c14475cfb3cb9ac Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 10 Jan 2023 12:08:13 +0000 Subject: [PATCH] Mobile: Add support for multiple profiles --- .eslintignore | 18 ++ .gitignore | 22 ++- .../ProfileSwitcher/ProfileEditor.tsx | 102 ++++++++++ .../ProfileSwitcher/ProfileSwitcher.tsx | 176 ++++++++++++++++++ .../ProfileSwitcher/useProfileConfig.ts | 20 ++ packages/app-mobile/components/TextInput.tsx | 37 ++++ packages/app-mobile/components/app-nav.js | 2 +- .../app-mobile/components/global-style.js | 3 + .../components/screens/ConfigScreen.tsx | 8 + .../app-mobile/components/screens/folder.js | 32 +--- .../components/side-menu-content.tsx | 16 ++ packages/app-mobile/ios/Podfile.lock | 6 + packages/app-mobile/package.json | 1 + packages/app-mobile/root.tsx | 48 ++++- .../app-mobile/services/profiles/index.ts | 51 +++++ packages/app-mobile/utils/createRootStyle.ts | 11 ++ packages/lib/services/profileConfig/index.ts | 12 ++ packages/lib/themes/type.ts | 3 + renovate.json5 | 2 +- yarn.lock | 8 + 20 files changed, 540 insertions(+), 38 deletions(-) create mode 100644 packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx create mode 100644 packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.tsx create mode 100644 packages/app-mobile/components/ProfileSwitcher/useProfileConfig.ts create mode 100644 packages/app-mobile/components/TextInput.tsx create mode 100644 packages/app-mobile/services/profiles/index.ts create mode 100644 packages/app-mobile/utils/createRootStyle.ts diff --git a/.eslintignore b/.eslintignore index 29aed09957..5b3bc64e43 100644 --- a/.eslintignore +++ b/.eslintignore @@ -984,6 +984,15 @@ packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map packages/app-mobile/components/NoteEditor/types.d.ts packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteEditor/types.js.map +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.d.ts +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js.map +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.d.ts +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js.map +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.d.ts +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js.map packages/app-mobile/components/ScreenHeader.d.ts packages/app-mobile/components/ScreenHeader.js packages/app-mobile/components/ScreenHeader.js.map @@ -993,6 +1002,9 @@ packages/app-mobile/components/SelectDateTimeDialog.js.map packages/app-mobile/components/SideMenu.d.ts packages/app-mobile/components/SideMenu.js packages/app-mobile/components/SideMenu.js.map +packages/app-mobile/components/TextInput.d.ts +packages/app-mobile/components/TextInput.js +packages/app-mobile/components/TextInput.js.map packages/app-mobile/components/biometrics/BiometricPopup.d.ts packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js.map @@ -1038,6 +1050,9 @@ packages/app-mobile/services/AlarmServiceDriver.ios.js.map packages/app-mobile/services/e2ee/RSA.react-native.d.ts packages/app-mobile/services/e2ee/RSA.react-native.js packages/app-mobile/services/e2ee/RSA.react-native.js.map +packages/app-mobile/services/profiles/index.d.ts +packages/app-mobile/services/profiles/index.js +packages/app-mobile/services/profiles/index.js.map packages/app-mobile/setupQuickActions.d.ts packages/app-mobile/setupQuickActions.js packages/app-mobile/setupQuickActions.js.map @@ -1056,6 +1071,9 @@ packages/app-mobile/utils/TlsUtils.js.map packages/app-mobile/utils/checkPermissions.d.ts packages/app-mobile/utils/checkPermissions.js packages/app-mobile/utils/checkPermissions.js.map +packages/app-mobile/utils/createRootStyle.d.ts +packages/app-mobile/utils/createRootStyle.js +packages/app-mobile/utils/createRootStyle.js.map packages/app-mobile/utils/debounce.d.ts packages/app-mobile/utils/debounce.js packages/app-mobile/utils/debounce.js.map diff --git a/.gitignore b/.gitignore index 0e23f6a993..e704d380c5 100644 --- a/.gitignore +++ b/.gitignore @@ -972,6 +972,15 @@ packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map packages/app-mobile/components/NoteEditor/types.d.ts packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteEditor/types.js.map +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.d.ts +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js +packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js.map +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.d.ts +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js +packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js.map +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.d.ts +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js +packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js.map packages/app-mobile/components/ScreenHeader.d.ts packages/app-mobile/components/ScreenHeader.js packages/app-mobile/components/ScreenHeader.js.map @@ -981,6 +990,9 @@ packages/app-mobile/components/SelectDateTimeDialog.js.map packages/app-mobile/components/SideMenu.d.ts packages/app-mobile/components/SideMenu.js packages/app-mobile/components/SideMenu.js.map +packages/app-mobile/components/TextInput.d.ts +packages/app-mobile/components/TextInput.js +packages/app-mobile/components/TextInput.js.map packages/app-mobile/components/biometrics/BiometricPopup.d.ts packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/BiometricPopup.js.map @@ -1026,6 +1038,9 @@ packages/app-mobile/services/AlarmServiceDriver.ios.js.map packages/app-mobile/services/e2ee/RSA.react-native.d.ts packages/app-mobile/services/e2ee/RSA.react-native.js packages/app-mobile/services/e2ee/RSA.react-native.js.map +packages/app-mobile/services/profiles/index.d.ts +packages/app-mobile/services/profiles/index.js +packages/app-mobile/services/profiles/index.js.map packages/app-mobile/setupQuickActions.d.ts packages/app-mobile/setupQuickActions.js packages/app-mobile/setupQuickActions.js.map @@ -1044,6 +1059,9 @@ packages/app-mobile/utils/TlsUtils.js.map packages/app-mobile/utils/checkPermissions.d.ts packages/app-mobile/utils/checkPermissions.js packages/app-mobile/utils/checkPermissions.js.map +packages/app-mobile/utils/createRootStyle.d.ts +packages/app-mobile/utils/createRootStyle.js +packages/app-mobile/utils/createRootStyle.js.map packages/app-mobile/utils/debounce.d.ts packages/app-mobile/utils/debounce.js packages/app-mobile/utils/debounce.js.map @@ -2371,6 +2389,4 @@ packages/tools/website/utils/types.d.ts packages/tools/website/utils/types.js packages/tools/website/utils/types.js.map # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD -packages/app-mobile/components/get-responsive-value.test.js -packages/app-mobile/components/get-responsive-value.test.js -packages/app-mobile/components/get-responsive-value.test.js + diff --git a/packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx b/packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx new file mode 100644 index 0000000000..6adc08b8ac --- /dev/null +++ b/packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx @@ -0,0 +1,102 @@ +const React = require('react'); +import { useCallback, useEffect, useMemo, useState } from 'react'; +const { View, StyleSheet } = require('react-native'); +import createRootStyle from '../../utils/createRootStyle'; +import ScreenHeader from '../ScreenHeader'; +import { _ } from '@joplin/lib/locale'; +import { loadProfileConfig, saveProfileConfig } from '../../services/profiles'; +import { createNewProfile } from '@joplin/lib/services/profileConfig'; +import useProfileConfig from './useProfileConfig'; +const { TextInput } = require('react-native-paper'); + +interface NavigationState { + profileId: string; +} + +interface Navigation { + state: NavigationState; +} + +interface Props { + themeId: number; + dispatch: Function; + navigation: Navigation; +} + +const useStyle = (themeId: number) => { + return useMemo(() => { + return StyleSheet.create({ + ...createRootStyle(themeId), + }); + }, [themeId]); +}; + +export default (props: Props) => { + const profileId = props.navigation.state?.profileId; + const isNew = !profileId; + const profileConfig = useProfileConfig(); + + const style = useStyle(props.themeId); + const [name, setName] = useState(''); + + const profile = !isNew && profileConfig ? profileConfig.profiles.find(p => p.id === profileId) : null; + + useEffect(() => { + if (!profile) return; + setName(profile.name); + }, [profile]); + + const onSaveButtonPress = useCallback(async () => { + if (isNew) { + const profileConfig = await loadProfileConfig(); + const result = createNewProfile(profileConfig, name); + await saveProfileConfig(result.newConfig); + } else { + const newProfiles = profileConfig.profiles.map(p => { + if (p.id === profile.id) { + return { + ...profile, + name, + }; + } + return p; + }); + + const newProfileConfig = { + ...profileConfig, + profiles: newProfiles, + }; + + await saveProfileConfig(newProfileConfig); + } + + props.dispatch({ + type: 'NAV_BACK', + }); + }, [name, isNew, profileConfig, profile, props.dispatch]); + + const isModified = useMemo(() => { + if (isNew) return true; + if (!profile) return false; + return profile.name !== name; + }, [isNew, profile, name]); + + return ( + + + + setName(text)} + /> + + + ); +}; diff --git a/packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.tsx b/packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.tsx new file mode 100644 index 0000000000..b44f326176 --- /dev/null +++ b/packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.tsx @@ -0,0 +1,176 @@ +const React = require('react'); +import { useCallback, useMemo, useState } from 'react'; +const { View, FlatList, StyleSheet } = require('react-native'); +import createRootStyle from '../../utils/createRootStyle'; +import ScreenHeader from '../ScreenHeader'; +const { FAB, List } = require('react-native-paper'); +import { Profile } from '@joplin/lib/services/profileConfig/types'; +import useProfileConfig from './useProfileConfig'; +import { Alert } from 'react-native'; +import { _ } from '@joplin/lib/locale'; +import { deleteProfileById } from '@joplin/lib/services/profileConfig'; +import { saveProfileConfig, switchProfile } from '../../services/profiles'; +const { themeStyle } = require('../global-style'); + +interface Props { + themeId: number; + dispatch: Function; +} + +const useStyle = (themeId: number) => { + return useMemo(() => { + const theme = themeStyle(themeId); + + return StyleSheet.create({ + ...createRootStyle(themeId), + fab: { + position: 'absolute', + margin: 16, + right: 0, + bottom: 0, + }, + profileListItem: { + paddingLeft: theme.margin, + paddingRight: theme.margin, + }, + }); + }, [themeId]); +}; + +export default (props: Props) => { + const style = useStyle(props.themeId); + const [profileConfigTime, setProfileConfigTime] = useState(Date.now()); + + const profileConfig = useProfileConfig(profileConfigTime); + + const profiles = useMemo(() => { + return profileConfig ? profileConfig.profiles : []; + }, [profileConfig]); + + const onProfileItemPress = useCallback(async (profile: Profile) => { + const doIt = async () => { + try { + await switchProfile(profile.id); + } catch (error) { + Alert.alert(_('Could not switch profile: %s', error.message)); + } + }; + + Alert.alert( + _('Confirmation'), + _('To switch the profile, the app is going to close and you will need to restart it.'), + [ + { + text: _('Continue'), + onPress: () => doIt(), + style: 'default', + }, + { + text: _('Cancel'), + onPress: () => {}, + style: 'cancel', + }, + ] + ); + }, []); + + const onEditProfile = useCallback(async (profileId: string) => { + props.dispatch({ + type: 'NAV_GO', + routeName: 'ProfileEditor', + profileId: profileId, + }); + }, [props.dispatch]); + + const onDeleteProfile = useCallback(async (profile: Profile) => { + const doIt = async () => { + try { + const newConfig = deleteProfileById(profileConfig, profile.id); + await saveProfileConfig(newConfig); + setProfileConfigTime(Date.now()); + } catch (error) { + Alert.alert(error.message); + } + }; + + Alert.alert( + _('Delete this profile?'), + _('All data, including notes, notebooks and tags will be permanently deleted.'), + [ + { + text: _('Delete profile "%s"', profile.name), + onPress: () => doIt(), + style: 'destructive', + }, + { + text: _('Cancel'), + onPress: () => {}, + style: 'cancel', + }, + ] + ); + }, [profileConfig]); + + const renderProfileItem = (event: any) => { + const profile = event.item as Profile; + const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' }; + return ( + } + key={profile.id} + profileId={profile.id} + onPress={() => { void onProfileItemPress(profile); }} + onLongPress={() => { + Alert.alert( + _('Configuration'), + '', + [ + { + text: _('Edit'), + onPress: () => onEditProfile(profile.id), + style: 'default', + }, + { + text: _('Delete'), + onPress: () => onDeleteProfile(profile), + style: 'default', + }, + { + text: _('Close'), + onPress: () => {}, + style: 'cancel', + }, + ] + ); + }} + /> + ); + }; + + return ( + + + + profile.id} + /> + + { + props.dispatch({ + type: 'NAV_GO', + routeName: 'ProfileEditor', + }); + }} + /> + + + ); +}; diff --git a/packages/app-mobile/components/ProfileSwitcher/useProfileConfig.ts b/packages/app-mobile/components/ProfileSwitcher/useProfileConfig.ts new file mode 100644 index 0000000000..01e49850c7 --- /dev/null +++ b/packages/app-mobile/components/ProfileSwitcher/useProfileConfig.ts @@ -0,0 +1,20 @@ +import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; +import { ProfileConfig } from '@joplin/lib/services/profileConfig/types'; +import { useState } from 'react'; +import { loadProfileConfig } from '../../services/profiles'; + +export default (timestamp: number = 0) => { + const [profileConfig, setProfileConfig] = useState(null); + + useAsyncEffect(async (event: AsyncEffectEvent) => { + const load = async () => { + const r = await loadProfileConfig(); + if (event.cancelled) return; + setProfileConfig(r); + }; + + void load(); + }, [timestamp]); + + return profileConfig; +}; diff --git a/packages/app-mobile/components/TextInput.tsx b/packages/app-mobile/components/TextInput.tsx new file mode 100644 index 0000000000..fac6deeb1c --- /dev/null +++ b/packages/app-mobile/components/TextInput.tsx @@ -0,0 +1,37 @@ +const React = require('react'); +import { useMemo } from 'react'; +import { themeStyle } from '@joplin/lib/theme'; +import { TextInput, TextInputProps, StyleSheet } from 'react-native'; + +interface Props extends TextInputProps { + themeId: number; +} + +export default (props: Props) => { + const theme = themeStyle(props.themeId); + const finalProps = { ...props }; + + if (!('placeholderTextColor' in finalProps)) finalProps.placeholderTextColor = theme.colorFaded; + if (!('underlineColorAndroid' in finalProps)) finalProps.underlineColorAndroid = theme.dividerColor; + if (!('selectionColor' in finalProps)) finalProps.selectionColor = theme.textSelectionColor; + if (!('keyboardAppearance' in finalProps)) finalProps.keyboardAppearance = theme.keyboardAppearance; + if (!('style' in finalProps)) finalProps.style = {}; + + const defaultStyle = useMemo(() => { + const theme = themeStyle(finalProps.themeId); + + return StyleSheet.create({ + textInput: { + color: theme.color, + paddingLeft: 14, + paddingRight: 14, + paddingTop: 12, + paddingBottom: 12, + }, + }); + }, [finalProps.themeId]); + + finalProps.style = [defaultStyle.textInput, finalProps.style]; + + return ; +}; diff --git a/packages/app-mobile/components/app-nav.js b/packages/app-mobile/components/app-nav.js index 9bbd89887b..440cf36fe5 100644 --- a/packages/app-mobile/components/app-nav.js +++ b/packages/app-mobile/components/app-nav.js @@ -71,7 +71,7 @@ class AppNavComponent extends Component { {searchScreenLoaded && } - {!notesScreenVisible && !searchScreenVisible && } + {!notesScreenVisible && !searchScreenVisible && } ); diff --git a/packages/app-mobile/components/global-style.js b/packages/app-mobile/components/global-style.js index a6f42d1597..81e2b9b2c5 100644 --- a/packages/app-mobile/components/global-style.js +++ b/packages/app-mobile/components/global-style.js @@ -69,6 +69,9 @@ function addExtraStyles(style) { style.keyboardAppearance = style.appearance; + style.color5 = style.backgroundColor4; + style.backgroundColor5 = style.color4; + return style; } diff --git a/packages/app-mobile/components/screens/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen.tsx index 8e934cb9c9..b3c4c9ddaa 100644 --- a/packages/app-mobile/components/screens/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen.tsx @@ -100,6 +100,13 @@ class ConfigScreenComponent extends BaseScreenComponent { void NavService.go('Status'); }; + this.manageProfilesButtonPress_ = () => { + this.props.dispatch({ + type: 'NAV_GO', + routeName: 'ProfileSwitcher', + }); + }; + this.exportDebugButtonPress_ = async () => { this.setState({ creatingReport: true }); const service = new ReportService(); @@ -564,6 +571,7 @@ class ConfigScreenComponent extends BaseScreenComponent { settingComps.push(this.renderHeader('tools', _('Tools'))); + settingComps.push(this.renderButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_)); settingComps.push(this.renderButton('status_button', _('Sync Status'), this.syncStatusButtonPress_)); settingComps.push(this.renderButton('log_button', _('Log'), this.logButtonPress_)); if (Platform.OS === 'android') { diff --git a/packages/app-mobile/components/screens/folder.js b/packages/app-mobile/components/screens/folder.js index 5e6315f32d..5dc8a2b905 100644 --- a/packages/app-mobile/components/screens/folder.js +++ b/packages/app-mobile/components/screens/folder.js @@ -1,14 +1,14 @@ const React = require('react'); -const { View, TextInput, StyleSheet } = require('react-native'); +const { View } = require('react-native'); const { connect } = require('react-redux'); const Folder = require('@joplin/lib/models/Folder').default; const BaseModel = require('@joplin/lib/BaseModel').default; const { ScreenHeader } = require('../ScreenHeader'); const { BaseScreenComponent } = require('../base-screen.js'); const { dialogs } = require('../../utils/dialogs.js'); -const { themeStyle } = require('../global-style.js'); const { _ } = require('@joplin/lib/locale'); +const TextInput = require('../TextInput').default; class FolderScreenComponent extends BaseScreenComponent { static navigationOptions() { @@ -21,25 +21,6 @@ class FolderScreenComponent extends BaseScreenComponent { folder: Folder.new(), lastSavedFolder: null, }; - this.styles_ = {}; - } - - styles() { - const theme = themeStyle(this.props.themeId); - - if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId]; - this.styles_ = {}; - - const styles = { - textInput: { - color: theme.color, - paddingLeft: theme.marginLeft, - marginTop: theme.marginTop, - }, - }; - - this.styles_[this.props.themeId] = StyleSheet.create(styles); - return this.styles_[this.props.themeId]; } UNSAFE_componentWillMount() { @@ -103,12 +84,17 @@ class FolderScreenComponent extends BaseScreenComponent { render() { const saveButtonDisabled = !this.isModified(); - const theme = themeStyle(this.props.themeId); return ( this.saveFolderButton_press()} showSideMenuButton={false} showSearchButton={false} /> - this.title_changeText(text)} /> + this.title_changeText(text)} + /> { this.dialogbox = dialogbox; diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 821bd85676..ba02636b89 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -13,6 +13,7 @@ import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types'; import { AppState } from '../utils/types'; import Setting from '@joplin/lib/models/Setting'; import { reg } from '@joplin/lib/registry'; +import { ProfileConfig } from '@joplin/lib/services/profileConfig/types'; // We need this to suppress the useless warning // https://github.com/oblador/react-native-vector-icons/issues/1465 @@ -31,6 +32,7 @@ interface Props { notesParentType: string; folders: FolderEntity[]; opacity: number; + profileConfig: ProfileConfig; } const syncIconRotationValue = new Animated.Value(0); @@ -200,6 +202,15 @@ const SideMenuContentComponent = (props: Props) => { }); }; + const switchProfileButton_press = () => { + props.dispatch({ type: 'SIDE_MENU_CLOSE' }); + + props.dispatch({ + type: 'NAV_GO', + routeName: 'ProfileSwitcher', + }); + }; + const configButton_press = () => { props.dispatch({ type: 'SIDE_MENU_CLOSE' }); void NavService.go('Config'); @@ -403,6 +414,10 @@ const SideMenuContentComponent = (props: Props) => { items.push(renderSidebarButton('tag_button', _('Tags'), 'md-pricetag', tagButton_press)); + if (props.profileConfig && props.profileConfig.profiles.length > 1) { + items.push(renderSidebarButton('switchProfile_button', _('Switch profile'), 'md-people-circle-outline', switchProfileButton_press)); + } + items.push(renderSidebarButton('config_button', _('Configuration'), 'md-settings', configButton_press)); items.push(makeDivider('divider_2')); @@ -502,5 +517,6 @@ export default connect((state: AppState) => { resourceFetcher: state.resourceFetcher, isOnMobileData: state.isOnMobileData, syncOnlyOverWifi: state.settings['sync.mobileWifiOnly'], + profileConfig: state.profileConfig, }; })(SideMenuContentComponent); diff --git a/packages/app-mobile/ios/Podfile.lock b/packages/app-mobile/ios/Podfile.lock index d642a5988a..cb0ff37934 100644 --- a/packages/app-mobile/ios/Podfile.lock +++ b/packages/app-mobile/ios/Podfile.lock @@ -422,6 +422,8 @@ PODS: - React-Core - RNDateTimePicker (6.7.1): - React-Core + - RNExitApp (1.1.0): + - React - RNFileViewer (2.1.5): - React-Core - RNFS (2.20.0): @@ -520,6 +522,7 @@ DEPENDENCIES: - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" - "RNCPushNotificationIOS (from `../node_modules/@react-native-community/push-notification-ios`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" + - RNExitApp (from `../node_modules/react-native-exit-app`) - RNFileViewer (from `../node_modules/react-native-file-viewer`) - RNFS (from `../node_modules/react-native-fs`) - RNQuickAction (from `../node_modules/react-native-quick-actions`) @@ -657,6 +660,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/push-notification-ios" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" + RNExitApp: + :path: "../node_modules/react-native-exit-app" RNFileViewer: :path: "../node_modules/react-native-file-viewer" RNFS: @@ -741,6 +746,7 @@ SPEC CHECKSUMS: RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 RNCPushNotificationIOS: 87b8d16d3ede4532745e05b03c42cff33a36cc45 RNDateTimePicker: 0530a73a6f3a1a85814cbde0802736993b9e675e + RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3 RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93 diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index 016b634716..fd1c74dee4 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -45,6 +45,7 @@ "react-native-dialogbox": "0.6.10", "react-native-document-picker": "8.1.3", "react-native-dropdownalert": "4.5.1", + "react-native-exit-app": "1.1.0", "react-native-file-viewer": "2.1.5", "react-native-fingerprint-scanner": "6.0.0", "react-native-fs": "2.20.0", diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index fdc5423966..91607c476d 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -15,7 +15,6 @@ import KvStore from '@joplin/lib/services/KvStore'; import NoteScreen from './components/screens/Note'; import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen'; import Setting, { Env } from '@joplin/lib/models/Setting'; -import RNFetchBlob from 'rn-fetch-blob'; import PoorManIntervals from '@joplin/lib/PoorManIntervals'; import reducer from '@joplin/lib/reducer'; import ShareExtension from './utils/ShareExtension'; @@ -27,6 +26,7 @@ import { setLocale, closestSupportedLocale, defaultLocale } from '@joplin/lib/lo import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer'; import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud'; import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive'; +import initProfile from '@joplin/lib/services/profileConfig/initProfile'; const VersionInfo = require('react-native-version-info').default; const { Keyboard, NativeModules, BackHandler, Animated, View, StatusBar, Linking, Platform, Dimensions } = require('react-native'); const RNAppState = require('react-native').AppState; @@ -36,7 +36,7 @@ const DropdownAlert = require('react-native-dropdownalert').default; const AlarmServiceDriver = require('./services/AlarmServiceDriver').default; const SafeAreaView = require('./components/SafeAreaView'); const { connect, Provider } = require('react-redux'); -import { Provider as PaperProvider, MD2DarkTheme as PaperDarkTheme, MD2LightTheme as PaperLightTheme } from 'react-native-paper'; +import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper'; const { BackButtonService } = require('./services/back-button.js'); import NavService from '@joplin/lib/services/NavService'; import { createStore, applyMiddleware } from 'redux'; @@ -111,7 +111,11 @@ import RSA from './services/e2ee/RSA.react-native'; import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils'; import { Theme, ThemeAppearance } from '@joplin/lib/themes/type'; import { AppState } from './utils/types'; +import ProfileSwitcher from './components/ProfileSwitcher/ProfileSwitcher'; +import ProfileEditor from './components/ProfileSwitcher/ProfileEditor'; import sensorInfo from './components/biometrics/sensorInfo'; +import { getCurrentProfile } from '@joplin/lib/services/profileConfig'; +import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles'; let storeDispatch = function(_action: any) {}; @@ -409,11 +413,23 @@ function decryptionWorker_resourceMetadataButNotBlobDecrypted() { async function initialize(dispatch: Function) { shimInit(); + setDispatch(dispatch); + const { profileConfig, isSubProfile } = await initProfile(getProfilesRootDir()); + const currentProfile = getCurrentProfile(profileConfig); + + dispatch({ + type: 'PROFILE_CONFIG_SET', + value: profileConfig, + }); + // @ts-ignore Setting.setConstant('env', __DEV__ ? 'dev' : 'prod'); Setting.setConstant('appId', 'net.cozic.joplin-mobile'); Setting.setConstant('appType', 'mobile'); - Setting.setConstant('resourceDir', RNFetchBlob.fs.dirs.DocumentDir); + const resourceDir = getResourceDir(currentProfile, isSubProfile); + Setting.setConstant('resourceDir', resourceDir); + + await shim.fsDriver().mkdir(resourceDir); const logDatabase = new Database(new DatabaseDriverReactNative()); await logDatabase.open({ name: 'log.sqlite' }); @@ -481,9 +497,9 @@ async function initialize(dispatch: Function) { try { if (Setting.value('env') === 'prod') { - await db.open({ name: 'joplin.sqlite' }); + await db.open({ name: getDatabaseName(currentProfile, isSubProfile) }); } else { - await db.open({ name: 'joplin-101.sqlite' }); + await db.open({ name: getDatabaseName(currentProfile, isSubProfile) }); // await db.clearForTesting(); } @@ -771,6 +787,13 @@ class AppComponent extends React.Component { type: 'APP_STATE_SET', state: 'ready', }); + + // setTimeout(() => { + // this.props.dispatch({ + // type: 'NAV_GO', + // routeName: 'ProfileSwitcher', + // }); + // }, 1000); } Linking.addEventListener('url', this.handleOpenURL_); @@ -904,6 +927,8 @@ class AppComponent extends React.Component { DropboxLogin: { screen: DropboxLoginScreen }, EncryptionConfig: { screen: EncryptionConfigScreen }, UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen }, + ProfileSwitcher: { screen: ProfileSwitcher }, + ProfileEditor: { screen: ProfileEditor }, Log: { screen: LogScreen }, Status: { screen: StatusScreen }, Search: { screen: SearchScreen }, @@ -934,7 +959,7 @@ class AppComponent extends React.Component { - + this.dropdownAlert_ = ref} tapToCloseEnabled={true} /> @@ -949,17 +974,20 @@ class AppComponent extends React.Component { ); - const paperTheme = theme.appearance === ThemeAppearance.Dark ? PaperDarkTheme : PaperLightTheme; + const paperTheme = theme.appearance === ThemeAppearance.Dark ? MD3DarkTheme : MD3LightTheme; // Wrap everything in a PaperProvider -- this allows using components from react-native-paper return ( {mainContent} diff --git a/packages/app-mobile/services/profiles/index.ts b/packages/app-mobile/services/profiles/index.ts new file mode 100644 index 0000000000..8636fe97e6 --- /dev/null +++ b/packages/app-mobile/services/profiles/index.ts @@ -0,0 +1,51 @@ +// Helper functions to reduce the boiler plate of loading and saving profiles on +// mobile + +const RNExitApp = require('react-native-exit-app').default; +import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types'; +import { loadProfileConfig as libLoadProfileConfig, saveProfileConfig as libSaveProfileConfig } from '@joplin/lib/services/profileConfig/index'; +import RNFetchBlob from 'rn-fetch-blob'; + +let dispatch_: Function = null; +export const setDispatch = (dispatch: Function) => { + dispatch_ = dispatch; +}; + +export const getProfilesRootDir = () => { + return RNFetchBlob.fs.dirs.DocumentDir; +}; + +export const getProfilesConfigPath = () => { + return `${getProfilesRootDir()}/profiles.json`; +}; + +export const getResourceDir = (profile: Profile, isSubProfile: boolean) => { + if (!isSubProfile) return getProfilesRootDir(); + return `${getProfilesRootDir()}/resources-${profile.id}`; +}; + +export const getDatabaseName = (profile: Profile, isSubProfile: boolean) => { + if (!isSubProfile) return 'joplin.sqlite'; + return `joplin-${profile.id}.sqlite`; +}; + +export const loadProfileConfig = async () => { + return libLoadProfileConfig(getProfilesConfigPath()); +}; + +export const saveProfileConfig = async (profileConfig: ProfileConfig) => { + await libSaveProfileConfig(getProfilesConfigPath(), profileConfig); + dispatch_({ + type: 'PROFILE_CONFIG_SET', + value: profileConfig, + }); +}; + +export const switchProfile = async (profileId: string) => { + const config = await loadProfileConfig(); + if (config.currentProfileId === profileId) throw new Error('This profile is already active'); + + config.currentProfileId = profileId; + await saveProfileConfig(config); + RNExitApp.exitApp(); +}; diff --git a/packages/app-mobile/utils/createRootStyle.ts b/packages/app-mobile/utils/createRootStyle.ts new file mode 100644 index 0000000000..61122d7c3d --- /dev/null +++ b/packages/app-mobile/utils/createRootStyle.ts @@ -0,0 +1,11 @@ +const { themeStyle } = require('../components/global-style'); + +export default (themeId: number) => { + const theme = themeStyle(themeId); + return { + root: { + flex: 1, + backgroundColor: theme.backgroundColor, + }, + }; +}; diff --git a/packages/lib/services/profileConfig/index.ts b/packages/lib/services/profileConfig/index.ts index 9ad9a5b9db..5e9fc04a26 100644 --- a/packages/lib/services/profileConfig/index.ts +++ b/packages/lib/services/profileConfig/index.ts @@ -2,6 +2,7 @@ import { rtrimSlashes } from '../../path-utils'; import shim from '../../shim'; import { CurrentProfileVersion, defaultProfile, defaultProfileConfig, DefaultProfileId, Profile, ProfileConfig } from './types'; import { customAlphabet } from 'nanoid/non-secure'; +import { _ } from '../../locale'; export const migrateProfileConfig = (profileConfig: any, toVersion: number): ProfileConfig => { let version = 2; @@ -99,6 +100,17 @@ export const createNewProfile = (config: ProfileConfig, profileName: string) => }; }; +export const deleteProfileById = (config: ProfileConfig, profileId: string): ProfileConfig => { + if (profileId === DefaultProfileId) throw new Error(_('The default profile cannot be deleted')); + if (profileId === config.currentProfileId) throw new Error(_('The active profile cannot be deleted. Switch to a different profile and try again.')); + + const newProfiles = config.profiles.filter(p => p.id !== profileId); + return { + ...config, + profiles: newProfiles, + }; +}; + export const profileIdByIndex = (config: ProfileConfig, index: number): string => { return config.profiles[index].id; }; diff --git a/packages/lib/themes/type.ts b/packages/lib/themes/type.ts index 79e7817229..5f7c5643d7 100644 --- a/packages/lib/themes/type.ts +++ b/packages/lib/themes/type.ts @@ -41,6 +41,9 @@ export interface Theme { backgroundColor4: string; color4: string; + backgroundColor5?: string; + color5?: string; + raisedBackgroundColor: string; raisedColor: string; searchMarkerBackgroundColor: string; diff --git a/renovate.json5 b/renovate.json5 index f3d3c270af..d9a33107c5 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -112,7 +112,7 @@ "matchUpdateTypes": ["minor", "patch"], "automerge": true, "labels": ["automerge"], - "schedule": "on the first day of the week", + "extends": ["schedule:monthly"], "matchPackageNames": [ // AWS packages are updated too frequently and we can assume minor // updates are stable. diff --git a/yarn.lock b/yarn.lock index bca5d5b9ed..25ada09f49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4829,6 +4829,7 @@ __metadata: react-native-dialogbox: 0.6.10 react-native-document-picker: 8.1.3 react-native-dropdownalert: 4.5.1 + react-native-exit-app: 1.1.0 react-native-file-viewer: 2.1.5 react-native-fingerprint-scanner: 6.0.0 react-native-fs: 2.20.0 @@ -27685,6 +27686,13 @@ __metadata: languageName: node linkType: hard +"react-native-exit-app@npm:1.1.0": + version: 1.1.0 + resolution: "react-native-exit-app@npm:1.1.0" + checksum: 383e28e03759ebf21ae54cb914e06462fb3ef43ed78895e83395386d615c9a56faacba599edf17677e44a4ebf641102b295545964b787cc09b01b16c9384e8d9 + languageName: node + linkType: hard + "react-native-file-viewer@npm:2.1.5": version: 2.1.5 resolution: "react-native-file-viewer@npm:2.1.5"