From 5ec55c2dbbf306d45b008b81c5b97ef7690318e7 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 20 May 2020 16:54:57 -0700 Subject: [PATCH] Rewrite app core to remove data races and fix data flow Trap account name when connecting Type Simperium for real Re-connect note editing to Simperium; bounce updated notes Add sync indicators on note-level remove pending ccid list; small cleanup Fix or skip or delete failing tests Bow to the linting gatekeepers Bow to the linting gatekeepers Try: remove arm builds to get electron-builder to go Move all checkbox translation into the editor component Adjust editor cursor/selection in response to remote updates Fix edge cases with tasks and selection tracking Track editor selection direction Earmark selection tracking on undo/redo for later Debounce search when receiving updates Select first note that loads in app Use search sort index instead of sorting on every search Fix performance and display issues with note list previews Allow syncing new notes Add revision panel back in Start making tag list sortable and editable Inline checkbox/task font I tried using a file-loader and things got complicated with Monaco's fonts By inlining the font as a data-uri there's no complexity other than maintaining the font, but it's trivial enough to recreate the font and convert it into base64 that I felt it was sufficient to let this be. Continue restoring functionality to tag list Add some types to tag list interface Connect to remote tag updates Small cluterr Start tracking tag changes More tag tracking Add back has-loaded Allow tag reordering Allow deleting tags Fix title formatting/decorations Remove syntax highlighting and set auto-indent Support renaming tags; small font fix and type error fix Allow escaping from preview mode if note un-markdownd Fix revisions display issue Reconnect settings to Electron Add back "insert task" via hotkey Extract generic bucket providers; sync tag renames Synchronize preferences Converge to a single ghost provider Remove old noteChangeVersion reducer Start introducing offline persistence Load and persist revisions separately from the state Disable revisions button if no revisions are avialable Fix typo Defer loading editor and fetching revisions for performance while switching notes Fix Safari matchMedia bug Fix Safari Monaco issue: advanced text-wrap crashes Degrade gracefully in the absence of indexedDB (private mode) Remove constraint on having indexedDB in platform support Doubt relevance of test Refactor code to avoid lint issue Use published @types/simperium Sync changes with bucket-queue worker Use queue function not setTimeout for note sync Create Note Doctor to find discrepancies with note sync Warn when logging out with unconfirmed changes Reconnect the rest of the menu options/settings Fix Export (#2180) Fix export on rewrite/no-races Send notification on note update Send message when note trashed Limit notification length and provide default text Allow logging out when no changes are unconfirmed Move notifications code into new module Separate local confirmations from remote updates (dev) expose buckets to console Add (un)publish notifications; skip some notification spam restore tag icons, add overflow for scrolling (#2183) Fix/search case sensitivity (#2182) * search should be case insensitive * fix missing search results due to column alignment * lowercase term instead of regex Rewrite tags Confirm new tags on note creation; delete old tag on rename Replace Monaco with textarea Fix: Copy all html if nothing selected (#2184) * first hack at this, broken * pass state to useEffect and rename it * this div is still not working and i am mad * hook to promise chain, realize clipboard things are hard * make it work in Safari too Maintain consistency of login sessions across browser sessions Fix notifications permissions requests: stop spamming Fix tag-suggestion tests Fix lint issues More lint issues Add beta warning Allow exporiting unsyced notes on logout (Electron) Add logout confirmation dialog Warn before closing tab with synced changes Marginally improve cursor when entering task item from hotkey Stop resizing content editor Linting issues Use slightly-faster checkbox replacement Style last synced indicator (#2203) Update note list note status icons (#2204) Add safety for broken/missing indexedDB props: @codebykat Empty trash (#2209) * got the basics, not working * typos in comments * make it go Fix tag list CSS (#2210) * move scrolling to child list * add some right padding to avoid overlapping with scrollbar * more small fixes Reset some optimizations in the editor Removes bottom border in the tag list (#2214) Updates CSS for server connection (#2213) Fix scroll bars on narrow line length (#2211) When the narrow line length is set the scroll bar was not on the edge of the screen. This moves it back to the edge. Update/offline badge (#2202) Moves the offline badge from the bottom right to the note toolbar. Filter collaboration (email) tags from tag list (#2205) Updates unsynced notes log out dialog (#2206) Display unknown in last updated when it is unknown (#2212) Display unknown in the last synced indicator when we do not know when it was last synced. Move all tasklist insertion into the editor itself Use function-ref to boot editor; update text transform in reducer Insert checkboxes through editor Continue lists Fix interaction of lists and tasks Fix font issues Add indent/outdent support Stop indenting lines that follow blank lines Show checkboxes in previews Preserve some markdown settings [rewrite branch] Change the unsynced notes icon to a less-scary one (#2218) This changes the icon when a note has unsynced changes to the cloud sync icon. Also changes it to blue and changes both this and the pin to simplenote-blue-20 in dark mode. Fixes missing ids in note export (#2220) Clear note list height cache when window width changes Stop crashing when viewing a trashed note Remove all text processing make condensed actually condense (#2222) [rewrite branch] update note list status icons (#2221) * add smaller icons and restyle * forgot to commit the style fix for the bottom border * larger icons more padding * clean up styles Bring back Monaco Spin sync indicator only spin the sync icon, not the publish icon Stop sync spinner when offline Only load window-close-confirmation to browser Update the editor width css to allow narrow/wide views (#2224) Remove the add/confirm step which isn't necessary Remove confirm step for tags Fixes styling of offline badge (#2234) Updates the color of the link in the logout confirm dialog (#2235) Dont scroll editor past last line (#2238) When you scroll a note it currently scrolls the note completely off the screen. This PR changes it so it does not scroll past the end of the note. I like the current behavior and think we should add this back as an option. Allow scroll bar to be any height fixing it only scrolling partially to the bottom (#2239) We had a fixed height on the scroll bar. This caused it to look like it hadn't scrolled all the way to the bottom. This removes that max height so that the scroll bar displays correctly. Fixes editor width when narrow line length is selected (#2237) My previous fix for the editor width only worked on the left margin. This fixes it so the editor width is constrained on both sides. It does however cause the scroll bar to be just inside the narrow view and not on the edge of the screen, see screenshot. I am not sure how to fix it to push it to the side. This PR also fixes the jerkiness of the note when it opens. The padding applied to the content before the editor is ready is now the same as it is once Monaco renders. Update height of note list item when condensed (#2246) Clean up disable restore note button when is newset revision (#2248) The disable on the restore note button when you are currently on the most recent revision is CSS and was not actually disabling the functionality. Additionally, the logic failed when you first opened the revisions panel. This gets everything into tip top. Use distinct name for indexedDB persistence Checks for Notification on window (#2225) * Checks for Notification on window * Use optional chaioning * Dynamically load change announcer Co-authored-by: Dennis Snell Try for some easy wins on optimizing load Hides last syncd message when it is unknown (#2236) Hides the last syncd when it is unknown Autolist behavior Only use fast edit mode for long notes Continue task lists too Allow bullets at start of line, stop logging actions Remove some pre-prod TODO items Transform note content properly when clicking on tasks Stop trying to fully-load an editor when it's gone Updates to version 2.0.0-beta1 (#2252) Disable note doctor for now; uncover bugs easier Refactor some tag rename logic [rewrite branch] Add analytics as middleware (#2223) * centralize analytics again - not working attempt to convert to meta key - fixed addCollaborator args - add removeCollaborator reducer with withEvent - Add COLLABORATOR actions - remove invalid comments - remove unused setAccountName and some more comments - trying event queue to add application_opened before preferences are loaded - documented bugs :[ * Update analytics setting when bucket connects * Set initial accountName in Redux init vs. dispatched action * Send index request for preferences * Persist account name properly Co-authored-by: Dennis Snell Remove tri-state analytics toggle Sequence analytics updates --- .gitignore | 1 + CONTRIBUTING.md | 9 +- desktop/menus/file-menu.js | 2 +- desktop/preload.js | 28 +- e2e/test.ts | 2 +- electron-builder.json | 8 +- lib/analytics/tracks.ts | 9 +- lib/app-layout/index.tsx | 79 +- lib/app.test.tsx | 24 - lib/app.tsx | 590 +- lib/boot-with-auth.tsx | 88 +- lib/boot-without-auth.tsx | 4 +- lib/boot.ts | 110 +- lib/browser-shell.tsx | 64 - lib/client.ts | 10 - .../checkbox/__snapshots__/test.tsx.snap | 31 - lib/components/checkbox/index.tsx | 30 - lib/components/checkbox/style.scss | 27 - lib/components/checkbox/test.tsx | 30 - lib/components/note-preview/index.tsx | 158 + lib/components/slider/index.tsx | 3 + lib/components/slider/style.scss | 1 - .../sync-status/get-note-titles.test.ts | 46 - lib/components/sync-status/get-note-titles.ts | 32 - lib/components/sync-status/index.tsx | 78 - lib/components/sync-status/popover.tsx | 114 - lib/components/sync-status/style.scss | 61 - lib/connection-status/index.tsx | 38 + lib/controls/toggle/index.tsx | 28 +- lib/dialog-renderer/index.tsx | 24 +- lib/dialog/index.tsx | 4 +- lib/dialog/style.scss | 2 +- lib/dialogs/beta-warning/index.tsx | 62 + lib/dialogs/import/index.tsx | 8 +- .../import/source-importer/executor/index.tsx | 157 +- lib/dialogs/logout-confirmation/index.tsx | 104 + lib/dialogs/logout-confirmation/style.scss | 87 + lib/dialogs/settings-group.tsx | 2 +- lib/dialogs/settings/index.tsx | 115 +- lib/dialogs/settings/panels/account.tsx | 52 +- lib/dialogs/settings/panels/display.tsx | 262 +- lib/dialogs/settings/panels/tools.tsx | 45 +- lib/dialogs/share/index.tsx | 64 +- lib/dialogs/toggle-settings-group.tsx | 26 +- lib/editable-list/index.tsx | 355 - lib/editable-list/style.scss | 118 - lib/editor/checkbox-decorator.test.ts | 56 - lib/editor/checkbox-decorator.ts | 42 - lib/editor/checkbox-utils.test.ts | 141 - lib/editor/checkbox-utils.ts | 68 - lib/editor/css-class-wrapper.tsx | 13 - .../insert-or-remove-checkboxes.test.ts | 136 - lib/editor/insert-or-remove-checkboxes.ts | 117 - lib/editor/matching-text-decorator.ts | 43 - lib/editor/text-manipulation-helpers.ts | 129 - lib/editor/utils.test.ts | 140 - lib/editor/utils.ts | 83 - lib/flux/action-map.ts | 79 - lib/flux/app-state.ts | 324 - lib/flux/test.ts | 44 - lib/global.d.ts | 1 + lib/icon-button/index.tsx | 19 +- lib/icons/attention.tsx | 16 + lib/icons/cloud-sync.tsx | 18 + lib/icons/pinned-small.tsx | 16 + lib/icons/pinned.tsx | 16 + lib/icons/published-small.tsx | 17 + lib/icons/sync-small.tsx | 16 + lib/icons/trash.tsx | 9 +- lib/navigation-bar/index.tsx | 31 +- lib/navigation-bar/style.scss | 9 + lib/note-content-editor.tsx | 724 +- lib/note-detail/index.tsx | 291 +- lib/note-detail/render-to-node.ts | 3 +- lib/note-detail/style.scss | 10 +- lib/note-detail/toggle-task/constants.ts | 8 - .../toggle-task/get-index-in-text.test.ts | 39 - .../toggle-task/get-index-in-text.ts | 53 - lib/note-detail/toggle-task/index.test.ts | 140 - lib/note-detail/toggle-task/index.ts | 64 - lib/note-editor/index.tsx | 138 +- lib/note-editor/style.scss | 8 + lib/note-info/index.tsx | 72 +- lib/note-list/decorators.tsx | 40 +- lib/note-list/index.tsx | 380 +- lib/note-list/note-cell.tsx | 183 + lib/note-list/style.scss | 102 +- lib/note-toolbar-container.ts | 100 - lib/note-toolbar/index.tsx | 103 +- lib/note-toolbar/style.scss | 31 +- lib/revision-selector/index.tsx | 174 +- lib/search-bar/index.tsx | 18 +- lib/search-field/index.tsx | 21 +- lib/search/index.ts | 516 +- lib/search/worker.ts | 142 - lib/simperium/bucket-store.ts | 54 - lib/simperium/ghost-store.ts | 211 - lib/simperium/index.ts | 151 - lib/simperium/local-queue-store.test.ts | 91 - lib/simperium/local-queue-store.ts | 45 - lib/simperium/store-provider.ts | 30 - lib/state/action-types.ts | 372 +- lib/state/actions.ts | 4 +- lib/state/analytics/actions.ts | 27 + lib/state/analytics/middleware.ts | 125 + lib/state/browser/index.ts | 50 + lib/state/data/actions.ts | 69 + lib/state/data/middleware.ts | 140 + lib/state/data/reducer.ts | 486 + lib/state/domain/buckets.ts | 11 - lib/state/domain/notes.ts | 50 - lib/state/domain/tags.ts | 74 - lib/state/electron/middleware.ts | 100 + lib/state/index.ts | 93 +- lib/state/persistence.ts | 207 + lib/state/selectors.ts | 43 + lib/state/settings/actions.ts | 92 +- lib/state/settings/reducer.ts | 45 +- lib/state/simperium/actions.ts | 4 +- lib/state/simperium/functions/bucket-queue.ts | 86 + .../simperium/functions/change-announcer.ts | 120 + .../simperium/functions/connection-monitor.ts | 39 + .../simperium/functions/in-memory-bucket.ts | 41 + .../simperium/functions/in-memory-ghost.ts | 44 + lib/state/simperium/functions/note-bucket.ts | 55 + lib/state/simperium/functions/note-doctor.ts | 112 + lib/state/simperium/functions/redux-ghost.ts | 75 + .../functions/tab-close-confirmation.ts | 21 + lib/state/simperium/functions/tag-bucket.ts | 55 + .../functions/unconfirmed-changes.ts | 36 + .../simperium/functions/username-monitor.ts | 16 + lib/state/simperium/middleware.ts | 359 +- lib/state/simperium/reducer.ts | 86 + lib/state/tags/actions.ts | 11 - lib/state/tags/reducer.ts | 29 - lib/state/ui/actions.ts | 95 +- lib/state/ui/middleware.ts | 97 + lib/state/ui/reducer.ts | 212 +- lib/tag-email-tooltip/index.tsx | 21 +- lib/tag-field/index.tsx | 165 +- lib/tag-input/index.tsx | 49 +- lib/tag-list/index.tsx | 217 +- lib/tag-list/style.scss | 192 +- lib/tag-suggestions/index.tsx | 90 +- lib/tag-suggestions/test.tsx | 132 +- lib/types.ts | 52 +- lib/typings/simperium/index.d.ts | 38 - lib/utils/ensure-platform-support.tsx | 20 +- lib/utils/export/export-notes.ts | 102 +- lib/utils/export/index.ts | 6 +- lib/utils/export/types.ts | 18 + lib/utils/filter-notes.ts | 120 - lib/utils/import/evernote/index.ts | 14 +- lib/utils/import/index.ts | 25 +- lib/utils/import/simplenote/index.ts | 12 +- lib/utils/import/simplenote/test.ts | 8 +- lib/utils/import/test.ts | 35 +- lib/utils/import/text-files/index.ts | 12 +- lib/utils/is-email-tag.ts | 5 +- lib/utils/note-utils.test.ts | 55 +- lib/utils/note-utils.ts | 107 +- lib/utils/render-note-to-html.ts | 6 +- lib/utils/sync/activity-hooks.test.ts | 40 - lib/utils/sync/activity-hooks.ts | 49 - lib/utils/sync/get-unsynced-note-ids.ts | 14 - lib/utils/sync/index.ts | 5 - lib/utils/sync/last-synced-time.ts | 25 - lib/utils/sync/nudge-unsynced.test.ts | 53 - lib/utils/sync/nudge-unsynced.ts | 57 - lib/utils/tag-hash.ts | 33 + lib/utils/task-transform.ts | 11 + package-lock.json | 8023 ++++++++++++----- package.json | 84 +- scss/_components.scss | 4 +- scss/_variables.scss | 2 +- scss/style.scss | 3 +- scss/theme.scss | 75 +- tsconfig.json | 5 +- webpack.config.js | 31 + 179 files changed, 12409 insertions(+), 9904 deletions(-) delete mode 100644 lib/app.test.tsx delete mode 100644 lib/browser-shell.tsx delete mode 100644 lib/client.ts delete mode 100644 lib/components/checkbox/__snapshots__/test.tsx.snap delete mode 100644 lib/components/checkbox/index.tsx delete mode 100644 lib/components/checkbox/style.scss delete mode 100644 lib/components/checkbox/test.tsx create mode 100644 lib/components/note-preview/index.tsx delete mode 100644 lib/components/sync-status/get-note-titles.test.ts delete mode 100644 lib/components/sync-status/get-note-titles.ts delete mode 100644 lib/components/sync-status/index.tsx delete mode 100644 lib/components/sync-status/popover.tsx delete mode 100644 lib/components/sync-status/style.scss create mode 100644 lib/connection-status/index.tsx create mode 100644 lib/dialogs/beta-warning/index.tsx create mode 100644 lib/dialogs/logout-confirmation/index.tsx create mode 100644 lib/dialogs/logout-confirmation/style.scss delete mode 100644 lib/editable-list/index.tsx delete mode 100644 lib/editable-list/style.scss delete mode 100644 lib/editor/checkbox-decorator.test.ts delete mode 100644 lib/editor/checkbox-decorator.ts delete mode 100644 lib/editor/checkbox-utils.test.ts delete mode 100644 lib/editor/checkbox-utils.ts delete mode 100644 lib/editor/css-class-wrapper.tsx delete mode 100644 lib/editor/insert-or-remove-checkboxes.test.ts delete mode 100644 lib/editor/insert-or-remove-checkboxes.ts delete mode 100644 lib/editor/matching-text-decorator.ts delete mode 100644 lib/editor/text-manipulation-helpers.ts delete mode 100644 lib/editor/utils.test.ts delete mode 100644 lib/editor/utils.ts delete mode 100644 lib/flux/action-map.ts delete mode 100644 lib/flux/app-state.ts delete mode 100644 lib/flux/test.ts create mode 100644 lib/icons/attention.tsx create mode 100644 lib/icons/cloud-sync.tsx create mode 100644 lib/icons/pinned-small.tsx create mode 100644 lib/icons/pinned.tsx create mode 100644 lib/icons/published-small.tsx create mode 100644 lib/icons/sync-small.tsx delete mode 100644 lib/note-detail/toggle-task/constants.ts delete mode 100644 lib/note-detail/toggle-task/get-index-in-text.test.ts delete mode 100644 lib/note-detail/toggle-task/get-index-in-text.ts delete mode 100644 lib/note-detail/toggle-task/index.test.ts delete mode 100644 lib/note-detail/toggle-task/index.ts create mode 100644 lib/note-list/note-cell.tsx delete mode 100644 lib/note-toolbar-container.ts delete mode 100644 lib/search/worker.ts delete mode 100644 lib/simperium/bucket-store.ts delete mode 100644 lib/simperium/ghost-store.ts delete mode 100644 lib/simperium/index.ts delete mode 100644 lib/simperium/local-queue-store.test.ts delete mode 100644 lib/simperium/local-queue-store.ts delete mode 100644 lib/simperium/store-provider.ts create mode 100644 lib/state/analytics/actions.ts create mode 100644 lib/state/analytics/middleware.ts create mode 100644 lib/state/browser/index.ts create mode 100644 lib/state/data/actions.ts create mode 100644 lib/state/data/middleware.ts create mode 100644 lib/state/data/reducer.ts delete mode 100644 lib/state/domain/buckets.ts delete mode 100644 lib/state/domain/notes.ts delete mode 100644 lib/state/domain/tags.ts create mode 100644 lib/state/electron/middleware.ts create mode 100644 lib/state/persistence.ts create mode 100644 lib/state/selectors.ts create mode 100644 lib/state/simperium/functions/bucket-queue.ts create mode 100644 lib/state/simperium/functions/change-announcer.ts create mode 100644 lib/state/simperium/functions/connection-monitor.ts create mode 100644 lib/state/simperium/functions/in-memory-bucket.ts create mode 100644 lib/state/simperium/functions/in-memory-ghost.ts create mode 100644 lib/state/simperium/functions/note-bucket.ts create mode 100644 lib/state/simperium/functions/note-doctor.ts create mode 100644 lib/state/simperium/functions/redux-ghost.ts create mode 100644 lib/state/simperium/functions/tab-close-confirmation.ts create mode 100644 lib/state/simperium/functions/tag-bucket.ts create mode 100644 lib/state/simperium/functions/unconfirmed-changes.ts create mode 100644 lib/state/simperium/functions/username-monitor.ts create mode 100644 lib/state/simperium/reducer.ts delete mode 100644 lib/state/tags/actions.ts delete mode 100644 lib/state/tags/reducer.ts create mode 100644 lib/state/ui/middleware.ts delete mode 100644 lib/typings/simperium/index.d.ts create mode 100644 lib/utils/export/types.ts delete mode 100644 lib/utils/sync/activity-hooks.test.ts delete mode 100644 lib/utils/sync/activity-hooks.ts delete mode 100644 lib/utils/sync/get-unsynced-note-ids.ts delete mode 100644 lib/utils/sync/index.ts delete mode 100644 lib/utils/sync/last-synced-time.ts delete mode 100644 lib/utils/sync/nudge-unsynced.test.ts delete mode 100644 lib/utils/sync/nudge-unsynced.ts create mode 100644 lib/utils/tag-hash.ts create mode 100644 lib/utils/task-transform.ts 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/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/preload.js b/desktop/preload.js index 1b68a09e3..8a477a19d 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, remote } = require('electron'); const validChannels = [ 'appCommand', @@ -11,6 +11,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/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..6781be677 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() { @@ -81,14 +82,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 +114,18 @@ export class AppLayout extends Component {
- - + +
{editorVisible && (
- - } - /> - + {hasRevisions && } + + {showRevisions ? ( + + ) : ( + + )}
)}
@@ -136,14 +134,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..e558f74a9 100644 --- a/lib/app.tsx +++ b/lib/app.tsx @@ -1,481 +1,205 @@ 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, - toggleNavigation, - toggleSimperiumConnectionStatus, -} from './state/ui/actions'; +import { createNote, closeNote, toggleNavigation } 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; + showNavigation: boolean; + showNoteInfo: boolean; + theme: 'light' | 'dark'; +}; + +type DispatchProps = { closeNote: () => any; + createNote: () => any; focusSearchField: () => any; - selectNote: (note: T.NoteEntity) => any; - showDialog: (type: T.DialogType) => any; - trashNote: (previousIndex: number) => any; + openTagList: () => 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; }; -export type Props = OwnProps & DispatchProps; - -const mapStateToProps: S.MapState = (state) => state; +type Props = OwnProps & StateProps & DispatchProps; -const mapDispatchToProps: S.MapDispatch< - DispatchProps, - OwnProps -> = function mapDispatchToProps(dispatch, { noteBucket }) { - const actionCreators = Object.assign({}, appState.actionCreators); +class AppComponent extends Component { + static displayName = 'App'; - const thenReloadNotes = (action) => (a) => { - dispatch(action(a)); - dispatch(actionCreators.loadNotes({ noteBucket })); - }; - - const thenReloadTags = (action) => (a) => { - dispatch(action(a)); - dispatch(loadTags()); - }; + componentDidMount() { + window.electron?.send('setAutoHideMenuBar', this.props.autoHideMenuBar); - 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)), - }; -}; + this.toggleShortcuts(true); -export const App = connect( - mapStateToProps, - mapDispatchToProps -)( - class extends Component { - static displayName = 'App'; + recordEvent('application_opened'); + __TEST__ && window.testEvents.push('booted'); + } - 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, - }; + componentWillUnmount() { + this.toggleShortcuts(false); + } - UNSAFE_componentWillMount() { - this.onAuthChanged(); + handleShortcut = (event: KeyboardEvent) => { + const { hotkeysEnabled } = this.props; + if (!hotkeysEnabled) { + 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; } - 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); + if ( + (cmdOrCtrl && shiftKey && 'KeyS' === code) || + (isElectron && cmdOrCtrl && !shiftKey && 'KeyF' === code) + ) { + this.props.focusSearchField(); - this.props.tagBucket - .on('index', this.props.loadTags) - .on('update', debounce(this.props.loadTags, 200)) - .on('remove', this.props.loadTags); + event.stopPropagation(); + event.preventDefault(); + return false; + } - 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)); + if (cmdOrCtrl && shiftKey && 'KeyF' === code) { + this.props.toggleFocusMode(); - this.onLoadPreferences(() => - // Make sure that tracking starts only after preferences are loaded - analytics.tracks.recordEvent('application_opened') - ); + event.stopPropagation(); + event.preventDefault(); + return false; + } - this.toggleShortcuts(true); + if (cmdOrCtrl && shiftKey && 'KeyI' === code) { + this.props.createNote(); - __TEST__ && window.testEvents.push('booted'); + event.stopPropagation(); + event.preventDefault(); + return false; } - componentWillUnmount() { - this.toggleShortcuts(false); + // prevent default browser behavior for search + // will bubble up from note-detail + if (cmdOrCtrl && 'KeyG' === code) { + event.stopPropagation(); + event.preventDefault(); } - componentDidUpdate(prevProps) { - const { settings } = this.props; + return true; + }; - if (settings !== prevProps.settings) { - window.electron?.send('settingsUpdate', settings); - } + toggleShortcuts = (doEnable: boolean) => { + if (doEnable) { + window.addEventListener('keydown', this.handleShortcut, true); + } else { + window.removeEventListener('keydown', this.handleShortcut, true); } + }; - 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 && } + 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 && } + -
- {showNavigation && } - - {showNoteInfo && } -
- + {showNoteInfo && }
- ); - } + +
+ ); } -); +} + +const mapStateToProps: S.MapState = (state) => ({ + autoHideMenuBar: state.settings.autoHideMenuBar, + hotkeysEnabled: state.settings.keyboardShortcuts, + 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)), + closeNote: () => dispatch(closeNote()), + createNote: () => dispatch(createNote()), + focusSearchField: () => dispatch(actions.ui.focusSearchField()), + openTagList: () => dispatch(toggleNavigation()), + 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()), + }; +}; -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..afc15e5b7 100644 --- a/lib/boot-with-auth.tsx +++ b/lib/boot-with-auth.tsx @@ -1,32 +1,27 @@ +import { showDialog } from './state/ui/actions'; + 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 +29,33 @@ 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); + store.dispatch(showDialog('BETA-WARNING')); - 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..b8d626ba6 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'; @@ -105,7 +105,7 @@ class AppWithoutAuth extends Component { throw new Error('missing access token'); } - analytics.tracks.recordEvent('user_account_created'); + recordEvent('user_account_created'); this.props.onAuth(user.access_token, username, true); }) .catch(() => { diff --git a/lib/boot.ts b/lib/boot.ts index ed67af637..8b0abda8b 100644 --- a/lib/boot.ts +++ b/lib/boot.ts @@ -6,11 +6,10 @@ 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 = () => +const clearStorage = (): Promise => new Promise((resolve) => { localStorage.removeItem('access_token'); localStorage.removeItem('lastSyncedTime'); @@ -18,13 +17,42 @@ const clearStorage = () => 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) { + const { accountName, ...otherSettings } = settings; + localStorage.setItem('simpleNote', otherSettings); + } + + 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'); + resolve(); + }) + .catch(() => resolve()); }); const forceReload = () => history.go(); @@ -83,17 +111,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 +194,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/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/note-preview/index.tsx b/lib/components/note-preview/index.tsx new file mode 100644 index 000000000..352b4df8b --- /dev/null +++ b/lib/components/note-preview/index.tsx @@ -0,0 +1,158 @@ +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; +}; + +type DispatchProps = { + editNote: (noteId: T.EntityId, changes: Partial) => any; +}; + +type Props = OwnProps & StateProps & DispatchProps; + +export const NotePreview: FunctionComponent = ({ + editNote, + fontSize, + isFocused, + note, + noteId, + searchQuery, +}) => { + 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 (note.tagName === 'A') { + event.preventDefault(); + event.stopPropagation(); + + // skip internal note links (e.g. anchor links, footnotes) + if (!node.href.startsWith('http://localhost')) { + viewExternalUrl(node.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 && note?.systemTags.includes('markdown')) { + renderToNode(previewNode.current, note!.content, searchQuery); + } else { + previewNode.current.innerText = withCheckboxCharacters( + note?.content ?? '' + ); + } + }, [note?.content, note?.systemTags, searchQuery]); + + return ( +
+
+
+
+ {withCheckboxCharacters(note?.content ?? '')} +
+
+
+
+ ); +}; + +const mapStateToProps: S.MapState = (state, props) => ({ + fontSize: state.settings.fontSize, + isFocused: state.ui.dialogs.length === 0 && !state.ui.showNoteInfo, + note: props.note ?? state.data.notes.get(props.noteId), + noteId: props.noteId ?? state.ui.openedNote, + searchQuery: state.ui.searchQuery, +}); + +const mapDispatchToProps: S.MapDispatch = { + editNote: actions.data.editNote, +}; + +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..716752f33 --- /dev/null +++ b/lib/connection-status/index.tsx @@ -0,0 +1,38 @@ +import React, { FunctionComponent } from 'react'; +import { connect } from 'react-redux'; +import { Tooltip } from '@material-ui/core'; + +import * as S from '../state'; +import * as T from '../types'; + +type StateProps = { + connectionStatus: T.ConnectionState; +}; + +type Props = StateProps; + +export const ConnectionStatus: FunctionComponent = ({ + connectionStatus, +}) => ( +
+ +

Server connection: {connectionStatus === 'green' ? '🟢' : '🔴'}

+
+
+); + +const mapStateToProps: S.MapState = (state) => ({ + connectionStatus: state.simperium.connectionStatus, +}); + +export default connect(mapStateToProps)(ConnectionStatus); 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..a85fad221 --- /dev/null +++ b/lib/dialogs/beta-warning/index.tsx @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import SimplenoteLogo from '../../icons/simplenote'; +import CrossIcon from '../../icons/cross'; +import TopRightArrowIcon from '../../icons/arrow-top-right'; +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..bf54840a4 100644 --- a/lib/dialogs/import/source-importer/executor/index.tsx +++ b/lib/dialogs/import/source-importer/executor/index.tsx @@ -1,40 +1,58 @@ -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 { recordEvent } from '../../../../state/analytics/middleware'; 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 +64,54 @@ 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: + } + }); + + 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