diff --git a/.eslintignore b/.eslintignore index 943d8f3ad..b28d3ac87 100644 --- a/.eslintignore +++ b/.eslintignore @@ -154,6 +154,7 @@ packages/app-desktop/gui/HelpButton.js packages/app-desktop/gui/IconButton.js packages/app-desktop/gui/ImportScreen.js packages/app-desktop/gui/ItemList.js +packages/app-desktop/gui/JoplinCloudConfigScreen.js packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.js packages/app-desktop/gui/KeymapConfig/styles/index.js @@ -800,6 +801,7 @@ packages/lib/themes/solarizedLight.js packages/lib/themes/type.js packages/lib/time.js packages/lib/utils/credentialFiles.js +packages/lib/utils/inboxFetcher.js packages/lib/utils/joplinCloud.js packages/lib/utils/webDAVUtils.js packages/lib/utils/webDAVUtils.test.js diff --git a/.gitignore b/.gitignore index 546bf729b..e703270f7 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,7 @@ packages/app-desktop/gui/HelpButton.js packages/app-desktop/gui/IconButton.js packages/app-desktop/gui/ImportScreen.js packages/app-desktop/gui/ItemList.js +packages/app-desktop/gui/JoplinCloudConfigScreen.js packages/app-desktop/gui/KeymapConfig/KeymapConfigScreen.js packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.js packages/app-desktop/gui/KeymapConfig/styles/index.js @@ -785,6 +786,7 @@ packages/lib/themes/solarizedLight.js packages/lib/themes/type.js packages/lib/time.js packages/lib/utils/credentialFiles.js +packages/lib/utils/inboxFetcher.js packages/lib/utils/joplinCloud.js packages/lib/utils/webDAVUtils.js packages/lib/utils/webDAVUtils.test.js diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 813763e46..943283d62 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -67,6 +67,7 @@ import eventManager from '@joplin/lib/eventManager'; import path = require('path'); import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils'; // import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils'; +import { initializeInboxFetcher, inboxFetcher } from '@joplin/lib/utils/inboxFetcher'; const pluginClasses = [ require('./plugins/GotoAnything').default, @@ -487,6 +488,9 @@ class Application extends BaseApplication { shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000); } + initializeInboxFetcher(); + shim.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60); + this.updateTray(); shim.setTimeout(() => { diff --git a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx index f20dbf11f..8b8edea21 100644 --- a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx @@ -18,6 +18,7 @@ import restart from '../../services/restart'; import PluginService from '@joplin/lib/services/plugins/PluginService'; import { getDefaultPluginsInstallState, updateDefaultPluginsInstallState } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils'; import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo'; +import JoplinCloudConfigScreen from '../JoplinCloudConfigScreen'; const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen'); const settingKeyToControl: any = { @@ -106,6 +107,7 @@ class ConfigScreenComponent extends React.Component { if (screenName === 'encryption') return ; if (screenName === 'server') return ; if (screenName === 'keymap') return ; + if (screenName === 'joplinCloud') return ; throw new Error(`Invalid screen name: ${screenName}`); } diff --git a/packages/app-desktop/gui/JoplinCloudConfigScreen.scss b/packages/app-desktop/gui/JoplinCloudConfigScreen.scss new file mode 100644 index 000000000..96be5c706 --- /dev/null +++ b/packages/app-desktop/gui/JoplinCloudConfigScreen.scss @@ -0,0 +1,3 @@ +.inbox-email-value { + font-weight: bold; +} \ No newline at end of file diff --git a/packages/app-desktop/gui/JoplinCloudConfigScreen.tsx b/packages/app-desktop/gui/JoplinCloudConfigScreen.tsx new file mode 100644 index 000000000..2e1886dca --- /dev/null +++ b/packages/app-desktop/gui/JoplinCloudConfigScreen.tsx @@ -0,0 +1,32 @@ +const { connect } = require('react-redux'); +import { AppState } from '../app.reducer'; +import { _ } from '@joplin/lib/locale'; +import { clipboard } from 'electron'; +import Button from './Button/Button'; + +type JoplinCloudConfigScreenProps = { + inboxEmail: string; +}; + +const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => { + const copyToClipboard = () => { + clipboard.writeText(props.inboxEmail); + }; + + return ( +
+

{_('Email to note')}

+

{_('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')}

+

{props.inboxEmail}

+
+ ); +}; + +const mapStateToProps = (state: AppState) => { + return { + inboxEmail: state.settings['emailToNote.inboxEmail'], + }; +}; + +export default connect(mapStateToProps)(JoplinCloudConfigScreen); diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts index be49ddb66..dbfd21ec9 100644 --- a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts +++ b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts @@ -17,10 +17,12 @@ export const runtime = (): CommandRuntime => { const folder = await Folder.load(folderId); if (!folder) throw new Error(`No such folder: ${folderId}`); - const ok = bridge().showConfirmMessageBox(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)), { - buttons: [_('Delete'), _('Cancel')], - defaultId: 1, - }); + let deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)); + if (folderId === context.state.settings['emailToNote.inboxJopId']) { + deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'); + } + + const ok = bridge().showConfirmMessageBox(deleteMessage); if (!ok) return; await Folder.delete(folderId); diff --git a/packages/app-desktop/style.scss b/packages/app-desktop/style.scss index ebec2c09b..8f0f58fe4 100644 --- a/packages/app-desktop/style.scss +++ b/packages/app-desktop/style.scss @@ -2,6 +2,7 @@ @use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog; @use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen; @use 'gui/PasswordInput/style.scss' as password-input; +@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen; @use 'gui/Dropdown/style.scss' as dropdown-control; @use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog; @use 'main.scss' as main; \ No newline at end of file diff --git a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx index 0ad97c062..a6544d49b 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx @@ -27,6 +27,7 @@ import biometricAuthenticate from '../../biometrics/biometricAuthenticate'; import configScreenStyles from './configScreenStyles'; import NoteExportButton from './NoteExportSection/NoteExportButton'; import ConfigScreenButton from './ConfigScreenButton'; +import Clipboard from '@react-native-community/clipboard'; class ConfigScreenComponent extends BaseScreenComponent { public static navigationOptions(): any { @@ -355,6 +356,26 @@ class ConfigScreenComponent extends BaseScreenComponent { settingComps.push(this.renderButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_)); } + if (section.name === 'joplinCloud') { + 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( + + + {_('Email to note')} + {this.props.settings['emailToNote.inboxEmail']} + + { + this.renderButton( + 'emailToNote.inboxEmail', + _('Copy to clipboard'), + () => Clipboard.setString(this.props.settings['emailToNote.inboxEmail']), + { description } + ) + } + + ); + } + if (!settingComps.length) return null; return ( diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 586e1b416..917bdd95c 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -35,6 +35,7 @@ interface Props { folders: FolderEntity[]; opacity: number; profileConfig: ProfileConfig; + inboxJopId: string; } const syncIconRotationValue = new Animated.Value(0); @@ -136,6 +137,31 @@ const SideMenuContentComponent = (props: Props) => { const folder = folderOrAll as FolderEntity; + const generateFolderDeletion = () => { + const folderDeletion = (message: string) => { + Alert.alert('', message, [ + { + text: _('OK'), + onPress: () => { + void Folder.delete(folder.id); + }, + }, + { + text: _('Cancel'), + onPress: () => { }, + style: 'cancel', + }, + ]); + }; + + if (folder.id === props.inboxJopId) { + return folderDeletion( + _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.') + ); + } + return folderDeletion(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title)); + }; + Alert.alert( '', _('Notebook: %s', folder.title), @@ -154,21 +180,7 @@ const SideMenuContentComponent = (props: Props) => { }, { text: _('Delete'), - onPress: () => { - Alert.alert('', _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title), [ - { - text: _('OK'), - onPress: () => { - void Folder.delete(folder.id); - }, - }, - { - text: _('Cancel'), - onPress: () => {}, - style: 'cancel', - }, - ]); - }, + onPress: generateFolderDeletion, style: 'destructive', }, { @@ -516,5 +528,6 @@ export default connect((state: AppState) => { isOnMobileData: state.isOnMobileData, syncOnlyOverWifi: state.settings['sync.mobileWifiOnly'], profileConfig: state.profileConfig, + inboxJopId: state.settings['emailToNote.inboxJopId'], }; })(SideMenuContentComponent); diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 11852a935..5f458dd81 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -117,6 +117,7 @@ import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo'; import { getCurrentProfile } from '@joplin/lib/services/profileConfig'; import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles'; import { ReactNode } from 'react'; +import { initializeInboxFetcher, inboxFetcher } from '@joplin/lib/utils/inboxFetcher'; import { parseShareCache } from '@joplin/lib/services/share/reducer'; import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme'; @@ -664,6 +665,9 @@ async function initialize(dispatch: Function) { reg.setupRecurrentSync(); + initializeInboxFetcher(); + PoorManIntervals.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60); + PoorManIntervals.setTimeout(() => { void AlarmService.garbageCollect(); }, 1000 * 60 * 60); diff --git a/packages/lib/Synchronizer.ts b/packages/lib/Synchronizer.ts index 2a5be0e3d..f6a6d917e 100644 --- a/packages/lib/Synchronizer.ts +++ b/packages/lib/Synchronizer.ts @@ -452,6 +452,7 @@ export default class Synchronizer { try { let remoteInfo = await fetchSyncInfo(this.api()); logger.info('Sync target remote info:', remoteInfo); + eventManager.emit('sessionEstablished'); let syncTargetIsNew = false; diff --git a/packages/lib/components/shared/config-shared.js b/packages/lib/components/shared/config-shared.js index 7b370b24f..fadfc7797 100644 --- a/packages/lib/components/shared/config-shared.js +++ b/packages/lib/components/shared/config-shared.js @@ -186,6 +186,17 @@ shared.settingsSections = createSelector( isScreen: true, }); + // 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 + const syncTargetIsJoplinCloud = settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud'); + if (syncTargetIsJoplinCloud) { + output.push({ + name: 'joplinCloud', + metadatas: [], + isScreen: true, + }); + } + return output; } ); diff --git a/packages/lib/eventManager.ts b/packages/lib/eventManager.ts index 79450f656..0fa09f900 100644 --- a/packages/lib/eventManager.ts +++ b/packages/lib/eventManager.ts @@ -132,6 +132,10 @@ export class EventManager { } } + public once(eventName: string, callback: any) { + return this.emitter_.once(eventName, callback); + } + } const eventManager = new EventManager(); diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index e5e577667..f8c5cceac 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -1714,6 +1714,10 @@ class Setting extends BaseModel { label: () => _('Voice typing language files (URL)'), section: 'note', }, + + 'emailToNote.inboxEmail': { value: '', type: SettingItemType.String, public: false }, + + 'emailToNote.inboxJopId': { value: '', type: SettingItemType.String, public: false }, }; this.metadata_ = { ...this.buildInMetadata_ }; @@ -2528,6 +2532,7 @@ class Setting extends BaseModel { if (name === 'encryption') return _('Encryption'); if (name === 'server') return _('Web Clipper'); if (name === 'keymap') return _('Keyboard Shortcuts'); + if (name === 'joplinCloud') return _('Joplin Cloud'); if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label; @@ -2556,6 +2561,7 @@ class Setting extends BaseModel { 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'; if (this.customSections_[name] && this.customSections_[name].iconName) return this.customSections_[name].iconName; diff --git a/packages/lib/utils/inboxFetcher.ts b/packages/lib/utils/inboxFetcher.ts new file mode 100644 index 000000000..071d2c371 --- /dev/null +++ b/packages/lib/utils/inboxFetcher.ts @@ -0,0 +1,30 @@ +import SyncTargetRegistry from '../SyncTargetRegistry'; +import eventManager from '../eventManager'; +import Setting from '../models/Setting'; +import { reg } from '../registry'; + +export const inboxFetcher = async () => { + + if (Setting.value('sync.target') !== SyncTargetRegistry.nameToId('joplinCloud')) { + return; + } + + const syncTarget = reg.syncTarget(); + const fileApi = await syncTarget.fileApi(); + const api = fileApi.driver().api(); + + const owner = await api.exec('GET', `api/users/${api.userId}`); + + if (owner.inbox) { + Setting.setValue('emailToNote.inboxJopId', owner.inbox.jop_id); + } + + if (owner.inbox_email) { + Setting.setValue('emailToNote.inboxEmail', owner.inbox_email); + } +}; + +// Listen to the event only once +export const initializeInboxFetcher = () => { + eventManager.once('sessionEstablished', inboxFetcher); +};