diff --git a/.gitignore b/.gitignore index 1849507e3..02e8b13d4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ release/ .idea .DS_Store dev-app-update.yml +lib/state/data/test_account.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2e45eb6e..b02745eb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ type OwnProps = { } type StateProps = { - notes: T.NoteEntity[]; + notes: T.Note[]; } type DispatchProps = { @@ -156,17 +156,14 @@ const loginAttempts: A.Reducer = (state = 0, action) => { } } -const selectedNote: A.Reducer = (state = null, action) => { +const selectedNote: A.Reducer = (state = null, action) => { switch (action.type) { case 'CREATE_NOTE': - return makeNote(); + return action.noteId; case 'TRASH_NOTE': return null; - case 'FILTER_NOTES': - return action.filteredNotes.has(state) ? state : null; - default: return state; } diff --git a/desktop/app.js b/desktop/app.js index 7398ea31f..e4d24a6ef 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -18,7 +18,7 @@ const importNotes = require('./evernote-import'); const platform = require('./detect/platform'); const updater = require('./updater'); const { isDev } = require('./env'); -const spellcheck = require('./spellchecker'); +const contextMenu = require('./context-menu'); require('module').globalPaths.push(path.resolve(path.join(__dirname))); @@ -77,7 +77,7 @@ module.exports = function main() { mainWindow.loadUrl(url); } - spellcheck(mainWindow); + contextMenu(mainWindow); if ( 'test' !== process.env.NODE_ENV && @@ -124,8 +124,8 @@ module.exports = function main() { mainWindow.setMenuBarVisibility(!autoHideMenuBar); }); - ipcMain.on('wpLogin', function (event, url) { - shell.openExternal(url); + ipcMain.on('wpLogin', function (event, wpLoginUrl) { + shell.openExternal(wpLoginUrl); }); ipcMain.on('importNotes', function (event, filePath) { diff --git a/desktop/context-menu/index.js b/desktop/context-menu/index.js new file mode 100644 index 000000000..b0bee4c4e --- /dev/null +++ b/desktop/context-menu/index.js @@ -0,0 +1,43 @@ +'use strict'; + +/** + * External dependencies + */ +const { Menu } = require('electron'); + +const { editorCommandSender } = require('../menus/utils'); + +module.exports = function (mainWindow) { + mainWindow.webContents.on('context-menu', (event, params) => { + const { editFlags } = params; + Menu.buildFromTemplate([ + { + id: 'selectAll', + label: 'Select All', + click: editorCommandSender({ action: 'selectAll' }), + enabled: editFlags.canSelectAll, + }, + { + id: 'cut', + label: 'Cut', + role: 'cut', + enabled: editFlags.canCut, + }, + { + id: 'copy', + label: 'Copy', + role: 'copy', + enabled: editFlags.canCopy, + }, + { + id: 'paste', + label: 'Paste', + role: 'paste', + enabled: editFlags.canPaste, + }, + { + type: 'separator', + }, + ]).popup({}); + }); +}; diff --git a/desktop/menus/edit-menu.js b/desktop/menus/edit-menu.js index fd4a14867..77ebf3902 100644 --- a/desktop/menus/edit-menu.js +++ b/desktop/menus/edit-menu.js @@ -1,4 +1,4 @@ -const { appCommandSender } = require('./utils'); +const { appCommandSender, editorCommandSender } = require('./utils'); const buildEditMenu = (settings, isAuthenticated) => { settings = settings || {}; @@ -9,11 +9,13 @@ const buildEditMenu = (settings, isAuthenticated) => { submenu: [ { label: '&Undo', - role: 'undo', + click: editorCommandSender({ action: 'undo' }), + accelerator: 'CommandOrControl+Z', }, { label: '&Redo', - role: 'redo', + click: editorCommandSender({ action: 'redo' }), + accelerator: 'CommandOrControl+Shift+Z', }, { type: 'separator', @@ -32,7 +34,8 @@ const buildEditMenu = (settings, isAuthenticated) => { }, { label: '&Select All', - role: 'selectall', + click: editorCommandSender({ action: 'selectAll' }), + accelerator: 'CommandOrControl+A', }, { type: 'separator' }, { @@ -45,6 +48,19 @@ const buildEditMenu = (settings, isAuthenticated) => { label: 'Search &Notes…', visible: isAuthenticated, click: appCommandSender({ action: 'focusSearchField' }), + accelerator: 'CommandOrControl+Shift+S', + }, + { + label: '&Find in Note', + visible: isAuthenticated, + click: editorCommandSender({ action: 'find' }), + accelerator: 'CommandOrControl+F', + }, + { + label: 'Find A&gain', + visible: isAuthenticated, + click: editorCommandSender({ action: 'findAgain' }), + accelerator: 'CommandOrControl+G', }, { type: 'separator' }, { diff --git a/desktop/menus/file-menu.js b/desktop/menus/file-menu.js index 7f53d61fd..d63a0751e 100644 --- a/desktop/menus/file-menu.js +++ b/desktop/menus/file-menu.js @@ -26,7 +26,7 @@ const buildFileMenu = (isAuthenticated) => { visible: isAuthenticated, accelerator: 'CommandOrControl+Shift+E', click: appCommandSender({ - action: 'exportZipArchive', + action: 'exportNotes', }), }, { type: 'separator' }, diff --git a/desktop/menus/format-menu.js b/desktop/menus/format-menu.js index 79becb6d9..ff03cad46 100644 --- a/desktop/menus/format-menu.js +++ b/desktop/menus/format-menu.js @@ -1,4 +1,4 @@ -const { appCommandSender } = require('./utils'); +const { editorCommandSender } = require('./utils'); const buildFormatMenu = (isAuthenticated) => { isAuthenticated = isAuthenticated || false; @@ -6,7 +6,7 @@ const buildFormatMenu = (isAuthenticated) => { { label: 'Insert &Checklist', accelerator: 'CommandOrControl+Shift+C', - click: appCommandSender({ action: 'insertChecklist' }), + click: editorCommandSender({ action: 'insertChecklist' }), }, ]; diff --git a/desktop/menus/help-menu.js b/desktop/menus/help-menu.js index 1beed0bd9..bbb811eba 100644 --- a/desktop/menus/help-menu.js +++ b/desktop/menus/help-menu.js @@ -19,7 +19,10 @@ const buildHelpMenu = (mainWindow, isAuthenticated) => { { label: '&Keyboard Shortcuts', visible: isAuthenticated, - click: appCommandSender({ action: 'showDialog', dialog: 'KEYBINDINGS' }), + click: appCommandSender({ + action: 'showDialog', + dialog: 'KEYBINDINGS', + }), }, { type: 'separator' }, { diff --git a/desktop/menus/mac-app-menu.js b/desktop/menus/mac-app-menu.js index 274b0bc71..106413d39 100644 --- a/desktop/menus/mac-app-menu.js +++ b/desktop/menus/mac-app-menu.js @@ -21,6 +21,13 @@ const buildMacAppMenu = (isAuthenticated) => { { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, + ...(isAuthenticated + ? [ + { type: 'separator' }, + menuItems.emptyTrash(isAuthenticated), + menuItems.signout(isAuthenticated), + ] + : []), { type: 'separator' }, { role: 'quit' }, ]; diff --git a/desktop/menus/menu-items.js b/desktop/menus/menu-items.js index 667f8593b..89449dcc5 100644 --- a/desktop/menus/menu-items.js +++ b/desktop/menus/menu-items.js @@ -16,6 +16,14 @@ const checkForUpdates = { click: updater.pingAndShowProgress.bind(updater), }; +const emptyTrash = (isAuthenticated) => { + return { + label: '&Empty Trash', + visible: isAuthenticated, + click: appCommandSender({ action: 'emptyTrash' }), + }; +}; + const preferences = (isAuthenticated) => { return { label: 'P&references…', @@ -28,8 +36,20 @@ const preferences = (isAuthenticated) => { }; }; +const signout = (isAuthenticated) => { + return { + label: '&Sign Out', + visible: isAuthenticated, + click: appCommandSender({ + action: 'logout', + }), + }; +}; + module.exports = { about, checkForUpdates, + emptyTrash, preferences, + signout, }; diff --git a/desktop/menus/utils.js b/desktop/menus/utils.js index 27d73a99d..0e3116058 100644 --- a/desktop/menus/utils.js +++ b/desktop/menus/utils.js @@ -15,9 +15,17 @@ const buildRadioGroup = ({ action, propName, settings }) => { }; const appCommandSender = (arg) => { + return commandSender('appCommand', arg); +}; + +const editorCommandSender = (arg) => { + return commandSender('editorCommand', arg); +}; + +const commandSender = (commandName, arg) => { return (item, focusedWindow) => { if (focusedWindow) { - focusedWindow.webContents.send('appCommand', arg); + focusedWindow.webContents.send(commandName, arg); } }; }; @@ -25,4 +33,5 @@ const appCommandSender = (arg) => { module.exports = { buildRadioGroup, appCommandSender, + editorCommandSender, }; diff --git a/desktop/menus/view-menu.js b/desktop/menus/view-menu.js index 26cfbcdd6..2019013ea 100644 --- a/desktop/menus/view-menu.js +++ b/desktop/menus/view-menu.js @@ -40,7 +40,9 @@ const buildViewMenu = (settings, isAuthenticated) => { label: '&Reversed', type: 'checkbox', checked: settings.sortReversed, - click: appCommandSender({ action: 'toggleSortOrder' }), + click: appCommandSender({ + action: 'toggleSortOrder', + }), }, ]), }, @@ -96,7 +98,9 @@ const buildViewMenu = (settings, isAuthenticated) => { label: '&Sort Alphabetically', type: 'checkbox', checked: settings.sortTagsAlpha, - click: appCommandSender({ action: 'toggleSortTagsAlpha' }), + click: appCommandSender({ + action: 'toggleSortTagsAlpha', + }), }, ], }, diff --git a/desktop/preload.js b/desktop/preload.js index 1b68a09e3..4114f9529 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,8 +1,9 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, remote } = require('electron'); const validChannels = [ 'appCommand', 'clearCookies', + 'editorCommand', 'importNotes', 'noteImportChannel', 'setAutoHideMenuBar', @@ -11,6 +12,32 @@ const validChannels = [ ]; contextBridge.exposeInMainWorld('electron', { + confirmLogout: (changes) => { + const response = remote.dialog.showMessageBoxSync({ + type: 'warning', + buttons: [ + 'Export Unsynced Notes', + "Don't Logout Yet", + 'Lose Changes and Logout', + ], + title: 'Unsynced Notes Detected', + message: + 'Logging out will delete any unsynced notes. ' + + 'Do you want to continue or give it a little more time to finish trying to sync?\n\n' + + changes, + }); + + switch (response) { + case 0: + return 'export'; + + case 1: + return 'reconsider'; + + case 2: + return 'logout'; + } + }, send: (channel, data) => { // whitelist channels if (validChannels.includes(channel)) { diff --git a/desktop/spellchecker/index.js b/desktop/spellchecker/index.js deleted file mode 100644 index 8a6829ec6..000000000 --- a/desktop/spellchecker/index.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; - -/** - * External dependencies - */ -const { Menu, MenuItem } = require('electron'); - -module.exports = function (mainWindow) { - mainWindow.webContents.on('context-menu', (event, params) => { - const menu = new Menu(); - - const copy = new MenuItem({ label: 'Copy', role: 'copy' }); - - if (!params.isEditable) { - // If text is not editable, only permit the `Copy` action - menu.append(copy); - } else { - // Add each spelling suggestion - for (const suggestion of params.dictionarySuggestions) { - menu.append( - new MenuItem({ - label: suggestion, - click: () => mainWindow.webContents.replaceMisspelling(suggestion), - }) - ); - } - - // Allow users to add the misspelled word to the dictionary - if (params.misspelledWord) { - menu.append(new MenuItem({ type: 'separator' })); - menu.append( - new MenuItem({ - label: 'Add to Dictionary', - click: () => - mainWindow.webContents.session.addWordToSpellCheckerDictionary( - params.misspelledWord - ), - }) - ); - } - - // If text is editable, permit the Select All, Cut, Copy and Paste actions - const cut = new MenuItem({ label: 'Cut', role: 'cut' }); - const paste = new MenuItem({ label: 'Paste', role: 'paste' }); - const selectAll = new MenuItem({ - label: 'Select All', - role: 'selectAll', - }); - - const menuItems = [selectAll, cut, copy, paste]; - - if (params?.dictionarySuggestions?.length > 0) { - menu.append(new MenuItem({ type: 'separator' })); - } - - for (const item of menuItems) { - menu.append(item); - } - } - - menu.popup(); - }); -}; diff --git a/e2e/test.ts b/e2e/test.ts index 7e466a52b..663d9dcc1 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -24,7 +24,7 @@ const waitForEvent = async ( return new Promise((resolve, reject) => { const f = async () => { const result = await app.client.execute(function () { - var events = window.testEvents; + const events = window.testEvents; if (!events.length) { return undefined; diff --git a/electron-builder.json b/electron-builder.json index d38c2c37b..cca7a9389 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -59,19 +59,19 @@ "target": [ { "target": "AppImage", - "arch": ["x64", "ia32", "armv7l", "arm64"] + "arch": ["x64", "ia32"] }, { "target": "deb", - "arch": ["x64", "ia32", "armv7l", "arm64"] + "arch": ["x64", "ia32"] }, { "target": "rpm", - "arch": ["x64", "ia32", "armv7l", "arm64"] + "arch": ["x64", "ia32"] }, { "target": "tar.gz", - "arch": ["x64", "ia32", "armv7l", "arm64"] + "arch": ["x64", "ia32"] } ], "synopsis": "The simplest way to keep notes", diff --git a/lib/analytics/tracks.ts b/lib/analytics/tracks.ts index ba8621182..ff337ea33 100644 --- a/lib/analytics/tracks.ts +++ b/lib/analytics/tracks.ts @@ -31,7 +31,7 @@ function buildTracks() { let userLogin: string | null | undefined; const localCache: { [key: string]: string } = {}; let context = {}; - let pixel = 'https://pixel.wp.com/t.gif'; + const pixel = 'https://pixel.wp.com/t.gif'; let cookieDomain: string | null = null; const cookiePrefix = 'tk_'; const testCookie = 'tc'; @@ -129,6 +129,7 @@ function buildTracks() { } } + // eslint-disable-next-line return btoa(String.fromCharCode.apply(String, randomBytes as number[])); }; @@ -150,7 +151,7 @@ function buildTracks() { }; const getQueries = function () { - var queries = get(queriesCookie); + const queries = get(queriesCookie); return queries ? queries.split(' ') : []; }; @@ -181,7 +182,7 @@ function buildTracks() { const saveQuery = function (query: string) { removeQuery(query); - let queries = getQueries(); + const queries = getQueries(); queries.push(query); saveQueries(queries); }; @@ -244,7 +245,7 @@ function buildTracks() { if (userLogin) { query._ul = userLogin; } - let date = new Date(); + const date = new Date(); query._ts = date.getTime(); query._tz = date.getTimezoneOffset() / 60; diff --git a/lib/app-layout/index.tsx b/lib/app-layout/index.tsx index 6d40cd1e9..b38ed455b 100644 --- a/lib/app-layout/index.tsx +++ b/lib/app-layout/index.tsx @@ -2,13 +2,13 @@ import React, { Component, Suspense } from 'react'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import NoteToolbarContainer from '../note-toolbar-container'; import NoteToolbar from '../note-toolbar'; import RevisionSelector from '../revision-selector'; import SearchBar from '../search-bar'; import SimplenoteCompactLogo from '../icons/simplenote-compact'; import TransitionDelayEnter from '../components/transition-delay-enter'; import actions from '../state/actions'; +import * as selectors from '../state/selectors'; import * as S from '../state'; import * as T from '../types'; @@ -21,22 +21,23 @@ const NoteEditor = React.lazy(() => import(/* webpackChunkName: 'note-editor' */ '../note-editor') ); -type OwnProps = { +const NotePreview = React.lazy(() => + import(/* webpackChunkName: 'note-preview' */ '../components/note-preview') +); + +type StateProps = { + hasRevisions: boolean; isFocusMode: boolean; isNavigationOpen: boolean; isNoteInfoOpen: boolean; - isSmallScreen: boolean; - note: T.NoteEntity; - noteBucket: T.Bucket; - onUpdateContent: Function; - syncNote: Function; -}; - -type StateProps = { isNoteOpen: boolean; + isSmallScreen: boolean; keyboardShortcuts: boolean; keyboardShortcutsAreOpen: boolean; + openedNote: T.EntityId | null; + openedRevision: number | null; showNoteList: boolean; + showRevisions: boolean; }; type DispatchProps = { @@ -44,7 +45,7 @@ type DispatchProps = { showKeyboardShortcuts: () => any; }; -type Props = OwnProps & StateProps & DispatchProps; +type Props = StateProps & DispatchProps; export class AppLayout extends Component { componentDidMount() { @@ -64,11 +65,12 @@ export class AppLayout extends Component { keyboardShortcutsAreOpen, showKeyboardShortcuts, } = this.props; - const { ctrlKey, code, metaKey } = event; + const { ctrlKey, metaKey } = event; + const key = event.key.toLowerCase(); const cmdOrCtrl = ctrlKey || metaKey; - if (cmdOrCtrl && code === 'Slash') { + if (cmdOrCtrl && key === '/') { keyboardShortcutsAreOpen ? hideKeyboardShortcuts() : showKeyboardShortcuts(); @@ -81,14 +83,15 @@ export class AppLayout extends Component { render = () => { const { showNoteList, + hasRevisions, isFocusMode = false, isNavigationOpen, isNoteInfoOpen, isNoteOpen, isSmallScreen, - noteBucket, - onUpdateContent, - syncNote, + openedNote, + openedRevision, + showRevisions, } = this.props; const mainClasses = classNames('app-layout', { @@ -112,22 +115,18 @@ export class AppLayout extends Component {
- - + +
{editorVisible && (
- - } - /> - + {hasRevisions && } + + {showRevisions ? ( + + ) : ( + + )}
)}
@@ -136,14 +135,25 @@ export class AppLayout extends Component { }; } -const mapStateToProps: S.MapState = ({ - ui: { dialogs, showNoteList }, - settings: { keyboardShortcuts }, -}) => ({ - keyboardShortcutsAreOpen: dialogs.includes('KEYBINDINGS'), - keyboardShortcuts, - isNoteOpen: !showNoteList, - showNoteList, +const mapStateToProps: S.MapState = (state) => ({ + hasRevisions: + state.ui.showRevisions && state.data.noteRevisions.has(state.ui.openedNote), + keyboardShortcutsAreOpen: state.ui.dialogs.includes('KEYBINDINGS'), + keyboardShortcuts: state.settings.keyboardShortcuts, + isFocusMode: state.settings.focusModeEnabled, + isNavigationOpen: state.ui.showNavigation, + isNoteInfoOpen: state.ui.showNoteInfo, + isNoteOpen: !state.ui.showNoteList, + isSmallScreen: selectors.isSmallScreen(state), + openedRevision: + state.ui.openedRevision?.[0] === state.ui.openedNote + ? state.data.noteRevisions + .get(state.ui.openedNote) + ?.get(state.ui.openedRevision?.[1]) ?? null + : null, + openedNote: state.ui.openedNote, + showNoteList: state.ui.showNoteList, + showRevisions: state.ui.showRevisions, }); const mapDispatchToProps: S.MapDispatch = { diff --git a/lib/app.test.tsx b/lib/app.test.tsx deleted file mode 100644 index 9da2ac5f1..000000000 --- a/lib/app.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import App from './app'; - -window.matchMedia = jest.fn().mockImplementation((query) => { - return { - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - }; -}); - -describe('App', () => { - it('should render', () => { - const app = shallow(); - expect(app.exists()).toBe(true); - }); -}); diff --git a/lib/app.tsx b/lib/app.tsx index c8a50736f..510e7af62 100644 --- a/lib/app.tsx +++ b/lib/app.tsx @@ -1,481 +1,211 @@ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import 'focus-visible/dist/focus-visible.js'; -import appState from './flux/app-state'; -import { loadTags } from './state/domain/tags'; -import browserShell from './browser-shell'; import NoteInfo from './note-info'; import NavigationBar from './navigation-bar'; import AppLayout from './app-layout'; -import BetaBar from './components/beta-bar'; import DevBadge from './components/dev-badge'; import DialogRenderer from './dialog-renderer'; -import exportZipArchive from './utils/export'; import { isElectron, isMac } from './utils/platform'; -import { activityHooks, getUnsyncedNoteIds, nudgeUnsynced } from './utils/sync'; -import { setLastSyncedTime } from './utils/sync/last-synced-time'; -import analytics from './analytics'; import classNames from 'classnames'; -import { debounce, get, has, isObject, overEvery, pick, values } from 'lodash'; import { createNote, closeNote, - setUnsyncedNoteIds, + search, toggleNavigation, - toggleSimperiumConnectionStatus, } from './state/ui/actions'; +import { recordEvent } from './state/analytics/middleware'; import * as settingsActions from './state/settings/actions'; import actions from './state/actions'; +import * as selectors from './state/selectors'; import * as S from './state'; import * as T from './types'; -export type OwnProps = { - noteBucket: object; +type OwnProps = { + isDevConfig: boolean; }; -export type DispatchProps = { - createNote: () => any; +type StateProps = { + autoHideMenuBar: boolean; + hotkeysEnabled: boolean; + isSmallScreen: boolean; + lineLength: T.LineLength; + isSearchActive: boolean; + showNavigation: boolean; + showNoteInfo: boolean; + theme: 'light' | 'dark'; +}; + +type DispatchProps = { + clearSearch: () => any; closeNote: () => any; + createNote: () => any; focusSearchField: () => any; - selectNote: (note: T.NoteEntity) => any; - showDialog: (type: T.DialogType) => any; - trashNote: (previousIndex: number) => any; + setLineLength: (length: T.LineLength) => any; + setNoteDisplay: (displayMode: T.ListDisplayMode) => any; + setSortType: (sortType: T.SortType) => any; + toggleAutoHideMenuBar: () => any; + toggleFocusMode: () => any; + toggleSortOrder: () => any; + toggleSortTagsAlpha: () => any; + toggleSpellCheck: () => any; + toggleTagList: () => any; }; -export type Props = OwnProps & DispatchProps; +type Props = OwnProps & StateProps & DispatchProps; -const mapStateToProps: S.MapState = (state) => state; +class AppComponent extends Component { + static displayName = 'App'; -const mapDispatchToProps: S.MapDispatch< - DispatchProps, - OwnProps -> = function mapDispatchToProps(dispatch, { noteBucket }) { - const actionCreators = Object.assign({}, appState.actionCreators); + componentDidMount() { + window.electron?.send('setAutoHideMenuBar', this.props.autoHideMenuBar); - const thenReloadNotes = (action) => (a) => { - dispatch(action(a)); - dispatch(actionCreators.loadNotes({ noteBucket })); - }; + this.toggleShortcuts(true); - const thenReloadTags = (action) => (a) => { - dispatch(action(a)); - dispatch(loadTags()); - }; + recordEvent('application_opened'); + __TEST__ && window.testEvents.push('booted'); + } - return { - actions: bindActionCreators(actionCreators, dispatch), - ...bindActionCreators( - pick(settingsActions, [ - 'activateTheme', - 'decreaseFontSize', - 'increaseFontSize', - 'resetFontSize', - 'setLineLength', - 'setNoteDisplay', - 'setAccountName', - 'toggleAutoHideMenuBar', - 'toggleFocusMode', - 'toggleSpellCheck', - ]), - dispatch - ), - closeNote: () => dispatch(closeNote()), - remoteNoteUpdate: (noteId, data) => - dispatch(actions.simperium.remoteNoteUpdate(noteId, data)), - loadTags: () => dispatch(loadTags()), - setSortType: thenReloadNotes(settingsActions.setSortType), - toggleSortOrder: thenReloadNotes(settingsActions.toggleSortOrder), - toggleSortTagsAlpha: thenReloadTags(settingsActions.toggleSortTagsAlpha), - createNote: () => dispatch(createNote()), - openTagList: () => dispatch(toggleNavigation()), - selectNote: (note: T.NoteEntity) => dispatch(actions.ui.selectNote(note)), - focusSearchField: () => dispatch(actions.ui.focusSearchField()), - setSimperiumConnectionStatus: (connected) => - dispatch(toggleSimperiumConnectionStatus(connected)), - setUnsyncedNoteIds: (noteIds) => dispatch(setUnsyncedNoteIds(noteIds)), - showDialog: (dialog) => dispatch(actions.ui.showDialog(dialog)), - trashNote: (previousIndex) => dispatch(actions.ui.trashNote(previousIndex)), - }; -}; + componentWillUnmount() { + this.toggleShortcuts(false); + } + + handleShortcut = (event: KeyboardEvent) => { + const { hotkeysEnabled } = this.props; + if (!hotkeysEnabled) { + return; + } + const { ctrlKey, metaKey, shiftKey } = event; + const key = event.key.toLowerCase(); + + // Is either cmd or ctrl pressed? (But not both) + const cmdOrCtrl = (ctrlKey || metaKey) && ctrlKey !== metaKey; + + // open tag list + if (cmdOrCtrl && shiftKey && 'u' === key) { + this.props.toggleTagList(); -export const App = connect( - mapStateToProps, - mapDispatchToProps -)( - class extends Component { - static displayName = 'App'; - - static propTypes = { - actions: PropTypes.object.isRequired, - appState: PropTypes.object.isRequired, - client: PropTypes.object.isRequired, - isDevConfig: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - loadTags: PropTypes.func.isRequired, - openTagList: PropTypes.func.isRequired, - settings: PropTypes.object.isRequired, - preferencesBucket: PropTypes.object.isRequired, - systemTheme: PropTypes.string.isRequired, - tagBucket: PropTypes.object.isRequired, - }; - - UNSAFE_componentWillMount() { - this.onAuthChanged(); + event.stopPropagation(); + event.preventDefault(); + return false; } - componentDidMount() { - window.electron?.receive('appCommand', this.onAppCommand); - window.electron?.send( - 'setAutoHideMenuBar', - this.props.settings.autoHideMenuBar - ); - window.electron?.send('settingsUpdate', this.props.settings); - - this.props.noteBucket - .on('index', this.onNotesIndex) - .on('update', this.onNoteUpdate) - .on('update', debounce(this.onNotesIndex, 200, { maxWait: 1000 })) // refresh notes list - .on('remove', this.onNoteRemoved) - .beforeNetworkChange((noteId) => - this.props.actions.onNoteBeforeRemoteUpdate({ - noteId, - }) - ); - - this.props.preferencesBucket.on('update', this.onLoadPreferences); - - this.props.tagBucket - .on('index', this.props.loadTags) - .on('update', debounce(this.props.loadTags, 200)) - .on('remove', this.props.loadTags); - - this.props.client - .on('authorized', this.onAuthChanged) - .on('unauthorized', this.onAuthChanged) - .on('message', setLastSyncedTime) - .on('message', this.syncActivityHooks) - .on('send', this.syncActivityHooks) - .on('connect', () => this.props.setSimperiumConnectionStatus(true)) - .on('disconnect', () => this.props.setSimperiumConnectionStatus(false)); - - this.onLoadPreferences(() => - // Make sure that tracking starts only after preferences are loaded - analytics.tracks.recordEvent('application_opened') - ); - - this.toggleShortcuts(true); - - __TEST__ && window.testEvents.push('booted'); + if ( + (cmdOrCtrl && shiftKey && 's' === key) || + (cmdOrCtrl && !shiftKey && 'f' === key) + ) { + this.props.focusSearchField(); + + event.stopPropagation(); + event.preventDefault(); + return false; } - componentWillUnmount() { - this.toggleShortcuts(false); + if (('Escape' === key || 'Esc' === key) && this.props.isSearchActive) { + this.props.clearSearch(); } - componentDidUpdate(prevProps) { - const { settings } = this.props; + if (cmdOrCtrl && shiftKey && 'f' === key) { + this.props.toggleFocusMode(); - if (settings !== prevProps.settings) { - window.electron?.send('settingsUpdate', settings); - } + event.stopPropagation(); + event.preventDefault(); + return false; } - handleShortcut = (event: KeyboardEvent) => { - const { - settings: { keyboardShortcuts }, - } = this.props; - if (!keyboardShortcuts) { - return; - } - const { code, ctrlKey, metaKey, shiftKey } = event; - - // Is either cmd or ctrl pressed? (But not both) - const cmdOrCtrl = (ctrlKey || metaKey) && ctrlKey !== metaKey; - - // open tag list - if ( - cmdOrCtrl && - shiftKey && - 'KeyU' === code && - !this.props.showNavigation - ) { - this.props.openTagList(); - - event.stopPropagation(); - event.preventDefault(); - return false; - } - - if ( - (cmdOrCtrl && shiftKey && 'KeyS' === code) || - (isElectron && cmdOrCtrl && !shiftKey && 'KeyF' === code) - ) { - this.props.focusSearchField(); - - event.stopPropagation(); - event.preventDefault(); - return false; - } - - if (cmdOrCtrl && shiftKey && 'KeyF' === code) { - this.props.toggleFocusMode(); - - event.stopPropagation(); - event.preventDefault(); - return false; - } - - if (cmdOrCtrl && shiftKey && 'KeyI' === code) { - this.props.actions.newNote({ - noteBucket: this.props.noteBucket, - }); - analytics.tracks.recordEvent('list_note_created'); - - event.stopPropagation(); - event.preventDefault(); - return false; - } - - // prevent default browser behavior for search - // will bubble up from note-detail - if (cmdOrCtrl && 'KeyG' === code) { - event.stopPropagation(); - event.preventDefault(); - } - - return true; - }; - - onAppCommand = (event) => { - if ('exportZipArchive' === event.action) { - exportZipArchive(); - } - - if ('printNote' === event.action) { - return window.print(); - } - - if ('focusSearchField' === event.action) { - return this.props.focusSearchField(); - } - - if ('showDialog' === event.action) { - return this.props.showDialog(event.dialog); - } - - if ('trashNote' === event.action && this.props.ui.note) { - return this.props.actions.trashNote({ - noteBucket: this.props.noteBucket, - note: this.props.ui.note, - previousIndex: this.props.appState.notes.findIndex( - ({ id }) => this.props.ui.note.id === id - ), - }); - } - - const canRun = overEvery( - isObject, - (o) => o.action !== null, - (o) => has(this.props.actions, o.action) || has(this.props, o.action) - ); - - if (canRun(event)) { - // newNote expects a bucket to be passed in, but the action method itself wouldn't do that - if (event.action === 'newNote') { - this.props.actions.newNote({ - noteBucket: this.props.noteBucket, - }); - analytics.tracks.recordEvent('list_note_created'); - } else if (has(this.props, event.action)) { - const { action, ...args } = event; - - this.props[action](...values(args)); - } else { - this.props.actions[event.action](event); - } - } - }; - - onAuthChanged = () => { - const { - appState: { accountName }, - } = this.props; - - window.electron?.send('settingsUpdate', this.props.settings); - - analytics.initialize(accountName); - this.onLoadPreferences(); - - // 'Kick' the app to ensure content is loaded after signing in - this.onNotesIndex(); - this.props.loadTags(); - }; - - onNotesIndex = () => { - const { noteBucket, setUnsyncedNoteIds } = this.props; - const { loadNotes } = this.props.actions; - - loadNotes({ noteBucket }); - setUnsyncedNoteIds(getUnsyncedNoteIds(noteBucket)); - - __TEST__ && window.testEvents.push('notesLoaded'); - }; - - onNoteRemoved = () => this.onNotesIndex(); - - onNoteUpdate = ( - noteId: T.EntityId, - data, - remoteUpdateInfo: { patch?: object } = {} - ) => { - const { - noteBucket, - selectNote, - ui: { note }, - } = this.props; - - this.props.remoteNoteUpdate(noteId, data); - - if (note && noteId === note.id) { - noteBucket.get(noteId, (e: unknown, storedNote: T.NoteEntity) => { - if (e) { - return; - } - const updatedNote = remoteUpdateInfo.patch - ? { ...storedNote, hasRemoteUpdate: true } - : storedNote; - selectNote(updatedNote); - }); - } - }; - - onLoadPreferences = (callback) => - this.props.actions.loadPreferences({ - callback, - preferencesBucket: this.props.preferencesBucket, - }); - - getTheme = () => { - const { - settings: { theme }, - systemTheme, - } = this.props; - return 'system' === theme ? systemTheme : theme; - }; - - onUpdateContent = (note, content, sync = false) => { - if (!note) { - return; - } - - const updatedNote = { - ...note, - data: { - ...note.data, - content, - modificationDate: Math.floor(Date.now() / 1000), - }, - }; - - this.props.selectNote(updatedNote); - - const { noteBucket } = this.props; - noteBucket.update(note.id, updatedNote.data, {}, { sync }); - if (sync) { - this.syncNote(note.id); - } - }; - - syncNote = (noteId) => { - this.props.noteBucket.touch(noteId); - }; - - syncActivityHooks = (data) => { - activityHooks(data, { - onIdle: () => { - const { - appState: { notes }, - client, - noteBucket, - setUnsyncedNoteIds, - } = this.props; - - nudgeUnsynced({ client, noteBucket, notes }); - setUnsyncedNoteIds(getUnsyncedNoteIds(noteBucket)); - }, - }); - }; - - toggleShortcuts = (doEnable) => { - if (doEnable) { - window.addEventListener('keydown', this.handleShortcut, true); - } else { - window.removeEventListener('keydown', this.handleShortcut, true); - } - }; - - loadPreferences = () => { - this.props.actions.loadPreferences({ - preferencesBucket: this.props.preferencesBucket, - }); - }; - - render() { - const { - appState: state, - isDevConfig, - noteBucket, - preferencesBucket, - settings, - tagBucket, - isSmallScreen, - ui: { showNavigation, showNoteInfo }, - } = this.props; - - const themeClass = `theme-${this.getTheme()}`; - - const appClasses = classNames('app', themeClass, { - 'is-line-length-full': settings.lineLength === 'full', - 'touch-enabled': 'ontouchstart' in document.body, - }); - - const mainClasses = classNames('simplenote-app', { - 'note-info-open': showNoteInfo, - 'navigation-open': showNavigation, - 'is-electron': isElectron, - 'is-macos': isMac, - }); - - return ( -
- {isDevConfig && } - -
- {showNavigation && } - - {showNoteInfo && } -
- -
- ); + if (cmdOrCtrl && shiftKey && 'i' === key) { + this.props.createNote(); + + event.stopPropagation(); + event.preventDefault(); + return false; + } + + // prevent default browser behavior for search and find + if (cmdOrCtrl && ('g' === key || 'f' === key)) { + event.stopPropagation(); + event.preventDefault(); + } + + return true; + }; + + toggleShortcuts = (doEnable: boolean) => { + if (doEnable) { + window.addEventListener('keydown', this.handleShortcut, true); + } else { + window.removeEventListener('keydown', this.handleShortcut, true); } + }; + + render() { + const { + isDevConfig, + lineLength, + showNavigation, + showNoteInfo, + theme, + } = this.props; + + const appClasses = classNames('app', `theme-${theme}`, { + 'is-line-length-full': lineLength === 'full', + 'touch-enabled': 'ontouchstart' in document.body, + }); + + const mainClasses = classNames('simplenote-app', { + 'note-info-open': showNoteInfo, + 'navigation-open': showNavigation, + 'is-electron': isElectron, + 'is-macos': isMac, + }); + + return ( +
+ {isDevConfig && } +
+ {showNavigation && } + + {showNoteInfo && } +
+ +
+ ); } -); +} + +const mapStateToProps: S.MapState = (state) => ({ + autoHideMenuBar: state.settings.autoHideMenuBar, + hotkeysEnabled: state.settings.keyboardShortcuts, + isSearchActive: !!state.ui.searchQuery.length, + isSmallScreen: selectors.isSmallScreen(state), + lineLength: state.settings.lineLength, + showNavigation: state.ui.showNavigation, + showNoteInfo: state.ui.showNoteInfo, + theme: selectors.getTheme(state), +}); + +const mapDispatchToProps: S.MapDispatch = (dispatch) => { + return { + activateTheme: (theme: T.Theme) => + dispatch(settingsActions.activateTheme(theme)), + clearSearch: () => dispatch(search('')), + closeNote: () => dispatch(closeNote()), + createNote: () => dispatch(createNote()), + focusSearchField: () => dispatch(actions.ui.focusSearchField()), + setLineLength: (length) => dispatch(settingsActions.setLineLength(length)), + setNoteDisplay: (displayMode) => + dispatch(settingsActions.setNoteDisplay(displayMode)), + setSortType: (sortType) => dispatch(settingsActions.setSortType(sortType)), + toggleAutoHideMenuBar: () => + dispatch(settingsActions.toggleAutoHideMenuBar()), + toggleFocusMode: () => dispatch(settingsActions.toggleFocusMode()), + toggleSortOrder: () => dispatch(settingsActions.toggleSortOrder()), + toggleSortTagsAlpha: () => dispatch(settingsActions.toggleSortTagsAlpha()), + toggleSpellCheck: () => dispatch(settingsActions.toggleSpellCheck()), + toggleTagList: () => dispatch(toggleNavigation()), + }; +}; -export default browserShell(App); +export default connect(mapStateToProps, mapDispatchToProps)(AppComponent); diff --git a/lib/boot-with-auth.tsx b/lib/boot-with-auth.tsx index ab59c0455..725934b7d 100644 --- a/lib/boot-with-auth.tsx +++ b/lib/boot-with-auth.tsx @@ -2,31 +2,20 @@ if (__TEST__) { window.testEvents = []; } -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import 'unorm'; - import React from 'react'; import App from './app'; import Modal from 'react-modal'; -import Debug from 'debug'; -import { initClient } from './client'; import getConfig from '../get-config'; import { makeStore } from './state'; -import actions from './state/actions'; -import { initSimperium } from './state/simperium/middleware'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; +import { initSimperium } from './state/simperium/middleware'; import '../scss/style.scss'; import isDevConfig from './utils/is-dev-config'; -import { normalizeForSorting } from './utils/note-utils'; - -import * as T from './types'; const config = getConfig(); -const appID = config.app_id; export const bootWithToken = ( logout: () => any, @@ -34,64 +23,32 @@ export const bootWithToken = ( username: string | null, createWelcomeNote: boolean ) => { - const client = initClient({ - appID, - token, - bucketConfig: { - note: { - beforeIndex: function (note: T.NoteEntity) { - var content = (note.data && note.data.content) || ''; + Modal.setAppElement('#root'); - return { - ...note, - contentKey: normalizeForSorting(content), - }; - }, - configure: function (objectStore) { - objectStore.createIndex('modificationDate', 'data.modificationDate'); - objectStore.createIndex('creationDate', 'data.creationDate'); - objectStore.createIndex('alphabetical', 'contentKey'); + makeStore( + username, + initSimperium(logout, token, username, createWelcomeNote) + ).then((store) => { + Object.defineProperties(window, { + dispatch: { + get() { + return store.dispatch; }, }, - preferences: function (objectStore) { - console.log('Configure preferences', objectStore); // eslint-disable-line no-console - }, - tag: function (objectStore) { - console.log('Configure tag', objectStore); // eslint-disable-line no-console + state: { + get() { + return store.getState(); + }, }, - }, - database: 'simplenote', - version: 42, - }); - - const debug = Debug('client'); - const l = (msg: string) => (...args: unknown[]) => debug(msg, ...args); + }); - client - .on('connect', l('Connected')) - .on('disconnect', l('Not connected')) - .on('message', l('<=')) - .on('send', l('=>')) - .on('unauthorized', l('Not authorized')); - - Modal.setAppElement('#root'); + window.electron?.send('settingsUpdate', store.getState().settings); - const store = makeStore( - initSimperium(logout, token, username, createWelcomeNote, client) - ); - - store.dispatch(actions.settings.setAccountName(username)); - - render( - - - , - document.getElementById('root') - ); + render( + + + , + document.getElementById('root') + ); + }); }; diff --git a/lib/boot-without-auth.tsx b/lib/boot-without-auth.tsx index 207076cd9..bdebd52a5 100644 --- a/lib/boot-without-auth.tsx +++ b/lib/boot-without-auth.tsx @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import { render } from 'react-dom'; import { Auth as AuthApp } from './auth'; import { Auth as SimperiumAuth } from 'simperium'; -import analytics from './analytics'; +import { recordEvent } from './state/analytics/middleware'; import { validatePassword } from './utils/validate-password'; import Modal from 'react-modal'; import classNames from 'classnames'; @@ -44,8 +44,9 @@ class AppWithoutAuth extends Component { window.electron?.receive('appCommand', this.onAppCommand); } - componentWillUnmount() { + login(token: string, username: string, createWelcomeNote: boolean) { window.electron?.removeListener('appCommand'); + this.props.onAuth(token, username, createWelcomeNote); } onAppCommand = (event) => { @@ -76,7 +77,7 @@ class AppWithoutAuth extends Component { if (!user.access_token) { throw new Error('missing access token'); } - this.props.onAuth(user.access_token, username, false); + this.login(user.access_token, username, false); }) .catch((error: unknown) => { if ( @@ -105,8 +106,8 @@ class AppWithoutAuth extends Component { throw new Error('missing access token'); } - analytics.tracks.recordEvent('user_account_created'); - this.props.onAuth(user.access_token, username, true); + recordEvent('user_account_created'); + this.login(user.access_token, username, true); }) .catch(() => { this.setState({ authStatus: 'unknown-error' }); @@ -115,7 +116,7 @@ class AppWithoutAuth extends Component { }; tokenLogin = (username: string, token: string) => { - this.props.onAuth(token, username, false); + this.login(token, username, false); }; render() { diff --git a/lib/boot.ts b/lib/boot.ts index ed67af637..bd675b121 100644 --- a/lib/boot.ts +++ b/lib/boot.ts @@ -1,30 +1,65 @@ +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; + import './utils/ensure-platform-support'; import { parse } from 'cookie'; -import analytics from './analytics'; import getConfig from '../get-config'; import { boot as bootWithoutAuth } from './boot-without-auth'; import { boot as bootLoggingOut } from './logging-out'; -import { isElectron } from './utils/platform'; const config = getConfig(); -const clearStorage = () => - new Promise((resolve) => { +const clearStorage = (): Promise => + new Promise((resolveStorage) => { localStorage.removeItem('access_token'); localStorage.removeItem('lastSyncedTime'); localStorage.removeItem('localQueue:note'); localStorage.removeItem('localQueue:preferences'); localStorage.removeItem('localQueue:tag'); localStorage.removeItem('stored_user'); - indexedDB.deleteDatabase('ghost'); - indexedDB.deleteDatabase('simplenote'); - window.electron?.send('clearCookies'); window.electron?.send('settingsUpdate', {}); - // let everything settle - setTimeout(() => resolve(), 500); + const settings = localStorage.getItem('simpleNote'); + if (settings) { + try { + const { accountName, ...otherSettings } = JSON.parse(settings); + localStorage.setItem('simpleNote', JSON.stringify(otherSettings)); + } catch (e) { + // pass - we only care if we can successfully do this, + // not if we fail to do it + } + } + + Promise.all([ + new Promise((resolve) => { + const r = indexedDB.deleteDatabase('ghost'); + r.onupgradeneeded = resolve; + r.onblocked = resolve; + r.onsuccess = resolve; + r.onerror = resolve; + }), + new Promise((resolve) => { + const r = indexedDB.deleteDatabase('simplenote'); + r.onupgradeneeded = resolve; + r.onblocked = resolve; + r.onsuccess = resolve; + r.onerror = resolve; + }), + new Promise((resolve) => { + const r = indexedDB.deleteDatabase('simplenote_v2'); + r.onupgradeneeded = resolve; + r.onblocked = resolve; + r.onsuccess = resolve; + r.onerror = resolve; + }), + ]) + .then(() => { + window.electron?.send('clearCookies'); + resolveStorage(); + }) + .catch(() => resolveStorage()); }); const forceReload = () => history.go(); @@ -83,17 +118,75 @@ if (config.is_app_engine && !storedToken) { }); } +const ensureNormalization = () => + !('normalize' in String.prototype) + ? import(/* webpackChunkName: 'unorm' */ 'unorm') + : Promise.resolve(); + +// @TODO: Move this into some framework spot +// still no IE support +// https://tc39.github.io/ecma262/#sec-array.prototype.findindex +/* eslint-disable */ +if (!Array.prototype.findIndex) { + Object.defineProperty(Array.prototype, 'findIndex', { + value: function (predicate: Function) { + // 1. Let O be ? ToObject(this value). + if (this == null) { + throw new TypeError('"this" is null or not defined'); + } + + var o = Object(this); + + // 2. Let len be ? ToLength(? Get(O, "length")). + var len = o.length >>> 0; + + // 3. If IsCallable(predicate) is false, throw a TypeError exception. + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + + // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. + var thisArg = arguments[1]; + + // 5. Let k be 0. + var k = 0; + + // 6. Repeat, while k < len + while (k < len) { + // a. Let Pk be ! ToString(k). + // b. Let kValue be ? Get(O, Pk). + // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). + // d. If testResult is true, return k. + var kValue = o[k]; + if (predicate.call(thisArg, kValue, k, o)) { + return k; + } + // e. Increase k by 1. + k++; + } + + // 7. Return -1. + return -1; + }, + configurable: true, + writable: true, + }); +} +/* eslint-enable */ + const run = ( token: string | null, username: string | null, createWelcomeNote: boolean ) => { if (token) { - import('./boot-with-auth').then(({ bootWithToken }) => { + Promise.all([ + ensureNormalization(), + import(/* webpackChunkName: 'boot-with-auth' */ './boot-with-auth'), + ]).then(([unormPolyfillLoaded, { bootWithToken }]) => { bootWithToken( () => { bootLoggingOut(); - analytics.tracks.recordEvent('user_signed_out'); clearStorage().then(() => { if (window.webConfig?.signout) { window.webConfig.signout(forceReload); @@ -108,10 +201,14 @@ const run = ( ); }); } else { + window.addEventListener('storage', (event) => { + if (event.key === 'stored_user') { + forceReload(); + } + }); bootWithoutAuth( (token: string, username: string, createWelcomeNote: boolean) => { saveAccount(token, username); - analytics.tracks.recordEvent('user_signed_in'); run(token, username, createWelcomeNote); } ); diff --git a/lib/browser-shell.tsx b/lib/browser-shell.tsx deleted file mode 100644 index 0e53eb66c..000000000 --- a/lib/browser-shell.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { Component } from 'react'; - -/** - * Get window-related attributes - * - * There is no need for `this` here; `window` is global - * - * @returns {{windowWidth: Number, isSmallScreen: boolean, systemTheme: String }} - * window attributes - */ -const getState = () => { - const windowWidth = window.innerWidth; - - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - - return { - windowWidth, - isSmallScreen: windowWidth <= 750, // Magic number here corresponds to $single-column value in variables.scss - systemTheme, - }; -}; - -/** - * Passes window-related attributes into child component - * - * Passes: - * - viewport width (including scrollbar) - * - whether width is considered small - * - system @media theme (dark or light) - * - * @param {Element} Wrapped React component dependent on window attributes - * @returns {Component} wrapped React component with window attributes as props - */ -export const browserShell = (Wrapped) => - class extends Component { - static displayName = 'BrowserShell'; - - state = getState(); - - componentDidMount() { - window.addEventListener('resize', this.updateWindowSize); - window - .matchMedia('(prefers-color-scheme: dark)') - .addListener(this.updateSystemTheme); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.updateWindowSize); - window - .matchMedia('(prefers-color-scheme: dark)') - .removeListener(this.updateSystemTheme); - } - - updateWindowSize = () => this.setState(getState()); - updateSystemTheme = () => this.setState(getState()); - - render() { - return ; - } - }; - -export default browserShell; diff --git a/lib/client.ts b/lib/client.ts deleted file mode 100644 index 5efff966b..000000000 --- a/lib/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import simperium from './simperium'; - -let client; - -export const initClient = (config) => { - client = simperium(config); - return client; -}; - -export default () => client; diff --git a/lib/components/beta-bar/index.tsx b/lib/components/beta-bar/index.tsx deleted file mode 100644 index 31ddc7f42..000000000 --- a/lib/components/beta-bar/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useState } from 'react'; -import SmallCrossIcon from '../../icons/cross-small'; -import { isElectron } from '../../utils/platform'; - -const loadOptOut = (): boolean => { - try { - const stored = localStorage.getItem('betaBannerDismissedAt'); - const dismissedAt = stored ? parseInt(stored, 10) : -Infinity; - - return ( - isNaN(dismissedAt) || Date.now() - dismissedAt > 7 * 24 * 60 * 60 * 1000 - ); - } catch (e) { - return true; - } -}; - -const storeOptOut = () => { - try { - localStorage.setItem('betaBannerDismissedAt', Date.now().toString()); - } catch (e) { - // pass - } -}; - -const BetaBar = () => { - const [isVisible, setIsVisible] = useState(loadOptOut()); - const dismissBanner = () => { - storeOptOut(); - setIsVisible(false); - }; - - return !isElectron && isVisible ? ( -
- You‘re invited to try Simplenote Beta. - Try it now. - - - -
- ) : null; -}; - -export default BetaBar; diff --git a/lib/components/beta-bar/style.scss b/lib/components/beta-bar/style.scss deleted file mode 100644 index 301509b69..000000000 --- a/lib/components/beta-bar/style.scss +++ /dev/null @@ -1,23 +0,0 @@ -.beta-bar { - position: absolute; - width: 100%; - height: 36px; - bottom: 0; - box-shadow: 0 2px 7px 0 rgba(0, 0, 0, 0.2); - background-color: #3d3f41; - color: #fff; - text-align: center; - line-height: 32px; - z-index: 1; - - a { - color: #fff !important; - margin-left: 5px; - } - svg { - position: absolute; - right: 12px; - bottom: 7px; - cursor: pointer; - } -} diff --git a/lib/components/checkbox/__snapshots__/test.tsx.snap b/lib/components/checkbox/__snapshots__/test.tsx.snap deleted file mode 100644 index 53e6c6c10..000000000 --- a/lib/components/checkbox/__snapshots__/test.tsx.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`hasn't had its output unintentionally altered 1`] = ` - - - -`; diff --git a/lib/components/checkbox/index.tsx b/lib/components/checkbox/index.tsx deleted file mode 100644 index 03a608a45..000000000 --- a/lib/components/checkbox/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import CheckmarkIcon from '../../icons/checkmark'; -import CircleIcon from '../../icons/circle'; - -const Checkbox = ({ checked = false, onChange }) => { - // A custom checkbox with an ARIA role is used here to work around a bug in - // DraftJS, where using a hidden will trigger a error. - return ( - - - - ); -}; - -Checkbox.propTypes = { - checked: PropTypes.bool, - onChange: PropTypes.func, -}; - -export default Checkbox; diff --git a/lib/components/checkbox/style.scss b/lib/components/checkbox/style.scss deleted file mode 100644 index 262a52ea7..000000000 --- a/lib/components/checkbox/style.scss +++ /dev/null @@ -1,27 +0,0 @@ -input[type="checkbox"] { - cursor: pointer; -} - -.checkbox { - cursor: pointer; - line-height: inherit; - - &[aria-checked="true"] { - opacity: .7; - } - - &[aria-checked="false"] { - opacity: .4; - } -} - -.checkbox__icon { - display: inline; - position: relative; - top: -0.09em; - - svg { - height: 1.3em; - width: 1.3em; - } -} diff --git a/lib/components/checkbox/test.tsx b/lib/components/checkbox/test.tsx deleted file mode 100644 index 48e0e4456..000000000 --- a/lib/components/checkbox/test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Checkbox from './'; -import CheckmarkIcon from '../../icons/checkmark'; -import CircleIcon from '../../icons/circle'; -import React from 'react'; -import renderer from 'react-test-renderer'; -import { shallow } from 'enzyme'; - -it("hasn't had its output unintentionally altered", () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); -}); - -it('renders the circle icon when unchecked', () => { - const checkbox = shallow(); - expect(checkbox.find(CircleIcon)).toHaveLength(1); - expect(checkbox.find(CheckmarkIcon)).toHaveLength(0); -}); - -it('renders the checkmark icon when checked', () => { - const checkbox = shallow(); - expect(checkbox.find(CircleIcon)).toHaveLength(0); - expect(checkbox.find(CheckmarkIcon)).toHaveLength(1); -}); - -it('should call onChange prop when span is clicked', () => { - const noop = jest.fn(); - const output = renderer.create(); - output.root.findByType('span').props.onClick(); - expect(noop).toHaveBeenCalled(); -}); diff --git a/lib/components/last-sync-time/index.tsx b/lib/components/last-sync-time/index.tsx new file mode 100644 index 000000000..0567a8d1e --- /dev/null +++ b/lib/components/last-sync-time/index.tsx @@ -0,0 +1,33 @@ +import React, { FunctionComponent } from 'react'; +import { connect } from 'react-redux'; + +import type * as S from '../../state'; +import type * as T from '../../types'; + +type OwnProps = { + noteId: T.EntityId; +}; + +type StateProps = { + lastUpdated?: number; +}; + +type Props = StateProps; + +export const LastSyncTime: FunctionComponent = ({ lastUpdated }) => + 'undefined' !== typeof lastUpdated ? ( + + ) : ( + + ); + +const mapStateToProps: S.MapState = ( + state, + { noteId } +) => ({ + lastUpdated: state.simperium.lastSync.get(noteId), +}); + +export default connect(mapStateToProps)(LastSyncTime); diff --git a/lib/components/note-preview/index.tsx b/lib/components/note-preview/index.tsx new file mode 100644 index 000000000..1fed1ecd2 --- /dev/null +++ b/lib/components/note-preview/index.tsx @@ -0,0 +1,184 @@ +import React, { FunctionComponent, useEffect, useRef } from 'react'; +import { connect } from 'react-redux'; + +import renderToNode from '../../note-detail/render-to-node'; +import { viewExternalUrl } from '../../utils/url-utils'; +import { withCheckboxCharacters } from '../../utils/task-transform'; + +import actions from '../../state/actions'; + +import * as S from '../../state'; +import * as T from '../../types'; + +type OwnProps = { + noteId: T.EntityId; + note?: T.Note; +}; + +type StateProps = { + fontSize: number; + isFocused: boolean; + note: T.Note | null; + noteId: T.EntityId | null; + searchQuery: string; + showRenderedView: boolean; +}; + +type DispatchProps = { + editNote: (noteId: T.EntityId, changes: Partial) => any; + openNote: (noteId: T.EntityId) => any; +}; + +type Props = OwnProps & StateProps & DispatchProps; + +export const NotePreview: FunctionComponent = ({ + editNote, + fontSize, + isFocused, + note, + noteId, + openNote, + searchQuery, + showRenderedView, +}) => { + const previewNode = useRef(); + + useEffect(() => { + const copyRenderedNote = (event: ClipboardEvent) => { + if (!isFocused) { + return true; + } + + // Only copy the rendered content if nothing is selected + if (!document.getSelection().isCollapsed) { + return true; + } + + const div = document.createElement('div'); + renderToNode(div, note.content, searchQuery).then(() => { + try { + // this works in Chrome and Safari but not Firefox + event.clipboardData.setData('text/plain', div.innerHTML); + } catch (DOMException) { + // try it the Firefox way - this works in Firefox and Chrome + navigator.clipboard.writeText(div.innerHTML); + } + }); + + event.preventDefault(); + }; + + document.addEventListener('copy', copyRenderedNote, false); + return () => document.removeEventListener('copy', copyRenderedNote, false); + }, [isFocused, searchQuery]); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + for (let node = event.target; node !== null; node = node.parentNode) { + if (node.tagName === 'A') { + event.preventDefault(); + event.stopPropagation(); + + const tag = node as HTMLAnchorElement; + + // Intercept inter-note links + if (tag.href.startsWith('simplenote://note/')) { + const match = /^simplenote:\/\/note\/(.+)$/.exec(tag.href); + if (!match) { + return; + } + + const [fullMatch, linkedNoteId] = match; + openNote(linkedNoteId as T.EntityId); + return; + } + + // skip internal note links (e.g. anchor links, footnotes) + if (!tag.href.startsWith('http://localhost')) { + viewExternalUrl(tag.href); + } + + return; + } + + if (node.className === 'task-list-item') { + event.preventDefault(); + event.stopPropagation(); + + const allTasks = previewNode!.current.querySelectorAll( + '[data-markdown-root] .task-list-item' + ); + const taskIndex = Array.prototype.indexOf.call(allTasks, node); + + let matchCount = 0; + const content = note.content.replace(/[\ue000|\ue001]/g, (match) => + matchCount++ === taskIndex + ? match === '\ue000' + ? '\ue001' + : '\ue000' + : match + ); + + editNote(noteId, { content }); + return; + } + } + }; + previewNode.current?.addEventListener('click', handleClick, true); + return () => + previewNode.current?.removeEventListener('click', handleClick, true); + }, []); + + useEffect(() => { + if (!previewNode.current) { + return; + } + + if (note?.content && showRenderedView) { + renderToNode(previewNode.current, note!.content, searchQuery); + } else { + previewNode.current.innerText = withCheckboxCharacters( + note?.content ?? '' + ); + } + }, [note?.content, searchQuery, showRenderedView]); + + return ( +
+
+
+
+ {!showRenderedView && withCheckboxCharacters(note?.content ?? '')} +
+
+
+
+ ); +}; + +const mapStateToProps: S.MapState = (state, props) => { + const noteId = props.noteId ?? state.ui.openedNote; + const note = props.note ?? state.data.notes.get(noteId); + + return { + fontSize: state.settings.fontSize, + isFocused: state.ui.dialogs.length === 0 && !state.ui.showNoteInfo, + note, + noteId, + searchQuery: state.ui.searchQuery, + showRenderedView: + !!note?.systemTags.includes('markdown') && !state.ui.editMode, + }; +}; + +const mapDispatchToProps: S.MapDispatch = { + editNote: actions.data.editNote, + openNote: actions.ui.selectNote, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(NotePreview); diff --git a/lib/components/slider/index.tsx b/lib/components/slider/index.tsx index c5dc92188..acfb643e8 100644 --- a/lib/components/slider/index.tsx +++ b/lib/components/slider/index.tsx @@ -1,6 +1,7 @@ import React, { ChangeEventHandler, FunctionComponent } from 'react'; type Props = { + disabled: boolean; onChange: ChangeEventHandler; min: number; max: number; @@ -8,6 +9,7 @@ type Props = { }; export const Slider: FunctionComponent = ({ + disabled, min, max, value, @@ -15,6 +17,7 @@ export const Slider: FunctionComponent = ({ }) => ( { - const originalConsoleLog = console.log; // eslint-disable-line no-console - - afterEach(() => { - global.console.log = originalConsoleLog; - }); - - it('should return the titles for the given note ids', () => { - const result = getNoteTitles( - ['foo', 'baz'], - [ - { id: 'foo', data: { content: 'title\nexcerpt', systemTags: [] } }, - { id: 'bar' }, - { id: 'baz', data: { content: 'title\nexcerpt', systemTags: [] } }, - ] - ); - expect(result).toEqual([ - { id: 'foo', title: 'title' }, - { id: 'baz', title: 'title' }, - ]); - }); - - it('should not choke on invalid ids', () => { - global.console.log = jest.fn(); - const result = getNoteTitles( - ['foo', 'bar'], - [{ id: 'foo', data: { content: 'title', systemTags: [] } }] - ); - expect(result).toEqual([{ id: 'foo', title: 'title' }]); - }); - - it('should return no more than `limit` items', () => { - const limit = 1; - const result = getNoteTitles( - ['foo', 'bar'], - [ - { id: 'foo', data: { content: 'title', systemTags: [] } }, - { id: 'bar' }, - ], - limit - ); - expect(result).toHaveLength(limit); - }); -}); diff --git a/lib/components/sync-status/get-note-titles.ts b/lib/components/sync-status/get-note-titles.ts deleted file mode 100644 index 513bea576..000000000 --- a/lib/components/sync-status/get-note-titles.ts +++ /dev/null @@ -1,32 +0,0 @@ -import filterAtMost from '../../utils/filter-at-most'; -import noteTitleAndPreview from '../../utils/note-utils'; - -import * as T from '../../types'; - -type NoteTitle = { - id: T.EntityId; - title: string; -}; - -const getNoteTitles = ( - ids: T.EntityId[], - notes: T.NoteEntity[] | null, - limit: number = Infinity -): NoteTitle[] => { - if (!notes) { - return []; - } - const wantedIds = new Set(ids); - const wantedNotes = filterAtMost( - notes, - ({ id }: { id: T.EntityId }) => wantedIds.has(id), - limit - ); - - return wantedNotes.map((note: T.NoteEntity) => ({ - id: note.id, - title: noteTitleAndPreview(note).title, - })); -}; - -export default getNoteTitles; diff --git a/lib/components/sync-status/index.tsx b/lib/components/sync-status/index.tsx deleted file mode 100644 index 638b78767..000000000 --- a/lib/components/sync-status/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; - -import AlertIcon from '../../icons/alert'; -import SyncIcon from '../../icons/sync'; -import SyncStatusPopover from './popover'; - -import * as S from '../../state'; -import * as T from '../../types'; - -type StateProps = { - simperiumConnected: boolean; - unsyncedNoteIds: T.EntityId[]; -}; - -class SyncStatus extends Component { - state = { - anchorEl: null, - }; - - handlePopoverOpen = (event) => { - this.setState({ anchorEl: event.currentTarget }); - }; - - handlePopoverClose = () => { - this.setState({ anchorEl: null }); - }; - - render() { - const { simperiumConnected, unsyncedNoteIds } = this.props; - const { anchorEl } = this.state; - - const popoverId = 'sync-status__popover'; - - const unsyncedChangeCount = unsyncedNoteIds.length; - const unit = unsyncedChangeCount === 1 ? 'change' : 'changes'; - const text = unsyncedChangeCount - ? `${unsyncedChangeCount} unsynced ${unit}` - : simperiumConnected - ? 'All changes synced' - : 'No connection'; - - return ( -
-
- - {simperiumConnected ? : } - - {text} -
- - -
- ); - } -} - -const mapStateToProps: S.MapState = ({ - ui: { simperiumConnected, unsyncedNoteIds }, -}) => ({ - simperiumConnected, - unsyncedNoteIds, -}); - -export default connect(mapStateToProps)(SyncStatus); diff --git a/lib/components/sync-status/popover.tsx b/lib/components/sync-status/popover.tsx deleted file mode 100644 index 53aa34a3d..000000000 --- a/lib/components/sync-status/popover.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; -import formatDistanceToNow from 'date-fns/formatDistanceToNow'; -import { Popover } from '@material-ui/core'; - -import { getLastSyncedTime } from '../../utils/sync/last-synced-time'; -import getNoteTitles from './get-note-titles'; - -import * as S from '../../state'; -import * as T from '../../types'; - -type StateProps = { - notes: T.NoteEntity[] | null; - theme: T.Theme; - unsyncedNoteIds: T.EntityId[]; -}; - -type OwnProps = { - anchorEl: HTMLElement; - id: T.EntityId; - onClose: () => void; -}; - -type Props = StateProps & OwnProps; - -class SyncStatusPopover extends React.Component { - render() { - const { anchorEl, id, notes, onClose, theme, unsyncedNoteIds } = this.props; - const themeClass = `theme-${theme}`; - const open = Boolean(anchorEl); - const hasUnsyncedChanges = unsyncedNoteIds.length > 0; - - const QUERY_LIMIT = 10; - const noteTitles = hasUnsyncedChanges - ? getNoteTitles(unsyncedNoteIds, notes, QUERY_LIMIT) - : []; - const overflowCount = unsyncedNoteIds.length - noteTitles.length; - const unit = overflowCount === 1 ? 'note' : 'notes'; - const lastSyncedTime = getLastSyncedTime(); - - return ( - - {hasUnsyncedChanges && ( -
-

- Notes with unsynced changes -

-
    - {noteTitles.map((note) => ( -
  • {note.title}
  • - ))} -
- {!!overflowCount && ( -

- and {overflowCount} more {unit} -

- )} -
- If a note isn’t syncing, try switching networks or editing the - note again. -
-
- )} - {lastSyncedTime > -Infinity ? ( - - Last synced:{' '} - {formatDistanceToNow(lastSyncedTime, { addSuffix: true })} - - ) : ( - Unknown sync status - )} -
- ); - } -} - -const mapStateToProps: S.MapState = ({ - appState, - settings, - ui: { unsyncedNoteIds }, -}) => ({ - notes: appState.notes, - theme: settings.theme, - unsyncedNoteIds, -}); - -export default connect(mapStateToProps)(SyncStatusPopover); diff --git a/lib/components/sync-status/style.scss b/lib/components/sync-status/style.scss deleted file mode 100644 index 799ed9868..000000000 --- a/lib/components/sync-status/style.scss +++ /dev/null @@ -1,61 +0,0 @@ -.sync-status { - display: flex; - align-items: center; - padding: 1.25em 1.75em; - font-size: .75rem; - line-height: 1; -} - -.sync-status__icon { - width: 18px; - margin-right: .5em; - text-align: center; -} - -.sync-status-popover { - pointer-events: none; - - &.theme-light, - &.theme-dark { - background: transparent; - } -} - -.sync-status-popover__paper { - padding: .5em 1em; - border-radius: $border-radius; - border: 1px solid $studio-gray-5; - font-size: .75rem; - - &.has-unsynced-changes { - padding: 1em 1.5em; - } -} - -.sync-status-popover__unsynced { - max-width: 18em; - margin-bottom: .75em; - padding-bottom: 1em; - border-bottom: 1px $studio-gray-5; - line-height: 1.45; -} - -.sync-status-popover__heading { - margin: .5em 0 0; - font-size: .75rem; - font-weight: $bold; - text-transform: uppercase; -} - -.sync-status-popover__notes { - margin: 1em 0; - padding-left: .5em; - list-style-position: inside; - font-size: .875rem; - - li { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } -} diff --git a/lib/connection-status/index.tsx b/lib/connection-status/index.tsx new file mode 100644 index 000000000..f3318fe2c --- /dev/null +++ b/lib/connection-status/index.tsx @@ -0,0 +1,49 @@ +import React, { FunctionComponent } from 'react'; +import { connect } from 'react-redux'; +import { Tooltip } from '@material-ui/core'; +import ConnectionIcon from '../icons/connection'; +import NoConnectionIcon from '../icons/no-connection'; + +import * as S from '../state'; +import * as T from '../types'; + +import './style'; + +type StateProps = { + connectionStatus: T.ConnectionState; +}; + +type Props = StateProps; + +export const ConnectionStatus: FunctionComponent = ({ + connectionStatus, +}) => ( +
+ +

+ {connectionStatus === 'green' ? ( + + ) : ( + + )} + Server connection +

+
+
+); + +const mapStateToProps: S.MapState = (state) => ({ + connectionStatus: state.simperium.connectionStatus, +}); + +export default connect(mapStateToProps)(ConnectionStatus); diff --git a/lib/connection-status/style.scss b/lib/connection-status/style.scss new file mode 100644 index 000000000..84c44a7f6 --- /dev/null +++ b/lib/connection-status/style.scss @@ -0,0 +1,3 @@ +.server-connection__label { + margin-left: 6px; +} diff --git a/lib/controls/toggle/index.tsx b/lib/controls/toggle/index.tsx index 1ead6660c..873b72503 100644 --- a/lib/controls/toggle/index.tsx +++ b/lib/controls/toggle/index.tsx @@ -1,11 +1,23 @@ -import React from 'react'; -import classNames from 'classnames'; -import PropTypes from 'prop-types'; +import React, { ChangeEventHandler, FunctionComponent } from 'react'; + +type OwnProps = Partial & { + onChange: (isNowToggled: boolean) => any; +}; + +type Props = OwnProps; + +export const ToggleControl: FunctionComponent = ({ + className, + onChange, + ...props +}) => { + const onToggle: ChangeEventHandler = ({ + currentTarget: { checked }, + }) => onChange(checked); -function ToggleControl({ className, ...props }) { return ( - - + + @@ -13,10 +25,6 @@ function ToggleControl({ className, ...props }) { ); -} - -ToggleControl.propTypes = { - className: PropTypes.string, }; export default ToggleControl; diff --git a/lib/dialog-renderer/index.tsx b/lib/dialog-renderer/index.tsx index 356ee61a1..8e30bdd1d 100644 --- a/lib/dialog-renderer/index.tsx +++ b/lib/dialog-renderer/index.tsx @@ -1,26 +1,27 @@ import React, { Component, Fragment } from 'react'; import { connect } from 'react-redux'; import Modal from 'react-modal'; -import classNames from 'classnames'; import AboutDialog from '../dialogs/about'; +import BetaWarning from '../dialogs/beta-warning'; import ImportDialog from '../dialogs/import'; import KeybindingsDialog from '../dialogs/keybindings'; +import LogoutConfirmation from '../dialogs/logout-confirmation'; import SettingsDialog from '../dialogs/settings'; import ShareDialog from '../dialogs/share'; import { closeDialog } from '../state/ui/actions'; import * as S from '../state'; import * as T from '../types'; +import { getTheme } from '../state/selectors'; type OwnProps = { appProps: object; - buckets: Record<'noteBucket' | 'tagBucket' | 'preferencesBucket', T.Bucket>; - themeClass: string; }; type StateProps = { dialogs: T.DialogType[]; + theme: 'light' | 'dark'; }; type DispatchProps = { @@ -33,7 +34,7 @@ export class DialogRenderer extends Component { static displayName = 'DialogRenderer'; render() { - const { appProps, buckets, themeClass, closeDialog } = this.props; + const { theme, closeDialog } = this.props; return ( @@ -45,16 +46,20 @@ export class DialogRenderer extends Component { isOpen onRequestClose={closeDialog} overlayClassName="dialog-renderer__overlay" - portalClassName={classNames('dialog-renderer__portal', themeClass)} + portalClassName={`dialog-renderer__portal theme-${theme}`} > {'ABOUT' === dialog ? ( + ) : 'BETA-WARNING' === dialog ? ( + ) : 'IMPORT' === dialog ? ( - + ) : 'KEYBINDINGS' === dialog ? ( + ) : 'LOGOUT-CONFIRMATION' === dialog ? ( + ) : 'SETTINGS' === dialog ? ( - + ) : 'SHARE' === dialog ? ( ) : null} @@ -65,8 +70,9 @@ export class DialogRenderer extends Component { } } -const mapStateToProps: S.MapState = ({ ui: { dialogs } }) => ({ - dialogs, +const mapStateToProps: S.MapState = (state) => ({ + dialogs: state.ui.dialogs, + theme: getTheme(state), }); const mapDispatchToProps: S.MapDispatch = { diff --git a/lib/dialog/index.tsx b/lib/dialog/index.tsx index 7c7799681..ee61c12a5 100644 --- a/lib/dialog/index.tsx +++ b/lib/dialog/index.tsx @@ -2,6 +2,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import CrossIcon from '../icons/cross'; + export class Dialog extends Component { static propTypes = { children: PropTypes.node.isRequired, @@ -41,7 +43,7 @@ export class Dialog extends Component { className="button button-borderless" onClick={onDone} > - {closeBtnLabel} + )}
diff --git a/lib/dialog/style.scss b/lib/dialog/style.scss index fcd5f3084..5b0eea7a1 100644 --- a/lib/dialog/style.scss +++ b/lib/dialog/style.scss @@ -16,7 +16,7 @@ .dialog-title-side { display: flex; flex: none; - width: 6.5em; + width: 3.5em; button { width: 100%; diff --git a/lib/dialogs/beta-warning/index.tsx b/lib/dialogs/beta-warning/index.tsx new file mode 100644 index 000000000..1f771f37c --- /dev/null +++ b/lib/dialogs/beta-warning/index.tsx @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import SimplenoteLogo from '../../icons/simplenote'; +import CrossIcon from '../../icons/cross'; +import Dialog from '../../dialog'; +import { closeDialog } from '../../state/ui/actions'; + +import * as S from '../../state'; + +type DispatchProps = { + closeDialog: () => any; +}; + +type Props = DispatchProps; + +export class BetaWarning extends Component { + render() { + const { closeDialog } = this.props; + + return ( +
+ +
+ + +

Simplenote

+
+ +

+ This is a beta release of Simplenote. +

+ +

+ This release provides an opportunity to test and share early + feedback for a major overhaul of the internals of the app. +

+ +

+ Please use with caution and the understanding that
+ this comes without any stability guarantee. +

+ + +
+
+ ); + } +} + +const mapDispatchToProps: S.MapDispatch = { + closeDialog, +}; + +export default connect(null, mapDispatchToProps)(BetaWarning); diff --git a/lib/dialogs/import/index.tsx b/lib/dialogs/import/index.tsx index 79554baa2..087d9cb5b 100644 --- a/lib/dialogs/import/index.tsx +++ b/lib/dialogs/import/index.tsx @@ -1,6 +1,5 @@ import React, { Component, Suspense } from 'react'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import Dialog from '../../dialog'; import ImportSourceSelector from './source-selector'; @@ -22,17 +21,13 @@ type DispatchProps = { type Props = DispatchProps; class ImportDialog extends Component { - static propTypes = { - buckets: PropTypes.object, - }; - state = { importStarted: false, selectedSource: undefined, }; render() { - const { buckets, closeDialog } = this.props; + const { closeDialog } = this.props; const { importStarted, selectedSource } = this.state; const selectSource = (source) => this.setState({ selectedSource: source }); @@ -66,7 +61,6 @@ class ImportDialog extends Component { > this.setState({ importStarted: true })} diff --git a/lib/dialogs/import/source-importer/executor/index.tsx b/lib/dialogs/import/source-importer/executor/index.tsx index af30dab45..eb16f65ea 100644 --- a/lib/dialogs/import/source-importer/executor/index.tsx +++ b/lib/dialogs/import/source-importer/executor/index.tsx @@ -1,40 +1,57 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; import { throttle } from 'lodash'; -import analytics from '../../../../analytics'; +import actions from '../../../../state/actions'; import PanelTitle from '../../../../components/panel-title'; import TransitionFadeInOut from '../../../../components/transition-fade-in-out'; import ImportProgress from '../progress'; -import EvernoteImporter from '../../../../utils/import/evernote'; -import SimplenoteImporter from '../../../../utils/import/simplenote'; -import TextFileImporter from '../../../../utils/import/text-files'; +import type * as S from '../../../../state/'; +import type * as T from '../../../../types'; -const importers = { - evernote: EvernoteImporter, - plaintext: TextFileImporter, - simplenote: SimplenoteImporter, +type ImporterSource = 'evernote' | 'plaintext' | 'simplenote'; + +const getImporter = (importer: ImporterSource): Promise => { + switch (importer) { + case 'evernote': + return import( + /* webpackChunkName: 'utils-import-evernote' */ '../../../../utils/import/evernote' + ); + + case 'plaintext': + return import( + /* webpackChunkName: 'utils-import-text-files' */ '../../../../utils/import/text-files' + ); + + case 'simplenote': + return import( + /* webpackChunkName: 'utils-import-simplenote' */ '../../../../utils/import/simplenote' + ); + } }; -class ImportExecutor extends React.Component { - static propTypes = { - buckets: PropTypes.shape({ - noteBucket: PropTypes.object.isRequired, - tagBucket: PropTypes.object.isRequired, - }), - endValue: PropTypes.number, - files: PropTypes.array, - locked: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onStart: PropTypes.func.isRequired, - source: PropTypes.shape({ - optionsHint: PropTypes.string, - slug: PropTypes.string.isRequired, - }), +type OwnProps = { + endValue: number; + files: string[]; + locked: boolean; + onClose: Function; + onStart: Function; + source: { + optionsHint: string; + slug: ImporterSource; }; +}; + +type DispatchProps = { + importNote: (note: T.Note) => any; + recordEvent: (eventName: string, eventProperties: T.JSONSerializable) => any; +}; +type Props = OwnProps & DispatchProps; + +class ImportExecutor extends Component { state = { errorMessage: undefined, finalNoteCount: undefined, @@ -46,56 +63,55 @@ class ImportExecutor extends React.Component { initImporter = () => { const { slug: sourceSlug } = this.props.source; - const Importer = importers[sourceSlug]; - if (!Importer) { - throw new Error('Unrecognized importer slug "${slug}"'); - } + return getImporter(sourceSlug).then(({ default: Importer }) => { + const thisImporter = new Importer(this.props.importNote, { + isMarkdown: this.state.setMarkdown, + }); + const updateProgress = throttle((arg) => { + this.setState({ importedNoteCount: arg }); + }, 20); - const thisImporter = new Importer({ - ...this.props.buckets, - options: { isMarkdown: this.state.setMarkdown }, - }); - const updateProgress = throttle((arg) => { - this.setState({ importedNoteCount: arg }); - }, 20); - - thisImporter.on('status', (type, arg) => { - switch (type) { - case 'progress': - updateProgress(arg); - break; - case 'complete': - this.setState({ - finalNoteCount: arg, - isDone: true, - }); - analytics.tracks.recordEvent('importer_import_completed', { - source: sourceSlug, - note_count: arg, - }); - break; - case 'error': - this.setState({ - errorMessage: arg, - shouldShowProgress: false, - }); - window.setTimeout(() => { - this.setState({ isDone: true }); - }, 200); - break; - default: - } + thisImporter.on('status', (type, arg) => { + switch (type) { + case 'progress': + updateProgress(arg); + break; + case 'complete': + this.setState({ + finalNoteCount: arg, + isDone: true, + }); + this.props.recordEvent('importer_import_completed', { + source: sourceSlug, + note_count: arg, + }); + break; + case 'error': + this.setState({ + errorMessage: arg, + shouldShowProgress: false, + }); + window.setTimeout(() => { + this.setState({ isDone: true }); + }, 200); + break; + default: + break; + } + }); + + return thisImporter; }); - return thisImporter; }; startImport = () => { this.setState({ shouldShowProgress: true }); this.props.onStart(); - const importer = this.initImporter(); - importer.importNotes(this.props.files); + this.initImporter().then((importer) => { + importer.importNotes(this.props.files); + }); }; render() { @@ -113,7 +129,7 @@ class ImportExecutor extends React.Component { return (
- Options + Options