1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Mobile: Settings screen: Create separate pages for each screen (#8567)

This commit is contained in:
Henry Heino 2023-11-09 11:19:08 -08:00 committed by GitHub
parent 0340c7f65c
commit 672d028d29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1293 additions and 544 deletions

View File

@ -427,6 +427,7 @@ packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
@ -467,17 +468,29 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
packages/app-mobile/components/screens/ConfigScreen/SettingItem.js
packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
@ -600,6 +613,7 @@ packages/lib/commands/index.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/synchronize.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/note-screen-shared.js

18
.gitignore vendored
View File

@ -409,6 +409,7 @@ packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
@ -449,17 +450,29 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebugReportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
packages/app-mobile/components/screens/ConfigScreen/SettingItem.js
packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
@ -582,6 +595,7 @@ packages/lib/commands/index.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/synchronize.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/note-screen-shared.js

View File

@ -12,7 +12,7 @@ const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme');
const pathUtils = require('@joplin/lib/path-utils');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const shared = require('@joplin/lib/components/shared/config/config-shared.js');
import * as shared from '@joplin/lib/components/shared/config/config-shared.js';
import ClipperConfigScreen from '../ClipperConfigScreen';
import restart from '../../services/restart';
import PluginService from '@joplin/lib/services/plugins/PluginService';
@ -35,9 +35,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
public constructor(props: any) {
super(props);
shared.init(this, reg);
shared.init(reg);
this.state = {
...shared.defaultScreenState,
selectedSectionName: 'general',
screenName: '',
changedSettingKeys: [],
@ -98,7 +99,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
}
public sectionByName(name: string) {
const sections = shared.settingsSections({ device: 'desktop', settings: this.state.settings });
const sections = shared.settingsSections({ device: AppType.Desktop, settings: this.state.settings });
for (const section of sections) {
if (section.name === name) return section;
}
@ -699,7 +700,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
const hasChanges = this.hasChanges();
const settingComps = shared.settingsToComponents2(this, 'desktop', settings, this.state.selectedSectionName);
const settingComps = shared.settingsToComponents2(this, AppType.Desktop, settings, this.state.selectedSectionName);
// screenComp is a custom config screen, such as the encryption config screen or keymap config screen.
// These screens handle their own loading/saving of settings and have bespoke rendering.
@ -708,7 +709,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
if (screenComp) containerStyle.display = 'none';
const sections = shared.settingsSections({ device: 'desktop', settings });
const sections = shared.settingsSections({ device: AppType.Desktop, settings });
const needRestartComp: any = this.state.needRestart ? (
<div style={{ ...theme.textStyle, padding: 10, paddingLeft: 24, backgroundColor: theme.warningBackgroundColor, color: theme.color }}>

View File

@ -1,4 +1,4 @@
import { SettingSectionSource } from '@joplin/lib/models/Setting';
import { AppType, SettingSectionSource } from '@joplin/lib/models/Setting';
import * as React from 'react';
import { useMemo } from 'react';
import Setting from '@joplin/lib/models/Setting';
@ -93,7 +93,9 @@ export default function Sidebar(props: Props) {
const selected = props.selection === section.name;
return (
<StyledListItem key={section.name} isSubSection={Setting.isSubSection(section.name)} selected={selected} onClick={() => { props.onSelectionChange({ section: section }); }}>
<StyledListItemIcon className={Setting.sectionNameToIcon(section.name)} />
<StyledListItemIcon
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
/>
<StyledListItemLabel>
{Setting.sectionNameToLabel(section.name)}
</StyledListItemLabel>

View File

@ -0,0 +1,44 @@
import * as React from 'react';
import { TextStyle } from 'react-native';
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
interface Props {
name: string;
style: TextStyle;
// If `null` is given, the content must be labeled elsewhere.
accessibilityLabel: string|null;
}
const Icon: React.FC<Props> = props => {
// Matches:
// 1. A prefix of word characters (\w+)
// 2. A suffix of non-spaces (\S+)
// An "fa-" at the beginning of the suffix is ignored.
const nameMatch = props.name.match(/^(\w+)\s+(?:fa-)?(\S+)$/);
const namePrefix = nameMatch ? nameMatch[1] : '';
const nameSuffix = nameMatch ? nameMatch[2] : props.name;
// If there's no label, make sure that the screen reader doesn't try
// to read the characters from the icon font (they don't make sense
// without the icon font applied).
const accessibilityHidden = props.accessibilityLabel === null;
return (
<FontAwesomeIcon
brand={namePrefix.startsWith('fab')}
solid={namePrefix.startsWith('fas')}
accessibilityLabel={props.accessibilityLabel}
aria-hidden={accessibilityHidden}
importantForAccessibility={
accessibilityHidden ? 'no-hide-descendants' : 'yes'
}
name={nameSuffix}
style={props.style}
/>
);
};
export default Icon;

View File

@ -1,12 +1,12 @@
const React = require('react');
const { StyleSheet } = require('react-native');
import * as React from 'react';
import { StyleSheet } from 'react-native';
const { themeStyle } = require('./global-style.js');
const rootStyles_ = {};
const rootStyles_: Record<number, any> = {};
class BaseScreenComponent extends React.Component {
class BaseScreenComponent<Props, State> extends React.Component<Props, State> {
rootStyle(themeId) {
protected rootStyle(themeId: number) {
const theme = themeStyle(themeId);
if (rootStyles_[themeId]) return rootStyles_[themeId];
rootStyles_[themeId] = StyleSheet.create({
@ -19,4 +19,5 @@ class BaseScreenComponent extends React.Component {
}
}
module.exports = { BaseScreenComponent };
export { BaseScreenComponent };
export default BaseScreenComponent;

View File

@ -1,214 +1,168 @@
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
import Slider from '@react-native-community/slider';
const React = require('react');
import { Platform, Linking, View, Switch, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid, TouchableNativeFeedback } from 'react-native';
import * as React from 'react';
import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
import Setting, { AppType } from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService';
import ReportService from '@joplin/lib/services/ReportService';
import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
import checkPermissions from '../../../utils/checkPermissions';
import time from '@joplin/lib/time';
import shim from '@joplin/lib/shim';
import setIgnoreTlsErrors from '../../../utils/TlsUtils';
import { reg } from '@joplin/lib/registry';
import { State } from '@joplin/lib/reducer';
const { BackButtonService } = require('../../../services/back-button.js');
const VersionInfo = require('react-native-version-info').default;
const { connect } = require('react-redux');
import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../../base-screen.js');
const { Dropdown } = require('../../Dropdown');
import { _ } from '@joplin/lib/locale';
import BaseScreenComponent from '../../base-screen';
const { themeStyle } = require('../../global-style.js');
const shared = require('@joplin/lib/components/shared/config/config-shared.js');
import * as shared from '@joplin/lib/components/shared/config/config-shared';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import { openDocumentTree } from '@joplin/react-native-saf-x';
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
import configScreenStyles from './configScreenStyles';
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
import NoteExportButton from './NoteExportSection/NoteExportButton';
import ConfigScreenButton from './ConfigScreenButton';
import SettingsButton from './SettingsButton';
import Clipboard from '@react-native-community/clipboard';
import { ReactNode } from 'react';
import { Dispatch } from 'redux';
import SectionHeader from './SectionHeader';
import ExportProfileButton from './NoteExportSection/ExportProfileButton';
import SettingComponent from './SettingComponent';
import ExportDebugReportButton from './NoteExportSection/ExportDebugReportButton';
import SectionSelector from './SectionSelector';
class ConfigScreenComponent extends BaseScreenComponent {
interface ConfigScreenState {
settings: any;
changedSettingKeys: string[];
fixingSearchIndex: boolean;
checkSyncConfigResult: { ok: boolean; errorMessage: string }|'checking'|null;
showAdvancedSettings: boolean;
selectedSectionName: string|null;
sidebarWidth: number;
}
interface ConfigScreenProps {
settings: any;
themeId: number;
navigation: any;
dispatch: Dispatch;
}
class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, ConfigScreenState> {
public static navigationOptions(): any {
return { header: null };
}
private componentsY_: Record<string, number> = {};
private styles_: Record<number, ConfigScreenStyles> = {};
private scrollViewRef_: React.RefObject<ScrollView>;
public constructor() {
super();
this.styles_ = {};
public constructor(props: ConfigScreenProps) {
super(props);
this.state = {
creatingReport: false,
profileExportStatus: 'idle',
profileExportPath: '',
fileSystemSyncPath: Setting.value('sync.2.path'),
...shared.defaultScreenState,
selectedSectionName: null,
fixingSearchIndex: false,
sidebarWidth: 100,
};
this.scrollViewRef_ = React.createRef();
this.scrollViewRef_ = React.createRef<ScrollView>();
shared.init(this, reg);
this.selectDirectoryButtonPress = async () => {
try {
const doc = await openDocumentTree(true);
if (doc?.uri) {
this.setState({ fileSystemSyncPath: doc.uri });
shared.updateSettingValue(this, 'sync.2.path', doc.uri);
} else {
throw new Error('User cancelled operation');
}
} catch (e) {
reg.logger().info('Didn\'t pick sync dir: ', e);
}
};
this.checkSyncConfig_ = async () => {
// to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state
// this call sets the new value and returns the previous one which we can use later to revert the change
const prevIgnoreTlsErrors = await setIgnoreTlsErrors(this.state.settings['net.ignoreTlsErrors']);
const result = await shared.checkSyncConfig(this, this.state.settings);
if (!result || !result.ok) {
await setIgnoreTlsErrors(prevIgnoreTlsErrors);
}
};
this.e2eeConfig_ = () => {
void NavService.go('EncryptionConfig');
};
this.saveButton_press = async () => {
if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem')) {
if (Platform.OS === 'android') {
if (Platform.Version < 29) {
if (!(await this.checkFilesystemPermission())) {
Alert.alert(_('Warning'), _('In order to use file system synchronisation your permission to write to external storage is required.'));
}
}
}
// Save settings anyway, even if permission has not been granted
}
// changedSettingKeys is cleared in shared.saveSettings so reading it now
const setIgnoreTlsErrors = this.state.changedSettingKeys.includes('net.ignoreTlsErrors');
await shared.saveSettings(this);
if (setIgnoreTlsErrors) {
await setIgnoreTlsErrors(Setting.value('net.ignoreTlsErrors'));
}
};
this.saveButton_press = this.saveButton_press.bind(this);
this.syncStatusButtonPress_ = () => {
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();
const logItems = await reg.logger().lastEntries(null);
const logItemRows = [['Date', 'Level', 'Message']];
for (let i = 0; i < logItems.length; i++) {
const item = logItems[i];
logItemRows.push([time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'), item.level, item.message]);
}
const logItemCsv = service.csvCreate(logItemRows);
const itemListCsv = await service.basicItemList({ format: 'csv' });
const externalDir = await shim.fsDriver().getExternalDirectoryPath();
if (!externalDir) {
this.setState({ creatingReport: false });
return;
}
const filePath = `${externalDir}/syncReport-${new Date().getTime()}.txt`;
const finalText = [logItemCsv, itemListCsv].join('\n================================================================================\n');
await shim.fsDriver().writeFile(filePath, finalText, 'utf8');
alert(`Debug report exported to ${filePath}`);
this.setState({ creatingReport: false });
};
this.fixSearchEngineIndexButtonPress_ = async () => {
this.setState({ fixingSearchIndex: true });
await SearchEngine.instance().rebuildIndex();
this.setState({ fixingSearchIndex: false });
};
this.exportProfileButtonPress_ = async () => {
const externalDir = await shim.fsDriver().getExternalDirectoryPath();
if (!externalDir) {
return;
}
const p = this.state.profileExportPath ? this.state.profileExportPath : `${externalDir}/JoplinProfileExport`;
this.setState({
profileExportStatus: 'prompt',
profileExportPath: p,
});
};
this.exportProfileButtonPress2_ = async () => {
this.setState({ profileExportStatus: 'exporting' });
const dbPath = '/data/data/net.cozic.joplin/databases';
const exportPath = this.state.profileExportPath;
const resourcePath = `${exportPath}/resources`;
try {
const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE);
if (response !== PermissionsAndroid.RESULTS.GRANTED) {
throw new Error('Permission denied');
}
const copyFiles = async (source: string, dest: string) => {
await shim.fsDriver().mkdir(dest);
const files = await shim.fsDriver().readDirStats(source);
for (const file of files) {
const source_ = `${source}/${file.path}`;
const dest_ = `${dest}/${file.path}`;
if (!file.isDirectory()) {
reg.logger().info(`Copying profile: ${source_} => ${dest_}`);
await shim.fsDriver().copy(source_, dest_);
} else {
await copyFiles(source_, dest_);
}
}
};
await copyFiles(dbPath, exportPath);
await copyFiles(Setting.value('resourceDir'), resourcePath);
alert('Profile has been exported!');
} catch (error) {
alert(`Could not export files: ${error.message}`);
} finally {
this.setState({ profileExportStatus: 'idle' });
}
};
this.logButtonPress_ = () => {
void NavService.go('Log');
};
this.handleSetting = this.handleSetting.bind(this);
shared.init(reg);
}
private checkSyncConfig_ = async () => {
// to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state
// this call sets the new value and returns the previous one which we can use later to revert the change
const prevIgnoreTlsErrors = await setIgnoreTlsErrors(this.state.settings['net.ignoreTlsErrors']);
const result = await shared.checkSyncConfig(this, this.state.settings);
if (!result || !result.ok) {
await setIgnoreTlsErrors(prevIgnoreTlsErrors);
}
};
private e2eeConfig_ = () => {
void NavService.go('EncryptionConfig');
};
private saveButton_press = async () => {
if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem')) {
if (Platform.OS === 'android') {
if (Platform.Version < 29) {
if (!(await this.checkFilesystemPermission())) {
Alert.alert(_('Warning'), _('In order to use file system synchronisation your permission to write to external storage is required.'));
}
}
}
// Save settings anyway, even if permission has not been granted
}
// changedSettingKeys is cleared in shared.saveSettings so reading it now
const shouldSetIgnoreTlsErrors = this.state.changedSettingKeys.includes('net.ignoreTlsErrors');
await shared.saveSettings(this);
if (shouldSetIgnoreTlsErrors) {
await setIgnoreTlsErrors(Setting.value('net.ignoreTlsErrors'));
}
};
private syncStatusButtonPress_ = () => {
void NavService.go('Status');
};
private manageProfilesButtonPress_ = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'ProfileSwitcher',
});
};
private fixSearchEngineIndexButtonPress_ = async () => {
this.setState({ fixingSearchIndex: true });
await SearchEngine.instance().rebuildIndex();
this.setState({ fixingSearchIndex: false });
};
private logButtonPress_ = () => {
void NavService.go('Log');
};
private updateSidebarWidth = () => {
const windowWidth = Dimensions.get('window').width;
let sidebarNewWidth = windowWidth;
const sidebarValidWidths = [280, 230];
const maxFractionOfWindowSize = 1 / 3;
for (const width of sidebarValidWidths) {
if (width < windowWidth * maxFractionOfWindowSize) {
sidebarNewWidth = width;
break;
}
}
this.setState({ sidebarWidth: sidebarNewWidth });
};
private navigationFillsScreen() {
const windowWidth = Dimensions.get('window').width;
return this.state.sidebarWidth > windowWidth / 2;
}
private switchSectionPress_ = (section: string) => {
const label = Setting.sectionNameToLabel(section);
AccessibilityInfo.announceForAccessibility(_('Opening section %s', label));
this.setState({ selectedSectionName: section });
};
private showSectionNavigation_ = () => {
this.setState({ selectedSectionName: null });
};
public async checkFilesystemPermission() {
if (Platform.OS !== 'android') {
// Not implemented yet
@ -225,7 +179,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
this.setState({ settings: this.props.settings });
}
public styles() {
public styles(): ConfigScreenStyles {
const themeId = this.props.themeId;
if (this.styles_[themeId]) return this.styles_[themeId];
@ -258,6 +212,12 @@ class ConfigScreenComponent extends BaseScreenComponent {
await BackButtonService.back();
};
// Show navigation when pressing "back" (unless always visible).
if (this.state.selectedSectionName && this.navigationFillsScreen()) {
this.showSectionNavigation_();
return true;
}
if (this.state.changedSettingKeys.length > 0) {
const dialogTitle: string|null = null;
Alert.alert(
@ -284,6 +244,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
public componentDidMount() {
if (this.props.navigation.state.sectionName) {
this.setState({ selectedSectionName: this.props.navigation.state.sectionName });
setTimeout(() => {
this.scrollViewRef_.current.scrollTo({
x: 0,
@ -294,24 +255,17 @@ class ConfigScreenComponent extends BaseScreenComponent {
}
BackButtonService.addHandler(this.handleBackButtonPress);
Dimensions.addEventListener('change', this.updateSidebarWidth);
this.updateSidebarWidth();
}
public componentWillUnmount() {
BackButtonService.removeHandler(this.handleBackButtonPress);
}
public renderHeader(key: string, title: string) {
const theme = themeStyle(this.props.themeId);
return (
<View key={key} style={this.styles().headerWrapperStyle} onLayout={(event: any) => this.onHeaderLayout(key, event)}>
<Text style={theme.headerStyle}>{title}</Text>
</View>
);
}
private renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) {
return (
<ConfigScreenButton
<SettingsButton
key={key}
title={title}
clickHandler={clickHandler}
@ -322,9 +276,11 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
public sectionToComponent(key: string, section: any, settings: any) {
public sectionToComponent(key: string, section: any, settings: any, isSelected: boolean) {
const settingComps = [];
const styleSheet = this.styles().styleSheet;
for (let i = 0; i < section.metadatas.length; i++) {
const md = section.metadatas[i];
@ -335,10 +291,10 @@ class ConfigScreenComponent extends BaseScreenComponent {
const messages = shared.checkSyncConfigMessages(this);
const statusComp = !messages.length ? null : (
<View style={{ flex: 1, marginTop: 10 }}>
<Text style={this.styles().descriptionText}>{messages[0]}</Text>
<Text style={this.styles().styleSheet.descriptionText}>{messages[0]}</Text>
{messages.length >= 1 ? (
<View style={{ marginTop: 10 }}>
<Text style={this.styles().descriptionText}>{messages[1]}</Text>
<Text style={this.styles().styleSheet.descriptionText}>{messages[1]}</Text>
</View>
) : null}
</View>
@ -360,8 +316,8 @@ class ConfigScreenComponent extends BaseScreenComponent {
const description = _('Any email sent to this address will be converted into a note and added to your collection. The note will be saved into the Inbox notebook');
settingComps.push(
<View key="joplinCloud">
<View style={this.styles().settingContainerNoBottomBorder}>
<Text style={this.styles().settingText}>{_('Email to note')}</Text>
<View style={this.styles().styleSheet.settingContainerNoBottomBorder}>
<Text style={this.styles().styleSheet.settingText}>{_('Email to note')}</Text>
<Text style={{ fontWeight: 'bold' }}>{this.props.settings['sync.10.inboxEmail']}</Text>
</View>
{
@ -376,11 +332,129 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
if (section.name === '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_));
settingComps.push(this.renderButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') }));
}
if (section.name === 'export') {
settingComps.push(<NoteExportButton key='export_as_jex_button' styles={this.styles()} />);
settingComps.push(<ExportDebugReportButton key='export_report_button' styles={this.styles()}/>);
settingComps.push(<ExportProfileButton key='export_data' styles={this.styles()}/>);
}
if (section.name === 'moreInfo') {
if (Platform.OS === 'android' && Platform.Version >= 23) {
// Note: `PermissionsAndroid` doesn't work so we have to ask the user to manually
// set these permissions. https://stackoverflow.com/questions/49771084/permission-always-returns-never-ask-again
settingComps.push(
<View key="permission_info" style={styleSheet.settingContainer}>
<View key="permission_info_wrapper">
<Text key="perm1a" style={styleSheet.settingText}>
{_('To work correctly, the app needs the following permissions. Please enable them in your phone settings, in Apps > Joplin > Permissions')}
</Text>
<Text key="perm2" style={styleSheet.permissionText}>
{_('- Storage: to allow attaching files to notes and to enable filesystem synchronisation.')}
</Text>
<Text key="perm3" style={styleSheet.permissionText}>
{_('- Camera: to allow taking a picture and attaching it to a note.')}
</Text>
<Text key="perm4" style={styleSheet.permissionText}>
{_('- Location: to allow attaching geo-location information to a note.')}
</Text>
</View>
</View>,
);
}
settingComps.push(
<View key="donate_link" style={styleSheet.settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/donate/');
}}
>
<Text key="label" style={styleSheet.linkText}>
{_('Make a donation')}
</Text>
</TouchableOpacity>
</View>,
);
settingComps.push(
<View key="website_link" style={styleSheet.settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/');
}}
>
<Text key="label" style={styleSheet.linkText}>
{_('Joplin website')}
</Text>
</TouchableOpacity>
</View>,
);
settingComps.push(
<View key="privacy_link" style={styleSheet.settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/privacy/');
}}
>
<Text key="label" style={styleSheet.linkText}>
{_('Privacy Policy')}
</Text>
</TouchableOpacity>
</View>,
);
settingComps.push(
<View key="version_info_app" style={styleSheet.settingContainer}>
<Text style={styleSheet.settingText}>{`Joplin ${VersionInfo.appVersion}`}</Text>
</View>,
);
settingComps.push(
<View key="version_info_db" style={styleSheet.settingContainer}>
<Text style={styleSheet.settingText}>{_('Database v%s', reg.db().version())}</Text>
</View>,
);
settingComps.push(
<View key="version_info_fts" style={styleSheet.settingContainer}>
<Text style={styleSheet.settingText}>{_('FTS enabled: %d', this.props.settings['db.ftsEnabled'])}</Text>
</View>,
);
settingComps.push(
<View key="version_info_hermes" style={styleSheet.settingContainer}>
<Text style={styleSheet.settingText}>{_('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0)}</Text>
</View>,
);
const featureFlagKeys = Setting.featureFlagKeys(AppType.Mobile);
if (featureFlagKeys.length) {
const headerKey = 'featureFlags';
settingComps.push(<SectionHeader
key={headerKey}
styles={this.styles().styleSheet}
title={_('Feature flags')}
onLayout={event => this.onHeaderLayout(headerKey, event)}
/>);
settingComps.push(<View key="featureFlagsContainer">{this.renderFeatureFlags(settings, featureFlagKeys)}</View>);
}
}
if (!settingComps.length) return null;
if (!isSelected) return null;
return (
<View key={key} onLayout={(event: any) => this.onSectionLayout(key, event)}>
{this.renderHeader(section.name, Setting.sectionNameToLabel(section.name))}
<View>{settingComps}</View>
</View>
);
@ -392,22 +466,18 @@ class ConfigScreenComponent extends BaseScreenComponent {
return (
<View key={key}>
<View style={this.containerStyle(false)}>
<Text key="label" style={this.styles().switchSettingText}>
<View style={this.styles().getContainerStyle(false)}>
<Text key="label" style={this.styles().styleSheet.switchSettingText}>
{label}
</Text>
<Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => void updateSettingValue(key, value)} />
<Switch key="control" style={this.styles().styleSheet.switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => void updateSettingValue(key, value)} />
</View>
{descriptionComp}
</View>
);
}
private containerStyle(hasDescription: boolean): any {
return !hasDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder;
}
private async handleSetting(key: string, value: any): Promise<boolean> {
private handleSetting = async (key: string, value: any): Promise<boolean> => {
// When the user tries to enable biometrics unlock, we ask for the
// fingerprint or Face ID, and if it's correct we save immediately. If
// it's not, we don't turn on the setting.
@ -428,123 +498,24 @@ class ConfigScreenComponent extends BaseScreenComponent {
}
return false;
}
};
public settingToComponent(key: string, value: any) {
const themeId = this.props.themeId;
const theme = themeStyle(themeId);
const output: any = null;
const updateSettingValue = async (key: string, value: any) => {
const handled = await this.handleSetting(key, value);
if (!handled) shared.updateSettingValue(this, key, value);
};
const md = Setting.settingMetadata(key);
const settingDescription = md.description ? md.description() : '';
const descriptionComp = !settingDescription ? null : <Text style={this.styles().settingDescriptionText}>{settingDescription}</Text>;
const containerStyle = this.containerStyle(!!settingDescription);
if (md.isEnum) {
value = value.toString();
const items = Setting.enumOptionsToValueLabels(md.options(), md.optionsOrder ? md.optionsOrder() : []);
return (
<View key={key} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View style={containerStyle}>
<Text key="label" style={this.styles().settingText}>
{md.label()}
</Text>
<Dropdown
key="control"
style={this.styles().settingControl}
items={items}
selectedValue={value}
itemListStyle={{
backgroundColor: theme.backgroundColor,
}}
headerStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
itemStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
onValueChange={(itemValue: string) => {
void updateSettingValue(key, itemValue);
}}
/>
</View>
{descriptionComp}
</View>
);
} else if (md.type === Setting.TYPE_BOOL) {
return this.renderToggle(key, md.label(), value, updateSettingValue, descriptionComp);
// return (
// <View key={key}>
// <View style={containerStyle}>
// <Text key="label" style={this.styles().switchSettingText}>
// {md.label()}
// </Text>
// <Switch key="control" style={this.styles().switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value:any) => updateSettingValue(key, value)} />
// </View>
// {descriptionComp}
// </View>
// );
} else if (md.type === Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(value) : value;
const minimum = 'minimum' in md ? md.minimum : 0;
const maximum = 'maximum' in md ? md.maximum : 10;
// Note: Do NOT add the minimumTrackTintColor and maximumTrackTintColor props
// on the Slider as they are buggy and can crash the app on certain devices.
// https://github.com/laurent22/joplin/issues/2733
// https://github.com/react-native-community/react-native-slider/issues/161
return (
<View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>
{md.label()}
</Text>
<View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flex: 1 }}>
<Text style={this.styles().sliderUnits}>{unitLabel}</Text>
<Slider key="control" style={{ flex: 1 }} step={md.step} minimumValue={minimum} maximumValue={maximum} value={value} onValueChange={value => void updateSettingValue(key, value)} />
</View>
</View>
);
} else if (md.type === Setting.TYPE_STRING) {
if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) {
return (
<TouchableNativeFeedback key={key} onPress={this.selectDirectoryButtonPress} style={this.styles().settingContainer}>
<View style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>
{md.label()}
</Text>
<Text style={this.styles().settingControl}>
{this.state.fileSystemSyncPath}
</Text>
</View>
</TouchableNativeFeedback>
);
}
return (
<View key={key} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View key={key} style={containerStyle}>
<Text key="label" style={this.styles().settingText}>
{md.label()}
</Text>
<TextInput autoCorrect={false} autoComplete="off" selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={(value: any) => void updateSettingValue(key, value)} secureTextEntry={!!md.secure} />
</View>
{descriptionComp}
</View>
);
} else {
// throw new Error('Unsupported setting type: ' + md.type);
}
return output;
return (
<SettingComponent
key={key}
settingId={key}
value={value}
themeId={this.props.themeId}
updateSettingValue={updateSettingValue}
styles={this.styles()}
/>
);
}
private renderFeatureFlags(settings: any, featureFlagKeys: string[]): any[] {
@ -562,139 +533,77 @@ class ConfigScreenComponent extends BaseScreenComponent {
public render() {
const settings = this.state.settings;
const theme = themeStyle(this.props.themeId);
const showAsSidebar = !this.navigationFillsScreen();
const settingComps = shared.settingsToComponents2(this, 'mobile', settings);
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_));
settingComps.push(this.renderButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') }));
settingComps.push(this.renderHeader('export', _('Export')));
settingComps.push(<NoteExportButton key={'export_as_jex_button'} styles={this.styles()} />);
if (shim.mobilePlatform() === 'android') {
settingComps.push(this.renderButton('export_report_button', this.state.creatingReport ? _('Creating report...') : _('Export Debug Report'), this.exportDebugButtonPress_, { disabled: this.state.creatingReport }));
settingComps.push(this.renderButton('export_data', this.state.profileExportStatus === 'exporting' ? _('Exporting profile...') : _('Export profile'), this.exportProfileButtonPress_, { disabled: this.state.profileExportStatus === 'exporting', description: _('For debugging purpose only: export your profile to an external SD card.') }));
if (this.state.profileExportStatus === 'prompt') {
const profileExportPrompt = (
<View style={this.styles().settingContainer} key="profileExport">
<Text style={{ ...this.styles().settingText, flex: 0 }}>Path:</Text>
<TextInput style={{ ...this.styles().textInput, paddingRight: 20, width: '75%', marginRight: 'auto' }} onChange={(event: any) => this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance} />
<Button title="OK" onPress={this.exportProfileButtonPress2_} />
</View>
);
settingComps.push(profileExportPrompt);
}
// If the navigation is a sidebar, always show a section.
let currentSectionName = this.state.selectedSectionName;
if (showAsSidebar && !currentSectionName) {
currentSectionName = 'general';
}
const featureFlagKeys = Setting.featureFlagKeys(AppType.Mobile);
if (featureFlagKeys.length) {
settingComps.push(this.renderHeader('featureFlags', _('Feature flags')));
settingComps.push(<View key="featureFlagsContainer">{this.renderFeatureFlags(settings, featureFlagKeys)}</View>);
}
const sectionSelector = (
<SectionSelector
selectedSectionName={currentSectionName}
styles={this.styles()}
settings={settings}
openSection={this.switchSectionPress_}
width={this.state.sidebarWidth}
/>
);
settingComps.push(this.renderHeader('moreInfo', _('More information')));
let currentSection: ReactNode;
if (currentSectionName) {
const settingComps = shared.settingsToComponents2(
this, AppType.Mobile, settings, currentSectionName,
if (Platform.OS === 'android' && Platform.Version >= 23) {
// Note: `PermissionsAndroid` doesn't work so we have to ask the user to manually
// set these permissions. https://stackoverflow.com/questions/49771084/permission-always-returns-never-ask-again
// TODO: Remove this cast. Currently necessary because of different versions
// of React in lib/ and app-mobile/
) as ReactNode[];
settingComps.push(
<View key="permission_info" style={this.styles().settingContainer}>
<View key="permission_info_wrapper">
<Text key="perm1a" style={this.styles().settingText}>
{_('To work correctly, the app needs the following permissions. Please enable them in your phone settings, in Apps > Joplin > Permissions')}
</Text>
<Text key="perm2" style={this.styles().permissionText}>
{_('- Storage: to allow attaching files to notes and to enable filesystem synchronisation.')}
</Text>
<Text key="perm3" style={this.styles().permissionText}>
{_('- Camera: to allow taking a picture and attaching it to a note.')}
</Text>
<Text key="perm4" style={this.styles().permissionText}>
{_('- Location: to allow attaching geo-location information to a note.')}
</Text>
</View>
</View>,
currentSection = (
<ScrollView
ref={this.scrollViewRef_}
style={{ flexGrow: 1 }}
>
{settingComps}
</ScrollView>
);
} else {
currentSection = sectionSelector;
}
settingComps.push(
<View key="donate_link" style={this.styles().settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/donate/');
}}
>
<Text key="label" style={this.styles().linkText}>
{_('Make a donation')}
</Text>
</TouchableOpacity>
</View>,
);
let mainComponent;
if (showAsSidebar && currentSectionName) {
mainComponent = (
<View style={{
flex: 1,
flexDirection: 'row',
}}>
{sectionSelector}
<View style={{ width: 10 }}/>
{currentSection}
</View>
);
} else {
mainComponent = currentSection;
}
settingComps.push(
<View key="website_link" style={this.styles().settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/');
}}
>
<Text key="label" style={this.styles().linkText}>
{_('Joplin website')}
</Text>
</TouchableOpacity>
</View>,
);
settingComps.push(
<View key="privacy_link" style={this.styles().settingContainer}>
<TouchableOpacity
onPress={() => {
void Linking.openURL('https://joplinapp.org/privacy/');
}}
>
<Text key="label" style={this.styles().linkText}>
{_('Privacy Policy')}
</Text>
</TouchableOpacity>
</View>,
);
settingComps.push(
<View key="version_info_app" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{`Joplin ${VersionInfo.appVersion}`}</Text>
</View>,
);
settingComps.push(
<View key="version_info_db" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{_('Database v%s', reg.db().version())}</Text>
</View>,
);
settingComps.push(
<View key="version_info_fts" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{_('FTS enabled: %d', this.props.settings['db.ftsEnabled'])}</Text>
</View>,
);
settingComps.push(
<View key="version_info_hermes" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{_('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0)}</Text>
</View>,
);
let screenHeadingText = _('Configuration');
if (currentSectionName) {
screenHeadingText = Setting.sectionNameToLabel(currentSectionName);
}
return (
<View style={this.rootStyle(this.props.themeId).root}>
<ScreenHeader title={_('Configuration')} showSaveButton={true} showSearchButton={false} showSideMenuButton={false} saveButtonDisabled={!this.state.changedSettingKeys.length} onSaveButtonPress={this.saveButton_press} />
<ScrollView ref={this.scrollViewRef_}>{settingComps}</ScrollView>
<ScreenHeader
title={screenHeadingText}
showSaveButton={true}
showSearchButton={false}
showSideMenuButton={false}
saveButtonDisabled={!this.state.changedSettingKeys.length}
onSaveButtonPress={this.saveButton_press}
/>
{mainComponent}
</View>
);
}

View File

@ -0,0 +1,65 @@
import * as React from 'react';
import shim from '@joplin/lib/shim';
import { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { ConfigScreenStyles } from './configScreenStyles';
import { TouchableNativeFeedback, View, Text } from 'react-native';
import Setting, { SettingItem } from '@joplin/lib/models/Setting';
import { openDocumentTree } from '@joplin/react-native-saf-x';
import { UpdateSettingValueCallback } from './types';
import { reg } from '@joplin/lib/registry';
interface Props {
styles: ConfigScreenStyles;
settingMetadata: SettingItem;
updateSettingValue: UpdateSettingValueCallback;
}
const FileSystemPathSelector: FunctionComponent<Props> = props => {
const [fileSystemPath, setFileSystemPath] = useState<string>('');
const settingId = props.settingMetadata.key;
useEffect(() => {
setFileSystemPath(Setting.value(settingId));
}, [settingId]);
const selectDirectoryButtonPress = useCallback(async () => {
try {
const doc = await openDocumentTree(true);
if (doc?.uri) {
setFileSystemPath(doc.uri);
await props.updateSettingValue(settingId, doc.uri);
} else {
throw new Error('User cancelled operation');
}
} catch (e) {
reg.logger().info('Didn\'t pick sync dir: ', e);
}
}, [props.updateSettingValue, settingId]);
// Unsupported on non-Android platforms.
if (!shim.fsDriver().isUsingAndroidSAF()) {
return null;
}
const styleSheet = props.styles.styleSheet;
return (
<TouchableNativeFeedback
onPress={selectDirectoryButtonPress}
style={styleSheet.settingContainer}
>
<View style={styleSheet.settingContainer}>
<Text key="label" style={styleSheet.settingText}>
{props.settingMetadata.label()}
</Text>
<Text style={styleSheet.settingControl}>
{fileSystemPath}
</Text>
</View>
</TouchableNativeFeedback>
);
};
export default FileSystemPathSelector;

View File

@ -0,0 +1,42 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import exportDebugReport from './utils/exportDebugReport';
import shim from '@joplin/lib/shim';
import SettingsButton from '../SettingsButton';
import { ConfigScreenStyles } from '../configScreenStyles';
interface Props {
styles: ConfigScreenStyles;
}
const ExportDebugReportButton = (props: Props) => {
const [creatingReport, setCreatingReport] = useState(false);
const exportDebugButtonPress = useCallback(async () => {
setCreatingReport(true);
await exportDebugReport();
setCreatingReport(false);
}, [setCreatingReport]);
const exportDebugReportButton = (
<SettingsButton
title={creatingReport ? _('Creating report...') : _('Export Debug Report')}
clickHandler={exportDebugButtonPress}
styles={props.styles}
disabled={creatingReport}
/>
);
// The debug functionality is only supported on Android.
if (shim.mobilePlatform() !== 'android') {
return null;
}
return exportDebugReportButton;
};
export default ExportDebugReportButton;

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { View, Button } from 'react-native';
import { TextInput } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import exportProfile from './utils/exportProfile';
import { ConfigScreenStyles } from '../configScreenStyles';
import SettingsButton from '../SettingsButton';
interface Props {
styles: ConfigScreenStyles;
}
const ExportProfileButton = (props: Props) => {
const [profileExportStatus, setProfileExportStatus] = useState<'idle'|'prompt'|'exporting'>('idle');
const [profileExportPath, setProfileExportPath] = useState<string>('');
const exportProfileButtonPress = useCallback(async () => {
const externalDir = await shim.fsDriver().getExternalDirectoryPath();
if (!externalDir) {
return;
}
const p = profileExportPath ? profileExportPath : `${externalDir}/JoplinProfileExport`;
setProfileExportStatus('prompt');
setProfileExportPath(p);
}, [profileExportPath]);
const exportProfileButton = (
<SettingsButton
styles={props.styles}
title={profileExportStatus === 'exporting' ? _('Exporting profile...') : _('Export profile')}
clickHandler={exportProfileButtonPress}
description={_('For debugging purpose only: export your profile to an external SD card.')}
disabled={profileExportStatus === 'exporting'}
/>
);
const exportProfileButtonPress2 = useCallback(async () => {
setProfileExportStatus('exporting');
await exportProfile(profileExportPath);
setProfileExportStatus('idle');
}, [profileExportPath]);
const profileExportPrompt = (
<View>
<TextInput
label={_('Path:')}
onChangeText={text => setProfileExportPath(text)}
value={profileExportPath}
placeholder="/path/to/sdcard"
keyboardAppearance={props.styles.keyboardAppearance} />
<Button
onPress={exportProfileButtonPress2}
title={_('OK')}
/>
</View>
);
const mainContent = (
<>
{exportProfileButton}
{profileExportStatus === 'prompt' ? profileExportPrompt : null}
</>
);
// The debug functionality is only supported on Android.
if (shim.mobilePlatform() !== 'android') {
return null;
}
return mainContent;
};
export default ExportProfileButton;

View File

@ -7,10 +7,10 @@ import { FunctionComponent, useCallback, useState } from 'react';
import shim from '@joplin/lib/shim';
import { join } from 'path';
import Share from 'react-native-share';
import exportAllFolders, { makeExportCacheDirectory } from './exportAllFolders';
import exportAllFolders, { makeExportCacheDirectory } from './utils/exportAllFolders';
import { ExportProgressState } from '@joplin/lib/services/interop/types';
import { ConfigScreenStyles } from '../configScreenStyles';
import ConfigScreenButton from '../ConfigScreenButton';
import SettingsButton from '../SettingsButton';
const logger = Logger.create('NoteExportButton');
@ -83,7 +83,7 @@ const NoteExportButton: FunctionComponent<Props> = props => {
const descriptionText = _('Share a copy of all notes in a file format that can be imported by Joplin on a computer.');
const startOrCancelExportButton = (
<ConfigScreenButton
<SettingsButton
title={exportStatus === ExportStatus.Exporting ? _('Exporting...') : _('Export all notes as JEX')}
disabled={exportStatus === ExportStatus.Exporting}
description={exportStatus === ExportStatus.NotStarted ? descriptionText : null}
@ -96,14 +96,14 @@ const NoteExportButton: FunctionComponent<Props> = props => {
return startOrCancelExportButton;
} else {
const warningComponent = (
<Text style={props.styles.warningText}>
<Text style={props.styles.styleSheet.warningText}>
{_('Warnings:\n%s', warnings)}
</Text>
);
const exportSummary = (
<View style={props.styles.settingContainer}>
<Text style={props.styles.descriptionText}>{_('Exported successfully!')}</Text>
<View style={props.styles.styleSheet.settingContainer}>
<Text style={props.styles.styleSheet.descriptionText}>{_('Exported successfully!')}</Text>
{warnings.length > 0 ? warningComponent : null}
</View>
);

View File

@ -0,0 +1,32 @@
import { reg } from '@joplin/lib/registry';
import ReportService from '@joplin/lib/services/ReportService';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
const exportDebugReport = async () => {
const service = new ReportService();
const logItems = await reg.logger().lastEntries(null);
const logItemRows = [['Date', 'Level', 'Message']];
for (let i = 0; i < logItems.length; i++) {
const item = logItems[i];
logItemRows.push([time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'), item.level, item.message]);
}
const logItemCsv = service.csvCreate(logItemRows);
const itemListCsv = await service.basicItemList({ format: 'csv' });
const externalDir = await shim.fsDriver().getExternalDirectoryPath();
if (!externalDir) {
return;
}
const filePath = `${externalDir}/syncReport-${new Date().getTime()}.txt`;
const finalText = [logItemCsv, itemListCsv].join('\n================================================================================\n');
await shim.fsDriver().writeFile(filePath, finalText, 'utf8');
alert(`Debug report exported to ${filePath}`);
};
export default exportDebugReport;

View File

@ -0,0 +1,35 @@
import shim from '@joplin/lib/shim';
import { reg } from '@joplin/lib/registry';
import Setting from '@joplin/lib/models/Setting';
const exportProfile = async (profileExportPath: string) => {
const dbPath = '/data/data/net.cozic.joplin/databases';
const exportPath = profileExportPath;
const resourcePath = `${exportPath}/resources`;
try {
const copyFiles = async (source: string, dest: string) => {
await shim.fsDriver().mkdir(dest);
const files = await shim.fsDriver().readDirStats(source);
for (const file of files) {
const source_ = `${source}/${file.path}`;
const dest_ = `${dest}/${file.path}`;
if (!file.isDirectory()) {
reg.logger().info(`Copying profile: ${source_} => ${dest_}`);
await shim.fsDriver().copy(source_, dest_);
} else {
await copyFiles(source_, dest_);
}
}
};
await copyFiles(dbPath, exportPath);
await copyFiles(Setting.value('resourceDir'), resourcePath);
alert('Profile has been exported!');
} catch (error) {
alert(`Could not export files: ${error.message}`);
}
};
export default exportProfile;

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { ConfigScreenStyleSheet } from './configScreenStyles';
import { View, Text, LayoutChangeEvent } from 'react-native';
interface Props {
styles: ConfigScreenStyleSheet;
title: string;
onLayout?: (event: LayoutChangeEvent)=> void;
}
const SectionHeader: React.FunctionComponent<Props> = props => {
return (
<View
style={props.styles.headerWrapperStyle}
onLayout={props.onLayout}
>
<Text style={props.styles.headerTextStyle}>
{props.title}
</Text>
</View>
);
};
export default SectionHeader;

View File

@ -0,0 +1,101 @@
import * as React from 'react';
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
import { FunctionComponent, useEffect, useMemo, useState } from 'react';
import { ConfigScreenStyles } from './configScreenStyles';
import { FlatList, Text, Pressable, View } from 'react-native';
import { settingsSections } from '@joplin/lib/components/shared/config/config-shared';
import Icon from '../../Icon';
interface Props {
styles: ConfigScreenStyles;
width: number|undefined;
settings: any;
selectedSectionName: string|null;
openSection: (sectionName: string)=> void;
}
const SectionSelector: FunctionComponent<Props> = props => {
const sections = useMemo(() => {
return settingsSections({ device: AppType.Mobile, settings: props.settings });
}, [props.settings]);
const styles = props.styles.styleSheet;
const itemHeight = styles.sidebarButton.height;
const onRenderButton = ({ item }: { item: SettingMetadataSection }) => {
const section = item;
const selected = props.selectedSectionName === section.name;
const icon = Setting.sectionNameToIcon(section.name, AppType.Mobile);
const label = Setting.sectionNameToLabel(section.name);
const shortDescription = Setting.sectionMetadataToSummary(section);
return (
<Pressable
key={section.name}
role='tab'
aria-selected={selected}
onPress={() => props.openSection(section.name)}
style={selected ? styles.selectedSidebarButton : styles.sidebarButton}
>
<Icon
name={icon}
accessibilityLabel={null}
style={styles.sidebarIcon}
/>
<View style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<Text
style={selected ? styles.sidebarSelectedButtonText : styles.sidebarButtonMainText}
>
{label}
</Text>
<Text
style={styles.sidebarButtonDescriptionText}
numberOfLines={2}
ellipsizeMode='tail'
>
{shortDescription ?? ''}
</Text>
</View>
</Pressable>
);
};
const [flatListRef, setFlatListRef] = useState<FlatList|null>(null);
useEffect(() => {
if (flatListRef && props.selectedSectionName) {
let selectedIndex = 0;
for (const section of sections) {
if (section.name === props.selectedSectionName) {
break;
}
selectedIndex ++;
}
flatListRef.scrollToIndex({
index: selectedIndex,
viewPosition: 0.5,
});
}
}, [props.selectedSectionName, flatListRef, sections]);
return (
<View style={{ width: props.width, flexDirection: 'column' }}>
<FlatList
role='tablist'
ref={setFlatListRef}
data={sections}
renderItem={onRenderButton}
keyExtractor={item => item.name}
getItemLayout={(_data, index) => ({
length: itemHeight, offset: itemHeight * index, index,
})}
/>
</View>
);
};
export default SectionSelector;

View File

@ -0,0 +1,156 @@
import * as React from 'react';
import { UpdateSettingValueCallback } from './types';
import { View, Text, TextInput } from 'react-native';
import Setting from '@joplin/lib/models/Setting';
import Dropdown from '../../Dropdown';
import { ConfigScreenStyles } from './configScreenStyles';
import Slider from '@react-native-community/slider';
import SettingsToggle from './SettingsToggle';
import FileSystemPathSelector from './FileSystemPathSelector';
import shim from '@joplin/lib/shim';
const { themeStyle } = require('../../global-style.js');
interface Props {
settingId: string;
// The value associated with the given settings key
value: any;
styles: ConfigScreenStyles;
themeId: number;
updateSettingValue: UpdateSettingValueCallback;
}
const SettingComponent: React.FunctionComponent<Props> = props => {
const themeId = props.themeId;
const theme = themeStyle(themeId);
const output: any = null;
const md = Setting.settingMetadata(props.settingId);
const settingDescription = md.description ? md.description() : '';
const styleSheet = props.styles.styleSheet;
const descriptionComp = !settingDescription ? null : <Text style={styleSheet.settingDescriptionText}>{settingDescription}</Text>;
const containerStyle = props.styles.getContainerStyle(!!settingDescription);
if (md.isEnum) {
const value = props.value.toString();
const items = Setting.enumOptionsToValueLabels(md.options(), md.optionsOrder ? md.optionsOrder() : []);
return (
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View style={containerStyle}>
<Text key="label" style={styleSheet.settingText}>
{md.label()}
</Text>
<Dropdown
key="control"
items={items as any}
selectedValue={value}
itemListStyle={{
backgroundColor: theme.backgroundColor,
}}
headerStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
itemStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
onValueChange={(itemValue: string) => {
void props.updateSettingValue(props.settingId, itemValue);
}}
/>
</View>
{descriptionComp}
</View>
);
} else if (md.type === Setting.TYPE_BOOL) {
return (
<SettingsToggle
settingId={props.settingId}
value={props.value}
themeId={props.themeId}
styles={props.styles}
label={md.label()}
updateSettingValue={props.updateSettingValue}
description={descriptionComp}
/>
);
} else if (md.type === Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(props.value) : props.value;
const minimum = 'minimum' in md ? md.minimum : 0;
const maximum = 'maximum' in md ? md.maximum : 10;
// Note: Do NOT add the minimumTrackTintColor and maximumTrackTintColor props
// on the Slider as they are buggy and can crash the app on certain devices.
// https://github.com/laurent22/joplin/issues/2733
// https://github.com/react-native-community/react-native-slider/issues/161
return (
<View key={props.settingId} style={styleSheet.settingContainer}>
<Text key="label" style={styleSheet.settingText}>
{md.label()}
</Text>
<View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flex: 1 }}>
<Text style={styleSheet.sliderUnits}>{unitLabel}</Text>
<Slider
key="control"
style={{ flex: 1 }}
step={md.step}
minimumValue={minimum}
maximumValue={maximum}
value={props.value}
onValueChange={newValue => void props.updateSettingValue(props.settingId, newValue)}
/>
</View>
</View>
);
} else if (md.type === Setting.TYPE_STRING) {
if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) {
return (
<FileSystemPathSelector
styles={props.styles}
settingMetadata={md}
updateSettingValue={props.updateSettingValue}
/>
);
}
return (
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View key={props.settingId} style={containerStyle}>
<Text key="label" style={styleSheet.settingText}>
{md.label()}
</Text>
<TextInput
autoCorrect={false}
autoComplete="off"
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.settingKeyboardAppearance}
autoCapitalize="none"
key="control"
style={styleSheet.settingControl}
value={props.value}
onChangeText={(newValue: string) => void props.updateSettingValue(props.settingId, newValue)}
secureTextEntry={!!md.secure}
/>
</View>
{descriptionComp}
</View>
);
} else if (md.type === Setting.TYPE_BUTTON) {
// TODO: Not yet supported
} else if (Setting.value('env') === 'dev') {
throw new Error(`Unsupported setting type: ${md.type}`);
}
return output;
};
export default SettingComponent;

View File

@ -6,25 +6,27 @@ import { ConfigScreenStyles } from './configScreenStyles';
interface Props {
title: string;
description: string;
description?: string;
clickHandler: ()=> void;
styles: ConfigScreenStyles;
disabled?: boolean;
statusComponent?: ReactNode;
}
const ConfigScreenButton: FunctionComponent<Props> = props => {
const SettingsButton: FunctionComponent<Props> = props => {
const styles = props.styles.styleSheet;
let descriptionComp = null;
if (props.description) {
descriptionComp = (
<View style={{ flex: 1, marginTop: 10 }}>
<Text style={props.styles.descriptionText}>{props.description}</Text>
<Text style={styles.descriptionText}>{props.description}</Text>
</View>
);
}
return (
<View style={props.styles.settingContainer}>
<View style={styles.settingContainer}>
<View style={{ flex: 1, flexDirection: 'column' }}>
<View style={{ flex: 1 }}>
<Button title={props.title} onPress={props.clickHandler} disabled={!!props.disabled} />
@ -35,4 +37,4 @@ const ConfigScreenButton: FunctionComponent<Props> = props => {
</View>
);
};
export default ConfigScreenButton;
export default SettingsButton;

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { FunctionComponent, ReactNode } from 'react';
import { View, Text, Switch } from 'react-native';
import { UpdateSettingValueCallback } from './types';
import { themeStyle } from '@joplin/lib/theme';
import { ConfigScreenStyles } from './configScreenStyles';
interface Props {
settingId: string;
value: any;
themeId: number;
styles: ConfigScreenStyles;
label: string;
updateSettingValue: UpdateSettingValueCallback;
description?: ReactNode;
}
const SettingsToggle: FunctionComponent<Props> = props => {
const theme = themeStyle(props.themeId);
const styleSheet = props.styles.styleSheet;
return (
<View>
<View style={props.styles.getContainerStyle(false)}>
<Text key="label" style={styleSheet.switchSettingText}>
{props.label}
</Text>
<Switch
key="control"
style={styleSheet.switchSettingControl}
trackColor={{ false: theme.dividerColor }}
value={props.value}
onValueChange={(value: boolean) => void props.updateSettingValue(props.settingId, value)}
/>
</View>
{props.description}
</View>
);
};
export default SettingsToggle;

View File

@ -1,13 +1,16 @@
import { TextStyle, ViewStyle, StyleSheet } from 'react-native';
const { themeStyle } = require('../../global-style.js');
export interface ConfigScreenStyles {
type SidebarButtonStyle = ViewStyle & { height: number };
export interface ConfigScreenStyleSheet {
body: ViewStyle;
settingContainer: ViewStyle;
settingContainerNoBottomBorder: ViewStyle;
headerWrapperStyle: ViewStyle;
headerTextStyle: TextStyle;
settingText: TextStyle;
linkText: TextStyle;
descriptionText: TextStyle;
@ -22,9 +25,24 @@ export interface ConfigScreenStyles {
switchSettingContainer: ViewStyle;
switchSettingControl: TextStyle;
sidebarButton: SidebarButtonStyle;
sidebarIcon: TextStyle;
selectedSidebarButton: SidebarButtonStyle;
sidebarButtonMainText: TextStyle;
sidebarSelectedButtonText: TextStyle;
sidebarButtonDescriptionText: TextStyle;
settingControl: TextStyle;
}
export interface ConfigScreenStyles {
styleSheet: ConfigScreenStyleSheet;
selectedSectionButtonColor: string;
keyboardAppearance: 'default'|'light'|'dark';
getContainerStyle(hasDescription: boolean): ViewStyle;
}
const configScreenStyles = (themeId: number): ConfigScreenStyles => {
const theme = themeStyle(themeId);
@ -54,7 +72,29 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
borderBottomColor: theme.dividerColor,
};
const styles: ConfigScreenStyles = {
const sidebarButtonHeight = theme.fontSize * 4 + 5;
const sidebarButton: SidebarButtonStyle = {
height: sidebarButtonHeight,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingEnd: theme.marginRight,
};
const sidebarButtonMainText: TextStyle = {
color: theme.color,
fontSize: theme.fontSize,
};
const sidebarButtonDescriptionText: TextStyle = {
...sidebarButtonMainText,
fontSize: theme.fontSizeSmaller,
color: theme.color,
opacity: 0.75,
};
const styles: ConfigScreenStyleSheet = {
body: {
flex: 1,
justifyContent: 'flex-start',
@ -119,6 +159,8 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
justifyContent: 'space-between',
},
headerTextStyle: theme.headerStyle,
headerWrapperStyle: {
...settingContainerStyle,
...theme.headerWrapperStyle,
@ -129,9 +171,39 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
color: undefined,
flex: 0,
},
sidebarButton,
selectedSidebarButton: {
...sidebarButton,
backgroundColor: theme.selectedColor,
},
sidebarButtonMainText: sidebarButtonMainText,
sidebarIcon: {
...sidebarButtonMainText,
textAlign: 'center',
fontSize: 18,
width: sidebarButtonHeight * 0.8,
},
sidebarSelectedButtonText: {
...sidebarButtonMainText,
fontWeight: 'bold',
},
sidebarButtonDescriptionText,
};
return StyleSheet.create(styles);
const styleSheet = StyleSheet.create(styles);
return {
styleSheet,
selectedSectionButtonColor: theme.selectedColor,
keyboardAppearance: theme.keyboardAppearance,
getContainerStyle: (hasDescription) => {
return !hasDescription ? styleSheet.settingContainer : styleSheet.settingContainerNoBottomBorder;
},
};
};
export default configScreenStyles;

View File

@ -0,0 +1,10 @@
import { ReactElement } from 'react';
export interface CustomSettingSection {
component: ReactElement;
icon: string;
title: string;
keywords: string[];
}
export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>;

View File

@ -32,7 +32,7 @@ const { Checkbox } = require('../checkbox.js');
import { _, currentLocale } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const { themeStyle, editorFont } = require('../global-style.js');
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;

View File

@ -13,7 +13,7 @@ import { _ } from '@joplin/lib/locale';
import ActionButton from '../ActionButton';
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const { BackButtonService } = require('../../services/back-button.js');
import { AppState } from '../../utils/types';

View File

@ -4,7 +4,7 @@ const { View, Button, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView
const { connect } = require('react-redux');
const { ScreenHeader } = require('../ScreenHeader');
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const DialogBox = require('react-native-dialogbox').default;
const { dialogs } = require('../../utils/dialogs.js');
const Shared = require('@joplin/lib/components/shared/dropbox-login-shared');

View File

@ -5,7 +5,7 @@ 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 { BaseScreenComponent } = require('../base-screen');
const { dialogs } = require('../../utils/dialogs.js');
const { _ } = require('@joplin/lib/locale');
const { default: FolderPicker } = require('../FolderPicker');

View File

@ -7,7 +7,7 @@ const { connect } = require('react-redux');
const { ScreenHeader } = require('../ScreenHeader');
const { reg } = require('@joplin/lib/registry.js');
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const parseUri = require('@joplin/lib/parseUri');
const { themeStyle } = require('../global-style.js');
const shim = require('@joplin/lib/shim').default;

View File

@ -7,7 +7,7 @@ const Icon = require('react-native-vector-icons/Ionicons').default;
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
const { NoteItem } = require('../note-item.js');
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const { themeStyle } = require('../global-style.js');
const DialogBox = require('react-native-dialogbox').default;
import SearchEngineUtils from '@joplin/lib/services/searchengine/SearchEngineUtils';

View File

@ -6,7 +6,7 @@ const { connect } = require('react-redux');
const { ScreenHeader } = require('../ScreenHeader');
const ReportService = require('@joplin/lib/services/ReportService').default;
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
const { themeStyle } = require('../global-style.js');
class StatusScreenComponent extends BaseScreenComponent {

View File

@ -6,7 +6,7 @@ const Tag = require('@joplin/lib/models/Tag').default;
const { themeStyle } = require('../global-style.js');
const { ScreenHeader } = require('../ScreenHeader');
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js');
const { BaseScreenComponent } = require('../base-screen');
class TagsScreenComponent extends BaseScreenComponent {
static navigationOptions() {

View File

@ -1,24 +1,48 @@
const Setting = require('../../../models/Setting').default;
const SyncTargetRegistry = require('../../../SyncTargetRegistry').default;
import Setting, { AppType } from '../../../models/Setting';
import SyncTargetRegistry from '../../../SyncTargetRegistry';
const ObjectUtils = require('../../../ObjectUtils');
const { _ } = require('../../../locale');
const { createSelector } = require('reselect');
const Logger = require('@joplin/utils/Logger').default;
import { createSelector } from 'reselect';
import Logger from '@joplin/utils/Logger';
import { type ReactNode } from 'react';
import { type Registry } from '../../../registry';
const logger = Logger.create('config-shared');
const shared = {};
interface ConfigScreenState {
checkSyncConfigResult: { ok: boolean; errorMessage: string }|'checking'|null;
settings: any;
changedSettingKeys: string[];
showAdvancedSettings: boolean;
}
shared.onSettingsSaved = () => {};
export const defaultScreenState: ConfigScreenState = {
checkSyncConfigResult: null,
settings: {},
changedSettingKeys: [],
showAdvancedSettings: false,
};
shared.init = function(comp, reg) {
if (!comp.state) comp.state = {};
comp.state.checkSyncConfigResult = null;
comp.state.settings = {};
comp.state.changedSettingKeys = [];
comp.state.showAdvancedSettings = false;
interface ConfigScreenComponent {
settingToComponent(settingId: string, setting: any): ReactNode;
sectionToComponent(sectionName: string, section: any, settings: any, isSelected: boolean): ReactNode;
shared.onSettingsSaved = (event) => {
state: Partial<ConfigScreenState>;
setState(callbackOrNew: any, callback?: ()=> void): void;
}
interface SettingsSavedEvent {
savedSettingKeys: string[];
}
type OnSettingsSavedCallback = (event: SettingsSavedEvent)=> void;
let onSettingsSaved: OnSettingsSavedCallback = () => {};
export const init = (reg: Registry) => {
onSettingsSaved = (event) => {
const savedSettingKeys = event.savedSettingKeys;
// After changing the sync settings we immediately trigger a sync
@ -34,13 +58,13 @@ shared.init = function(comp, reg) {
};
};
shared.advancedSettingsButton_click = (comp) => {
comp.setState(state => {
export const advancedSettingsButton_click = (comp: ConfigScreenComponent) => {
comp.setState((state: ConfigScreenState) => {
return { showAdvancedSettings: !state.showAdvancedSettings };
});
};
shared.checkSyncConfig = async function(comp, settings) {
export const checkSyncConfig = async (comp: ConfigScreenComponent, settings: any) => {
const syncTargetId = settings['sync.target'];
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId);
@ -54,12 +78,12 @@ shared.checkSyncConfig = async function(comp, settings) {
if (result.ok) {
// Users often expect config to be auto-saved at this point, if the config check was successful
shared.saveSettings(comp);
saveSettings(comp);
}
return result;
};
shared.checkSyncConfigMessages = function(comp) {
export const checkSyncConfigMessages = (comp: ConfigScreenComponent) => {
const result = comp.state.checkSyncConfigResult;
const output = [];
@ -75,10 +99,10 @@ shared.checkSyncConfigMessages = function(comp) {
return output;
};
shared.updateSettingValue = function(comp, key, value, callback = null) {
export const updateSettingValue = (comp: ConfigScreenComponent, key: string, value: any, callback?: ()=> void) => {
if (!callback) callback = () => {};
comp.setState(state => {
comp.setState((state: ConfigScreenState) => {
// @react-native-community/slider (4.4.0) will emit a valueChanged event
// when the component is mounted, even though the value hasn't changed.
// We should ignore this, otherwise it will mark the settings as
@ -104,16 +128,17 @@ shared.updateSettingValue = function(comp, key, value, callback = null) {
}, callback);
};
shared.scheduleSaveSettings = function(comp) {
if (shared.scheduleSaveSettingsIID) clearTimeout(shared.scheduleSaveSettingsIID);
let scheduleSaveSettingsIID: ReturnType<typeof setTimeout>|null = null;
export const scheduleSaveSettings = (comp: ConfigScreenComponent) => {
if (scheduleSaveSettingsIID) clearTimeout(scheduleSaveSettingsIID);
shared.scheduleSaveSettingsIID = setTimeout(() => {
shared.scheduleSaveSettingsIID = null;
shared.saveSettings(comp);
scheduleSaveSettingsIID = setTimeout(() => {
scheduleSaveSettingsIID = null;
saveSettings(comp);
}, 100);
};
shared.saveSettings = function(comp) {
export const saveSettings = (comp: ConfigScreenComponent) => {
const savedSettingKeys = comp.state.changedSettingKeys.slice();
for (const key in comp.state.settings) {
@ -124,10 +149,10 @@ shared.saveSettings = function(comp) {
comp.setState({ changedSettingKeys: [] });
shared.onSettingsSaved({ savedSettingKeys });
onSettingsSaved({ savedSettingKeys });
};
shared.settingsToComponents = function(comp, device, settings) {
export const settingsToComponents = (comp: ConfigScreenComponent, device: AppType, settings: any) => {
const keys = Setting.keys(true, device);
const settingComps = [];
@ -146,10 +171,11 @@ shared.settingsToComponents = function(comp, device, settings) {
return settingComps;
};
const deviceSelector = (state) => state.device;
const settingsSelector = (state) => state.settings;
type SettingsSelectorState = { device: AppType; settings: any };
const deviceSelector = (state: SettingsSelectorState) => state.device;
const settingsSelector = (state: SettingsSelectorState) => state.settings;
shared.settingsSections = createSelector(
export const settingsSections = createSelector(
deviceSelector,
settingsSelector,
(device, settings) => {
@ -168,23 +194,34 @@ shared.settingsSections = createSelector(
const output = Setting.groupMetadatasBySections(metadatas);
output.push({
name: 'encryption',
metadatas: [],
isScreen: true,
});
if (device === AppType.Desktop || device === AppType.Cli) {
output.push({
name: 'encryption',
metadatas: [],
isScreen: true,
});
output.push({
name: 'server',
metadatas: [],
isScreen: true,
});
output.push({
name: 'server',
metadatas: [],
isScreen: true,
});
output.push({
name: 'keymap',
metadatas: [],
isScreen: true,
});
output.push({
name: 'keymap',
metadatas: [],
isScreen: true,
});
} else {
output.push(...([
'tools', 'export', 'moreInfo',
].map(name => {
return {
name,
metadatas: [],
};
})));
}
// Ideallly we would also check if the user was able to synchronize
// but we don't have a way of doing that besides making a request to Joplin Cloud
@ -209,9 +246,11 @@ shared.settingsSections = createSelector(
},
);
shared.settingsToComponents2 = function(comp, device, settings, selectedSectionName = '') {
const sectionComps = [];
const sections = shared.settingsSections({ device, settings });
export const settingsToComponents2 = (
comp: ConfigScreenComponent, device: AppType, settings: any, selectedSectionName = '',
) => {
const sectionComps: ReactNode[] = [];
const sections = settingsSections({ device, settings });
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
@ -222,5 +261,3 @@ shared.settingsToComponents2 = function(comp, device, settings, selectedSectionN
return sectionComps;
};
module.exports = shared;

View File

@ -90,7 +90,7 @@ export interface SettingItem {
isGlobal?: boolean;
}
interface SettingItems {
export interface SettingItems {
[key: string]: SettingItem;
}
@ -221,6 +221,13 @@ const userSettingMigration: UserSettingMigration[] = [
},
];
export type SettingMetadataSection = {
name: string;
isScreen?: boolean;
metadatas: SettingItem[];
};
export type MetadataBySection = SettingMetadataSection[];
class Setting extends BaseModel {
public static schemaUrl = 'https://joplinapp.org/schema/settings.json';
@ -2545,6 +2552,9 @@ class Setting extends BaseModel {
'revisionService',
'server',
'keymap',
'tools',
'export',
'moreInfo',
];
}
@ -2557,7 +2567,7 @@ class Setting extends BaseModel {
return ['encryption', 'application', 'appearance', 'joplinCloud'].includes(sectionName);
}
public static groupMetadatasBySections(metadatas: SettingItem[]) {
public static groupMetadatasBySections(metadatas: SettingItem[]): MetadataBySection {
const sections = [];
const generalSection: any = { name: 'general', metadatas: [] };
const nameToSections: any = {};
@ -2605,6 +2615,9 @@ class Setting extends BaseModel {
if (name === 'server') return _('Web Clipper');
if (name === 'keymap') return _('Keyboard Shortcuts');
if (name === 'joplinCloud') return _('Joplin Cloud');
if (name === 'tools') return _('Tools');
if (name === 'export') return _('Export');
if (name === 'moreInfo') return _('More information');
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
@ -2620,20 +2633,68 @@ class Setting extends BaseModel {
return '';
}
public static sectionNameToIcon(name: string) {
if (name === 'general') return 'icon-general';
if (name === 'sync') return 'icon-sync';
if (name === 'appearance') return 'icon-appearance';
if (name === 'note') return 'icon-note';
if (name === 'folder') return 'icon-notebooks';
if (name === 'plugins') return 'icon-plugins';
if (name === 'markdownPlugins') return 'fab fa-markdown';
if (name === 'application') return 'icon-application';
if (name === 'revisionService') return 'icon-note-history';
if (name === 'encryption') return 'icon-encryption';
if (name === 'server') return 'far fa-hand-scissors';
if (name === 'keymap') return 'fa fa-keyboard';
if (name === 'joplinCloud') return 'fa fa-cloud';
public static sectionMetadataToSummary(metadata: SettingMetadataSection): string {
// TODO: This is currently specific to the mobile app
const sectionNameToSummary: Record<string, string> = {
'general': _('Language, date format'),
'appearance': _('App theme, editor font'),
'sync': _('Sync, encryption, proxy'),
'joplinCloud': _('Email To Note, login information'),
'markdownPlugins': _('Media player, math, diagrams, table of contents'),
'note': _('Geolocation, spellcheck, editor toolbar, image resize'),
'revisionService': _('Toggle note history, keep notes for'),
'tools': _('Application log, profiles, sync status'),
'export': _('Export your data'),
'moreInfo': _('Privacy policy, donate, website'),
};
return sectionNameToSummary[metadata.name] ?? '';
}
public static sectionNameToIcon(name: string, appType: AppType) {
const nameToIconMap: Record<string, string> = {
'general': 'icon-general',
'sync': 'icon-sync',
'appearance': 'icon-appearance',
'note': 'icon-note',
'folder': 'icon-notebooks',
'plugins': 'icon-plugins',
'markdownPlugins': 'fab fa-markdown',
'application': 'icon-application',
'revisionService': 'icon-note-history',
'encryption': 'icon-encryption',
'server': 'far fa-hand-scissors',
'keymap': 'fa fa-keyboard',
'joplinCloud': 'fa fa-cloud',
'tools': 'fa fa-toolbox',
'export': 'fa fa-file-export',
'moreInfo': 'fa fa-info-circle',
};
// Icomoon icons are currently not present in the mobile app -- we override these
// below.
//
// These icons come from react-native-vector-icons.
// See https://oblador.github.io/react-native-vector-icons/
const mobileNameToIconMap: Record<string, string> = {
'general': 'fa fa-sliders-h',
'sync': 'fa fa-sync',
'appearance': 'fa fa-ruler',
'note': 'fa fa-sticky-note',
'revisionService': 'far fa-history',
'plugins': 'fa fa-puzzle-piece',
'application': 'fa fa-cog',
'encryption': 'fa fa-key',
};
// Overridden?
if (appType === AppType.Mobile && name in mobileNameToIconMap) {
return mobileNameToIconMap[name];
}
if (name in nameToIconMap) {
return nameToIconMap[name];
}
if (this.customSections_[name] && this.customSections_[name].iconName) return this.customSections_[name].iconName;

View File

@ -77,7 +77,7 @@ export interface State {
syncStarted: boolean;
syncReport: any;
searchQuery: string;
settings: any;
settings: Record<string, any>;
sharedData: any;
appState: string;
biometricsDone: boolean;

View File

@ -260,6 +260,8 @@ class Registry {
}
export type { Registry };
const reg = new Registry();
// eslint-disable-next-line import/prefer-default-export