You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Add support for multiple profiles
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										22
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										102
									
								
								packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
| 		<View style={style.root}> | ||||
| 			<ScreenHeader | ||||
| 				title={isNew ? _('Create new profile...') : _('Edit profile')} | ||||
| 				onSaveButtonPress={onSaveButtonPress} | ||||
| 				saveButtonDisabled={!isModified} | ||||
| 				showSaveButton={true} | ||||
| 				showSideMenuButton={false} | ||||
| 				showSearchButton={false} | ||||
| 			/> | ||||
| 			<View style={{}}> | ||||
| 				<TextInput label={_('Profile name')} | ||||
| 					value={name} | ||||
| 					onChangeText={(text: string) => setName(text)} | ||||
| 				/> | ||||
| 			</View> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
| @@ -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 ( | ||||
| 			<List.Item | ||||
| 				title={profile.name} | ||||
| 				style={style.profileListItem} | ||||
| 				titleStyle={titleStyle} | ||||
| 				left={() => <List.Icon icon="file-account-outline" />} | ||||
| 				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 ( | ||||
| 		<View style={style.root}> | ||||
| 			<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} /> | ||||
| 			<View> | ||||
| 				<FlatList | ||||
| 					data={profiles} | ||||
| 					renderItem={renderProfileItem} | ||||
| 					keyExtractor={(profile: Profile) => profile.id} | ||||
| 				/> | ||||
| 			</View> | ||||
| 			<FAB | ||||
| 				icon="plus" | ||||
| 				style={style.fab} | ||||
| 				onPress={() => { | ||||
| 					props.dispatch({ | ||||
| 						type: 'NAV_GO', | ||||
| 						routeName: 'ProfileEditor', | ||||
| 					}); | ||||
| 				}} | ||||
| 			/> | ||||
|  | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
| @@ -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<ProfileConfig>(null); | ||||
|  | ||||
| 	useAsyncEffect(async (event: AsyncEffectEvent) => { | ||||
| 		const load = async () => { | ||||
| 			const r = await loadProfileConfig(); | ||||
| 			if (event.cancelled) return; | ||||
| 			setProfileConfig(r); | ||||
| 		}; | ||||
|  | ||||
| 		void load(); | ||||
| 	}, [timestamp]); | ||||
|  | ||||
| 	return profileConfig; | ||||
| }; | ||||
							
								
								
									
										37
									
								
								packages/app-mobile/components/TextInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								packages/app-mobile/components/TextInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 <TextInput {...finalProps} />; | ||||
| }; | ||||
| @@ -71,7 +71,7 @@ class AppNavComponent extends Component { | ||||
| 			<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : null} style={style}> | ||||
| 				<NotesScreen visible={notesScreenVisible} navigation={{ state: route }} /> | ||||
| 				{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} navigation={{ state: route }} />} | ||||
| 				{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} />} | ||||
| 				{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={this.props.themeId} dispatch={this.props.dispatch} />} | ||||
| 				<View style={{ height: this.state.autoCompletionBarExtraHeight }} /> | ||||
| 			</KeyboardAvoidingView> | ||||
| 		); | ||||
|   | ||||
| @@ -69,6 +69,9 @@ function addExtraStyles(style) { | ||||
|  | ||||
| 	style.keyboardAppearance = style.appearance; | ||||
|  | ||||
| 	style.color5 = style.backgroundColor4; | ||||
| 	style.backgroundColor5 = style.color4; | ||||
|  | ||||
| 	return style; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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') { | ||||
|   | ||||
| @@ -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 ( | ||||
| 			<View style={this.rootStyle(this.props.themeId).root}> | ||||
| 				<ScreenHeader title={_('Edit notebook')} showSaveButton={true} saveButtonDisabled={saveButtonDisabled} onSaveButtonPress={() => this.saveFolderButton_press()} showSideMenuButton={false} showSearchButton={false} /> | ||||
| 				<TextInput placeholder={_('Enter notebook title')} placeholderTextColor={theme.colorFaded} underlineColorAndroid={theme.dividerColor} selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} style={this.styles().textInput} autoFocus={true} value={this.state.folder.title} onChangeText={text => this.title_changeText(text)} /> | ||||
| 				<TextInput | ||||
| 					themeId={this.props.themeId} | ||||
| 					placeholder={_('Enter notebook title')} | ||||
| 					autoFocus={true} | ||||
| 					value={this.state.folder.title} | ||||
| 					onChangeText={text => this.title_changeText(text)} | ||||
| 				/> | ||||
| 				<dialogs.DialogBox | ||||
| 					ref={dialogbox => { | ||||
| 						this.dialogbox = dialogbox; | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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 { | ||||
| 						<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/> | ||||
| 						<SafeAreaView style={{ flex: 1 }}> | ||||
| 							<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}> | ||||
| 								<AppNav screens={appNavInit} /> | ||||
| 								<AppNav screens={appNavInit} dispatch={this.props.dispatch} /> | ||||
| 							</View> | ||||
| 							<DropdownAlert ref={(ref: any) => this.dropdownAlert_ = ref} tapToCloseEnabled={true} /> | ||||
| 							<Animated.View pointerEvents='none' style={{ position: 'absolute', backgroundColor: 'black', opacity: this.state.sideMenuContentOpacity, width: '100%', height: '120%' }}/> | ||||
| @@ -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 ( | ||||
| 			<PaperProvider theme={{ | ||||
| 				...paperTheme, | ||||
| 				version: 2, | ||||
| 				version: 3, | ||||
| 				colors: { | ||||
| 					...paperTheme.colors, | ||||
| 					primary: theme.backgroundColor, | ||||
| 					accent: theme.backgroundColor2, | ||||
| 					onPrimaryContainer: theme.color5, | ||||
| 					primaryContainer: theme.backgroundColor5, | ||||
| 					surfaceVariant: theme.backgroundColor, | ||||
| 					onSurfaceVariant: theme.color, | ||||
| 					primary: theme.color, | ||||
| 				}, | ||||
| 			}}> | ||||
| 				{mainContent} | ||||
|   | ||||
							
								
								
									
										51
									
								
								packages/app-mobile/services/profiles/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/app-mobile/services/profiles/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
| }; | ||||
							
								
								
									
										11
									
								
								packages/app-mobile/utils/createRootStyle.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/app-mobile/utils/createRootStyle.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
| @@ -41,6 +41,9 @@ export interface Theme { | ||||
| 	backgroundColor4: string; | ||||
| 	color4: string; | ||||
|  | ||||
| 	backgroundColor5?: string; | ||||
| 	color5?: string; | ||||
|  | ||||
| 	raisedBackgroundColor: string; | ||||
| 	raisedColor: string; | ||||
| 	searchMarkerBackgroundColor: string; | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user