mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
All: Got E2EE working in mobile app
This commit is contained in:
parent
d180e7b5e1
commit
6ff19063ef
@ -8,22 +8,13 @@ const { themeStyle } = require('../theme.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const dialogs = require('./dialogs');
|
||||
const shared = require('lib/components/shared/encryption-config-shared.js');
|
||||
|
||||
class EncryptionConfigScreenComponent extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
masterKeys: [],
|
||||
passwords: {},
|
||||
passwordChecks: {},
|
||||
stats: {
|
||||
encrypted: null,
|
||||
total: null,
|
||||
},
|
||||
};
|
||||
this.isMounted_ = false;
|
||||
this.refreshStatsIID_ = null;
|
||||
shared.constructor(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -35,28 +26,11 @@ class EncryptionConfigScreenComponent extends React.Component {
|
||||
}
|
||||
|
||||
initState(props) {
|
||||
this.setState({
|
||||
masterKeys: props.masterKeys,
|
||||
passwords: props.passwords ? props.passwords : {},
|
||||
}, () => {
|
||||
this.checkPasswords();
|
||||
});
|
||||
|
||||
this.refreshStats();
|
||||
|
||||
if (this.refreshStatsIID_) {
|
||||
clearInterval(this.refreshStatsIID_);
|
||||
this.refreshStatsIID_ = null;
|
||||
return shared.initState(this, props);
|
||||
}
|
||||
|
||||
this.refreshStatsIID_ = setInterval(() => {
|
||||
if (!this.isMounted_) {
|
||||
clearInterval(this.refreshStatsIID_);
|
||||
this.refreshStatsIID_ = null;
|
||||
return;
|
||||
}
|
||||
this.refreshStats();
|
||||
}, 3000);
|
||||
async refreshStats() {
|
||||
return shared.refreshStats(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
@ -67,40 +41,19 @@ class EncryptionConfigScreenComponent extends React.Component {
|
||||
this.initState(nextProps);
|
||||
}
|
||||
|
||||
async refreshStats() {
|
||||
const stats = await BaseItem.encryptedItemsStats();
|
||||
this.setState({ stats: stats });
|
||||
}
|
||||
|
||||
async checkPasswords() {
|
||||
const passwordChecks = Object.assign({}, this.state.passwordChecks);
|
||||
for (let i = 0; i < this.state.masterKeys.length; i++) {
|
||||
const mk = this.state.masterKeys[i];
|
||||
const password = this.state.passwords[mk.id];
|
||||
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
|
||||
passwordChecks[mk.id] = ok;
|
||||
}
|
||||
this.setState({ passwordChecks: passwordChecks });
|
||||
return shared.checkPasswords(this);
|
||||
}
|
||||
|
||||
renderMasterKey(mk) {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const onSaveClick = () => {
|
||||
const password = this.state.passwords[mk.id];
|
||||
if (!password) {
|
||||
Setting.deleteObjectKey('encryption.passwordCache', mk.id);
|
||||
} else {
|
||||
Setting.setObjectKey('encryption.passwordCache', mk.id, password);
|
||||
}
|
||||
|
||||
this.checkPasswords();
|
||||
return shared.onSavePasswordClick(this, mk);
|
||||
}
|
||||
|
||||
const onPasswordChange = (event) => {
|
||||
const passwords = this.state.passwords;
|
||||
passwords[mk.id] = event.target.value;
|
||||
this.setState({ passwords: passwords });
|
||||
return shared.onPasswordChange(this, mk, event.target.value);
|
||||
}
|
||||
|
||||
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
|
||||
@ -166,8 +119,7 @@ class EncryptionConfigScreenComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const stats = this.state.stats;
|
||||
const decryptedItemsInfo = this.props.encryptionEnabled ? <p style={theme.textStyle}>{_('Decrypted items: %s / %s', stats.encrypted !== null ? (stats.total - stats.encrypted) : '-', stats.total !== null ? stats.total : '-')}</p> : null;
|
||||
const decryptedItemsInfo = this.props.encryptionEnabled ? <p style={theme.textStyle}>{shared.decryptedStatText(this)}</p> : null;
|
||||
const toggleButton = <button onClick={() => { onToggleButtonClick() }}>{this.props.encryptionEnabled ? _('Disable encryption') : _('Enable encryption')}</button>
|
||||
|
||||
let masterKeySection = null;
|
||||
|
@ -19,6 +19,8 @@ const globalStyle = {
|
||||
raisedColor: "#003363",
|
||||
raisedHighlightedColor: "#ffffff",
|
||||
|
||||
warningBackgroundColor: "#FFD08D",
|
||||
|
||||
// For WebView - must correspond to the properties above
|
||||
htmlFontSize: '16px',
|
||||
htmlColor: 'black', // Note: CSS in WebView component only supports named colors or rgb() notation
|
||||
|
@ -43,7 +43,7 @@ class ScreenHeaderComponent extends Component {
|
||||
|
||||
let styleObject = {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.raisedBackgroundColor,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000000',
|
||||
@ -123,11 +123,17 @@ class ScreenHeaderComponent extends Component {
|
||||
},
|
||||
titleText: {
|
||||
flex: 1,
|
||||
textAlignVertical: 'center',
|
||||
marginLeft: 0,
|
||||
color: theme.raisedHighlightedColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
}
|
||||
},
|
||||
warningBox: {
|
||||
backgroundColor: "#ff9900",
|
||||
flexDirection: 'row',
|
||||
padding: theme.marginLeft,
|
||||
},
|
||||
};
|
||||
|
||||
styleObject.topIcon = Object.assign({}, theme.icon);
|
||||
@ -198,6 +204,20 @@ class ScreenHeaderComponent extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
encryptionConfig_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'EncryptionConfig',
|
||||
});
|
||||
}
|
||||
|
||||
warningBox_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'EncryptionConfig',
|
||||
});
|
||||
}
|
||||
|
||||
async debugReport_press() {
|
||||
const service = new ReportService();
|
||||
|
||||
@ -324,6 +344,11 @@ class ScreenHeaderComponent extends Component {
|
||||
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.encryptionConfig_press()} key={'menuOption_encryptionConfig'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Encryption Configuration')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.config_press()} key={'menuOption_config'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Configuration')}</Text>
|
||||
@ -405,6 +430,12 @@ class ScreenHeaderComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const warningComp = this.props.showMissingMasterKeyMessage ? (
|
||||
<TouchableOpacity style={this.styles().warningBox} onPress={() => this.warningBox_press()} activeOpacity={0.8}>
|
||||
<Text style={{flex:1}}>{_('Press to set the decryption password.')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null;
|
||||
|
||||
const titleComp = createTitleComponent();
|
||||
const sideMenuComp = this.props.noteSelectionEnabled ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
|
||||
const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack);
|
||||
@ -427,6 +458,7 @@ class ScreenHeaderComponent extends Component {
|
||||
|
||||
return (
|
||||
<View style={this.styles().container} >
|
||||
<View style={{flexDirection:'row'}}>
|
||||
{ sideMenuComp }
|
||||
{ backButtonComp }
|
||||
{ saveButton(this.styles(), () => { if (this.props.onSaveButtonPress) this.props.onSaveButtonPress() }, this.props.saveButtonDisabled === true, this.props.showSaveButton === true) }
|
||||
@ -434,6 +466,8 @@ class ScreenHeaderComponent extends Component {
|
||||
{ searchButtonComp }
|
||||
{ deleteButtonComp }
|
||||
{ menuComp }
|
||||
</View>
|
||||
{ warningComp }
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
@ -455,6 +489,7 @@ const ScreenHeader = connect(
|
||||
showAdvancedOptions: state.settings.showAdvancedOptions,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,
|
||||
};
|
||||
}
|
||||
)(ScreenHeaderComponent)
|
||||
|
151
ReactNativeClient/lib/components/screens/encryption-config.js
Normal file
151
ReactNativeClient/lib/components/screens/encryption-config.js
Normal file
@ -0,0 +1,151 @@
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { TextInput, TouchableOpacity, Linking, View, Switch, Slider, StyleSheet, Text, Button, ScrollView } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { Dropdown } = require('lib/components/Dropdown.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const shared = require('lib/components/shared/encryption-config-shared.js');
|
||||
|
||||
class EncryptionConfigScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
shared.constructor(this);
|
||||
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isMounted_ = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
initState(props) {
|
||||
return shared.initState(this, props);
|
||||
}
|
||||
|
||||
async refreshStats() {
|
||||
return shared.refreshStats(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.initState(this.props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.initState(nextProps);
|
||||
}
|
||||
|
||||
async checkPasswords() {
|
||||
return shared.checkPasswords(this);
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
titleText: {
|
||||
flex: 1,
|
||||
fontWeight: 'bold',
|
||||
flexDirection: 'column',
|
||||
fontSize: theme.fontSize,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
normalText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: theme.margin,
|
||||
},
|
||||
}
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
renderMasterKey(num, mk) {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const onSaveClick = () => {
|
||||
return shared.onSavePasswordClick(this, mk);
|
||||
}
|
||||
|
||||
const onPasswordChange = (text) => {
|
||||
return shared.onPasswordChange(this, mk, text);
|
||||
}
|
||||
|
||||
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
|
||||
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
const active = this.props.activeMasterKeyId === mk.id ? '✔' : '';
|
||||
|
||||
return (
|
||||
<View key={mk.id}>
|
||||
<Text style={this.styles().titleText}>{_('Master Key %d', num)}</Text>
|
||||
<Text style={this.styles().normalText}>{_('Created: %s', time.formatMsToLocal(mk.created_time))}</Text>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center'}}>
|
||||
<Text style={{flex:0, fontSize: theme.fontSize, marginRight: 10}}>{_('Password:')}</Text>
|
||||
<TextInput value={password} onChangeText={(text) => onPasswordChange(text)} style={{flex:1, marginRight: 10}}></TextInput>
|
||||
<Text style={{fontSize: theme.fontSize, marginRight: 10}}>{passwordOk}</Text>
|
||||
<Button title={_('Save')} onPress={() => onSaveClick()}></Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const masterKeys = this.state.masterKeys;
|
||||
const decryptedItemsInfo = this.props.encryptionEnabled ? <Text style={this.styles().normalText}>{shared.decryptedStatText(this)}</Text> : null;
|
||||
|
||||
const mkComps = [];
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
mkComps.push(this.renderMasterKey(i+1, mk));
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Configuration')}/>
|
||||
<ScrollView style={this.styles().container}>
|
||||
<Text style={this.styles().titleText}>{_('Status')}</Text>
|
||||
<Text style={this.styles().normalText}>{_('Encryption is: %s', this.props.encryptionEnabled ? _('Enabled') : _('Disabled'))}</Text>
|
||||
{decryptedItemsInfo}
|
||||
{mkComps}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const EncryptionConfigScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
masterKeys: state.masterKeys,
|
||||
passwords: state.settings['encryption.passwordCache'],
|
||||
encryptionEnabled: state.settings['encryption.enabled'],
|
||||
activeMasterKeyId: state.settings['encryption.activeMasterKeyId'],
|
||||
};
|
||||
}
|
||||
)(EncryptionConfigScreenComponent)
|
||||
|
||||
module.exports = { EncryptionConfigScreen };
|
@ -0,0 +1,87 @@
|
||||
const EncryptionService = require('lib/services/EncryptionService');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
|
||||
const shared = {};
|
||||
|
||||
shared.constructor = function(comp) {
|
||||
comp.state = {
|
||||
masterKeys: [],
|
||||
passwords: {},
|
||||
passwordChecks: {},
|
||||
stats: {
|
||||
encrypted: null,
|
||||
total: null,
|
||||
},
|
||||
};
|
||||
comp.isMounted_ = false;
|
||||
|
||||
comp.refreshStatsIID_ = null;
|
||||
}
|
||||
|
||||
shared.initState = function(comp, props) {
|
||||
comp.setState({
|
||||
masterKeys: props.masterKeys,
|
||||
passwords: props.passwords ? props.passwords : {},
|
||||
}, () => {
|
||||
comp.checkPasswords();
|
||||
});
|
||||
|
||||
comp.refreshStats();
|
||||
|
||||
if (comp.refreshStatsIID_) {
|
||||
clearInterval(comp.refreshStatsIID_);
|
||||
comp.refreshStatsIID_ = null;
|
||||
}
|
||||
|
||||
comp.refreshStatsIID_ = setInterval(() => {
|
||||
if (!comp.isMounted_) {
|
||||
clearInterval(comp.refreshStatsIID_);
|
||||
comp.refreshStatsIID_ = null;
|
||||
return;
|
||||
}
|
||||
comp.refreshStats();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
shared.refreshStats = async function(comp) {
|
||||
const stats = await BaseItem.encryptedItemsStats();
|
||||
comp.setState({ stats: stats });
|
||||
}
|
||||
|
||||
shared.checkPasswords = async function(comp) {
|
||||
const passwordChecks = Object.assign({}, comp.state.passwordChecks);
|
||||
for (let i = 0; i < comp.state.masterKeys.length; i++) {
|
||||
const mk = comp.state.masterKeys[i];
|
||||
const password = comp.state.passwords[mk.id];
|
||||
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
|
||||
passwordChecks[mk.id] = ok;
|
||||
}
|
||||
console.info(passwordChecks);
|
||||
comp.setState({ passwordChecks: passwordChecks });
|
||||
}
|
||||
|
||||
shared.decryptedStatText = function(comp) {
|
||||
const stats = comp.state.stats;
|
||||
return _('Decrypted items: %s / %s', stats.encrypted !== null ? (stats.total - stats.encrypted) : '-', stats.total !== null ? stats.total : '-');
|
||||
}
|
||||
|
||||
shared.onSavePasswordClick = function(comp, mk) {
|
||||
const password = comp.state.passwords[mk.id];
|
||||
if (!password) {
|
||||
Setting.deleteObjectKey('encryption.passwordCache', mk.id);
|
||||
} else {
|
||||
Setting.setObjectKey('encryption.passwordCache', mk.id, password);
|
||||
}
|
||||
|
||||
comp.checkPasswords();
|
||||
}
|
||||
|
||||
shared.onPasswordChange = function(comp, mk, password) {
|
||||
const passwords = comp.state.passwords;
|
||||
passwords[mk.id] = password;
|
||||
comp.setState({ passwords: passwords });
|
||||
}
|
||||
|
||||
module.exports = shared;
|
@ -33,6 +33,7 @@ const { StatusScreen } = require('lib/components/screens/status.js');
|
||||
const { WelcomeScreen } = require('lib/components/screens/welcome.js');
|
||||
const { SearchScreen } = require('lib/components/screens/search.js');
|
||||
const { OneDriveLoginScreen } = require('lib/components/screens/onedrive-login.js');
|
||||
const { EncryptionConfigScreen } = require('lib/components/screens/encryption-config.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const { MenuContext } = require('react-native-popup-menu');
|
||||
const { SideMenu } = require('lib/components/side-menu.js');
|
||||
@ -51,6 +52,10 @@ const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js');
|
||||
SyncTargetRegistry.addClass(SyncTargetOneDrive);
|
||||
SyncTargetRegistry.addClass(SyncTargetOneDriveDev);
|
||||
|
||||
const FsDriverRN = require('lib/fs-driver-rn.js').FsDriverRN;
|
||||
const DecryptionWorker = require('lib/services/DecryptionWorker');
|
||||
const EncryptionService = require('lib/services/EncryptionService');
|
||||
|
||||
const generalMiddleware = store => next => async (action) => {
|
||||
if (action.type !== 'SIDE_MENU_OPEN_PERCENT') reg.logger().info('Reducer action', action.type);
|
||||
PoorManIntervals.update(); // This function needs to be called regularly so put it here
|
||||
@ -85,6 +90,10 @@ const generalMiddleware = store => next => async (action) => {
|
||||
Setting.setValue('activeFolderId', newState.selectedFolderId);
|
||||
}
|
||||
|
||||
if (action.type === 'SYNC_GOT_ENCRYPTED_ITEM') {
|
||||
DecryptionWorker.instance().scheduleStart();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -307,6 +316,10 @@ async function initialize(dispatch) {
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
|
||||
const fsDriver = new FsDriverRN();
|
||||
|
||||
Resource.fsDriver_ = fsDriver;
|
||||
|
||||
AlarmService.setDriver(new AlarmServiceDriver());
|
||||
AlarmService.setLogger(mainLogger);
|
||||
|
||||
@ -343,6 +356,21 @@ async function initialize(dispatch) {
|
||||
|
||||
setLocale(Setting.value('locale'));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// E2EE SETUP
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
EncryptionService.fsDriver_ = fsDriver;
|
||||
EncryptionService.instance().setLogger(mainLogger);
|
||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||
DecryptionWorker.instance().setLogger(mainLogger);
|
||||
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
|
||||
await EncryptionService.instance().loadMasterKeysFromSettings();
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// / E2EE SETUP
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
reg.logger().info('Loading folders...');
|
||||
|
||||
await FoldersScreenUtils.refreshFolders();
|
||||
@ -354,6 +382,13 @@ async function initialize(dispatch) {
|
||||
items: tags,
|
||||
});
|
||||
|
||||
const masterKeys = await MasterKey.all();
|
||||
|
||||
dispatch({
|
||||
type: 'MASTERKEY_UPDATE_ALL',
|
||||
items: masterKeys,
|
||||
});
|
||||
|
||||
let folderId = Setting.value('activeFolderId');
|
||||
let folder = await Folder.load(folderId);
|
||||
|
||||
@ -385,6 +420,8 @@ async function initialize(dispatch) {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
AlarmService.updateAllNotifications();
|
||||
|
||||
DecryptionWorker.instance().scheduleStart();
|
||||
});
|
||||
|
||||
reg.logger().info('Application initialized');
|
||||
@ -472,6 +509,7 @@ class AppComponent extends React.Component {
|
||||
Note: { screen: NoteScreen },
|
||||
Folder: { screen: FolderScreen },
|
||||
OneDriveLogin: { screen: OneDriveLoginScreen },
|
||||
EncryptionConfig: { screen: EncryptionConfigScreen },
|
||||
Log: { screen: LogScreen },
|
||||
Status: { screen: StatusScreen },
|
||||
Search: { screen: SearchScreen },
|
||||
|
Loading…
Reference in New Issue
Block a user