feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,877 @@
|
||||
import * as React from 'react';
|
||||
import { Platform, Linking, View, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo, LayoutChangeEvent } from 'react-native';
|
||||
import Setting, { AppType, SettingMetadataSection, SettingValueType } from '@joplin/lib/models/Setting';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import checkPermissions from '../../../utils/checkPermissions';
|
||||
import setIgnoreTlsErrors from '../../../utils/TlsUtils';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
import BackButtonService from '../../../services/BackButtonService';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import ScreenHeader from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseScreenComponent from '../../base-screen';
|
||||
import * as shared from '@joplin/lib/components/shared/config/config-shared';
|
||||
import { shouldShowBySearch, hasNormalizedQuery } from '@joplin/lib/components/shared/config/config-search-text';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
|
||||
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
|
||||
import NoteExportButton, { exportButtonDescription, exportButtonDefaultTitle } from './NoteExportSection/NoteExportButton';
|
||||
import SettingsButton from './SettingsButton';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import SectionHeader from './SectionHeader';
|
||||
import ExportProfileButton, { exportProfileButtonTitle } from './NoteExportSection/ExportProfileButton';
|
||||
import SettingComponent from './SettingComponent';
|
||||
import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton';
|
||||
import SectionSelector from './SectionSelector';
|
||||
import { TextInput, List } from 'react-native-paper';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
|
||||
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
||||
import NoteImportButton, { importedFolderTitle, textImportExtensions } from './NoteExportSection/NoteImportButton';
|
||||
import SectionDescription from './SectionDescription';
|
||||
import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
|
||||
import getVersionInfoText from '../../../utils/getVersionInfoText';
|
||||
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
|
||||
import NoteLockConfig from './NoteLockConfig';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import SettingsToggle from './SettingsToggle';
|
||||
import { UpdateSettingValueCallback } from './types';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
|
||||
interface ConfigScreenState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Settings values are heterogeneous (string/number/boolean/object) and accessed by string key across many call sites; tightening to `unknown` forces casts everywhere
|
||||
settings: Record<string, any>;
|
||||
changedSettingKeys: string[];
|
||||
|
||||
searchQuery: string;
|
||||
searchSectionFilter: string|null;
|
||||
searching: boolean;
|
||||
|
||||
fixingSearchIndex: boolean;
|
||||
checkSyncConfigResult: { ok: boolean; errorMessage: string }|'checking'|null;
|
||||
showAdvancedSettings: boolean;
|
||||
|
||||
selectedSectionName: string|null;
|
||||
sidebarWidth: number;
|
||||
activeFolder: FolderEntity;
|
||||
}
|
||||
|
||||
interface ConfigScreenProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See ConfigScreenState.settings — same reason
|
||||
settings: Record<string, any>;
|
||||
themeId: number;
|
||||
navigation: { state?: { sectionName?: string } };
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, ConfigScreenState> {
|
||||
public static navigationOptions(): { header: null } {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
private componentsY_: Record<string, number> = {};
|
||||
private styles_: Record<number, ConfigScreenStyles> = {};
|
||||
private scrollViewRef_: React.RefObject<ScrollView>;
|
||||
|
||||
public constructor(props: ConfigScreenProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...shared.defaultScreenState,
|
||||
selectedSectionName: null,
|
||||
fixingSearchIndex: false,
|
||||
sidebarWidth: 100,
|
||||
searchQuery: '',
|
||||
searching: false,
|
||||
activeFolder: null,
|
||||
};
|
||||
|
||||
this.scrollViewRef_ = React.createRef<ScrollView>();
|
||||
|
||||
shared.init(reg);
|
||||
}
|
||||
|
||||
private goToJoplinServerSamlLogin_ = async () => {
|
||||
// Save the settings to allow for sync when the user completes authentication
|
||||
await this.saveButton_press();
|
||||
|
||||
await NavService.go('JoplinServerSamlLogin');
|
||||
};
|
||||
|
||||
private logoutJoplinServerSaml_ = () => {
|
||||
Setting.setValue('sync.11.id', '');
|
||||
Setting.setValue('sync.11.userId', '');
|
||||
};
|
||||
|
||||
private checkSyncConfig_ = async () => {
|
||||
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||
const isAuthenticated = await reg.syncTarget().isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
void NavService.go('JoplinCloudLogin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// to ignore TLS errors we need to change 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');
|
||||
|
||||
const done = await shared.saveSettings(this);
|
||||
if (!done) return;
|
||||
|
||||
if (shouldSetIgnoreTlsErrors) {
|
||||
await setIgnoreTlsErrors(Setting.value('net.ignoreTlsErrors'));
|
||||
}
|
||||
};
|
||||
|
||||
private syncStatusButtonPress_ = () => {
|
||||
void NavService.go('Status');
|
||||
};
|
||||
|
||||
private manageProfilesButtonPress_ = () => {
|
||||
void NavService.go('ProfileSwitcher');
|
||||
};
|
||||
|
||||
private noteResourcesButtonPress_ = () => {
|
||||
void NavService.go('NoteResources');
|
||||
};
|
||||
|
||||
private fixSearchEngineIndexButtonPress_ = async () => {
|
||||
this.setState({ fixingSearchIndex: true });
|
||||
await SearchEngine.instance().rebuildIndex();
|
||||
this.setState({ fixingSearchIndex: false });
|
||||
};
|
||||
|
||||
private logButtonPress_ = () => {
|
||||
void NavService.go('Log');
|
||||
};
|
||||
|
||||
private deletionLogButtonPress_ = () => {
|
||||
void NavService.go('Log', { defaultFilter: 'DeleteAction' });
|
||||
};
|
||||
|
||||
private manageSharesPress_ = () => {
|
||||
void NavService.go('ShareManager');
|
||||
};
|
||||
|
||||
private setShowSearch_(searching: boolean) {
|
||||
if (searching !== this.state.searching) {
|
||||
this.setState({ searching });
|
||||
AccessibilityInfo.announceForAccessibility(searching ? _('Search shown') : _('Search hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
private onSearchButtonPress_ = () => {
|
||||
this.setShowSearch_(!this.state.searching);
|
||||
};
|
||||
|
||||
private onSearchUpdate_ = (newQuery: string) => {
|
||||
this.setState({ searchQuery: newQuery });
|
||||
};
|
||||
|
||||
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 onJumpToSection_ = (section: string) => {
|
||||
const label = Setting.sectionNameToLabel(section);
|
||||
AccessibilityInfo.announceForAccessibility(_('Opening section %s', label));
|
||||
this.setState({
|
||||
selectedSectionName: section,
|
||||
searching: false,
|
||||
});
|
||||
};
|
||||
|
||||
private showSectionNavigation_ = () => {
|
||||
this.setState({ selectedSectionName: null });
|
||||
};
|
||||
|
||||
public async checkFilesystemPermission() {
|
||||
if (Platform.OS !== 'android') {
|
||||
// Not implemented yet
|
||||
return true;
|
||||
}
|
||||
return await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { rationale: {
|
||||
title: _('Information'),
|
||||
message: _('In order to use file system synchronisation your permission to write to external storage is required.'),
|
||||
buttonPositive: _('OK'),
|
||||
} });
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
this.setState({ settings: this.props.settings });
|
||||
}
|
||||
|
||||
public styles(): ConfigScreenStyles {
|
||||
const themeId = this.props.themeId;
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
this.styles_[themeId] = configScreenStyles(themeId);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
private onHeaderLayout(key: string, event: LayoutChangeEvent) {
|
||||
const layout = event.nativeEvent.layout;
|
||||
this.componentsY_[`header_${key}`] = layout.y;
|
||||
}
|
||||
|
||||
private onSectionLayout(key: string, event: LayoutChangeEvent) {
|
||||
const layout = event.nativeEvent.layout;
|
||||
this.componentsY_[`section_${key}`] = layout.y;
|
||||
}
|
||||
|
||||
private componentY(key: string): number {
|
||||
if ((`section_${key}`) in this.componentsY_) return this.componentsY_[`section_${key}`];
|
||||
if ((`header_${key}`) in this.componentsY_) return this.componentsY_[`header_${key}`];
|
||||
console.error(`ConfigScreen: Could not find key to scroll to: ${key}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private hasUnsavedChanges() {
|
||||
return this.state.changedSettingKeys.length > 0;
|
||||
}
|
||||
|
||||
private async promptSaveChanges(): Promise<void> {
|
||||
if (this.hasUnsavedChanges()) {
|
||||
const response = await shim.showMessageBox(_('There are unsaved changes.'), {
|
||||
buttons: [_('Discard changes'), _('Save changes')],
|
||||
cancelId: 0,
|
||||
});
|
||||
if (response === 1) {
|
||||
await this.saveButton_press();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleNavigateToNewScreen = async (): Promise<boolean> => {
|
||||
await this.promptSaveChanges();
|
||||
|
||||
// Continue navigation
|
||||
return false;
|
||||
};
|
||||
|
||||
private handleBackButtonPress = (): boolean => {
|
||||
const goBack = async () => {
|
||||
BackButtonService.removeHandler(this.handleBackButtonPress);
|
||||
await BackButtonService.back();
|
||||
};
|
||||
|
||||
// Cancel search on back
|
||||
if (this.state.searching) {
|
||||
this.setShowSearch_(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show navigation when pressing "back" (unless always visible).
|
||||
if (this.state.selectedSectionName && this.navigationFillsScreen()) {
|
||||
this.showSectionNavigation_();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasUnsavedChanges()) {
|
||||
void (async () => {
|
||||
await this.promptSaveChanges();
|
||||
await goBack();
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
if (this.props.navigation.state.sectionName) {
|
||||
this.setState({ selectedSectionName: this.props.navigation.state.sectionName });
|
||||
setTimeout(() => {
|
||||
this.scrollViewRef_.current.scrollTo({
|
||||
x: 0,
|
||||
y: this.componentY(this.props.navigation.state.sectionName),
|
||||
animated: true,
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
BackButtonService.addHandler(this.handleBackButtonPress);
|
||||
NavService.addHandler(this.handleNavigateToNewScreen);
|
||||
Dimensions.addEventListener('change', this.updateSidebarWidth);
|
||||
this.updateSidebarWidth();
|
||||
|
||||
const activeFolder = await Folder.getValidActiveFolder();
|
||||
this.setState({ activeFolder });
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
BackButtonService.removeHandler(this.handleBackButtonPress);
|
||||
NavService.removeHandler(this.handleNavigateToNewScreen);
|
||||
}
|
||||
|
||||
private renderButton(key: string, title: string, clickHandler: ()=> void, options: { description?: string; statusComp?: ReactElement; disabled?: boolean } = null) {
|
||||
return (
|
||||
<SettingsButton
|
||||
key={key}
|
||||
title={title}
|
||||
clickHandler={clickHandler}
|
||||
description={options?.description}
|
||||
statusComponent={options?.statusComp}
|
||||
styles={this.styles()}
|
||||
disabled={options?.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private onSettingButtonPress_ = async (key: string) => {
|
||||
await shared.onSettingButtonPress(this, Setting.settingMetadata(key));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See ConfigScreenState.settings — same reason
|
||||
public sectionToComponent(key: string, section: SettingMetadataSection, settings: Record<string, any>, isSelected: boolean) {
|
||||
const settingComps: ReactElement[] = [];
|
||||
const advancedSettingComps: ReactElement[] = [];
|
||||
|
||||
const headerTitle = Setting.sectionNameToLabel(section.name);
|
||||
const sectionDescription = Setting.sectionDescription(key, AppType.Mobile);
|
||||
if (sectionDescription && !this.state.searching) {
|
||||
settingComps.push(
|
||||
<SectionDescription
|
||||
key='section-description'
|
||||
content={sectionDescription}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const matchesSearchQuery = (relatedText: string|string[]) => {
|
||||
return shouldShowBySearch(this.state.searchQuery, headerTitle, relatedText);
|
||||
};
|
||||
|
||||
const addSettingComponent = (
|
||||
component: ReactElement,
|
||||
relatedText: string|string[],
|
||||
settingMetadata?: { advanced?: boolean },
|
||||
) => {
|
||||
const hiddenBySearch = this.state.searching && hasNormalizedQuery(this.state.searchQuery) && !matchesSearchQuery(relatedText);
|
||||
if (component && !hiddenBySearch) {
|
||||
if (settingMetadata?.advanced) {
|
||||
advancedSettingComps.push(component);
|
||||
} else {
|
||||
settingComps.push(component);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addSettingButton = (key: string, title: string, clickHandler: ()=> void, options: { description?: string; statusComp?: ReactElement; disabled?: boolean } = null) => {
|
||||
const relatedText = [title];
|
||||
if (typeof options === 'object' && options?.description) {
|
||||
relatedText.push(options.description);
|
||||
}
|
||||
addSettingComponent(this.renderButton(key, title, clickHandler, options), relatedText);
|
||||
};
|
||||
|
||||
const styleSheet = this.styles().styleSheet;
|
||||
const addSettingLink = (key: string, title: string, target: string) => {
|
||||
const component = (
|
||||
<View key={key} style={styleSheet.settingContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
void Linking.openURL(target);
|
||||
}}
|
||||
accessibilityRole='link'
|
||||
>
|
||||
<Text key="label" style={styleSheet.linkText}>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
addSettingComponent(component, title);
|
||||
};
|
||||
|
||||
const addSettingText = (key: string, text: string) => {
|
||||
addSettingComponent(
|
||||
<View key={key} style={styleSheet.settingContainer}>
|
||||
<Text style={styleSheet.settingText}>{text}</Text>
|
||||
</View>,
|
||||
text,
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < section.metadatas.length; i++) {
|
||||
const md = section.metadatas[i];
|
||||
|
||||
// Handled below
|
||||
if (md.key === 'plugins.states') continue;
|
||||
|
||||
if (section.name === 'sync' && md.key === 'sync.resourceDownloadMode') {
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
|
||||
if (syncTargetMd.supportsConfigCheck) {
|
||||
const messages = shared.checkSyncConfigMessages(this);
|
||||
const statusComp = !messages.length ? null : (
|
||||
<View style={{ flex: 1, marginTop: 10 }}>
|
||||
<Text style={this.styles().styleSheet.descriptionText}>{messages[0]}</Text>
|
||||
{messages.length >= 1 ? (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Text style={this.styles().styleSheet.descriptionText}>{messages[1]}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
addSettingButton('login_joplin_server_saml_button', _('Connect using your organisation account'), this.goToJoplinServerSamlLogin_);
|
||||
|
||||
if (Setting.value('sync.11.id') !== '' || Setting.value('sync.11.userId') !== '') {
|
||||
addSettingButton('logout_joplin_server_saml_button', _('Logout'), this.logoutJoplinServerSaml_);
|
||||
}
|
||||
}
|
||||
|
||||
addSettingButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp });
|
||||
}
|
||||
}
|
||||
|
||||
const settingComp = this.settingToComponent(md.key, settings[md.key]);
|
||||
const relatedText = [md.label?.() ?? '', md.description?.(AppType.Mobile) ?? ''];
|
||||
addSettingComponent(
|
||||
settingComp,
|
||||
relatedText,
|
||||
md,
|
||||
);
|
||||
}
|
||||
|
||||
if (section.name === 'plugins') {
|
||||
const pluginStatesKey = 'plugins.states';
|
||||
const pluginService = PluginService.instance();
|
||||
|
||||
const updatePluginStates = (newSettingValue: PluginSettings) => {
|
||||
const value = pluginService.serializePluginSettings(newSettingValue);
|
||||
shared.updateSettingValue(this, pluginStatesKey, value);
|
||||
};
|
||||
|
||||
if (settings['plugins.pluginSupportEnabled']) {
|
||||
addSettingComponent(
|
||||
<PluginStates
|
||||
key={'plugin-states'}
|
||||
styles={this.styles()}
|
||||
themeId={this.props.themeId}
|
||||
pluginSettings={settings[pluginStatesKey]}
|
||||
|
||||
updatePluginStates={updatePluginStates}
|
||||
shouldShowBasedOnSearchQuery={this.state.searching ? matchesSearchQuery : null}
|
||||
/>,
|
||||
getPluginStatesSearchText(),
|
||||
);
|
||||
|
||||
if (canInstallPluginsFromFile()) {
|
||||
addSettingComponent(
|
||||
<PluginUploadButton
|
||||
key='plugins-install-from-file'
|
||||
pluginSettings={settings[pluginStatesKey]}
|
||||
updatePluginStates={updatePluginStates}
|
||||
styles={this.styles()}
|
||||
/>,
|
||||
pluginUploadButtonSearchText(),
|
||||
{ advanced: true },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const enablePluginSupport = () => {
|
||||
shared.updateSettingValue(this, 'plugins.pluginSupportEnabled', true);
|
||||
};
|
||||
addSettingComponent(
|
||||
<EnablePluginSupportPage
|
||||
key='plugin-support-disabled-screen'
|
||||
themeId={this.props.themeId}
|
||||
onEnablePluginSupport={enablePluginSupport}
|
||||
/>,
|
||||
['plugins', _('Plugins')],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (section.name === 'sync') {
|
||||
addSettingButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_);
|
||||
}
|
||||
|
||||
if (section.name === 'noteLock' && isNoteLockEnabled()) {
|
||||
addSettingComponent(<NoteLockConfig key='note-lock-config'/>, [_('Password setup'), _('Note lock password')]);
|
||||
}
|
||||
|
||||
if (section.name === 'joplinCloud') {
|
||||
addSettingComponent(
|
||||
<JoplinCloudConfig
|
||||
key="joplin-cloud-config"
|
||||
accountType={this.props.settings['sync.10.accountType']}
|
||||
inboxEmail={this.props.settings['sync.10.inboxEmail']}
|
||||
userEmail={this.props.settings['sync.10.userEmail']}
|
||||
website={this.props.settings['sync.10.website']}
|
||||
styles={this.styles()}
|
||||
/>,
|
||||
[emailToNoteDescription(), emailToNoteLabel()],
|
||||
);
|
||||
}
|
||||
|
||||
if (section.name === 'tools') {
|
||||
addSettingButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_);
|
||||
addSettingButton('note_resources_button', _('Note attachments'), this.noteResourcesButtonPress_);
|
||||
addSettingButton('status_button', _('Sync Status'), this.syncStatusButtonPress_);
|
||||
addSettingButton('log_button', _('Log'), this.logButtonPress_);
|
||||
addSettingButton('deletion_log_button', _('Deletion log'), this.deletionLogButtonPress_);
|
||||
addSettingButton('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.') });
|
||||
const syncTargetInfo = SyncTargetRegistry.infoById(this.state.settings['sync.target']);
|
||||
if (syncTargetInfo.supportsShare) {
|
||||
addSettingButton('manage_shares_button', _('Manage shared notebooks'), this.manageSharesPress_);
|
||||
}
|
||||
}
|
||||
|
||||
if (section.name === 'importOrExport') {
|
||||
addSettingComponent(
|
||||
<NoteExportButton key='export_as_jex_button' styles={this.styles()} />,
|
||||
[exportButtonDefaultTitle(), exportButtonDescription()],
|
||||
);
|
||||
const importJexLabel = () => _('Import from JEX');
|
||||
const importJexDescription = () => _('Import notes from a JEX (Joplin Export) file.');
|
||||
addSettingComponent(
|
||||
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
|
||||
[importJexLabel(), importJexDescription()],
|
||||
);
|
||||
const importTxtLabel = () => _('Import from text file');
|
||||
const importTxtDescription = () => {
|
||||
let folderTitle = importedFolderTitle();
|
||||
if (this.state.activeFolder) folderTitle = this.state.activeFolder.title;
|
||||
return _('Import a note from a text file (%s). The note will be imported into notebook \'%s\'.', textImportExtensions.join(', '), substrWithEllipsis(folderTitle, 0, 32));
|
||||
};
|
||||
addSettingComponent(
|
||||
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
|
||||
[importTxtLabel(), importTxtDescription()],
|
||||
);
|
||||
addSettingComponent(
|
||||
<ExportDebugReportButton key='export_report_button' styles={this.styles()}/>,
|
||||
exportDebugReportTitle(),
|
||||
);
|
||||
addSettingComponent(
|
||||
<ExportProfileButton key='export_data' styles={this.styles()}/>,
|
||||
exportProfileButtonTitle(),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
addSettingComponent(
|
||||
<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>,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.OS !== 'ios') addSettingLink('donate_link', _('Make a donation'), 'https://joplinapp.org/donate/');
|
||||
addSettingLink('website_link', _('Joplin website'), 'https://joplinapp.org/');
|
||||
addSettingLink('privacy_link', _('Privacy Policy'), 'https://joplinapp.org/privacy/');
|
||||
addSettingLink('license_link', _('Open-source licences'), 'https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/licenses.md');
|
||||
|
||||
const versionInfoText = getVersionInfoText(settings['plugins.states']);
|
||||
|
||||
addSettingText('version_info', versionInfoText);
|
||||
addSettingButton('copy_app_info', _('Copy version info'), () => {
|
||||
Clipboard.setString(versionInfoText);
|
||||
});
|
||||
|
||||
const featureFlagKeys = Setting.featureFlagKeys(AppType.Mobile).filter(key => Setting.isPublic(key));
|
||||
if (featureFlagKeys.length) {
|
||||
const headerKey = 'featureFlags';
|
||||
const featureFlagsTitle = _('Feature flags');
|
||||
addSettingComponent(
|
||||
<SectionHeader
|
||||
key={headerKey}
|
||||
styles={this.styles().styleSheet}
|
||||
title={featureFlagsTitle}
|
||||
onLayout={event => this.onHeaderLayout(headerKey, event)}
|
||||
/>,
|
||||
_('Feature flags'),
|
||||
);
|
||||
|
||||
addSettingComponent(
|
||||
<View key="featureFlagsContainer">{this.renderFeatureFlags(settings, featureFlagKeys)}</View>,
|
||||
featureFlagsTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!settingComps.length && !advancedSettingComps.length) return null;
|
||||
if (!isSelected && !this.state.searching) return null;
|
||||
|
||||
const headerComponent = (
|
||||
<TouchableOpacity onPress={() => {
|
||||
this.onJumpToSection_(section.name);
|
||||
}}>
|
||||
<SectionHeader
|
||||
styles={styleSheet}
|
||||
title={headerTitle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderAdvancedSettings = () => {
|
||||
if (!advancedSettingComps.length) return null;
|
||||
|
||||
const toggleAdvancedLabel = _('Advanced settings');
|
||||
return (
|
||||
<List.Accordion
|
||||
title={toggleAdvancedLabel}
|
||||
expanded={this.state.showAdvancedSettings}
|
||||
onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })}
|
||||
>
|
||||
{this.state.showAdvancedSettings ? advancedSettingComps : null}
|
||||
</List.Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View key={key} onLayout={(event: LayoutChangeEvent) => this.onSectionLayout(key, event)}>
|
||||
<View>
|
||||
{this.state.searching ? headerComponent : null}
|
||||
{settingComps}
|
||||
{renderAdvancedSettings()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
private renderToggle(key: string, label: string, value: unknown, updateSettingValue: UpdateSettingValueCallback) {
|
||||
return <SettingsToggle
|
||||
key={key}
|
||||
settingId={key}
|
||||
value={!!value}
|
||||
label={label}
|
||||
updateSettingValue={updateSettingValue}
|
||||
styles={this.styles()}
|
||||
themeId={this.props.themeId}
|
||||
/>;
|
||||
}
|
||||
|
||||
public setSettingValue = async <Key extends string> (key: Key, value: SettingValueType<Key>): Promise<void> => {
|
||||
const handled = await this.handleSetting(key, value);
|
||||
if (!handled) shared.updateSettingValue(this, key, value);
|
||||
};
|
||||
|
||||
private handleSetting = async (key: string, value: unknown): 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.
|
||||
if (key === 'security.biometricsEnabled' && !!value) {
|
||||
try {
|
||||
await biometricAuthenticate();
|
||||
shared.updateSettingValue(this, key, value, async () => await this.saveButton_press());
|
||||
} catch (error) {
|
||||
shared.updateSettingValue(this, key, false);
|
||||
Alert.alert(error.message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'security.biometricsEnabled' && !value) {
|
||||
shared.updateSettingValue(this, key, value, async () => await this.saveButton_press());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public settingToComponent(key: string, value: unknown) {
|
||||
return (
|
||||
<SettingComponent
|
||||
key={key}
|
||||
settingId={key}
|
||||
value={value}
|
||||
themeId={this.props.themeId}
|
||||
onUpdateSettingValue={this.setSettingValue}
|
||||
onSettingButtonClick={this.onSettingButtonPress_}
|
||||
styles={this.styles()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See ConfigScreenState.settings — same reason
|
||||
private renderFeatureFlags(settings: Record<string, any>, featureFlagKeys: string[]): ReactElement[] {
|
||||
const output: ReactElement[] = [];
|
||||
for (const key of featureFlagKeys) {
|
||||
output.push(this.renderToggle(key, key, settings[key], this.setSettingValue));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const settings = this.state.settings;
|
||||
|
||||
const showAsSidebar = !this.navigationFillsScreen();
|
||||
|
||||
// If the navigation is a sidebar, always show a section.
|
||||
let currentSectionName = this.state.selectedSectionName;
|
||||
if (showAsSidebar && !currentSectionName) {
|
||||
currentSectionName = 'general';
|
||||
}
|
||||
|
||||
if (this.state.searching) {
|
||||
currentSectionName = null;
|
||||
}
|
||||
|
||||
const sectionSelector = (
|
||||
<SectionSelector
|
||||
selectedSectionName={currentSectionName}
|
||||
styles={this.styles()}
|
||||
settings={settings}
|
||||
openSection={this.onJumpToSection_}
|
||||
width={this.state.sidebarWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
let currentSection: ReactNode;
|
||||
if (currentSectionName || this.state.searching) {
|
||||
const settingComps = shared.settingsToComponents2(
|
||||
this, AppType.Mobile, settings, currentSectionName,
|
||||
|
||||
// TODO: Remove this cast. Currently necessary because of different versions
|
||||
// of React in lib/ and app-mobile/
|
||||
) as ReactNode[];
|
||||
|
||||
const searchInput = <TextInput
|
||||
value={this.state.searchQuery}
|
||||
label={_('Search')}
|
||||
placeholder={_('Search...')}
|
||||
onChangeText={this.onSearchUpdate_}
|
||||
autoFocus={true}
|
||||
autoCapitalize='none'
|
||||
autoComplete='off'
|
||||
autoCorrect={false}
|
||||
/>;
|
||||
|
||||
currentSection = (
|
||||
<ScrollView
|
||||
ref={this.scrollViewRef_}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
{this.state.searching ? searchInput : null}
|
||||
{settingComps}
|
||||
</ScrollView>
|
||||
);
|
||||
} else {
|
||||
currentSection = sectionSelector;
|
||||
}
|
||||
|
||||
let mainComponent;
|
||||
if (showAsSidebar && currentSectionName) {
|
||||
mainComponent = (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
}}>
|
||||
{sectionSelector}
|
||||
<View style={{ width: 10 }}/>
|
||||
{currentSection}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
mainComponent = currentSection;
|
||||
}
|
||||
|
||||
let screenHeadingText = _('Configuration');
|
||||
let showSearchButton = true;
|
||||
|
||||
if (currentSectionName) {
|
||||
screenHeadingText = Setting.sectionNameToLabel(currentSectionName);
|
||||
showSearchButton = currentSectionName !== 'plugins';
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.themeId).root}>
|
||||
<ScreenHeader
|
||||
title={screenHeadingText}
|
||||
showSaveButton={true}
|
||||
showSearchButton={showSearchButton}
|
||||
showSideMenuButton={false}
|
||||
saveButtonDisabled={!this.hasUnsavedChanges()}
|
||||
onSaveButtonPress={this.saveButton_press}
|
||||
onSearchButtonPress={this.onSearchButtonPress_}
|
||||
/>
|
||||
{mainComponent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ConfigScreen = connect((state: State) => {
|
||||
return {
|
||||
settings: state.settings,
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
})(ConfigScreenComponent);
|
||||
|
||||
export default ConfigScreen;
|
||||
在新工单中引用
屏蔽一个用户