From fe4900d25469372e2092d788d28ad2e586725adf Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 16 Aug 2021 15:20:14 +0100 Subject: [PATCH] Desktop: Add Sync Wizard dialog --- .eslintignore | 12 + .gitignore | 12 + packages/app-cli/app/command-sync.js | 2 +- packages/app-desktop/app.ts | 38 ++ .../build/images/syncTargetLogos/Dropbox.svg | 4 + .../images/syncTargetLogos/JoplinCloud.svg | 19 + .../build/images/syncTargetLogos/OneDrive.svg | 34 ++ .../gui/ConfigScreen/ConfigScreen.tsx | 17 +- packages/app-desktop/gui/Dialog.tsx | 3 +- packages/app-desktop/gui/DialogTitle.tsx | 5 +- packages/app-desktop/gui/Root.tsx | 43 ++- .../app-desktop/gui/SyncWizard/Dialog.tsx | 343 ++++++++++++++++++ .../components/screens/ConfigScreen.tsx | 2 +- packages/app-mobile/root.tsx | 2 +- packages/lib/BaseApplication.ts | 2 +- packages/lib/BaseSyncTarget.ts | 8 + packages/lib/SyncTargetAmazonS3.js | 4 + packages/lib/SyncTargetDropbox.js | 8 + packages/lib/SyncTargetJoplinCloud.ts | 8 + packages/lib/SyncTargetJoplinServer.ts | 4 + packages/lib/SyncTargetNextcloud.js | 4 + packages/lib/SyncTargetOneDrive.ts | 8 + ...argetRegistry.js => SyncTargetRegistry.ts} | 44 ++- packages/lib/SyncTargetWebDAV.js | 4 + .../lib/components/shared/config-shared.js | 2 +- .../components/shared/dropbox-login-shared.js | 2 +- packages/lib/hooks/useElementSize.ts | 38 ++ packages/lib/hooks/useEventListener.ts | 41 +++ packages/lib/models/Setting.ts | 14 +- packages/lib/registry.ts | 2 +- packages/lib/services/synchronizer/tools.ts | 2 +- packages/lib/testing/test-utils.ts | 2 +- 32 files changed, 701 insertions(+), 32 deletions(-) create mode 100644 packages/app-desktop/build/images/syncTargetLogos/Dropbox.svg create mode 100644 packages/app-desktop/build/images/syncTargetLogos/JoplinCloud.svg create mode 100644 packages/app-desktop/build/images/syncTargetLogos/OneDrive.svg create mode 100644 packages/app-desktop/gui/SyncWizard/Dialog.tsx rename packages/lib/{SyncTargetRegistry.js => SyncTargetRegistry.ts} (54%) create mode 100644 packages/lib/hooks/useElementSize.ts create mode 100644 packages/lib/hooks/useEventListener.ts diff --git a/.eslintignore b/.eslintignore index b3341f001c..f1b205b193 100644 --- a/.eslintignore +++ b/.eslintignore @@ -573,6 +573,9 @@ packages/app-desktop/gui/Sidebar/styles/index.js.map packages/app-desktop/gui/StatusScreen/StatusScreen.d.ts packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js.map +packages/app-desktop/gui/SyncWizard/Dialog.d.ts +packages/app-desktop/gui/SyncWizard/Dialog.js +packages/app-desktop/gui/SyncWizard/Dialog.js.map packages/app-desktop/gui/TagList.d.ts packages/app-desktop/gui/TagList.js packages/app-desktop/gui/TagList.js.map @@ -870,6 +873,9 @@ packages/lib/SyncTargetJoplinServer.js.map packages/lib/SyncTargetOneDrive.d.ts packages/lib/SyncTargetOneDrive.js packages/lib/SyncTargetOneDrive.js.map +packages/lib/SyncTargetRegistry.d.ts +packages/lib/SyncTargetRegistry.js +packages/lib/SyncTargetRegistry.js.map packages/lib/Synchronizer.d.ts packages/lib/Synchronizer.js packages/lib/Synchronizer.js.map @@ -927,6 +933,12 @@ packages/lib/fs-driver-node.js.map packages/lib/fsDriver.test.d.ts packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js.map +packages/lib/hooks/useElementSize.d.ts +packages/lib/hooks/useElementSize.js +packages/lib/hooks/useElementSize.js.map +packages/lib/hooks/useEventListener.d.ts +packages/lib/hooks/useEventListener.js +packages/lib/hooks/useEventListener.js.map packages/lib/htmlUtils.d.ts packages/lib/htmlUtils.js packages/lib/htmlUtils.js.map diff --git a/.gitignore b/.gitignore index 387ffae349..0e4abde230 100644 --- a/.gitignore +++ b/.gitignore @@ -558,6 +558,9 @@ packages/app-desktop/gui/Sidebar/styles/index.js.map packages/app-desktop/gui/StatusScreen/StatusScreen.d.ts packages/app-desktop/gui/StatusScreen/StatusScreen.js packages/app-desktop/gui/StatusScreen/StatusScreen.js.map +packages/app-desktop/gui/SyncWizard/Dialog.d.ts +packages/app-desktop/gui/SyncWizard/Dialog.js +packages/app-desktop/gui/SyncWizard/Dialog.js.map packages/app-desktop/gui/TagList.d.ts packages/app-desktop/gui/TagList.js packages/app-desktop/gui/TagList.js.map @@ -855,6 +858,9 @@ packages/lib/SyncTargetJoplinServer.js.map packages/lib/SyncTargetOneDrive.d.ts packages/lib/SyncTargetOneDrive.js packages/lib/SyncTargetOneDrive.js.map +packages/lib/SyncTargetRegistry.d.ts +packages/lib/SyncTargetRegistry.js +packages/lib/SyncTargetRegistry.js.map packages/lib/Synchronizer.d.ts packages/lib/Synchronizer.js packages/lib/Synchronizer.js.map @@ -912,6 +918,12 @@ packages/lib/fs-driver-node.js.map packages/lib/fsDriver.test.d.ts packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js.map +packages/lib/hooks/useElementSize.d.ts +packages/lib/hooks/useElementSize.js +packages/lib/hooks/useElementSize.js.map +packages/lib/hooks/useEventListener.d.ts +packages/lib/hooks/useEventListener.js +packages/lib/hooks/useEventListener.js.map packages/lib/htmlUtils.d.ts packages/lib/htmlUtils.js packages/lib/htmlUtils.js.map diff --git a/packages/app-cli/app/command-sync.js b/packages/app-cli/app/command-sync.js index 2219141fe7..88ef19ab94 100644 --- a/packages/app-cli/app/command-sync.js +++ b/packages/app-cli/app/command-sync.js @@ -10,7 +10,7 @@ const { cliUtils } = require('./cli-utils.js'); const md5 = require('md5'); const locker = require('proper-lockfile'); const fs = require('fs-extra'); -const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry'); +const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry').default; const MigrationHandler = require('@joplin/lib/services/synchronizer/MigrationHandler').default; class Command extends BaseCommand { diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 81a7deed82..b89c63fa23 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -114,6 +114,10 @@ interface AppStateRoute { props: any; } +export interface AppStateDialog { + name: string; +} + export interface AppState extends State { route: AppStateRoute; navHistory: any[]; @@ -130,6 +134,7 @@ export interface AppState extends State { // Extra reducer keys go here watchedResources: any; mainLayout: LayoutItem; + dialogs: AppStateDialog[]; } const appDefaultState: AppState = { @@ -150,6 +155,7 @@ const appDefaultState: AppState = { layoutMoveMode: false, mainLayout: null, startupPluginsLoaded: false, + dialogs: [], ...resourceEditWatcherDefaultState, }; @@ -370,6 +376,30 @@ class Application extends BaseApplication { } break; + case 'DIALOG_OPEN': + + { + newState = Object.assign({}, state); + const newDialogs = newState.dialogs.slice(); + + if (newDialogs.find(d => d.name === action.name)) throw new Error(`This dialog is already opened: ${action.name}`); + + newDialogs.push({ + name: action.name, + }); + newState.dialogs = newDialogs; + } + break; + + case 'DIALOG_CLOSE': + + { + newState = Object.assign({}, state); + const newDialogs = newState.dialogs.slice().filter(d => d.name !== action.name); + newState.dialogs = newDialogs; + } + break; + case 'LAYOUT_MOVE_MODE_SET': newState = { @@ -834,6 +864,14 @@ class Application extends BaseApplication { // }); // }, 5000); + + // setTimeout(() => { + // this.dispatch({ + // type: 'DIALOG_OPEN', + // name: 'syncWizard', + // }); + // }, 2000); + return null; } diff --git a/packages/app-desktop/build/images/syncTargetLogos/Dropbox.svg b/packages/app-desktop/build/images/syncTargetLogos/Dropbox.svg new file mode 100644 index 0000000000..84ac860264 --- /dev/null +++ b/packages/app-desktop/build/images/syncTargetLogos/Dropbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app-desktop/build/images/syncTargetLogos/JoplinCloud.svg b/packages/app-desktop/build/images/syncTargetLogos/JoplinCloud.svg new file mode 100644 index 0000000000..0d334a3fbd --- /dev/null +++ b/packages/app-desktop/build/images/syncTargetLogos/JoplinCloud.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/app-desktop/build/images/syncTargetLogos/OneDrive.svg b/packages/app-desktop/build/images/syncTargetLogos/OneDrive.svg new file mode 100644 index 0000000000..5ec83c96c3 --- /dev/null +++ b/packages/app-desktop/build/images/syncTargetLogos/OneDrive.svg @@ -0,0 +1,34 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + diff --git a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx index 96744342f4..4a9689de8f 100644 --- a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx @@ -11,7 +11,7 @@ import EncryptionConfigScreen from '../EncryptionConfigScreen'; const { connect } = require('react-redux'); const { themeStyle } = require('@joplin/lib/theme'); const pathUtils = require('@joplin/lib/path-utils'); -const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry'); +import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; const shared = require('@joplin/lib/components/shared/config-shared.js'); import ClipperConfigScreen from '../ClipperConfigScreen'; const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen'); @@ -94,6 +94,11 @@ class ConfigScreenComponent extends React.Component { Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalData); await Setting.saveAll(); bridge().restart(); + } else if (key === 'sync.openSyncWizard') { + this.props.dispatch({ + type: 'DIALOG_OPEN', + name: 'syncWizard', + }); } else { throw new Error(`Unhandled key: ${key}`); } @@ -606,11 +611,15 @@ class ConfigScreenComponent extends React.Component { ); } else if (md.type === Setting.TYPE_BUTTON) { + const labelComp = md.hideLabel ? null : ( +
+ +
+ ); + return (
-
- -
+ {labelComp}
diff --git a/packages/app-desktop/gui/Dialog.tsx b/packages/app-desktop/gui/Dialog.tsx index ff8456c9cc..2cd6298322 100644 --- a/packages/app-desktop/gui/Dialog.tsx +++ b/packages/app-desktop/gui/Dialog.tsx @@ -18,10 +18,11 @@ const DialogRoot = styled.div` background-color: ${props => props.theme.backgroundColor}; padding: 16px; box-shadow: 6px 6px 20px rgba(0,0,0,0.5); - margin-top: 20px; + margin: 20px; min-height: fit-content; display: flex; flex-direction: column; + border-radius: 10px; `; interface Props { diff --git a/packages/app-desktop/gui/DialogTitle.tsx b/packages/app-desktop/gui/DialogTitle.tsx index 0310ccc869..7bafa7f67e 100644 --- a/packages/app-desktop/gui/DialogTitle.tsx +++ b/packages/app-desktop/gui/DialogTitle.tsx @@ -1,6 +1,8 @@ import styled from 'styled-components'; const Root = styled.div` + display: flex; + justify-content: ${props => props.justifyContent ? props.justifyContent : 'flex-start'}; font-family: ${props => props.theme.fontFamily}; font-size: ${props => props.theme.fontSize * 1.5}px; line-height: 1.6em; @@ -12,10 +14,11 @@ const Root = styled.div` interface Props { title: string; + justifyContent?: string; } export default function DialogTitle(props: Props) { return ( - {props.title} + {props.title} ); } diff --git a/packages/app-desktop/gui/Root.tsx b/packages/app-desktop/gui/Root.tsx index 88d457ee7d..207dc4b900 100644 --- a/packages/app-desktop/gui/Root.tsx +++ b/packages/app-desktop/gui/Root.tsx @@ -1,4 +1,4 @@ -import app from '../app'; +import app, { AppState, AppStateDialog } from '../app'; import MainScreen from './MainScreen/MainScreen'; import ConfigScreen from './ConfigScreen/ConfigScreen'; import StatusScreen from './StatusScreen/StatusScreen'; @@ -10,7 +10,6 @@ import { Size } from './ResizableLayout/utils/types'; import MenuBar from './MenuBar'; import { _ } from '@joplin/lib/locale'; const React = require('react'); - const { render } = require('react-dom'); const { connect, Provider } = require('react-redux'); import Setting from '@joplin/lib/models/Setting'; @@ -19,6 +18,7 @@ import ClipperServer from '@joplin/lib/ClipperServer'; import DialogTitle from './DialogTitle'; import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow'; import Dialog from './Dialog'; +import SyncWizardDialog from './SyncWizard/Dialog'; const { ImportScreen } = require('./ImportScreen.min.js'); const { ResourceScreen } = require('./ResourceScreen.js'); const { Navigator } = require('./Navigator.min.js'); @@ -33,6 +33,7 @@ interface Props { size: Size; zoomFactor: number; needApiAuth: boolean; + dialogs: AppStateDialog; } interface ModalDialogProps { @@ -42,6 +43,24 @@ interface ModalDialogProps { onClick: ClickEventHandler; } +interface RegisteredDialogProps { + themeId: number; + key: string; + dispatch: Function; +} + +interface RegisteredDialog { + render: (props: RegisteredDialogProps)=> any; +} + +const registeredDialogs: Record = { + syncWizard: { + render: (props: RegisteredDialogProps) => { + return ; + }, + }, +}; + const GlobalStyle = createGlobalStyle` * { box-sizing: border-box; @@ -151,6 +170,22 @@ class RootComponent extends React.Component { }; } + private renderDialogs() { + if (!this.props.dialogs.length) return null; + + const output: any[] = []; + for (const dialog of this.props.dialogs) { + const md = registeredDialogs[dialog.name]; + if (!md) throw new Error(`Unknown dialog: ${dialog.name}`); + output.push(md.render({ + key: dialog.name, + themeId: this.props.themeId, + dispatch: this.props.dispatch, + })); + } + return output; + } + public render() { const navigatorStyle = { width: this.props.size.width / this.props.zoomFactor, @@ -176,19 +211,21 @@ class RootComponent extends React.Component { {this.renderModalMessage(this.modalDialogProps())} + {this.renderDialogs()} ); } } -const mapStateToProps = (state: any) => { +const mapStateToProps = (state: AppState) => { return { size: state.windowContentSize, zoomFactor: state.settings.windowContentZoomFactor / 100, appState: state.appState, themeId: state.settings.theme, needApiAuth: state.needApiAuth, + dialogs: state.dialogs, }; }; diff --git a/packages/app-desktop/gui/SyncWizard/Dialog.tsx b/packages/app-desktop/gui/SyncWizard/Dialog.tsx new file mode 100644 index 0000000000..04310353f0 --- /dev/null +++ b/packages/app-desktop/gui/SyncWizard/Dialog.tsx @@ -0,0 +1,343 @@ +import * as React from 'react'; +import { useState, useRef, useCallback } from 'react'; +import { _ } from '@joplin/lib/locale'; +import DialogButtonRow from '../DialogButtonRow'; +import Dialog from '../Dialog'; +import styled from 'styled-components'; +import DialogTitle from '../DialogTitle'; +import SyncTargetRegistry, { SyncTargetInfo } from '@joplin/lib/SyncTargetRegistry'; +import useElementSize from '@joplin/lib/hooks/useElementSize'; +import Button, { ButtonLevel } from '../Button/Button'; +import bridge from '../../services/bridge'; +import StyledInput from '../style/StyledInput'; +import Setting from '../../../lib/models/Setting'; +import SyncTargetJoplinCloud from '../../../lib/SyncTargetJoplinCloud'; +import StyledLink from '../style/StyledLink'; + +interface Props { + themeId: number; + dispatch: Function; +} + +const StyledRoot = styled.div` + min-width: 500px; + max-width: 1200px; +`; + +const SyncTargetDescription = styled.div` + ${props => props.height ? `height: ${props.height}px` : ''}; + margin-bottom: 1.3em; + line-height: ${props => props.theme.lineHeight}; + font-size: 16px; +`; + +const CreateAccountLink = styled(StyledLink)` + font-size: 16px; +`; + +const ContentRoot = styled.div` + background-color: ${props => props.theme.backgroundColor3}; + padding: 1em; + padding-right: 0; +`; + +const SelfHostingMessage = styled.div` + color: ${props => props.theme.color}; + padding-right: 1em; + font-style: italic; + margin-top: 1em; + opacity: 0.6; +`; + +const SyncTargetBoxes = styled.div` + display: flex; + flex-direction: row; + justify-content: center; +`; + +const SyncTargetTitle = styled.p` + display: flex; + flex-direction: row; + font-weight: bold; + font-size: 1.7em; + align-items: center; + white-space: nowrap; +`; + +const SyncTargetLogo = styled.img` + height: 1.3em; + margin-right: 0.4em; +`; + +const SyncTargetBox = styled.div` + display: flex; + flex: 1; + flex-direction: column; + font-family: ${props => props.theme.fontFamily}; + color: ${props => props.theme.color}; + background-color: ${props => props.theme.backgroundColor}; + border: 1px solid ${props => props.theme.dividerColor}; + border-radius: 8px; + padding: 0.8em 2.2em 2em 2.2em; + margin-right: 1em; + max-width: 400px; + opacity: ${props => props.faded ? 0.5 : 1}; +`; + +const FeatureList = styled.div` + margin-bottom: 1em; +`; + +const FeatureIcon = styled.i` + display: inline-flex; + width: 16px; + justify-content: center; + color: ${props => props.theme.color4}; + position: absolute; +`; + +const FeatureLine = styled.div` + margin-bottom: .5em; + opacity: ${props => props.enabled ? 1 : 0.5}; + position: relative; + font-size: 16px; +`; + +const FeatureLabel = styled.div` + margin-left: 24px; + line-height: ${props => props.theme.lineHeight}; +`; + +const SelectButton = styled(Button)` + padding: 10px 10px; + height: auto; + min-height: auto; + max-height: fit-content; + font-size: 1em; +`; + +const JoplinCloudLoginForm = styled.div` + display: flex; + flex-direction: column; +`; + +const FormLabel = styled.label` + font-weight: bold; + margin: 1em 0 0.6em 0; +`; + +const syncTargetNames: string[] = [ + 'joplinCloud', + 'dropbox', + 'onedrive', + 'nextcloud', + 'webdav', + 'amazon_s3', + 'joplinServer', +]; + + +const logosImageNames: Record = { + 'dropbox': 'Dropbox.svg', + 'joplinCloud': 'JoplinCloud.svg', + 'onedrive': 'OneDrive.svg', +}; + +export default function(props: Props) { + const [showJoplinCloudForm, setShowJoplinCloudForm] = useState(false); + const joplinCloudDescriptionRef = useRef(null); + const [joplinCloudEmail, setJoplinCloudEmail] = useState(''); + const [joplinCloudPassword, setJoplinCloudPassword] = useState(''); + const [joplinCloudLoginInProgress, setJoplinCloudLoginInProgress] = useState(false); + + function closeDialog(dispatch: Function) { + dispatch({ + type: 'DIALOG_CLOSE', + name: 'syncWizard', + }); + } + + const onButtonRowClick = useCallback(() => { + closeDialog(props.dispatch); + }, [props.dispatch]); + + const { height: descriptionHeight } = useElementSize(joplinCloudDescriptionRef); + + function renderFeature(enabled: boolean, label: string) { + const className = enabled ? 'fas fa-check' : 'fas fa-times'; + return ( + {label} + ); + } + + function renderFeatures(name: string) { + return ( + + {[ + renderFeature(true, _('Sync your notes')), + renderFeature(name === 'joplinCloud', _('Publish notes to the internet')), + renderFeature(name === 'joplinCloud', _('Collaborate on notebooks with others')), + ]} + + ); + } + + const onJoplinCloudEmailChange = useCallback((event: any) => { + setJoplinCloudEmail(event.target.value); + }, []); + + const onJoplinCloudPasswordChange = useCallback((event: any) => { + setJoplinCloudPassword(event.target.value); + }, []); + + const onJoplinCloudLoginClick = useCallback(async () => { + setJoplinCloudLoginInProgress(true); + + try { + const result = await SyncTargetJoplinCloud.checkConfig({ + password: () => joplinCloudPassword, + path: () => Setting.value('sync.10.path'), + userContentPath: () => Setting.value('sync.10.userContentPath'), + username: () => joplinCloudEmail, + }); + + if (result.ok) { + Setting.setValue('sync.target', 10); + Setting.setValue('sync.10.username', joplinCloudEmail); + Setting.setValue('sync.10.password', joplinCloudPassword); + await Setting.saveAll(); + + alert(_('Thank you! Your Joplin Cloud account is now setup and ready to use.')); + + closeDialog(props.dispatch); + + props.dispatch({ + type: 'NAV_GO', + routeName: 'Main', + }); + } else { + alert(_('There was an error setting up your Joplin Cloud account. Please verify your email and password and try again. Error was:\n\n%s', result.errorMessage)); + } + } finally { + setJoplinCloudLoginInProgress(false); + } + }, [joplinCloudEmail, joplinCloudPassword, props.dispatch]); + + const onJoplinCloudCreateAccountClick = useCallback(() => { + bridge().openExternal('https://joplinapp.org/plans/'); + }, []); + + function renderJoplinCloudLoginForm() { + return ( + +
{_('Login below.')} {_('Or create an account.')}
+ Email + + Password + + +
+ ); + } + + const onSelectButtonClick = useCallback(async (name: string) => { + if (name === 'joplinCloud') { + setShowJoplinCloudForm(true); + } else { + Setting.setValue('sync.target', name === 'dropbox' ? 7 : 3); + await Setting.saveAll(); + closeDialog(props.dispatch); + props.dispatch({ + type: 'NAV_GO', + routeName: name === 'dropbox' ? 'DropboxLogin' : 'OneDriveLogin', + }); + } + }, [props.dispatch]); + + function renderSelectArea(info: SyncTargetInfo) { + if (info.name === 'joplinCloud' && showJoplinCloudForm) { + return renderJoplinCloudLoginForm(); + } else { + return ( + onSelectButtonClick(info.name)} + disabled={joplinCloudLoginInProgress} + /> + ); + } + } + + function renderSyncTarget(info: SyncTargetInfo) { + const key = `syncTarget_${info.name}`; + const height = info.name !== 'joplinCloud' ? descriptionHeight : null; + + const logoImageName = logosImageNames[info.name]; + const logoImageSrc = logoImageName ? `${bridge().buildDir()}/images/syncTargetLogos/${logoImageName}` : ''; + const logo = logoImageSrc ? : null; + const descriptionComp = {info.description}; + const featuresComp = showJoplinCloudForm && info.name === 'joplinCloud' ? null : renderFeatures(info.name); + + return ( + + {logo}{info.label} + {descriptionComp} + {featuresComp} + {renderSelectArea(info)} + + ); + } + + const onSelfHostingClick = useCallback(() => { + closeDialog(props.dispatch); + + props.dispatch({ + type: 'NAV_GO', + routeName: 'Config', + props: { + defaultSection: 'sync', + }, + }); + }, [props.dispatch]); + + function renderContent() { + const boxes: any[] = []; + + for (const name of syncTargetNames) { + const info = SyncTargetRegistry.infoByName(name); + if (info.supportsSelfHosted) continue; + boxes.push(renderSyncTarget(info)); + } + + const selfHostingMessage = showJoplinCloudForm ? null : Self-hosting? Joplin also supports various self-hosting options such as Nextcloud, WebDAV, AWS S3 and Joplin Server. Click here to select one.; + + return ( + + + {boxes} + + {selfHostingMessage} + + ); + } + + function renderDialogWrapper() { + return ( + + + {renderContent()} + + + ); + } + + return ( + + ); +} diff --git a/packages/app-mobile/components/screens/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen.tsx index c03e428ab1..f126cc2d3b 100644 --- a/packages/app-mobile/components/screens/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen.tsx @@ -19,7 +19,7 @@ const { BaseScreenComponent } = require('../base-screen.js'); const { Dropdown } = require('../Dropdown.js'); const { themeStyle } = require('../global-style.js'); const shared = require('@joplin/lib/components/shared/config-shared.js'); -const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry'); +import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; const RNFS = require('react-native-fs'); class ConfigScreenComponent extends BaseScreenComponent { diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index b68cf415cf..9a073785f7 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -78,7 +78,7 @@ import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine'; const WelcomeUtils = require('@joplin/lib/WelcomeUtils'); const { themeStyle } = require('./components/global-style.js'); -const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry.js'); +import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; const SyncTargetFilesystem = require('@joplin/lib/SyncTargetFilesystem.js'); const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js'); const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js'); diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index ac1f5e37cf..a9fed4a0b2 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -30,7 +30,7 @@ const fs = require('fs-extra'); import JoplinError from './JoplinError'; const EventEmitter = require('events'); const syswidecas = require('./vendor/syswide-cas'); -const SyncTargetRegistry = require('./SyncTargetRegistry.js'); +import SyncTargetRegistry from './SyncTargetRegistry'; const SyncTargetFilesystem = require('./SyncTargetFilesystem.js'); const SyncTargetNextcloud = require('./SyncTargetNextcloud.js'); const SyncTargetWebDAV = require('./SyncTargetWebDAV.js'); diff --git a/packages/lib/BaseSyncTarget.ts b/packages/lib/BaseSyncTarget.ts index f4cc4cf41f..efa0bac64e 100644 --- a/packages/lib/BaseSyncTarget.ts +++ b/packages/lib/BaseSyncTarget.ts @@ -25,6 +25,14 @@ export default class BaseSyncTarget { return false; } + public static description(): string { + return ''; + } + + public static supportsSelfHosted(): boolean { + return true; + } + public option(name: string, defaultValue: any = null) { return this.options_ && name in this.options_ ? this.options_[name] : defaultValue; } diff --git a/packages/lib/SyncTargetAmazonS3.js b/packages/lib/SyncTargetAmazonS3.js index e84ae2a8ae..8265106c66 100644 --- a/packages/lib/SyncTargetAmazonS3.js +++ b/packages/lib/SyncTargetAmazonS3.js @@ -28,6 +28,10 @@ class SyncTargetAmazonS3 extends BaseSyncTarget { return `${_('AWS S3')} (Beta)`; } + static description() { + return 'A service offered by Amazon Web Services (AWS) that provides object storage through a web service interface.'; + } + async isAuthenticated() { return true; } diff --git a/packages/lib/SyncTargetDropbox.js b/packages/lib/SyncTargetDropbox.js index cf11844614..b692b0d8af 100644 --- a/packages/lib/SyncTargetDropbox.js +++ b/packages/lib/SyncTargetDropbox.js @@ -25,6 +25,14 @@ class SyncTargetDropbox extends BaseSyncTarget { return _('Dropbox'); } + static description() { + return 'A file hosting service that offers cloud storage and file synchronization'; + } + + static supportsSelfHosted() { + return false; + } + authRouteName() { return 'DropboxLogin'; } diff --git a/packages/lib/SyncTargetJoplinCloud.ts b/packages/lib/SyncTargetJoplinCloud.ts index 1f8cd1a766..f781a693d1 100644 --- a/packages/lib/SyncTargetJoplinCloud.ts +++ b/packages/lib/SyncTargetJoplinCloud.ts @@ -30,6 +30,14 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget { return _('Joplin Cloud'); } + public static description() { + return _('Joplin\'s own sync service. Also gives access to Joplin-specific features such as publishing notes or collaborating on notebooks with others.'); + } + + public static supportsSelfHosted(): boolean { + return false; + } + public async isAuthenticated() { return true; } diff --git a/packages/lib/SyncTargetJoplinServer.ts b/packages/lib/SyncTargetJoplinServer.ts index 9677c37325..e20cc7c9a1 100644 --- a/packages/lib/SyncTargetJoplinServer.ts +++ b/packages/lib/SyncTargetJoplinServer.ts @@ -51,6 +51,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget { return 'joplinServer'; } + public static description() { + return 'Besides synchronisation and improved performances, Joplin Server also gives access to Joplin-specific sharing features.'; + } + public static label() { return `${_('Joplin Server')} (Beta)`; } diff --git a/packages/lib/SyncTargetNextcloud.js b/packages/lib/SyncTargetNextcloud.js index b0684c77a3..b44585276b 100644 --- a/packages/lib/SyncTargetNextcloud.js +++ b/packages/lib/SyncTargetNextcloud.js @@ -25,6 +25,10 @@ class SyncTargetNextcloud extends BaseSyncTarget { return _('Nextcloud'); } + static description() { + return 'A suite of client-server software for creating and using file hosting services.'; + } + async isAuthenticated() { return true; } diff --git a/packages/lib/SyncTargetOneDrive.ts b/packages/lib/SyncTargetOneDrive.ts index 51bd7c6c5c..7d5f24e76e 100644 --- a/packages/lib/SyncTargetOneDrive.ts +++ b/packages/lib/SyncTargetOneDrive.ts @@ -29,6 +29,14 @@ export default class SyncTargetOneDrive extends BaseSyncTarget { return _('OneDrive'); } + public static description() { + return 'A file hosting service operated by Microsoft as part of its web version of Office.'; + } + + public static supportsSelfHosted(): boolean { + return false; + } + async isAuthenticated() { return !!this.api().auth(); } diff --git a/packages/lib/SyncTargetRegistry.js b/packages/lib/SyncTargetRegistry.ts similarity index 54% rename from packages/lib/SyncTargetRegistry.js rename to packages/lib/SyncTargetRegistry.ts index 21add8d7c9..8cb41783ac 100644 --- a/packages/lib/SyncTargetRegistry.js +++ b/packages/lib/SyncTargetRegistry.ts @@ -1,25 +1,47 @@ -class SyncTargetRegistry { - static classById(syncTargetId) { +export interface SyncTargetInfo { + id: number; + name: string; + label: string; + supportsSelfHosted: boolean; + supportsConfigCheck: boolean; + description: string; + classRef: any; +} + +export default class SyncTargetRegistry { + + private static reg_: Record = {}; + + public static classById(syncTargetId: number) { const info = SyncTargetRegistry.reg_[syncTargetId]; if (!info) throw new Error(`Invalid id: ${syncTargetId}`); return info.classRef; } - static addClass(SyncTargetClass) { + public static infoByName(name: string): SyncTargetInfo { + for (const [, info] of Object.entries(this.reg_)) { + if (info.name === name) return info; + } + throw new Error(`Unknown name: ${name}`); + } + + public static addClass(SyncTargetClass: any) { this.reg_[SyncTargetClass.id()] = { id: SyncTargetClass.id(), name: SyncTargetClass.targetName(), label: SyncTargetClass.label(), classRef: SyncTargetClass, + description: SyncTargetClass.description(), + supportsSelfHosted: SyncTargetClass.supportsSelfHosted(), supportsConfigCheck: SyncTargetClass.supportsConfigCheck(), }; } - static allIds() { + public static allIds() { return Object.keys(this.reg_); } - static nameToId(name) { + public static nameToId(name: string) { for (const n in this.reg_) { if (!this.reg_.hasOwnProperty(n)) continue; if (this.reg_[n].name === name) return this.reg_[n].id; @@ -27,7 +49,7 @@ class SyncTargetRegistry { throw new Error(`Name not found: ${name}. Was the sync target registered?`); } - static idToMetadata(id) { + public static idToMetadata(id: number) { for (const n in this.reg_) { if (!this.reg_.hasOwnProperty(n)) continue; if (this.reg_[n].id === id) return this.reg_[n]; @@ -35,12 +57,12 @@ class SyncTargetRegistry { throw new Error(`ID not found: ${id}`); } - static idToName(id) { + public static idToName(id: number) { return this.idToMetadata(id).name; } - static idAndLabelPlainObject(os) { - const output = {}; + public static idAndLabelPlainObject(os: string) { + const output: Record = {}; for (const n in this.reg_) { if (!this.reg_.hasOwnProperty(n)) continue; const info = this.reg_[n]; @@ -52,7 +74,3 @@ class SyncTargetRegistry { return output; } } - -SyncTargetRegistry.reg_ = {}; - -module.exports = SyncTargetRegistry; diff --git a/packages/lib/SyncTargetWebDAV.js b/packages/lib/SyncTargetWebDAV.js index 16f6f5e0ac..85d4c378ae 100644 --- a/packages/lib/SyncTargetWebDAV.js +++ b/packages/lib/SyncTargetWebDAV.js @@ -23,6 +23,10 @@ class SyncTargetWebDAV extends BaseSyncTarget { return _('WebDAV'); } + static description() { + return 'The WebDAV protocol allows users to create, change and move documents on a server. There are many WebDAV compatible servers, including SeaFile, Nginx or Apache.'; + } + async isAuthenticated() { return true; } diff --git a/packages/lib/components/shared/config-shared.js b/packages/lib/components/shared/config-shared.js index df57011f3c..002226afe6 100644 --- a/packages/lib/components/shared/config-shared.js +++ b/packages/lib/components/shared/config-shared.js @@ -1,5 +1,5 @@ const Setting = require('../../models/Setting').default; -const SyncTargetRegistry = require('../../SyncTargetRegistry'); +const SyncTargetRegistry = require('../../SyncTargetRegistry').default; const ObjectUtils = require('../../ObjectUtils'); const { _ } = require('../../locale'); const { createSelector } = require('reselect'); diff --git a/packages/lib/components/shared/dropbox-login-shared.js b/packages/lib/components/shared/dropbox-login-shared.js index 209b1a171b..7308eea0ee 100644 --- a/packages/lib/components/shared/dropbox-login-shared.js +++ b/packages/lib/components/shared/dropbox-login-shared.js @@ -1,5 +1,5 @@ const shim = require('../../shim').default; -const SyncTargetRegistry = require('../../SyncTargetRegistry'); +const SyncTargetRegistry = require('../../SyncTargetRegistry').default; const { reg } = require('../../registry.js'); const { _ } = require('../../locale'); const Setting = require('../../models/Setting').default; diff --git a/packages/lib/hooks/useElementSize.ts b/packages/lib/hooks/useElementSize.ts new file mode 100644 index 0000000000..2fd75e6ac9 --- /dev/null +++ b/packages/lib/hooks/useElementSize.ts @@ -0,0 +1,38 @@ +import shim from '../shim'; +const { useCallback, useEffect, useState } = shim.react(); +import useEventListener from './useEventListener'; + +interface Size { + width: number; + height: number; +} + +function useElementSize(elementRef: any): Size { + const [size, setSize] = useState({ + width: 0, + height: 0, + }); + + // Prevent too many rendering using useCallback + const updateSize = useCallback(() => { + const node = elementRef?.current; + if (node) { + setSize({ + width: node.offsetWidth || 0, + height: node.offsetHeight || 0, + }); + } + }, [elementRef]); + + // Initial size on mount + useEffect(() => { + updateSize(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEventListener('resize', updateSize); + + return size; +} + +export default useElementSize; diff --git a/packages/lib/hooks/useEventListener.ts b/packages/lib/hooks/useEventListener.ts new file mode 100644 index 0000000000..d137e66cca --- /dev/null +++ b/packages/lib/hooks/useEventListener.ts @@ -0,0 +1,41 @@ +import shim from '../shim'; +const { useEffect, useRef } = shim.react(); + +function useEventListener( + eventName: any, + handler: any, + element?: any +) { + // Create a ref that stores handler + const savedHandler = useRef(); + + useEffect(() => { + // Define the listening target + const targetElement = element?.current || window; + if (!(targetElement && targetElement.addEventListener)) { + return null; + } + + // Update saved handler if necessary + if (savedHandler.current !== handler) { + savedHandler.current = handler; + } + + // Create event listener that calls handler function stored in ref + const eventListener = (event: Event) => { + // eslint-disable-next-line no-extra-boolean-cast + if (!!savedHandler?.current) { + savedHandler.current(event); + } + }; + + targetElement.addEventListener(eventName, eventListener); + + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, eventListener); + }; + }, [eventName, element, handler]); +} + +export default useEventListener; diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 286485536b..5b784c4978 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -3,7 +3,7 @@ import { _, supportedLocalesToLanguages, defaultLocale } from '../locale'; import eventManager from '../eventManager'; import BaseModel from '../BaseModel'; import Database from '../database'; -const SyncTargetRegistry = require('../SyncTargetRegistry.js'); +import SyncTargetRegistry from '../SyncTargetRegistry'; import time from '../time'; import FileHandler, { SettingValues } from './settings/FileHandler'; const { sprintf } = require('sprintf-js'); @@ -55,6 +55,7 @@ export interface SettingItem { needRestart?: boolean; autoSave?: boolean; storage?: SettingStorage; + hideLabel?: boolean; } interface SettingItems { @@ -306,6 +307,17 @@ class Setting extends BaseModel { appTypes: [AppType.Desktop], storage: SettingStorage.File, }, + + 'sync.openSyncWizard': { + value: null, + type: SettingItemType.Button, + public: true, + appTypes: [AppType.Desktop], + label: () => _('Open Sync Wizard...'), + hideLabel: true, + section: 'sync', + }, + 'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), type: SettingItemType.Int, diff --git a/packages/lib/registry.ts b/packages/lib/registry.ts index 7056bf2cd6..09b7b6779b 100644 --- a/packages/lib/registry.ts +++ b/packages/lib/registry.ts @@ -1,7 +1,7 @@ import Logger from './Logger'; import Setting from './models/Setting'; import shim from './shim'; -const SyncTargetRegistry = require('./SyncTargetRegistry.js'); +import SyncTargetRegistry from './SyncTargetRegistry'; class Registry { diff --git a/packages/lib/services/synchronizer/tools.ts b/packages/lib/services/synchronizer/tools.ts index 037a8b7578..bff53c156d 100644 --- a/packages/lib/services/synchronizer/tools.ts +++ b/packages/lib/services/synchronizer/tools.ts @@ -2,7 +2,7 @@ import { SqlQuery } from '../../database'; import JoplinDatabase from '../../JoplinDatabase'; import BaseItem from '../../models/BaseItem'; import Setting from '../../models/Setting'; -const SyncTargetRegistry = require('../../SyncTargetRegistry'); +import SyncTargetRegistry from '../../SyncTargetRegistry'; async function clearSyncContext() { const syncTargetIds = SyncTargetRegistry.allIds(); diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index b8d165c447..34b364ff0d 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -35,7 +35,7 @@ const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js'); const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js'); const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js'); const { FileApiDriverAmazonS3 } = require('../file-api-driver-amazon-s3.js'); -const SyncTargetRegistry = require('../SyncTargetRegistry.js'); +import SyncTargetRegistry from '../SyncTargetRegistry'; const SyncTargetMemory = require('../SyncTargetMemory.js'); const SyncTargetFilesystem = require('../SyncTargetFilesystem.js'); const SyncTargetNextcloud = require('../SyncTargetNextcloud.js');