├── .buildkite ├── commands │ ├── install_node_dependencies.sh │ └── package_windows.ps1 ├── pipeline.yml └── shared-pipeline-vars ├── .bundle └── config ├── .configure ├── .configure-files └── app_store_connect_fastlane_api_key.p8.enc ├── .editorconfig ├── .gitattributes ├── .githooks └── pre-commit ├── .github ├── ISSUE_TEMPLATE │ ├── ---bug-report.md │ └── ---feature-request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .rubocop.yml ├── .ruby-version ├── .stylelintrc ├── .vscode └── launch.json ├── .xcode-version ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── Makefile ├── README.md ├── RELEASE-NOTES.md ├── TESTING-CHECKLIST.md ├── after_sign_hook.js ├── babel.config.js ├── bin └── deploy.sh ├── config.json ├── desktop ├── app-quit │ ├── README.md │ └── index.js ├── app.js ├── config-updater.json ├── config │ └── index.js ├── context-menu │ └── index.js ├── detect │ ├── build │ │ └── index.js │ └── platform │ │ └── index.js ├── env.js ├── evernote-import │ ├── enml-to-markdown.js │ ├── index.js │ └── test │ │ ├── correct-markdown.txt │ │ ├── enml-to-markdown.test.ts │ │ └── mock-enml.txt ├── index.js ├── logger │ ├── archiver.js │ ├── index.js │ ├── namespaces │ │ └── index.js │ └── zip-logs.js ├── menus │ ├── edit-menu.js │ ├── file-menu.js │ ├── format-menu.js │ ├── help-menu.js │ ├── index.js │ ├── mac-app-menu.js │ ├── menu-items.js │ ├── test │ │ ├── help-menu.js │ │ └── utils.js │ ├── utils.js │ └── view-menu.js ├── preload.js └── updater │ ├── README.md │ ├── auto-updater │ ├── README.md │ └── index.js │ ├── index.js │ ├── lib │ ├── Updater.js │ └── setup-progress-updates.js │ └── manual-updater │ ├── README.md │ └── index.js ├── docker-compose.yml ├── docs └── packaging.md ├── electron-builder-appx.json ├── electron-builder.json ├── eslint.config.mjs ├── fastlane ├── .gitignore ├── Fastfile ├── example.env └── lib │ └── helpers.rb ├── jest.config.js ├── lib ├── alternate-login-prompt │ ├── index.tsx │ └── style.scss ├── analytics │ ├── README.md │ ├── index.ts │ ├── test.ts │ ├── tracks.ts │ └── types.ts ├── app-layout │ ├── index.tsx │ └── style.scss ├── app.tsx ├── auth │ ├── index.tsx │ └── style.scss ├── boot-with-auth.tsx ├── boot-without-auth.tsx ├── boot.ts ├── components │ ├── boot-warning │ │ ├── index.tsx │ │ └── style.scss │ ├── clipboard-button │ │ └── index.tsx │ ├── dev-badge │ │ ├── index.tsx │ │ └── style.scss │ ├── last-sync-time │ │ └── index.tsx │ ├── note-preview │ │ ├── index.tsx │ │ └── style.scss │ ├── panel-title │ │ ├── index.tsx │ │ └── style.scss │ ├── progress-bar │ │ ├── index.tsx │ │ └── style.scss │ ├── slider │ │ ├── index.tsx │ │ └── style.scss │ ├── spinner │ │ ├── index.tsx │ │ └── style.scss │ ├── tab-panels │ │ ├── index.tsx │ │ └── style.scss │ ├── tag-chip │ │ ├── __snapshots__ │ │ │ └── test.tsx.snap │ │ ├── index.tsx │ │ ├── style.scss │ │ └── test.tsx │ ├── transition-delay-enter │ │ ├── index.tsx │ │ └── style.scss │ └── transition-fade-in-out │ │ ├── index.tsx │ │ └── style.scss ├── connection-status │ ├── index.tsx │ └── style.scss ├── controls │ ├── checkbox │ │ ├── index.tsx │ │ └── style.scss │ └── toggle │ │ ├── index.tsx │ │ └── style.scss ├── dialog-renderer │ ├── index.tsx │ └── style.scss ├── dialog │ ├── index.tsx │ └── style.scss ├── dialogs │ ├── about │ │ ├── index.tsx │ │ └── style.scss │ ├── beta-warning │ │ └── index.tsx │ ├── button-group │ │ ├── index.tsx │ │ └── style.scss │ ├── close-window-confirmation │ │ └── index.tsx │ ├── import │ │ ├── dropzone │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── importers.ts │ │ ├── index.tsx │ │ ├── source-importer │ │ │ ├── executor │ │ │ │ ├── index.tsx │ │ │ │ └── style.scss │ │ │ ├── index.tsx │ │ │ ├── progress │ │ │ │ ├── bar.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── text.tsx │ │ │ ├── style.scss │ │ │ └── utils │ │ │ │ └── test-importer.ts │ │ └── style.scss │ ├── keybindings │ │ ├── index.tsx │ │ └── style.scss │ ├── logout-confirmation │ │ └── index.tsx │ ├── radio-settings-group.tsx │ ├── settings-group.tsx │ ├── settings │ │ ├── index.tsx │ │ ├── panels │ │ │ ├── account.tsx │ │ │ ├── display.tsx │ │ │ └── tools.tsx │ │ └── style.scss │ ├── share │ │ ├── index.tsx │ │ └── style.scss │ ├── toggle-settings-group.tsx │ ├── trash-tag-confirmation │ │ ├── index.tsx │ │ └── style.scss │ └── unsynchronized │ │ ├── index.tsx │ │ └── style.scss ├── email-verification │ ├── index.tsx │ └── style.scss ├── error-boundary │ ├── index.tsx │ └── style.scss ├── global.d.ts ├── icon-button │ ├── index.tsx │ └── style.scss ├── icons │ ├── app-icon │ │ ├── app-icon.ico │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── arrow-left.tsx │ ├── arrow-top-right.tsx │ ├── attention.tsx │ ├── back.tsx │ ├── check-list.tsx │ ├── checkbox-checked.tsx │ ├── checkbox-unchecked.tsx │ ├── chevron-right-small.tsx │ ├── chevron-right.tsx │ ├── cloud-sync.tsx │ ├── cloud.tsx │ ├── connection.tsx │ ├── cross-small.tsx │ ├── cross.tsx │ ├── ellipsis-outline.tsx │ ├── ellipsis.tsx │ ├── file-small.tsx │ ├── help-small.tsx │ ├── info.tsx │ ├── mail.tsx │ ├── menu.tsx │ ├── new-note.tsx │ ├── no-connection.tsx │ ├── notes.tsx │ ├── pinned-small.tsx │ ├── pinned.tsx │ ├── preview-stop.tsx │ ├── preview.tsx │ ├── published-small.tsx │ ├── reorder.tsx │ ├── revisions.tsx │ ├── search-small.tsx │ ├── settings.tsx │ ├── share.tsx │ ├── sidebar.tsx │ ├── simplenote-compact.tsx │ ├── simplenote.tsx │ ├── style.scss │ ├── sync-small.tsx │ ├── tag.tsx │ ├── tags.tsx │ ├── trash.tsx │ ├── untagged-notes.tsx │ ├── warning.tsx │ └── wordpress.tsx ├── index.ejs ├── logging-out.tsx ├── menu-bar │ ├── index.tsx │ └── style.scss ├── navigation-bar │ ├── index.tsx │ ├── item │ │ ├── index.tsx │ │ └── style.scss │ └── style.scss ├── note-actions │ ├── index.tsx │ └── style.scss ├── note-content-editor.tsx ├── note-detail │ ├── index.tsx │ ├── render-to-node.ts │ └── style.scss ├── note-editor │ ├── index.tsx │ └── style.scss ├── note-info │ ├── index.tsx │ ├── reference.tsx │ ├── references.tsx │ └── style.scss ├── note-list │ ├── decorators.tsx │ ├── get-note-title-and-preview.test.ts │ ├── get-note-title-and-preview.ts │ ├── index.tsx │ ├── no-notes.tsx │ ├── note-cell.tsx │ └── style.scss ├── note-revisions │ ├── index.tsx │ └── style.scss ├── note-toolbar │ ├── index.tsx │ └── style.scss ├── revision-selector │ ├── index.tsx │ └── style.scss ├── search-field │ ├── index.tsx │ └── style.scss ├── search-results-bar │ ├── index.tsx │ └── style.scss ├── search │ └── index.ts ├── state │ ├── action-types.ts │ ├── actions.ts │ ├── analytics │ │ ├── actions.ts │ │ └── middleware.ts │ ├── browser │ │ └── index.ts │ ├── data │ │ ├── actions.ts │ │ ├── middleware.ts │ │ └── reducer.ts │ ├── electron │ │ ├── actions.ts │ │ └── middleware.ts │ ├── index.ts │ ├── persistence.ts │ ├── selectors.ts │ ├── settings │ │ ├── actions.ts │ │ └── reducer.ts │ ├── simperium │ │ ├── actions.ts │ │ ├── functions │ │ │ ├── bucket-queue.ts │ │ │ ├── change-announcer.ts │ │ │ ├── connection-monitor.ts │ │ │ ├── in-memory-bucket.ts │ │ │ ├── in-memory-ghost.ts │ │ │ ├── note-bucket.ts │ │ │ ├── note-doctor.ts │ │ │ ├── preferences-bucket.ts │ │ │ ├── redux-ghost.ts │ │ │ ├── tab-close-confirmation.ts │ │ │ ├── tag-bucket.ts │ │ │ ├── unconfirmed-changes.ts │ │ │ └── username-monitor.ts │ │ ├── middleware.ts │ │ └── reducer.ts │ └── ui │ │ ├── actions.ts │ │ ├── middleware.ts │ │ ├── reducer.ts │ │ └── search-field-middleware.ts ├── tag-email-tooltip │ ├── index.tsx │ └── style.scss ├── tag-field │ ├── index.tsx │ └── style.scss ├── tag-input │ ├── index.tsx │ └── style.scss ├── tag-list │ ├── index.tsx │ ├── input.tsx │ └── style.scss ├── tag-suggestions │ ├── index.tsx │ ├── style.scss │ └── test.tsx ├── types.ts └── utils │ ├── crypto-random-string.ts │ ├── ensure-platform-support.tsx │ ├── export │ ├── README.md │ ├── export-notes.ts │ ├── index.ts │ ├── normalize-line-break.ts │ ├── test │ │ └── normalize-line-breaks.ts │ ├── to-zip.ts │ └── types.ts │ ├── filter-at-most.ts │ ├── filter-notes.ts │ ├── get-note-references.ts │ ├── import │ ├── README.MD │ ├── evernote │ │ ├── index.ts │ │ └── test.ts │ ├── index.ts │ ├── multiple │ │ └── index.ts │ ├── simplenote │ │ ├── index.ts │ │ └── test.ts │ ├── test.ts │ └── text-files │ │ └── index.ts │ ├── is-dev-config │ ├── index.ts │ └── test.ts │ ├── is-email-tag.ts │ ├── note-scroll-position.ts │ ├── note-utils.test.ts │ ├── note-utils.ts │ ├── platform.ts │ ├── render-note-to-html.ts │ ├── sanitize-html.ts │ ├── tag-hash.ts │ ├── task-transform.ts │ ├── test │ └── is-email-tag.ts │ ├── url-utils.ts │ └── validate-password │ ├── index.ts │ └── test.ts ├── package-lock.json ├── package.json ├── resources ├── appx │ ├── Square150x150Logo.png │ ├── Square150x150Logo.targetsize-150_altform-unplated.png │ ├── Square44x44Logo.png │ ├── Square44x44Logo.targetsize-44_altform-unplated.png │ ├── StoreLogo.png │ └── Wide310x150Logo.png ├── certificates │ ├── mac.p12.enc │ └── win.p12.enc ├── images │ ├── app-icon.icns │ ├── dmg-background.png │ ├── dmg-background@2x.png │ ├── dmg-icon.icns │ ├── favicon.ico │ ├── icon_128x128.png │ ├── icon_16x16.png │ ├── icon_256x256.png │ ├── icon_32x32.png │ ├── icon_512x512.png │ └── simplenote.ico ├── macos │ ├── entitlements.mac.inherit.plist │ └── entitlements.mac.plist └── secrets │ ├── .gitkeep │ └── config.json.enc ├── scss ├── _components.scss ├── _general.scss ├── _mixins.scss ├── _normalize.scss ├── _scrollbar.scss ├── _variables.scss ├── buttons.scss ├── inputs.scss ├── print.scss ├── style.scss └── theme.scss ├── setup-tests.js ├── tsconfig.json ├── vip ├── package.json └── webapp │ └── index.js └── webpack.config.js /.buildkite/commands/install_node_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | echo "--- :npm: Install Node dependencies" 4 | # --legacy-peer-deps is necessary because of react-monaco-editor. 5 | # See README for more details 6 | npm ci --legacy-peer-deps 7 | -------------------------------------------------------------------------------- /.buildkite/shared-pipeline-vars: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This file is `source`'d before calling `buildkite-agent pipeline upload`, and can be used 4 | # to set up some variables that will be interpolated in the `.yml` pipeline before uploading it. 5 | 6 | # The ~> modifier is not currently used, but we check for it just in case 7 | XCODE_VERSION=$(sed -E -n 's/^(~> )?(.*)/xcode-\2/p' .xcode-version) 8 | CI_TOOLKIT_PLUGIN_VERSION='5.1.2' 9 | NVM_PLUGIN_VERSION='0.6.0' 10 | 11 | export IMAGE_ID="$XCODE_VERSION" 12 | export CI_TOOLKIT_PLUGIN="automattic/a8c-ci-toolkit#$CI_TOOLKIT_PLUGIN_VERSION" 13 | export NVM_PLUGIN="automattic/nvm#$NVM_PLUGIN_VERSION" 14 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | BUNDLE_JOBS: "3" 4 | BUNDLE_RETRY: "3" 5 | -------------------------------------------------------------------------------- /.configure: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "simplenote-electron", 3 | "branch": "trunk", 4 | "pinned_hash": "bbd0130868d58b2e59c909433df83f4ae6721b76", 5 | "files_to_copy": [ 6 | { 7 | "file": "iOS/app_store_connect_fastlane_api_key.p8", 8 | "destination": "~/.configure/simplenote-electron/secrets/app_store_connect_api_key.p8", 9 | "encrypt": true 10 | } 11 | ], 12 | "file_dependencies": [ 13 | 14 | ] 15 | } -------------------------------------------------------------------------------- /.configure-files/app_store_connect_fastlane_api_key.p8.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/.configure-files/app_store_connect_fastlane_api_key.p8.enc -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [Makefile] 16 | indent_style = tab 17 | tab_width = 2 18 | 19 | [package.json] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [*.{diff,md}] 24 | trim_trailing_whitespace = false 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Explicitly treat .enc files as binaries to prevent GitHub from occasionally 2 | # reporting changes to these files as "empty" instead of saying "binary file 3 | # not shown" 4 | *.enc binary 5 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | npx pretty-quick --staged 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E Bug Report" 3 | about: "Report a bug if something isn't working as expected in the Windows, Linux, or Web Simplenote app." 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | ### Expected 14 | 15 | 16 | ### Observed 17 | 18 | 19 | ### Reproduced 20 | 26 | 27 | 1. 28 | 2. 29 | 3. 30 | 31 | 32 | 33 | ### Where did you see the bug 34 | 35 | - System Make: 36 | - System Model: 37 | - OS: 38 | - OS version: 39 | - Browser (if applicable): 40 | - Browser version (if applicable): 41 | - Simplenote app version: 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature Request" 3 | about: "Suggest a new feature or enhancement to an existing one in the Simplenote Electron app." 4 | title: "" 5 | labels: feature request 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | ### What 14 | 15 | 16 | ### Why 17 | 18 | 19 | ### How 20 | 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Fix 2 | 3 | 8 | 9 | ### Test 10 | 11 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 22 | ### Release 23 | 24 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | config-local.json 3 | desktop-build/ 4 | dist/ 5 | logs/ 6 | release/ 7 | vendor/ 8 | *.pvk 9 | *.p12 10 | *.spc 11 | *.tgz 12 | .idea 13 | .DS_Store 14 | dev-app-update.yml 15 | lib/state/data/test_account.json 16 | # Ignore all files in the .configure-files folder apart from the encoded ones 17 | !.configure-files/*.enc 18 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | dist 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Metrics/BlockLength: 5 | Exclude: &fastlane 6 | - fastlane/Fastfile 7 | 8 | Metrics/MethodLength: 9 | Max: 30 10 | Exclude: *fastlane 11 | 12 | Layout/LineLength: 13 | Max: 180 14 | Exclude: *fastlane 15 | 16 | Layout/EmptyLines: 17 | Exclude: *fastlane 18 | 19 | Style/AsciiComments: 20 | Exclude: *fastlane 21 | 22 | Metrics/AbcSize: 23 | Exclude: *fastlane 24 | 25 | Metrics/CyclomaticComplexity: 26 | Exclude: *fastlane 27 | 28 | Metrics/PerceivedComplexity: 29 | Exclude: *fastlane 30 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": ["stylelint-config-standard", "stylelint-config-standard-scss"], 4 | "plugins": ["stylelint-prettier"], 5 | "rules": { 6 | "alpha-value-notation": null, 7 | "color-function-notation": "legacy", 8 | "declaration-property-value-no-unknown": null, 9 | "prettier/prettier": true, 10 | "no-descending-specificity": null, 11 | "selector-class-pattern": [ 12 | "^([a-z][a-z0-9]*)(-[a-z0-9]+)*((__([a-z][a-z0-9]*)(-[a-z0-9]+)*)?(--([a-z][a-z0-9]*)(-[a-z0-9]+)*)?)$", 13 | { 14 | "message": "Expected BEM naming convention for class." 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 12 | }, 13 | "args": ["."] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.xcode-version: -------------------------------------------------------------------------------- 1 | 15.4 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Normally, we'd let fastlane-plugin-wpmreleasetoolkit transitively fetch Fastlane. 6 | # But there's a bug in the version it resolves by default that we need to fix for while we wait for the plugin to update the resolved version. 7 | # 8 | # 2.219.0 includes a fix for a bug introduced in 2.218.0 9 | # See https://github.com/fastlane/fastlane/issues/21762#issuecomment-1875208663 10 | gem 'fastlane', '~> 2.219' 11 | # This comment avoids typing to switch to a development version for testing. 12 | # 13 | # gem 'fastlane-plugin-wpmreleasetoolkit', git: 'https://github.com/wordpress-mobile/release-toolkit', ref: '' 14 | gem 'fastlane-plugin-wpmreleasetoolkit', '~> 11.0' 15 | # TODO: Remove once Dangermattic is set up 16 | gem 'rubocop', '~> 1.61' 17 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | const presets = [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | corejs: '3', 9 | include: [ 10 | 'transform-class-properties', 11 | 'transform-object-rest-spread', 12 | 'transform-optional-chaining', 13 | ], 14 | useBuiltIns: 'entry', 15 | targets: { 16 | esmodules: true, 17 | }, 18 | }, 19 | ], 20 | '@babel/preset-react', 21 | '@babel/typescript', 22 | ]; 23 | const plugins = [ 24 | '@babel/plugin-syntax-dynamic-import', 25 | '@babel/plugin-transform-runtime', 26 | ]; 27 | const env = { 28 | development: { 29 | compact: false, 30 | }, 31 | test: { 32 | plugins: ['dynamic-import-node'], 33 | }, 34 | }; 35 | 36 | return { 37 | presets, 38 | plugins, 39 | env, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_id": "history-analyst-dad", 3 | "app_key": "be606bcfa3db4377bf488900281aa1cc", 4 | "development": true, 5 | "wpcc_client_id": "0", 6 | "wpcc_redirect_url": "https://simplenote.com" 7 | } 8 | -------------------------------------------------------------------------------- /desktop/app-quit/README.md: -------------------------------------------------------------------------------- 1 | App Quit 2 | ========= 3 | 4 | Determines whether the app should quit and exit, or quit to background. 5 | 6 | Note that the implementation of quit-to-background is left up to `platform`. 7 | -------------------------------------------------------------------------------- /desktop/app-quit/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module variables 5 | */ 6 | let quitter = false; 7 | 8 | function AppQuit() { 9 | this.canQuit = false; 10 | } 11 | 12 | AppQuit.prototype.shouldQuitToBackground = function () { 13 | if (this.canQuit) { 14 | this.canQuit = false; 15 | return false; 16 | } 17 | 18 | return true; 19 | }; 20 | 21 | AppQuit.prototype.allowQuit = function () { 22 | this.canQuit = true; 23 | }; 24 | 25 | if (!quitter) { 26 | quitter = new AppQuit(); 27 | } 28 | 29 | module.exports = quitter; 30 | -------------------------------------------------------------------------------- /desktop/config-updater.json: -------------------------------------------------------------------------------- 1 | { 2 | "updater": { 3 | "downloadUrl": "https://github.com/Automattic/simplenote-electron/releases/latest", 4 | "changelogUrl": "https://github.com/Automattic/simplenote-electron/blob/trunk/RELEASE-NOTES.md", 5 | "apiUrl": "https://api.github.com/repos/automattic/simplenote-electron/releases/latest", 6 | "delay": 2000, 7 | "interval": 600000 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /desktop/config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pkg = require('../../package.json'); 4 | let config = require('../config-updater.json'); 5 | 6 | // Merge in some details from package.json 7 | config.name = pkg.productName; 8 | config.description = 'Simplenote Desktop'; 9 | config.version = pkg.version; 10 | config.author = pkg.author; 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /desktop/context-menu/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * External dependencies 5 | */ 6 | const { Menu } = require('electron'); 7 | 8 | const { editorCommandSender } = require('../menus/utils'); 9 | 10 | module.exports = function (mainWindow) { 11 | mainWindow.webContents.on('context-menu', (event, params) => { 12 | const { editFlags } = params; 13 | Menu.buildFromTemplate([ 14 | { 15 | id: 'selectAll', 16 | label: 'Select All', 17 | click: editorCommandSender({ action: 'selectAll' }), 18 | enabled: editFlags.canSelectAll, 19 | }, 20 | { 21 | id: 'cut', 22 | label: 'Cut', 23 | role: 'cut', 24 | enabled: editFlags.canCut, 25 | }, 26 | { 27 | id: 'copy', 28 | label: 'Copy', 29 | role: 'copy', 30 | enabled: editFlags.canCopy, 31 | }, 32 | { 33 | id: 'paste', 34 | label: 'Paste', 35 | role: 'paste', 36 | enabled: editFlags.canPaste, 37 | }, 38 | { 39 | type: 'separator', 40 | }, 41 | ]).popup({}); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /desktop/detect/build/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module variables 5 | */ 6 | let build = false; 7 | 8 | function Build() { 9 | this.build = false; 10 | } 11 | 12 | // These process properties can either be true or undefined 13 | 14 | Build.prototype.isMAS = function () { 15 | return Boolean(process.mas); 16 | }; 17 | 18 | Build.prototype.isWindowsStore = function () { 19 | return Boolean(process.windowsStore); 20 | }; 21 | 22 | if (!build) { 23 | build = new Build(); 24 | } 25 | 26 | module.exports = build; 27 | -------------------------------------------------------------------------------- /desktop/detect/platform/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module variables 5 | */ 6 | let platform = false; 7 | 8 | function Platform() { 9 | this.platform = false; 10 | } 11 | 12 | Platform.prototype.isOSX = function () { 13 | return process.platform === 'darwin'; 14 | }; 15 | 16 | Platform.prototype.isWindows = function () { 17 | return process.platform === 'win32'; 18 | }; 19 | 20 | Platform.prototype.isLinux = function () { 21 | return process.platform === 'linux'; 22 | }; 23 | 24 | if (!platform) { 25 | platform = new Platform(); 26 | } 27 | 28 | module.exports = platform; 29 | -------------------------------------------------------------------------------- /desktop/env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isDev: 3 | process.env.NODE_ENV === 'development' || 4 | process.defaultApp || 5 | /node_modules[\\/]electron[\\/]/.test(process.execPath), 6 | }; 7 | -------------------------------------------------------------------------------- /desktop/evernote-import/test/correct-markdown.txt: -------------------------------------------------------------------------------- 1 | (image/png) 2 | 3 | * item 1 4 | * item 2 5 | * item 3 6 | 7 | 1. ordered item 1 8 | 2. ordered item 2 9 | 10 | - [x] task item checked 11 | - [ ] task item unchecked 12 | 13 | This is a [link](https://google.com/) 14 | https://google.com 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 17 | 18 | Lorem **bold** dolor *italic* amet. 19 | 20 | ``` 21 | var variable = true; 22 | ``` 23 | 24 | Another line. 25 | 26 | (audio/wav) 27 | -------------------------------------------------------------------------------- /desktop/evernote-import/test/mock-enml.txt: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

6 | 17 |

18 |
    19 |
  1. 20 |
    ordered item 1
    21 |
  2. 22 |
  3. 23 |
    ordered item 2
    24 |
  4. 25 |
26 |

27 |
28 | 29 | task item checked 30 |
31 |
32 | 33 | task item unchecked 34 |
35 |

36 |
This is a link
37 |
https://google.com
38 |

39 |
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
40 |

41 |
Lorem bold dolor italic amet.
42 |

43 |
44 |
var variable = true;
45 |
46 |

47 |
Another line.
48 |

49 |
50 | 51 |
52 |

-------------------------------------------------------------------------------- /desktop/index.js: -------------------------------------------------------------------------------- 1 | require('./app')(); 2 | -------------------------------------------------------------------------------- /desktop/logger/archiver.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * External dependencies 5 | */ 6 | const fs = require('fs'); 7 | const JSZip = require('jszip'); 8 | 9 | /** 10 | * Internal dependencies 11 | */ 12 | const log = require('./')('desktop:lib:archiver'); 13 | 14 | module.exports = { 15 | /** 16 | * Compresses `contents` to the archive at `dst`. 17 | * 18 | * @param {String[]} contents Paths to be zipped 19 | * @param {String} dst Path to destination archive 20 | * @param {function():void} onZipped Callback invoked when archive is complete 21 | */ 22 | zipContents: (logPath, dst, onZipped) => { 23 | const zip = new JSZip(); 24 | zip.file( 25 | 'simplenote.log', 26 | new Promise((resolve, reject) => 27 | fs.readFile(logPath, (error, data) => { 28 | if (error) { 29 | log.warn('Unexpected error: ', error); 30 | reject(error); 31 | } else { 32 | resolve(data); 33 | } 34 | }) 35 | ) 36 | ); 37 | 38 | const output = fs.createWriteStream(dst); 39 | 40 | // Catch warnings (e.g. stat failures and other non-blocking errors) 41 | output.on('warning', function (err) { 42 | log.warn('Unexpected error: ', err); 43 | }); 44 | 45 | output.on('error', function (err) { 46 | throw err; 47 | }); 48 | 49 | zip 50 | .generateNodeStream({ type: 'nodebuffer', streamFiles: true }) 51 | .pipe(output) 52 | .on('finish', function () { 53 | log.debug('Archive finalized'); 54 | if (typeof onZipped === 'function') { 55 | onZipped(); 56 | } 57 | }); 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /desktop/logger/namespaces/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit: Modified from `winston-namespace` by @SetaSouto: 3 | * https://github.com/SetaSouto/winston-namespace 4 | */ 5 | 6 | module.exports = { 7 | /** 8 | * Boolean indicating if the object is populated with the environment data. 9 | */ 10 | populated: false, 11 | /** 12 | * Populates the private data 'namespaces' as an array with the environment from the NODE_ENV 13 | * environment variable. It splits the data with ',' as separator. 14 | * @private 15 | */ 16 | populate: function () { 17 | this.namespaces = [process.env.NODE_ENV === 'production' ? '' : '*']; 18 | this.populated = true; 19 | }, 20 | /** 21 | * Checks if the namespace is available to debug. The namespace could be contained in wildcards. 22 | * Ex: 'server:api:controller' would pass the check (return true) if the 'server:api:controller' is in the 23 | * environment variable or if 'server:api:*' or 'server:*' is in the environment variable. 24 | * @param namespace {String} - Namespace to check. 25 | * @returns {boolean} Whether or not the namespace is available. 26 | */ 27 | check: function (namespace) { 28 | if (!this.populated) { 29 | this.populate(); 30 | } 31 | 32 | if (this.namespaces.includes('*')) { 33 | return true; 34 | } 35 | 36 | if (this.namespaces.includes(namespace)) { 37 | return true; 38 | } 39 | 40 | return namespace 41 | .split(':') 42 | .some((_, level, levels) => 43 | this.namespaces.includes(levels.slice(0, level).join(':') + ':*') 44 | ); 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /desktop/menus/file-menu.js: -------------------------------------------------------------------------------- 1 | const menuItems = require('./menu-items'); 2 | const platform = require('../detect/platform'); 3 | const { appCommandSender } = require('./utils'); 4 | 5 | const buildFileMenu = (isAuthenticated) => { 6 | isAuthenticated = isAuthenticated || false; 7 | 8 | let submenu = []; 9 | 10 | if (isAuthenticated) { 11 | submenu = [ 12 | { 13 | label: '&New Note', 14 | accelerator: 'CommandOrControl+Shift+I', 15 | click: appCommandSender({ action: 'newNote' }), 16 | }, 17 | { type: 'separator' }, 18 | { 19 | label: '&Import Notes…', 20 | click: appCommandSender({ 21 | action: 'showDialog', 22 | dialog: 'IMPORT', 23 | }), 24 | }, 25 | { 26 | label: '&Export Notes…', 27 | accelerator: 'CommandOrControl+Shift+E', 28 | click: appCommandSender({ 29 | action: 'exportNotes', 30 | }), 31 | }, 32 | { type: 'separator' }, 33 | { 34 | label: '&Print…', 35 | accelerator: 'CommandOrControl+P', 36 | click: appCommandSender({ action: 'printNote' }), 37 | }, 38 | ]; 39 | } 40 | 41 | const defaultSubmenuAdditions = [ 42 | { type: 'separator' }, 43 | menuItems.preferences(isAuthenticated), 44 | ...(isAuthenticated ? [{ type: 'separator' }] : []), 45 | { role: 'quit' }, 46 | ]; 47 | 48 | const fileMenu = { 49 | label: '&File', 50 | submenu: platform.isOSX() 51 | ? submenu 52 | : submenu.concat(defaultSubmenuAdditions), 53 | }; 54 | 55 | // we have nothing to show in the File menu on OSX logged out 56 | if (!isAuthenticated && platform.isOSX()) { 57 | fileMenu.visible = false; 58 | } 59 | 60 | return fileMenu; 61 | }; 62 | 63 | module.exports = buildFileMenu; 64 | -------------------------------------------------------------------------------- /desktop/menus/format-menu.js: -------------------------------------------------------------------------------- 1 | const { editorCommandSender } = require('./utils'); 2 | 3 | const buildFormatMenu = (isAuthenticated, editMode) => { 4 | isAuthenticated = isAuthenticated || false; 5 | editMode = editMode || false; 6 | const submenu = [ 7 | { 8 | label: 'Insert &Checklist', 9 | accelerator: 'CommandOrControl+Shift+C', 10 | click: editorCommandSender({ action: 'insertChecklist' }), 11 | enabled: editMode, 12 | }, 13 | ]; 14 | 15 | const formatMenu = { 16 | label: 'F&ormat', 17 | submenu, 18 | }; 19 | 20 | // we have nothing to show in this menu if not logged in 21 | return isAuthenticated ? formatMenu : null; 22 | }; 23 | 24 | module.exports = buildFormatMenu; 25 | -------------------------------------------------------------------------------- /desktop/menus/help-menu.js: -------------------------------------------------------------------------------- 1 | const { shell } = require('electron'); 2 | 3 | const menuItems = require('./menu-items'); 4 | const platform = require('../detect/platform'); 5 | const build = require('../detect/build'); 6 | const log = require('../logger')('desktop:menu:help'); 7 | const zipLogs = require('../logger/zip-logs'); 8 | 9 | const { appCommandSender } = require('./utils'); 10 | 11 | const buildHelpMenu = (mainWindow, isAuthenticated) => { 12 | isAuthenticated = isAuthenticated || false; 13 | const submenu = [ 14 | { 15 | label: 'Help && &Support', 16 | accelerator: platform.isLinux() ? 'F1' : null, 17 | click: () => shell.openExternal('https://simplenote.com/help'), 18 | }, 19 | { 20 | label: '&Keyboard Shortcuts', 21 | visible: isAuthenticated, 22 | click: appCommandSender({ 23 | action: 'showDialog', 24 | dialog: 'KEYBINDINGS', 25 | }), 26 | }, 27 | { type: 'separator' }, 28 | { 29 | label: 'Advanced', 30 | submenu: [ 31 | { 32 | label: 'Debugging Console', 33 | click: (item, focusedWindow) => focusedWindow?.toggleDevTools(), 34 | }, 35 | ], 36 | }, 37 | { 38 | type: 'separator', 39 | }, 40 | { 41 | label: 'Get Application Logs', 42 | click: function () { 43 | log.info("User selected 'Get Application Logs'..."); 44 | zipLogs(mainWindow); 45 | }, 46 | }, 47 | ]; 48 | 49 | const defaultSubmenuAdditions = [ 50 | { type: 'separator' }, 51 | ...(build.isWindowsStore() ? [] : [menuItems.checkForUpdates]), 52 | menuItems.about, 53 | ]; 54 | 55 | const helpMenu = { 56 | label: '&Help', 57 | role: 'help', 58 | submenu: platform.isOSX() 59 | ? submenu 60 | : submenu.concat(defaultSubmenuAdditions), 61 | }; 62 | 63 | return helpMenu; 64 | }; 65 | 66 | module.exports = buildHelpMenu; 67 | -------------------------------------------------------------------------------- /desktop/menus/index.js: -------------------------------------------------------------------------------- 1 | const platform = require('../detect/platform'); 2 | 3 | const buildMacAppMenu = require('./mac-app-menu'); 4 | const buildFileMenu = require('./file-menu'); 5 | const buildEditMenu = require('./edit-menu'); 6 | const buildViewMenu = require('./view-menu'); 7 | const buildFormatMenu = require('./format-menu'); 8 | const buildHelpMenu = require('./help-menu'); 9 | 10 | function createMenuTemplate(args, mainWindow) { 11 | args = args || {}; 12 | const settings = args['settings'] || {}; 13 | const isAuthenticated = settings && 'accountName' in settings; 14 | const editMode = args['editMode'] || false; 15 | const windowMenu = { 16 | role: 'window', 17 | submenu: [ 18 | { role: 'minimize' }, 19 | { role: 'close' }, 20 | { type: 'separator' }, 21 | { role: 'front' }, 22 | ], 23 | }; 24 | 25 | return [ 26 | platform.isOSX() ? buildMacAppMenu(isAuthenticated) : null, 27 | buildFileMenu(isAuthenticated), 28 | buildEditMenu(settings, isAuthenticated, editMode), 29 | buildViewMenu(settings, isAuthenticated), 30 | buildFormatMenu(isAuthenticated, editMode), 31 | platform.isOSX() ? windowMenu : null, 32 | buildHelpMenu(mainWindow, isAuthenticated), 33 | ].filter((menu) => menu !== null); 34 | } 35 | 36 | module.exports = createMenuTemplate; 37 | -------------------------------------------------------------------------------- /desktop/menus/mac-app-menu.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | 3 | const menuItems = require('./menu-items'); 4 | const build = require('../detect/build'); 5 | 6 | const buildMacAppMenu = (isAuthenticated) => { 7 | var submenu = []; 8 | isAuthenticated = isAuthenticated || false; 9 | 10 | submenu = [ 11 | menuItems.about, 12 | ...(build.isMAS() ? [] : [menuItems.checkForUpdates]), 13 | { type: 'separator' }, 14 | menuItems.preferences(isAuthenticated), 15 | ...(isAuthenticated ? [{ type: 'separator' }] : []), 16 | { 17 | role: 'services', 18 | submenu: [], 19 | }, 20 | { type: 'separator' }, 21 | { role: 'hide' }, 22 | { role: 'hideothers' }, 23 | { role: 'unhide' }, 24 | ...(isAuthenticated 25 | ? [ 26 | { type: 'separator' }, 27 | menuItems.emptyTrash(isAuthenticated), 28 | menuItems.signout(isAuthenticated), 29 | ] 30 | : []), 31 | { type: 'separator' }, 32 | { role: 'quit' }, 33 | ]; 34 | 35 | const menu = { 36 | label: app.name, 37 | submenu: submenu, 38 | }; 39 | 40 | return menu; 41 | }; 42 | 43 | module.exports = buildMacAppMenu; 44 | -------------------------------------------------------------------------------- /desktop/menus/menu-items.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | 3 | const { appCommandSender } = require('./utils'); 4 | const updater = require('../updater'); 5 | const { autoUpdater } = require('electron-updater'); 6 | 7 | const about = { 8 | label: '&About ' + app.name, 9 | click: appCommandSender({ 10 | action: 'showDialog', 11 | dialog: 'ABOUT', 12 | }), 13 | }; 14 | 15 | const checkForUpdates = { 16 | label: '&Check for Updates…', 17 | enabled: autoUpdater.isUpdaterActive(), 18 | click: updater.pingAndShowProgress.bind(updater), 19 | }; 20 | 21 | const emptyTrash = (isAuthenticated) => { 22 | return { 23 | label: '&Empty Trash', 24 | visible: isAuthenticated, 25 | click: appCommandSender({ action: 'emptyTrash' }), 26 | }; 27 | }; 28 | 29 | const preferences = (isAuthenticated) => { 30 | return { 31 | label: 'P&references…', 32 | visible: isAuthenticated, 33 | accelerator: 'CommandOrControl+,', 34 | click: appCommandSender({ 35 | action: 'showDialog', 36 | dialog: 'SETTINGS', 37 | }), 38 | }; 39 | }; 40 | 41 | const signout = (isAuthenticated) => { 42 | return { 43 | label: '&Sign Out', 44 | visible: isAuthenticated, 45 | click: appCommandSender({ 46 | action: 'logout', 47 | }), 48 | }; 49 | }; 50 | 51 | module.exports = { 52 | about, 53 | checkForUpdates, 54 | emptyTrash, 55 | preferences, 56 | signout, 57 | }; 58 | -------------------------------------------------------------------------------- /desktop/menus/test/help-menu.js: -------------------------------------------------------------------------------- 1 | import helpMenu from '../help-menu'; 2 | import '../menu-items'; 3 | 4 | jest.mock('../menu-items', () => ({ 5 | checkForUpdates: 'checkForUpdates', 6 | })); 7 | 8 | jest.mock('../../detect/platform', () => ({ 9 | isLinux: jest.fn().mockReturnValue(false), 10 | isOSX: jest.fn().mockReturnValue(false), 11 | isWindows: jest.fn().mockReturnValue(true), 12 | })); 13 | 14 | jest.mock('../../detect/build', () => ({ 15 | isWindowsStore: jest.fn().mockReturnValue(true), 16 | })); 17 | 18 | describe('Help Menu', () => { 19 | it('should not show Check for Updates menu item if Windows Store app', () => { 20 | expect(helpMenu.submenu).not.toEqual( 21 | expect.arrayContaining(['checkForUpdates']) 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /desktop/menus/test/utils.js: -------------------------------------------------------------------------------- 1 | import { buildRadioGroup } from '../utils'; 2 | 3 | describe('Menu Utils', () => { 4 | describe('buildRadioGroup', () => { 5 | const menuItems = [ 6 | { 7 | id: 'myValue', 8 | passThroughProp: 'passThroughValue', 9 | }, 10 | { 11 | id: 'myUnselectedValue', 12 | }, 13 | ]; 14 | const args = { 15 | action: 'myAction', 16 | propName: 'myProp', 17 | settings: { myProp: 'myValue' }, 18 | }; 19 | const result = menuItems.map(buildRadioGroup(args)); 20 | 21 | test('should pass through any props', () => { 22 | expect(result[0].passThroughProp).toBe('passThroughValue'); 23 | }); 24 | 25 | test('should set the correct item as "checked"', () => { 26 | expect(result[0].checked).toBe(true); 27 | expect(result[1].checked).toBe(false); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /desktop/menus/utils.js: -------------------------------------------------------------------------------- 1 | const buildRadioGroup = ({ action, propName, settings }) => { 2 | return (item) => { 3 | const { id, ...props } = item; 4 | 5 | return { 6 | type: 'radio', 7 | checked: id === settings[propName], 8 | click: appCommandSender({ 9 | action, 10 | [propName]: id, 11 | }), 12 | ...props, 13 | }; 14 | }; 15 | }; 16 | 17 | const appCommandSender = (arg) => { 18 | return commandSender('appCommand', arg); 19 | }; 20 | 21 | const editorCommandSender = (arg) => { 22 | return commandSender('editorCommand', arg); 23 | }; 24 | 25 | const commandSender = (commandName, arg) => { 26 | return (item, focusedWindow) => { 27 | if (typeof focusedWindow !== 'undefined') { 28 | focusedWindow.webContents.send(commandName, arg); 29 | } 30 | }; 31 | }; 32 | 33 | module.exports = { 34 | buildRadioGroup, 35 | appCommandSender, 36 | editorCommandSender, 37 | }; 38 | -------------------------------------------------------------------------------- /desktop/updater/README.md: -------------------------------------------------------------------------------- 1 | Updater 2 | ========= 3 | 4 | Uses either the manual or auto updater to check for updates. 5 | 6 | The config `updater.url` is used as the base URL and has the platform, version, and beta setting appended. 7 | 8 | The updater uses: 9 | 10 | - [Auto Updater](auto-updater/README.md) 11 | - [Manual Updater](manual-updater/README.md) 12 | -------------------------------------------------------------------------------- /desktop/updater/auto-updater/README.md: -------------------------------------------------------------------------------- 1 | Auto Updater 2 | ========= 3 | 4 | Handles Squirrel auto-update events. Note that the app must be signed for this to work. 5 | 6 | ## Functions 7 | 8 | `ping()` - checks for an update 9 | -------------------------------------------------------------------------------- /desktop/updater/auto-updater/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * External Dependencies 5 | */ 6 | const { autoUpdater } = require('electron-updater'); 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | const Updater = require('../lib/Updater'); 12 | const AppQuit = require('../../app-quit'); 13 | const setupProgressUpdates = require('../lib/setup-progress-updates'); 14 | 15 | class AutoUpdater extends Updater { 16 | constructor({ changelogUrl, options = {} }) { 17 | super(changelogUrl, options); 18 | 19 | autoUpdater.on('error', this.onError.bind(this)); 20 | autoUpdater.on('update-not-available', this.onNotAvailable.bind(this)); 21 | autoUpdater.on('update-downloaded', this.onDownloaded.bind(this)); 22 | 23 | autoUpdater.autoInstallOnAppQuit = false; 24 | } 25 | 26 | // For non-user-initiated checks. 27 | // Check and download in the background, and only notify the user if 28 | // an update exists and has completed downloading. 29 | ping() { 30 | autoUpdater.checkForUpdates(); 31 | } 32 | 33 | // For user-initiated checks. 34 | // Will check and download, displaying progress dialogs. 35 | pingAndShowProgress() { 36 | setupProgressUpdates({ updater: autoUpdater, willAutoDownload: true }); 37 | autoUpdater.checkForUpdates(); 38 | } 39 | 40 | onConfirm() { 41 | AppQuit.allowQuit(); 42 | autoUpdater.quitAndInstall(); 43 | } 44 | } 45 | 46 | module.exports = AutoUpdater; 47 | -------------------------------------------------------------------------------- /desktop/updater/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Internal dependencies 5 | */ 6 | const platform = require('../detect/platform'); 7 | const config = require('../config'); 8 | const AutoUpdater = require('./auto-updater'); 9 | const ManualUpdater = require('./manual-updater'); 10 | 11 | let updater = false; 12 | 13 | if (platform.isOSX() || platform.isWindows() || process.env.APPIMAGE) { 14 | updater = new AutoUpdater({ 15 | changelogUrl: config.updater.changelogUrl, 16 | }); 17 | } else { 18 | updater = new ManualUpdater({ 19 | downloadUrl: config.updater.downloadUrl, 20 | apiUrl: config.updater.apiUrl, 21 | changelogUrl: config.updater.changelogUrl, 22 | options: { 23 | dialogMessage: 24 | '{name} {newVersion} is now available — you have {currentVersion}. Would you like to download it now?', 25 | confirmLabel: 'Download', 26 | }, 27 | }); 28 | } 29 | 30 | module.exports = updater; 31 | -------------------------------------------------------------------------------- /desktop/updater/manual-updater/README.md: -------------------------------------------------------------------------------- 1 | Manual Updater 2 | ========= 3 | 4 | Provides a manual updater on platforms where the auto updater does not work. 5 | 6 | If an update is detected then a dialog is shown with a link to the update for download. 7 | 8 | The update is shown once until the app is restarted. 9 | 10 | ## Functions 11 | 12 | `ping()` - checks for an update 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | install: 5 | image: node:8.16.1 6 | command: npm install 7 | volumes: 8 | - $PWD:/app 9 | working_dir: /app 10 | 11 | dev: 12 | image: node:8.16.1 13 | command: npm start 14 | ports: 15 | - "4000:4000" 16 | volumes: 17 | - $PWD:/app 18 | working_dir: /app 19 | -------------------------------------------------------------------------------- /docs/packaging.md: -------------------------------------------------------------------------------- 1 | # Building a Release 2 | 3 | * `make osx` 4 | * `make linux` 5 | * `make win32` 6 | 7 | # Packaging 8 | 9 | Some builds require further packaging before they can be released: 10 | 11 | * `make package-win32` - Produces a signed `Setup.exe` install wizard 12 | * `make package-osx` - Produces a `DMG` file 13 | * `make package-linux` - Produces a `.deb`, `.rpm` and `.AppImage` file for `x86` and `x64` processors 14 | 15 | 16 | # Requirements 17 | 18 | ## Mac Package 19 | 20 | A Mac build requires the app to be signed. This prevents a security warning being issued when you run the app. 21 | 22 | You can obtain all the appropriate signing certificates from an Apple Developer account. 23 | 24 | Note that you need the certificates installed prior to building. 25 | 26 | ## Windows Package 27 | 28 | The Windows package requires installing a valid certificate, installing the `makensis`, `wine` and `mono` packages, installable from `brew`. 29 | 30 | `brew install mono wine makensis` 31 | 32 | The Windows build doesn't get signed until the packaging stage. 33 | 34 | ## Linux Package 35 | 36 | The Linux package is built using [electron-builder][1] which is a tool that makes it easy to build different package systems. electron-builder should be installed by `npm install`. 37 | 38 | ### Note for creating Linux package on Linux 39 | 40 | 1. Creating all Linux packages requires tool for converting `.icns` file to `.png`. May be installed via `apt` by typing: 41 | 42 | `sudo apt install --no-install-recommends -y icnsutils ` 43 | 44 | 2. Creating `.rpm` package requires `rpm` package, installable from 'apt'. 45 | 46 | `sudo apt install --no-install-recommends -y rpm` 47 | 48 | ### Note for creating Linux package on MacOS: 49 | 50 | 1. Creating Linux package on MacOS requires `rpm` package, installable from `brew`. 51 | 52 | `brew install rpm` 53 | 54 | [1]: https://www.electron.build/ 55 | -------------------------------------------------------------------------------- /electron-builder-appx.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Simplenote", 3 | "appId": "com.automattic.simplenote", 4 | "directories": { 5 | "output": "./release", 6 | "buildResources": "./resources" 7 | }, 8 | "files": ["desktop", "dist", "shared"], 9 | "win": { 10 | "icon": "resources/images/simplenote.ico", 11 | "target": [ 12 | { 13 | "target": "appx", 14 | "arch": ["ia32", "x64"] 15 | } 16 | ] 17 | }, 18 | "appx": { 19 | "applicationId": "Simplenote", 20 | "identityName": "22490Automattic.Simplenote", 21 | "publisher": "CN=E2E5A157-746D-4B04-9116-ABE5CB928306", 22 | "publisherDisplayName": "Automattic, Inc.", 23 | "backgroundColor": "transparent", 24 | "showNameOnTiles": true, 25 | "artifactName": "Simplenote-win-store-${version}-${arch}.${ext}" 26 | }, 27 | "protocols": [ 28 | { 29 | "name": "simplenote", 30 | "schemes": ["simplenote"] 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /fastlane/.gitignore: -------------------------------------------------------------------------------- 1 | report.xml 2 | README.md 3 | -------------------------------------------------------------------------------- /fastlane/example.env: -------------------------------------------------------------------------------- 1 | # You don't need to fill in these values for Fastlane to run. 2 | # 3 | # However, if a lane requires some of these environment values and they are not set here, it will fail. 4 | APP_STORE_CONNECT_API_KEY_KEY_ID= 5 | APP_STORE_CONNECT_API_KEY_ISSUER_ID= 6 | APP_STORE_CONNECT_API_KEY_KEY= 7 | MATCH_PASSWORD= 8 | MATCH_S3_ACCESS_KEY= 9 | MATCH_S3_SECRET_ACCESS_KEY= 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/setup-tests.js'], 3 | globals: { 4 | config: { 5 | appVersion: 'foo', 6 | }, 7 | }, 8 | roots: ['desktop', 'lib'], 9 | testEnvironment: 'jsdom', 10 | testRegex: '(/test/.*\\.[jt]sx?)|(test\\.[jt]sx?)$', 11 | }; 12 | -------------------------------------------------------------------------------- /lib/alternate-login-prompt/style.scss: -------------------------------------------------------------------------------- 1 | .alternate-login__overlay { 2 | position: fixed; 3 | inset: 0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | background: var(--overlay-color); 8 | } 9 | 10 | .alternate-login__dismiss { 11 | display: flex; 12 | height: 60px; 13 | justify-content: flex-end; 14 | align-items: center; 15 | margin: 0 16px; 16 | width: 100%; 17 | 18 | svg { 19 | height: 16px; 20 | width: 16px; 21 | margin: auto 0; 22 | } 23 | } 24 | 25 | .alternate-login__email { 26 | word-break: break-all; 27 | } 28 | 29 | .alternate-login__modal { 30 | align-items: center; 31 | background-color: var(--background-color); 32 | border-radius: 8px; 33 | color: var(--primary-color); 34 | display: flex; 35 | flex-direction: column; 36 | justify-content: center; 37 | margin: 20%; 38 | max-width: 420px; 39 | padding: 0 24px 16px; 40 | font-size: 16px; 41 | 42 | .icon-warning { 43 | color: var(--foreground-color); 44 | } 45 | 46 | p { 47 | margin: 0 26px; 48 | margin-block-start: 0; 49 | } 50 | 51 | p:not(:last-of-type) { 52 | padding-bottom: 20px; 53 | } 54 | 55 | .alternate-login__button-row { 56 | padding-top: 10px; 57 | padding-bottom: 10px; 58 | display: flex; 59 | justify-content: flex-end; 60 | flex-flow: row wrap; 61 | width: 100%; 62 | 63 | a { 64 | padding-right: 12px; 65 | } 66 | 67 | .button-borderless { 68 | color: var(--accent-color); 69 | } 70 | } 71 | 72 | &:focus { 73 | outline: 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/analytics/README.md: -------------------------------------------------------------------------------- 1 | Analytics 2 | ========= 3 | 4 | This module is a simplified version of the analytics module found in WP-Calypso. 5 | 6 | Original: https://github.com/Automattic/wp-calypso/tree/master/client/analytics 7 | 8 | ## Tracks API 9 | 10 | ### analytics#tracks#recordEvent( eventName, eventProperties ) 11 | 12 | Record an event with optional properties: 13 | 14 | ```js 15 | analytics.tracks.recordEvent( 'calypso_checkout_coupon_apply', { 16 | 'coupon_code': 'abc123' 17 | } ); 18 | ``` -------------------------------------------------------------------------------- /lib/analytics/types.ts: -------------------------------------------------------------------------------- 1 | import * as T from '../types'; 2 | 3 | export type TKQItem = 4 | | Function 5 | | [keyof TracksAPI, ...(string | T.JSONSerializable)[]]; 6 | 7 | export type TracksAPI = { 8 | storeContext: (c: object) => void; 9 | identifyUser: (user: string, login: string) => void; 10 | recordEvent: (name: string, props: T.JSONSerializable) => void; 11 | setProperties: (properties: object) => void; 12 | clearIdentity: () => void; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/boot-with-auth.tsx: -------------------------------------------------------------------------------- 1 | if (__TEST__) { 2 | window.testEvents = []; 3 | } 4 | 5 | import React from 'react'; 6 | import App from './app'; 7 | import { ErrorBoundaryWithAnalytics } from './error-boundary'; 8 | import Modal from 'react-modal'; 9 | import { makeStore } from './state'; 10 | import { render } from 'react-dom'; 11 | import { Provider } from 'react-redux'; 12 | import { initSimperium } from './state/simperium/middleware'; 13 | 14 | import '../scss/style.scss'; 15 | 16 | import isDevConfig from './utils/is-dev-config'; 17 | 18 | export const bootWithToken = ( 19 | logout: () => any, 20 | token: string, 21 | username: string | null 22 | ) => { 23 | Modal.setAppElement('#root'); 24 | 25 | makeStore(username, initSimperium(logout, token, username)).then((store) => { 26 | Object.defineProperties(window, { 27 | dispatch: { 28 | get() { 29 | return store.dispatch; 30 | }, 31 | }, 32 | state: { 33 | get() { 34 | return store.getState(); 35 | }, 36 | }, 37 | }); 38 | 39 | window.electron?.send('appStateUpdate', { 40 | settings: store.getState().settings, 41 | editMode: store.getState().ui.editMode, 42 | }); 43 | 44 | render( 45 | 46 | 49 | 50 | 51 | , 52 | document.getElementById('root') 53 | ); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /lib/components/boot-warning/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import './style'; 3 | 4 | const BootWarning: FunctionComponent = ({ children }) => ( 5 |

{children}

6 | ); 7 | 8 | export default BootWarning; 9 | -------------------------------------------------------------------------------- /lib/components/boot-warning/style.scss: -------------------------------------------------------------------------------- 1 | h3.boot-warning__message { 2 | margin: 50px auto; 3 | width: 50%; 4 | } 5 | -------------------------------------------------------------------------------- /lib/components/clipboard-button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import Clipboard from 'clipboard'; 3 | 4 | type Props = { 5 | container?: React.RefObject; 6 | linkText: string; 7 | text: string; 8 | }; 9 | 10 | function ClipboardButton({ container, linkText, text }: Props) { 11 | const buttonRef = useRef(); 12 | const textCallback = useRef(); 13 | const successCallback = useRef(); 14 | 15 | const onCopy = () => { 16 | setCopied(true); 17 | }; 18 | 19 | const [isCopied, setCopied] = useState(false); 20 | 21 | // toggle the `isCopied` flag back to `false` after 4 seconds 22 | useEffect(() => { 23 | if (isCopied) { 24 | const confirmationTimeout = setTimeout(() => setCopied(false), 4000); 25 | return () => clearTimeout(confirmationTimeout); 26 | } 27 | }, [isCopied]); 28 | 29 | // update the callbacks on rerenders that change `text` or `onCopy` 30 | useEffect(() => { 31 | textCallback.current = () => text; 32 | successCallback.current = onCopy; 33 | }, [text, onCopy]); 34 | 35 | // create the `Clipboard` object on mount and destroy on unmount 36 | useEffect(() => { 37 | const clipboard = new Clipboard(buttonRef.current, { 38 | container: container?.current || undefined, 39 | text: () => textCallback.current(), 40 | }); 41 | clipboard.on('success', () => successCallback.current()); 42 | 43 | return () => clipboard.destroy(); 44 | }, []); 45 | 46 | return ( 47 | 50 | ); 51 | } 52 | 53 | export default ClipboardButton; 54 | -------------------------------------------------------------------------------- /lib/components/dev-badge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DevBadge = (props: React.HTMLProps) => { 4 | return ( 5 |
6 | DEV 7 |
8 | ); 9 | }; 10 | 11 | export default DevBadge; 12 | -------------------------------------------------------------------------------- /lib/components/dev-badge/style.scss: -------------------------------------------------------------------------------- 1 | .dev-badge { 2 | position: absolute; 3 | opacity: 0.8; 4 | z-index: 1; 5 | right: 12px; 6 | bottom: 12px; 7 | padding: 2px 4px; 8 | background: var(--secondary-color); 9 | color: var(--primary-color); 10 | font-size: 0.75rem; 11 | line-height: 1; 12 | } 13 | -------------------------------------------------------------------------------- /lib/components/last-sync-time/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type * as S from '../../state'; 5 | import type * as T from '../../types'; 6 | 7 | type OwnProps = { 8 | noteId: T.EntityId; 9 | }; 10 | 11 | type StateProps = { 12 | lastUpdated?: number; 13 | }; 14 | 15 | type Props = StateProps; 16 | 17 | export const LastSyncTime: FunctionComponent = ({ lastUpdated }) => 18 | 'undefined' !== typeof lastUpdated ? ( 19 | 28 | ) : ( 29 | 30 | ); 31 | 32 | const mapStateToProps: S.MapState = ( 33 | state, 34 | { noteId } 35 | ) => ({ 36 | lastUpdated: state.simperium.lastSync.get(noteId), 37 | }); 38 | 39 | export default connect(mapStateToProps)(LastSyncTime); 40 | -------------------------------------------------------------------------------- /lib/components/note-preview/style.scss: -------------------------------------------------------------------------------- 1 | .note-preview { 2 | white-space: pre-wrap; 3 | } 4 | -------------------------------------------------------------------------------- /lib/components/panel-title/index.tsx: -------------------------------------------------------------------------------- 1 | import { createElement, FunctionComponent } from 'react'; 2 | 3 | type OwnProps = { 4 | children: string; 5 | headingLevel: 1 | 2 | 3 | 4 | 5 | 6; 6 | }; 7 | 8 | const PanelTitle: FunctionComponent = ({ 9 | children, 10 | headingLevel = 3, 11 | }) => { 12 | return createElement( 13 | `h${headingLevel}`, 14 | { className: 'panel-title' }, 15 | children 16 | ); 17 | }; 18 | 19 | export default PanelTitle; 20 | -------------------------------------------------------------------------------- /lib/components/panel-title/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/variables' as *; 2 | 3 | .panel-title { 4 | color: var(--foreground-color); 5 | margin: 0 0 0.5em; 6 | text-transform: uppercase; 7 | font-size: 90%; 8 | font-weight: $bold; 9 | } 10 | -------------------------------------------------------------------------------- /lib/components/progress-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LinearProgress } from '@mui/material'; 3 | 4 | const ProgressBar: typeof LinearProgress = (props) => { 5 | return ( 6 | 13 | ); 14 | }; 15 | 16 | export default ProgressBar; 17 | -------------------------------------------------------------------------------- /lib/components/progress-bar/style.scss: -------------------------------------------------------------------------------- 1 | .progress-bar { 2 | background-color: var(--secondary-color); 3 | } 4 | 5 | .progress-bar__bar { 6 | background-color: var(--foreground-color) !important; 7 | } 8 | -------------------------------------------------------------------------------- /lib/components/slider/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler, FunctionComponent } from 'react'; 2 | 3 | type OwnProps = { 4 | disabled: boolean; 5 | onChange: ChangeEventHandler; 6 | min: number; 7 | max: number; 8 | value: number; 9 | }; 10 | 11 | type Props = OwnProps & React.HTMLProps; 12 | 13 | export const Slider: FunctionComponent = ({ 14 | 'aria-valuetext': ariaValueText, 15 | disabled, 16 | min, 17 | max, 18 | value, 19 | onChange, 20 | }) => ( 21 | 32 | ); 33 | 34 | export default Slider; 35 | -------------------------------------------------------------------------------- /lib/components/spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { CircularProgress, CircularProgressProps } from '@mui/material'; 3 | import classnames from 'classnames'; 4 | 5 | const Spinner = ({ 6 | isWhite, 7 | ...props 8 | }: CircularProgressProps & { isWhite?: boolean }) => { 9 | return ( 10 | 17 | ); 18 | }; 19 | 20 | export default Spinner; 21 | -------------------------------------------------------------------------------- /lib/components/spinner/style.scss: -------------------------------------------------------------------------------- 1 | .spinner__circle { 2 | color: var(--secondary-color); 3 | 4 | &.is-white { 5 | color: var(--background-color); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/components/tab-panels/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; 4 | 5 | type OwnProps = { 6 | tabNames: string[]; 7 | }; 8 | 9 | export class TabPanels extends Component { 10 | static propTypes = { 11 | children: PropTypes.node.isRequired, 12 | className: PropTypes.string, 13 | tabNames: PropTypes.arrayOf(PropTypes.string).isRequired, 14 | }; 15 | 16 | render() { 17 | const { children, tabNames } = this.props; 18 | 19 | return ( 20 | 21 | 22 | {tabNames.map((tabName, key) => ( 23 | 24 | {tabName} 25 | 26 | ))} 27 | 28 | 29 |
30 | {React.Children.map(children, (tabPanel, key) => ( 31 | 32 |
{tabPanel}
33 |
34 | ))} 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | export default TabPanels; 42 | -------------------------------------------------------------------------------- /lib/components/tab-panels/style.scss: -------------------------------------------------------------------------------- 1 | .tab-panels__tab-list { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: center; 5 | margin: 0; 6 | padding: 0; 7 | border-bottom: 1px solid var(--secondary-color); 8 | list-style: none; 9 | 10 | li { 11 | margin: 0 0.5em; 12 | border-bottom: 2px solid; 13 | padding: 0.75em; 14 | color: var(--accent-color); 15 | text-transform: capitalize; 16 | border-radius: 0; 17 | 18 | &.button { 19 | border-bottom-color: var(--secondary-color); 20 | 21 | &.is-active { 22 | color: var(--accent-color); 23 | border-bottom-color: var(--accent-color); 24 | } 25 | } 26 | 27 | &:active { 28 | color: var(--background-color); 29 | } 30 | } 31 | } 32 | 33 | .tab-panels__panel { 34 | height: 30.5em; 35 | overflow: auto; 36 | -webkit-overflow-scrolling: touch; 37 | } 38 | 39 | .tab-panels__column { 40 | max-width: 374px; 41 | margin: 0 auto; 42 | padding: 36px 10px 50px; 43 | } 44 | -------------------------------------------------------------------------------- /lib/components/tag-chip/__snapshots__/test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TagChip should not introduce visual regressions 1`] = ` 4 |
8 | spline 9 | 12 | 17 | 23 | 26 | 27 | 28 |
29 | `; 30 | -------------------------------------------------------------------------------- /lib/components/tag-chip/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import SmallCrossIcon from '../../icons/cross-small'; 3 | import classNames from 'classnames'; 4 | 5 | import type * as T from '../../types'; 6 | 7 | type OwnProps = { 8 | onSelect?: (event: React.MouseEvent) => any; 9 | selected?: boolean; 10 | interactive?: boolean; 11 | deleted?: boolean; 12 | tagName: T.TagName | undefined; 13 | }; 14 | 15 | const TagChip: FunctionComponent = ({ 16 | onSelect, 17 | selected = false, 18 | interactive = true, 19 | deleted = false, 20 | tagName, 21 | }) => ( 22 |
27 | {tagName} 28 | {interactive && ( 29 | 30 | 31 | 32 | )} 33 |
34 | ); 35 | 36 | export default TagChip; 37 | -------------------------------------------------------------------------------- /lib/components/tag-chip/style.scss: -------------------------------------------------------------------------------- 1 | .tag-chip { 2 | color: var(--primary-color); 3 | flex: none; 4 | margin: 2px 8px 6px 0; 5 | padding: 1px 14px 3px; 6 | border-radius: 16px; 7 | line-height: 1.25em; 8 | white-space: nowrap; 9 | background: var(--primary-tag-chip-color); 10 | text-decoration: none; 11 | font-size: 14px; 12 | position: relative; 13 | -webkit-tap-highlight-color: transparent; 14 | 15 | &.selected, 16 | &:hover { 17 | .remove-tag-icon { 18 | display: inline-block; 19 | } 20 | } 21 | 22 | &.interactive { 23 | cursor: pointer; 24 | } 25 | 26 | &.deleted { 27 | background: var(--secondary-tag-chip-color); 28 | } 29 | 30 | .remove-tag-icon { 31 | display: none; 32 | position: absolute; 33 | background-color: var(--foreground-color); 34 | color: var(--background-color); 35 | border-radius: 50%; 36 | top: -8px; 37 | width: 16px; 38 | height: 16px; 39 | line-height: 14px; 40 | font-size: 14px; 41 | text-align: center; 42 | 43 | .icon-cross-small { 44 | height: 14px; 45 | width: 14px; 46 | position: absolute; 47 | left: 1px; 48 | top: 1px; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/components/transition-delay-enter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FunctionComponent, 3 | useEffect, 4 | useState, 5 | ReactNode, 6 | } from 'react'; 7 | import { CSSTransition } from 'react-transition-group'; 8 | 9 | type OwnProps = { 10 | children: ReactNode; 11 | delay: number; 12 | }; 13 | 14 | /** 15 | * A wrapper to delay the mounting of children. 16 | * 17 | * Useful for progress bars and spinners, that should generally have about a 18 | * 1000 ms delay before displaying to the user. 19 | */ 20 | const TransitionDelayEnter: FunctionComponent = ({ 21 | children, 22 | delay = 1000, 23 | }) => { 24 | const [shouldRender, setShouldRender] = useState(false); 25 | 26 | useEffect(() => { 27 | const timer = window.setTimeout(() => { 28 | setShouldRender(true); 29 | }, delay); 30 | 31 | return (): void => window.clearTimeout(timer); 32 | }, []); 33 | 34 | return ( 35 | 42 | {children} 43 | 44 | ); 45 | }; 46 | 47 | export default TransitionDelayEnter; 48 | -------------------------------------------------------------------------------- /lib/components/transition-delay-enter/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/mixins'; 2 | 3 | .transition-delay-enter { 4 | @include mixins.react-transition-fade-in; 5 | } 6 | -------------------------------------------------------------------------------- /lib/components/transition-fade-in-out/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactNode } from 'react'; 2 | import { CSSTransition } from 'react-transition-group'; 3 | 4 | type OwnProps = { 5 | children: ReactNode; 6 | shouldMount: boolean; 7 | wrapperClassName?: string; 8 | }; 9 | 10 | const TransitionFadeInOut: FunctionComponent = ({ 11 | children, 12 | shouldMount, 13 | wrapperClassName = '', 14 | }) => { 15 | return ( 16 | 23 |
{children}
24 |
25 | ); 26 | }; 27 | 28 | export default TransitionFadeInOut; 29 | -------------------------------------------------------------------------------- /lib/components/transition-fade-in-out/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/mixins'; 2 | 3 | .transition-fade-in-out { 4 | @include mixins.react-transition-fade-in; 5 | @include mixins.react-transition-fade-out; 6 | } 7 | -------------------------------------------------------------------------------- /lib/connection-status/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Tooltip } from '@mui/material'; 4 | import ConnectionIcon from '../icons/connection'; 5 | import NoConnectionIcon from '../icons/no-connection'; 6 | 7 | import * as S from '../state'; 8 | import * as T from '../types'; 9 | 10 | import './style'; 11 | 12 | type StateProps = { 13 | connectionStatus: T.ConnectionState; 14 | }; 15 | 16 | type Props = StateProps; 17 | 18 | export const ConnectionStatus: FunctionComponent = ({ 19 | connectionStatus, 20 | }) => ( 21 |
22 | 33 |

34 | {connectionStatus === 'green' ? ( 35 | 36 | ) : ( 37 | 38 | )} 39 | Server connection 40 |

41 |
42 |
43 | ); 44 | 45 | const mapStateToProps: S.MapState = (state) => ({ 46 | connectionStatus: state.simperium.connectionStatus, 47 | }); 48 | 49 | export default connect(mapStateToProps)(ConnectionStatus); 50 | -------------------------------------------------------------------------------- /lib/connection-status/style.scss: -------------------------------------------------------------------------------- 1 | .server-connection__label { 2 | margin-left: 6px; 3 | } 4 | -------------------------------------------------------------------------------- /lib/controls/checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import CheckedCheckbox from '../../icons/checkbox-checked'; 5 | import UncheckedCheckbox from '../../icons/checkbox-unchecked'; 6 | 7 | type OwnProps = React.HTMLProps & { 8 | className?: string; 9 | onChange: () => any; 10 | isStandard?: boolean; 11 | }; 12 | 13 | function CheckboxControl({ 14 | className, 15 | isStandard, 16 | checked, 17 | ...props 18 | }: OwnProps) { 19 | return ( 20 | 26 | 27 | {!isStandard ? ( 28 | 29 | 30 | 31 | ) : checked ? ( 32 | 33 | ) : ( 34 | 35 | )} 36 | 37 | ); 38 | } 39 | 40 | export default CheckboxControl; 41 | -------------------------------------------------------------------------------- /lib/controls/checkbox/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/variables' as *; 2 | 3 | .checkbox-control { 4 | -webkit-touch-callout: none; 5 | width: 16px; 6 | height: 16px; 7 | position: relative; 8 | display: inline-block; 9 | vertical-align: middle; 10 | overflow: hidden; 11 | 12 | &:focus-within { 13 | outline: $focus-outline; 14 | } 15 | 16 | input { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | height: 100%; 22 | 23 | /* hide the checkbox */ 24 | margin: 0; 25 | padding: 0; 26 | opacity: 0; 27 | border: none; 28 | outline: none; 29 | background: none; 30 | appearance: none; 31 | } 32 | 33 | .checkbox-control-base, 34 | .checkbox-control-checked { 35 | display: block; 36 | position: absolute; 37 | top: 0; 38 | left: 0; 39 | width: 100%; 40 | height: 100%; 41 | border-radius: 50%; 42 | } 43 | 44 | .checkbox-control-base { 45 | pointer-events: none; 46 | border-radius: 50%; 47 | overflow: hidden; 48 | border: 1px solid var(--secondary-color); 49 | } 50 | 51 | .checkbox-control-checked { 52 | background: var(--active-controls-color); 53 | transition: all 0.15s ease; 54 | opacity: 0; 55 | } 56 | 57 | input:checked + .checkbox-control-base { 58 | border: none; 59 | 60 | .checkbox-control-checked { 61 | opacity: 1; 62 | } 63 | } 64 | 65 | &.checkbox-standard { 66 | width: 18px; 67 | height: 18px; 68 | 69 | svg { 70 | vertical-align: top; 71 | fill: var(--foreground-color); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/controls/toggle/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler, FunctionComponent } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | type OwnProps = Partial & { 5 | onChange: (isNowToggled: boolean) => any; 6 | }; 7 | 8 | type Props = OwnProps; 9 | 10 | export const ToggleControl: FunctionComponent = ({ 11 | className, 12 | onChange, 13 | ...props 14 | }) => { 15 | const onToggle: ChangeEventHandler = ({ 16 | currentTarget: { checked }, 17 | }) => onChange(checked); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default ToggleControl; 32 | -------------------------------------------------------------------------------- /lib/dialog-renderer/style.scss: -------------------------------------------------------------------------------- 1 | .dialog-renderer__overlay { 2 | position: fixed; 3 | inset: 0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | background: var(--overlay-color); 8 | } 9 | 10 | .dialog-renderer__content { 11 | &:focus { 12 | outline: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | import CrossIconSmall from '../icons/cross-small'; 6 | 7 | export class Dialog extends Component { 8 | static propTypes = { 9 | children: PropTypes.node.isRequired, 10 | className: PropTypes.string, 11 | closeBtnLabel: PropTypes.string, 12 | hideTitleBar: PropTypes.bool, 13 | title: PropTypes.string, 14 | onDone: PropTypes.func, 15 | }; 16 | 17 | render() { 18 | const { 19 | className, 20 | closeBtnLabel = 'Done', 21 | hideTitleBar, 22 | title, 23 | children, 24 | onDone, 25 | } = this.props; 26 | 27 | return ( 28 |
29 | {!hideTitleBar && ( 30 |
31 |

{title}

32 |
33 | {!!onDone && ( 34 | 42 | )} 43 |
44 |
45 | )} 46 | 47 |
{children}
48 |
49 | ); 50 | } 51 | } 52 | 53 | export default Dialog; 54 | -------------------------------------------------------------------------------- /lib/dialog/style.scss: -------------------------------------------------------------------------------- 1 | $dialog-max-height: 420px; 2 | $dialog-title-height: 56px; 3 | 4 | .dialog { 5 | display: flex; 6 | flex-direction: column; 7 | width: calc(100vw - 2rem); 8 | background: var(--background-color); 9 | border-radius: 8px; 10 | max-height: $dialog-max-height; 11 | 12 | @media only screen and (max-height: $dialog-max-height) { 13 | max-height: calc(100vh - 2rem); 14 | } 15 | } 16 | 17 | .dialog-title-bar { 18 | display: flex; 19 | border-bottom: 1px solid var(--secondary-color); 20 | height: $dialog-title-height; 21 | 22 | .button { 23 | border: 0; 24 | } 25 | } 26 | 27 | .dialog-title-side { 28 | display: flex; 29 | flex: none; 30 | width: 3.5em; 31 | 32 | button { 33 | width: 100%; 34 | margin: 0 auto; 35 | } 36 | } 37 | 38 | .dialog-title-text { 39 | color: var(--primary-color); 40 | flex: 1 1 auto; 41 | margin: 15px 0 0 16px; 42 | text-align: left; 43 | font-weight: 600; 44 | font-size: 16px; 45 | } 46 | 47 | .dialog-content { 48 | display: flex; 49 | flex-direction: column; 50 | flex: 1 0 auto; 51 | max-height: $dialog-max-height - $dialog-title-height; 52 | overflow: auto; 53 | 54 | @media only screen and (max-height: $dialog-max-height) { 55 | max-height: calc(100vh - 2rem - #{$dialog-title-height}); 56 | } 57 | } 58 | 59 | .dialog-actions { 60 | margin: 1em 0 0; 61 | padding: 0; 62 | list-style: none; 63 | 64 | li + li { 65 | margin-top: 1em; 66 | } 67 | 68 | li:last-child { 69 | margin-top: 2.5em; 70 | } 71 | 72 | button { 73 | width: 100%; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/dialogs/beta-warning/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import SimplenoteLogo from '../../icons/simplenote'; 4 | import CrossIcon from '../../icons/cross'; 5 | import Dialog from '../../dialog'; 6 | import { closeDialog } from '../../state/ui/actions'; 7 | 8 | import * as S from '../../state'; 9 | 10 | type DispatchProps = { 11 | closeDialog: () => any; 12 | }; 13 | 14 | type Props = DispatchProps; 15 | 16 | export class BetaWarning extends Component { 17 | render() { 18 | const { closeDialog } = this.props; 19 | 20 | return ( 21 |
22 | 23 |
24 | 25 | 26 |

Simplenote

27 |
28 | 29 |

30 | This is a beta release of Simplenote. 31 |

32 | 33 |

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

37 | 38 |

39 | Please use with caution and the understanding that
40 | this comes without any stability guarantee. 41 |

42 | 43 | 51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | const mapDispatchToProps: S.MapDispatch = { 58 | closeDialog, 59 | }; 60 | 61 | export default connect(null, mapDispatchToProps)(BetaWarning); 62 | -------------------------------------------------------------------------------- /lib/dialogs/button-group/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SmallChevronRightIcon from '../../icons/chevron-right-small'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const ButtonGroup = (props) => { 6 | const { items, onClickItem } = props; 7 | 8 | return ( 9 |
    10 | {items.map((item) => ( 11 |
  • 12 | 16 |
  • 17 | ))} 18 |
19 | ); 20 | }; 21 | 22 | ButtonGroup.propTypes = { 23 | items: PropTypes.array.isRequired, 24 | onClickItem: PropTypes.func.isRequired, 25 | }; 26 | 27 | export default ButtonGroup; 28 | -------------------------------------------------------------------------------- /lib/dialogs/button-group/style.scss: -------------------------------------------------------------------------------- 1 | .button-group { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | 7 | .button-group__item { 8 | color: var(--primary-color); 9 | display: flex; 10 | height: 3em; 11 | margin: 0; 12 | border: 1px solid var(--secondary-color); 13 | 14 | & + & { 15 | border-top-width: 0; 16 | } 17 | 18 | button { 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | width: 100%; 23 | padding: 0 18px; 24 | text-align: left; 25 | font-size: 115%; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/dialogs/close-window-confirmation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import actions from '../../state/actions'; 5 | import UnsynchronizedConfirmation from '../unsynchronized'; 6 | 7 | import type * as S from '../../state'; 8 | 9 | type DispatchProps = { 10 | reallyCloseWindow: () => any; 11 | }; 12 | 13 | type Props = DispatchProps; 14 | 15 | const CloseWindowConfirmation = ({ reallyCloseWindow }: Props) => ( 16 | 22 | ); 23 | 24 | const mapDispatchToProps: S.MapDispatch = { 25 | reallyCloseWindow: actions.electron.reallyCloseWindow, 26 | }; 27 | 28 | export default connect(null, mapDispatchToProps)(CloseWindowConfirmation); 29 | -------------------------------------------------------------------------------- /lib/dialogs/import/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Suspense } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Dialog from '../../dialog'; 5 | import { isElectron } from '../../utils/platform'; 6 | import SourceImporter from './source-importer'; 7 | import { closeDialog } from '../../state/ui/actions'; 8 | 9 | import * as S from '../../state'; 10 | 11 | type DispatchProps = { 12 | closeDialog: () => any; 13 | }; 14 | 15 | type Props = DispatchProps; 16 | 17 | class ImportDialog extends Component { 18 | state = { 19 | importStarted: false, 20 | }; 21 | 22 | render() { 23 | const { closeDialog } = this.props; 24 | const { importStarted } = this.state; 25 | const source = { 26 | acceptedTypes: isElectron ? '.txt,.md,.json,.enex' : '.txt,.md,.json', 27 | title: `Select the notes you'd like to import.`, 28 | instructions: isElectron 29 | ? 'Accepted file formats: Simplenote (JSON), Text (TXT, MD) and Evernote (ENEX).' 30 | : 'Accepted file formats: Simplenote (JSON) and Text (TXT, MD).', 31 | multiple: true, 32 | }; 33 | 34 | return ( 35 | 41 |
42 | this.setState({ importStarted: true })} 46 | source={source} 47 | /> 48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | const mapDispatchToProps: S.MapDispatch = { 55 | closeDialog, 56 | }; 57 | 58 | export default connect(null, mapDispatchToProps)(ImportDialog); 59 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/executor/style.scss: -------------------------------------------------------------------------------- 1 | .source-importer-executor { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | flex: 1 0 auto; 6 | 7 | .import-progress { 8 | padding-left: 16px; 9 | padding-right: 16px; 10 | margin-top: 20px; 11 | } 12 | 13 | p[role='status'] { 14 | margin-top: 8px; 15 | margin-bottom: 5px; 16 | padding-left: 0; 17 | padding-right: 0; 18 | } 19 | } 20 | 21 | .source-importer-executor__options { 22 | flex: 1 0 auto; 23 | 24 | .panel-title { 25 | color: var(--foreground-color); 26 | text-transform: none; 27 | font-weight: normal; 28 | font-size: 14px; 29 | } 30 | 31 | label { 32 | display: flex; 33 | border-top: 1px solid var(--secondary-color); 34 | border-bottom: 1px solid var(--secondary-color); 35 | align-items: center; 36 | font-size: 16px; 37 | height: 28px; 38 | line-height: 28px; 39 | } 40 | 41 | .enable-markdown { 42 | color: var(--primary-color); 43 | flex: 1 1 auto; 44 | line-height: 28px; 45 | } 46 | 47 | .toggle-control { 48 | flex: none; 49 | } 50 | } 51 | 52 | .source-importer-executor__error { 53 | margin-bottom: 0.75em; 54 | color: var(--tertiary-highlight-color); 55 | line-height: 1.3; 56 | } 57 | 58 | .source-importer-executor__button { 59 | height: 56px; 60 | text-align: right; 61 | padding-left: 16px; 62 | padding-right: 16px; 63 | 64 | button { 65 | border-radius: 4px; 66 | padding: 6px 14px; 67 | margin-top: 14px; 68 | line-height: 16px; 69 | font-size: 14px; 70 | border: 0; 71 | font-weight: 400; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/progress/bar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ProgressBar from '../../../../components/progress-bar'; 5 | 6 | class ImportProgressBar extends React.Component { 7 | static propTypes = { 8 | currentValue: PropTypes.number.isRequired, 9 | endValue: PropTypes.number, 10 | isDone: PropTypes.bool.isRequired, 11 | }; 12 | 13 | shouldComponentUpdate(nextProps) { 14 | if (!this.props.endValue && this.props.isDone === nextProps.isDone) { 15 | return false; 16 | } 17 | return true; 18 | } 19 | 20 | render() { 21 | const { currentValue, endValue, isDone } = this.props; 22 | 23 | const IndeterminateProgressBar = () => { 24 | return isDone ? ( 25 | 26 | ) : ( 27 | 28 | ); 29 | }; 30 | 31 | if (endValue) { 32 | return ( 33 | 37 | ); 38 | } else { 39 | return ; 40 | } 41 | } 42 | } 43 | 44 | export default ImportProgressBar; 45 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/progress/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | import ImportProgressBar from './bar'; 4 | import ImportProgressText from './text'; 5 | 6 | type OwnProps = { 7 | currentValue?: number; 8 | endValue?: number; 9 | isDone: boolean; 10 | }; 11 | 12 | const ImportProgress: FunctionComponent = ({ 13 | currentValue = 0, 14 | endValue = 0, 15 | isDone, 16 | }) => { 17 | return ( 18 |
19 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default ImportProgress; 30 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/progress/text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ImportProgressText = (props) => { 5 | const { currentValue, isDone } = props; 6 | 7 | const unit = currentValue === 1 ? 'note' : 'notes'; 8 | let text; 9 | 10 | if (isDone) { 11 | text = `Done! ${currentValue} ${unit} imported.`; 12 | } else { 13 | text = currentValue 14 | ? `${currentValue} ${unit} imported...` 15 | : 'Importing...'; 16 | } 17 | 18 | return ( 19 |

20 | {text} 21 |

22 | ); 23 | }; 24 | 25 | ImportProgressText.propTypes = { 26 | currentValue: PropTypes.number.isRequired, 27 | isDone: PropTypes.bool.isRequired, 28 | }; 29 | 30 | export default ImportProgressText; 31 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/style.scss: -------------------------------------------------------------------------------- 1 | .source-importer, 2 | .source-importer__executor-wrapper { 3 | display: flex; 4 | flex-direction: column; 5 | flex: 1 0 auto; 6 | } 7 | 8 | .source-importer { 9 | .dialog-buttons { 10 | height: 56px; 11 | margin: 0 16px; 12 | text-align: right; 13 | 14 | button.disabled { 15 | border-radius: 4px; 16 | color: var(--primary-button-fg-color); 17 | padding: 6px 14px; 18 | margin-top: 14px; 19 | line-height: 16px; 20 | font-size: 14px; 21 | border: 0; 22 | background-color: var(--tertiary-color); 23 | opacity: 1; 24 | font-weight: 400; 25 | } 26 | } 27 | 28 | .importer-dropzone { 29 | margin: auto 16px 10px; 30 | 31 | .accepted-files-header { 32 | height: 28px; 33 | line-height: 28px; 34 | margin-top: 10px; 35 | font-size: 14px; 36 | } 37 | 38 | &.is-accepted + .dialog-buttons { 39 | display: none; 40 | } 41 | 42 | &.is-accepted { 43 | margin-left: 0; 44 | margin-right: 0; 45 | 46 | .accepted-files-header, 47 | .accepted-files { 48 | padding-left: 16px; 49 | padding-right: 16px; 50 | } 51 | } 52 | } 53 | 54 | p { 55 | padding-left: 16px; 56 | padding-right: 16px; 57 | color: var(--primary-color); 58 | } 59 | 60 | .source-importer-executor { 61 | h3, 62 | label { 63 | padding-left: 16px; 64 | padding-right: 16px; 65 | } 66 | } 67 | 68 | h3 { 69 | height: 28px; 70 | line-height: 28px; 71 | margin-bottom: 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/dialogs/import/source-importer/utils/test-importer.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | /** 4 | * A mock event emitter for testing/tweaking the UI. 5 | * @class 6 | */ 7 | class TestImporter extends EventEmitter { 8 | constructor(arg) { 9 | super(); 10 | console.log(arg); // eslint-disable-line no-console 11 | } 12 | 13 | importNotes(files) { 14 | console.log(files); // eslint-disable-line no-console 15 | 16 | let count = 0; 17 | const counter = window.setInterval(() => { 18 | this.emit('status', 'progress', ++count); 19 | }, 10); 20 | window.setTimeout(() => { 21 | window.clearInterval(counter); 22 | // Swap commented-out lines to test success/error events 23 | // this.emit('status', 'error', 'Error processing data.'); 24 | this.emit('status', 'complete', count); 25 | }, 1000); 26 | } 27 | } 28 | 29 | export default TestImporter; 30 | -------------------------------------------------------------------------------- /lib/dialogs/import/style.scss: -------------------------------------------------------------------------------- 1 | .import { 2 | max-width: 360px; 3 | max-height: 420px; 4 | 5 | .dialog-title-text { 6 | text-align: left; 7 | margin-left: 16px; 8 | } 9 | 10 | .dialog-title-side:first-child { 11 | display: none; 12 | } 13 | } 14 | 15 | .import__inner, 16 | .import__source-importer-wrapper { 17 | display: flex; 18 | flex-direction: column; 19 | flex: 1 0 auto; 20 | } 21 | 22 | .import__inner { 23 | width: 100%; 24 | overflow-y: auto; 25 | max-height: 360px; 26 | } 27 | 28 | .import__placeholder { 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | flex: 1 0 auto; 33 | } 34 | -------------------------------------------------------------------------------- /lib/dialogs/keybindings/style.scss: -------------------------------------------------------------------------------- 1 | .keybindings { 2 | .dialog { 3 | max-width: 500px; 4 | margin: auto; 5 | max-height: calc(100vh - 2rem); 6 | } 7 | 8 | .dialog-content { 9 | color: var(--primary-color); 10 | padding: 10px 20px 20px; 11 | max-height: calc(100vh - 2rem - 56px); 12 | } 13 | 14 | ul { 15 | padding-left: 0; 16 | } 17 | 18 | li { 19 | margin-bottom: 6px; 20 | list-style: none; 21 | } 22 | 23 | kbd { 24 | border: 1px solid var(--primary-color); 25 | border-radius: 5px; 26 | margin: 4px; 27 | padding: 0 5px; 28 | font-size: 12px; 29 | min-width: 8px; 30 | text-align: center; 31 | display: inline-block; 32 | box-shadow: 0 1px 1px gray; 33 | } 34 | } 35 | 36 | .keybindings__key-item { 37 | display: flex; 38 | align-items: baseline; 39 | } 40 | 41 | .keybindings__key-list { 42 | min-width: 180px; 43 | display: inline-block; 44 | text-align: right; 45 | } 46 | 47 | .keybindings__key-description { 48 | display: inline-block; 49 | margin-left: 4px; 50 | } 51 | 52 | .keybindings__sections { 53 | position: relative; 54 | max-height: 480px; 55 | overflow-y: auto; 56 | 57 | section { 58 | margin-right: 2em; 59 | 60 | h1 { 61 | font-size: 18px; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/dialogs/logout-confirmation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import actions from '../../state/actions'; 5 | import UnsynchronizedConfirmation from '../unsynchronized'; 6 | 7 | import type * as S from '../../state'; 8 | 9 | type DispatchProps = { 10 | reallyLogOut: () => any; 11 | }; 12 | 13 | type Props = DispatchProps; 14 | 15 | const LogoutConfirmation = ({ reallyLogOut }: Props) => ( 16 | 22 | ); 23 | 24 | const mapDispatchToProps: S.MapDispatch = { 25 | reallyLogOut: actions.ui.reallyLogOut, 26 | }; 27 | 28 | export default connect(null, mapDispatchToProps)(LogoutConfirmation); 29 | -------------------------------------------------------------------------------- /lib/dialogs/radio-settings-group.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import CheckboxControl from '../controls/checkbox'; 5 | 6 | const RadioGroup = ({ groupSlug, slug, isEnabled, onChange }) => ( 7 | onChange(slug)} 14 | /> 15 | ); 16 | 17 | RadioGroup.propTypes = { 18 | groupSlug: PropTypes.string, 19 | slug: PropTypes.string, 20 | isEnabled: PropTypes.bool.isRequired, 21 | onChange: PropTypes.func.isRequired, 22 | }; 23 | 24 | export default RadioGroup; 25 | -------------------------------------------------------------------------------- /lib/dialogs/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Dialog from '../../dialog'; 5 | import TabPanels from '../../components/tab-panels'; 6 | 7 | import AccountPanel from './panels/account'; 8 | import DisplayPanel from './panels/display'; 9 | import ToolsPanel from './panels/tools'; 10 | 11 | import { closeDialog } from '../../state/ui/actions'; 12 | 13 | const settingTabs = ['account', 'display', 'tools']; 14 | 15 | type DispatchProps = { 16 | closeDialog: () => any; 17 | }; 18 | 19 | type Props = DispatchProps; 20 | 21 | export const SettingsDialog: FunctionComponent = ({ closeDialog }) => ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | export default connect(null, { closeDialog })(SettingsDialog); 32 | -------------------------------------------------------------------------------- /lib/dialogs/settings/style.scss: -------------------------------------------------------------------------------- 1 | .settings { 2 | max-width: 630px; 3 | 4 | .button-borderless { 5 | color: var(--accent-color); 6 | } 7 | 8 | &.dialog { 9 | max-height: calc(100vh - 2rem); 10 | 11 | .dialog-content { 12 | max-height: calc(100vh - 2rem - 56px); 13 | } 14 | } 15 | 16 | input[type='radio'] { 17 | cursor: pointer; 18 | } 19 | 20 | .settings-account-name { 21 | font-size: 115%; 22 | text-align: center; 23 | width: 100%; 24 | } 25 | 26 | .settings-writing { 27 | padding-top: 48px; 28 | } 29 | } 30 | 31 | .settings-group { 32 | margin-bottom: 50px; 33 | 34 | &:last-child { 35 | margin-bottom: 0; 36 | } 37 | 38 | p { 39 | line-height: normal; 40 | color: var(--settings-fg-color); 41 | } 42 | } 43 | 44 | .settings-items { 45 | border: 1px solid var(--secondary-color); 46 | color: var(--primary-color); 47 | } 48 | 49 | .settings-item { 50 | display: flex; 51 | align-items: center; 52 | height: 3em; 53 | padding-left: 18px; 54 | padding-right: 18px; 55 | border-bottom: 1px solid var(--secondary-color); 56 | 57 | &:last-child { 58 | border-bottom: none; 59 | } 60 | 61 | .settings-item-label { 62 | flex: 1 1 auto; 63 | align-items: center; 64 | font-size: 115%; 65 | } 66 | 67 | .settings-item-control { 68 | flex: none; 69 | } 70 | 71 | .settings-item-text-input { 72 | display: block; 73 | width: 100%; 74 | height: 100%; 75 | padding: 0.25em 0.15em; 76 | border: none; 77 | line-height: 1.5em; 78 | font-size: 115%; 79 | 80 | &:focus { 81 | position: relative; 82 | z-index: 1; 83 | outline: none; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/dialogs/share/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../../scss/variables' as *; 2 | 3 | .share-collaborators-heading { 4 | border-bottom: 1px solid var(--secondary-color); 5 | } 6 | 7 | .share-collaborators { 8 | margin: 0; 9 | padding: 0; 10 | list-style: none; 11 | line-height: 1.5em; 12 | } 13 | 14 | .share-collaborator { 15 | display: flex; 16 | align-items: center; 17 | margin: 20px 0; 18 | 19 | .share-collaborator-photo { 20 | flex: none; 21 | margin-right: 10px; 22 | width: 34px; 23 | height: 34px; 24 | border-radius: 50%; 25 | overflow: hidden; 26 | background: var(--secondary-color); 27 | } 28 | 29 | .share-collaborator-name { 30 | color: var(--primary-color); 31 | flex: 1 1 auto; 32 | font-size: 143%; 33 | font-weight: $light; 34 | line-height: 1.5em; 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | white-space: nowrap; 38 | } 39 | 40 | .share-collaborator-remove { 41 | flex: none; 42 | margin-left: 10px; 43 | opacity: 0; 44 | transform: $anim; 45 | 46 | .touch-enabled & { 47 | opacity: 1; 48 | } 49 | } 50 | 51 | &:hover .share-collaborator-remove, 52 | .share-collaborator-remove:focus { 53 | opacity: 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/dialogs/toggle-settings-group.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | import ToggleControl from '../controls/toggle'; 4 | 5 | type OwnProps = { 6 | groupSlug: string; 7 | slug: string; 8 | isEnabled: boolean; 9 | onChange: () => any; 10 | }; 11 | 12 | type Props = OwnProps; 13 | 14 | const ToggleGroup: FunctionComponent = ({ 15 | groupSlug, 16 | slug, 17 | isEnabled, 18 | onChange, 19 | }) => ( 20 | 27 | ); 28 | 29 | export default ToggleGroup; 30 | -------------------------------------------------------------------------------- /lib/dialogs/trash-tag-confirmation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import actions from '../../state/actions'; 5 | import Dialog from '../../dialog'; 6 | 7 | import type * as S from '../../state'; 8 | import type * as T from '../../types'; 9 | 10 | type OwnProps = { 11 | tagName: T.TagName; 12 | }; 13 | 14 | type DispatchProps = { 15 | closeDialog: () => any; 16 | trashTag: (tagName: T.TagName) => any; 17 | }; 18 | 19 | type Props = OwnProps & DispatchProps; 20 | 21 | const TrashTagConfirmation: FunctionComponent = ({ 22 | closeDialog, 23 | tagName, 24 | trashTag, 25 | }) => ( 26 | 31 |
32 | Are you sure you want to delete " 33 | {tagName}"? 34 |
35 |
36 | 43 |
44 |
45 | ); 46 | 47 | const mapDispatchToProps: S.MapDispatch = { 48 | trashTag: (tagName) => ({ 49 | type: 'TRASH_TAG', 50 | tagName, 51 | }), 52 | closeDialog: actions.ui.closeDialog, 53 | }; 54 | 55 | export default connect(null, mapDispatchToProps)(TrashTagConfirmation); 56 | -------------------------------------------------------------------------------- /lib/dialogs/trash-tag-confirmation/style.scss: -------------------------------------------------------------------------------- 1 | .trash-tag-confirmation { 2 | width: 360px; 3 | 4 | .dialog-title-text { 5 | text-align: left; 6 | padding-left: 16px; 7 | font-size: 16px; 8 | font-weight: 600; 9 | line-height: 56px; 10 | margin: 0; 11 | } 12 | 13 | .dialog-title-side:first-child { 14 | display: none; 15 | } 16 | 17 | .dialog-content { 18 | color: var(--primary-color); 19 | padding: 0 16px; 20 | 21 | .dialog-inner-content { 22 | margin: 10px 0; 23 | 24 | span.tag-name { 25 | word-break: break-all; 26 | } 27 | } 28 | } 29 | 30 | .icon-cross { 31 | width: 16px; 32 | height: 16px; 33 | } 34 | 35 | .dialog-title-bar, 36 | .dialog-buttons, 37 | .dialog-title-text, 38 | .dialog-title-side { 39 | height: 56px; 40 | } 41 | 42 | .dialog-buttons { 43 | text-align: right; 44 | } 45 | 46 | button.delete-tag { 47 | border-radius: 4px; 48 | padding: 6px 14px; 49 | margin-top: 14px; 50 | line-height: 16px; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/email-verification/style.scss: -------------------------------------------------------------------------------- 1 | .email-verification__overlay { 2 | position: fixed; 3 | inset: 0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | background: var(--overlay-color); 8 | } 9 | 10 | .email-verification__dismiss { 11 | display: flex; 12 | height: 60px; 13 | align-items: center; 14 | justify-content: flex-end; 15 | margin: 0 16px; 16 | width: 100%; 17 | 18 | svg { 19 | height: 16px; 20 | width: 16px; 21 | margin: auto 0; 22 | } 23 | } 24 | 25 | .email-verification__modal { 26 | align-items: center; 27 | background-color: var(--background-color); 28 | border-radius: 8px; 29 | color: var(--primary-color); 30 | display: flex; 31 | flex-direction: column; 32 | justify-content: center; 33 | margin: 20%; 34 | max-width: 420px; 35 | padding: 0 24px 16px; 36 | font-size: 16px; 37 | 38 | .icon-mail, 39 | .icon-warning { 40 | color: var(--foreground-color); 41 | height: 48px; 42 | width: 48px; 43 | } 44 | 45 | p { 46 | margin: 0 26px; 47 | margin-block-start: 0; 48 | } 49 | 50 | p:not(:last-of-type) { 51 | padding-bottom: 20px; 52 | } 53 | 54 | .email-verification__button-row { 55 | padding-top: 10px; 56 | padding-bottom: 10px; 57 | display: flex; 58 | justify-content: flex-end; 59 | flex-flow: row wrap; 60 | width: 100%; 61 | 62 | a { 63 | padding-right: 12px; 64 | } 65 | 66 | .button-borderless { 67 | color: var(--accent-color); 68 | } 69 | } 70 | 71 | &:focus { 72 | outline: 0; 73 | } 74 | } 75 | 76 | .email-verification__email { 77 | word-break: break-all; 78 | } 79 | -------------------------------------------------------------------------------- /lib/error-boundary/style.scss: -------------------------------------------------------------------------------- 1 | .error-message { 2 | align-items: center; 3 | display: flex; 4 | flex-direction: column; 5 | flex: 1; 6 | height: 100vh; 7 | overflow-y: auto; 8 | padding: 2rem 1rem; 9 | } 10 | 11 | .error-message__content { 12 | align-items: center; 13 | font-size: 16px; 14 | max-width: 306px; 15 | margin: auto 0; 16 | text-align: center; 17 | } 18 | 19 | .error-message__icon { 20 | color: var(--foreground-color); 21 | height: 80px; 22 | margin: 0 auto 0.5rem; 23 | width: 80px; 24 | 25 | .icon-warning { 26 | height: 100%; 27 | width: 100%; 28 | } 29 | } 30 | 31 | .error-message__heading { 32 | font-size: 36px; 33 | margin: 0 0 0.25em; 34 | text-align: center; 35 | } 36 | 37 | .error-message p { 38 | margin: 0 0 1.67em; 39 | } 40 | 41 | .error-message__action { 42 | margin-bottom: 1.67em; 43 | } 44 | 45 | .error-message__footnote { 46 | color: var(--foreground-color); 47 | font-size: 14px; 48 | } 49 | -------------------------------------------------------------------------------- /lib/global.d.ts: -------------------------------------------------------------------------------- 1 | import { TKQItem, TracksAPI } from './analytics/types'; 2 | import { compose } from 'redux'; 3 | 4 | import { electronAPI } from './preload'; 5 | 6 | import * as S from './state'; 7 | 8 | declare global { 9 | const __TEST__: boolean; 10 | const config: { 11 | app_engine_url: string; 12 | app_id: string; 13 | app_key: string; 14 | development: boolean; 15 | is_app_engine: string; 16 | version: string; 17 | wpcc_client_id: string; 18 | wpcc_redirect_url: string; 19 | }; 20 | 21 | interface Window { 22 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; 23 | analyticsEnabled: boolean; 24 | electron: typeof electronAPI; 25 | location: Location; 26 | testEvents: (string | [string, ...any[]])[]; 27 | _tkq: TKQItem[] & { a: unknown }; 28 | webConfig?: { 29 | signout?: (callback: () => void) => void; 30 | }; 31 | wpcom: { 32 | tracks: TracksAPI; 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/icon-button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ElementType } from 'react'; 2 | import { Tooltip } from '@mui/material'; 3 | 4 | type OwnProps = { 5 | disableTooltip: boolean; 6 | icon: ElementType; 7 | title: string; 8 | }; 9 | 10 | type Props = OwnProps; 11 | 12 | export const IconButton = ({ icon, title, ...props }: Props) => ( 13 | 18 | 19 | 28 | 29 | 30 | ); 31 | 32 | export default IconButton; 33 | -------------------------------------------------------------------------------- /lib/icon-button/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .icon-button { 4 | width: 32px; 5 | height: 32px; 6 | color: var(--foreground-color); 7 | 8 | svg { 9 | transition: $anim-fast; 10 | } 11 | 12 | &:disabled { 13 | opacity: 0.4; 14 | pointer-events: none; 15 | } 16 | } 17 | 18 | .icon-button__tooltip { 19 | position: relative; 20 | top: -8px; 21 | font-size: 14px !important; 22 | } 23 | -------------------------------------------------------------------------------- /lib/icons/app-icon/app-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/app-icon.ico -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_128x128.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_128x128@2x.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_16x16.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_16x16@2x.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_256x256.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_256x256@2x.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_32x32.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_32x32@2x.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_512x512.png -------------------------------------------------------------------------------- /lib/icons/app-icon/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/lib/icons/app-icon/icon_512x512@2x.png -------------------------------------------------------------------------------- /lib/icons/arrow-left.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function LeftArrowIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/arrow-top-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function TopRightArrowIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/attention.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function AlertIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/back.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function BackIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/check-list.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ChecklistIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/checkbox-checked.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CheckedCheckbox() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/checkbox-unchecked.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function UncheckedCheckbox() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/chevron-right-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallChevronRightIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/chevron-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ChevronRightIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/cloud-sync.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CloudSyncIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/cloud.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CloudIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/connection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function connection() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/cross-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallCrossIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/cross.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CrossIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/ellipsis-outline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function EllipsisOutlineIcon() { 4 | return ( 5 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /lib/icons/ellipsis.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function EllipsisIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/file-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallFileIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/help-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallHelpIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/info.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function InfoIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/mail.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function MailIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function MenuIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/new-note.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function NewNoteIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/no-connection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function connection() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/notes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function NotesIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/pinned-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallPinnedIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/pinned.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function PinnedIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/preview-stop.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function PreviewStopIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function PreviewIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/published-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallPublishedIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/reorder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ReorderIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/revisions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function RevisionsIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/search-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallSearchIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SettingsIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/share.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ShareIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SidebarIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/simplenote-compact.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SimplenoteCompactLogo() { 4 | return ( 5 | 6 | 7 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/simplenote.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SimplenoteLogo() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/icons/style.scss: -------------------------------------------------------------------------------- 1 | svg[class^='icon-'] { 2 | fill: currentcolor; 3 | height: 24px; 4 | width: 24px; 5 | } 6 | 7 | svg[class^='icon-'][class$='-small'] { 8 | height: 16px; 9 | width: 16px; 10 | } 11 | 12 | // Color for the Simplenote logo 13 | .logo path { 14 | fill: var(--primary-branding-color); 15 | } 16 | 17 | .logo circle { 18 | fill: var(--background-color); 19 | } 20 | -------------------------------------------------------------------------------- /lib/icons/sync-small.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SmallSyncIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/tag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function TagIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/tags.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function TagsIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/trash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type OwnProps = { 4 | onClick: (event: React.MouseEvent) => any; 5 | }; 6 | 7 | type Props = OwnProps; 8 | 9 | export default function TrashIcon({ onClick }: Props) { 10 | return ( 11 | 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/icons/untagged-notes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function UntaggedNotesIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/warning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function WarningIcon() { 4 | return ( 5 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/icons/wordpress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function WordPressLogo() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/logging-out.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import '../scss/style.scss'; 5 | 6 | class LoggingOut extends Component { 7 | systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches 8 | ? 'dark' 9 | : 'light'; 10 | 11 | componentDidMount() { 12 | document.body.dataset.theme = this.systemTheme; 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 |
25 | Logging out… 26 |
27 |
28 | ); 29 | } 30 | } 31 | 32 | export const boot = () => { 33 | render(, document.getElementById('root')); 34 | }; 35 | -------------------------------------------------------------------------------- /lib/menu-bar/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .menu-bar { 4 | height: $toolbar-height; 5 | padding: 0 15px; 6 | border-bottom: 1px solid var(--secondary-color); 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | flex: 0 0 auto; 11 | background-color: var(--background-color); 12 | 13 | .notes-title { 14 | font-size: 16px; 15 | font-weight: 500; 16 | flex: 0 1 auto; 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | color: var(--primary-color); 20 | } 21 | 22 | .icon-button { 23 | flex: 0 0 auto; 24 | } 25 | 26 | .icon-button svg.icon-menu { 27 | width: 24px; 28 | height: 24px; 29 | } 30 | 31 | .button { 32 | padding: 0; 33 | width: 32px; 34 | height: 32px; 35 | } 36 | 37 | .icon-button:disabled { 38 | svg { 39 | cursor: default; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/navigation-bar/item/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | export const NavigationBarItem = ({ 6 | icon, 7 | isSelected = false, 8 | label, 9 | onClick, 10 | }) => { 11 | const classes = classNames('navigation-bar-item', { 12 | 'is-selected': isSelected, 13 | }); 14 | 15 | return ( 16 |
17 | 21 |
22 | ); 23 | }; 24 | 25 | NavigationBarItem.propTypes = { 26 | icon: PropTypes.element.isRequired, 27 | isSelected: PropTypes.bool, 28 | label: PropTypes.string.isRequired, 29 | onClick: PropTypes.func.isRequired, 30 | }; 31 | 32 | export default NavigationBarItem; 33 | -------------------------------------------------------------------------------- /lib/navigation-bar/item/style.scss: -------------------------------------------------------------------------------- 1 | .navigation-bar-item { 2 | padding-left: 16px; 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | text-align: left; 6 | 7 | .button { 8 | border: 0; 9 | border-top: 1px solid var(--secondary-color); 10 | border-radius: 0; 11 | color: var(--primary-color); 12 | font-size: 16px; 13 | font-weight: 400; 14 | height: 44px; 15 | padding: 0; 16 | text-align: left; 17 | width: 100%; 18 | 19 | &:active { 20 | background: none; 21 | } 22 | } 23 | 24 | &:hover:not(.is-selected) { 25 | background: var(--secondary-highlight-color); 26 | } 27 | 28 | &.is-selected { 29 | background-color: var(--highlight-color); 30 | 31 | button { 32 | border-top: none; 33 | 34 | svg[class^='icon-'] { 35 | fill: var(--accent-color); 36 | } 37 | } 38 | } 39 | 40 | .navigation-bar-item__icon { 41 | color: var(--foreground-color); 42 | display: inline-block; 43 | margin-right: 18px; 44 | vertical-align: middle; 45 | 46 | svg { 47 | vertical-align: middle; 48 | position: relative; 49 | top: -0.2em; 50 | } 51 | } 52 | } 53 | 54 | /* this is the last child (Settings) and prevents a double border on the bottom */ 55 | .navigation-bar-item:first-child .button { 56 | border-top: none !important; 57 | } 58 | 59 | .navigation-bar-item.is-selected + .navigation-bar-item .button { 60 | border-top: none; 61 | } 62 | -------------------------------------------------------------------------------- /lib/navigation-bar/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .navigation-bar { 4 | background-color: var(--background-color); 5 | display: flex; 6 | flex-direction: column; 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | padding-top: $toolbar-height; 11 | width: $navigation-bar-width; 12 | height: 100%; 13 | border-right: 1px solid var(--secondary-color); 14 | z-index: 2; 15 | transition: transform 200ms ease-in-out; 16 | } 17 | 18 | .navigation-bar__folders { 19 | display: flex; 20 | flex: none; 21 | flex-direction: column; 22 | border-bottom: 1px solid var(--secondary-color); 23 | } 24 | 25 | .navigation-bar__tags { 26 | display: flex; 27 | height: 100%; 28 | flex-direction: column; 29 | flex: 0 1 auto; 30 | overflow: hidden; 31 | min-height: 9em; 32 | padding: 12px 0 0; 33 | } 34 | 35 | .navigation-bar__untagged { 36 | border-top: 1px solid var(--secondary-color); 37 | } 38 | 39 | .navigation-bar__tools { 40 | flex: 1 0 auto; 41 | padding: 10px 0; 42 | border-top: 1px solid var(--secondary-color); 43 | } 44 | 45 | .navigation-bar__footer { 46 | color: var(--foreground-color); 47 | display: flex; 48 | flex: none; 49 | justify-content: flex-start; 50 | align-items: center; 51 | padding: 0 20px 20px; 52 | } 53 | 54 | .navigation-bar__server-connection { 55 | color: var(--foreground-color); 56 | display: flex; 57 | flex: none; 58 | justify-content: flex-start; 59 | align-items: center; 60 | padding: 0 20px; 61 | } 62 | 63 | .navigation-bar__footer-item { 64 | margin-right: 10px; 65 | 66 | &:last-child { 67 | margin-right: 0; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/note-editor/style.scss: -------------------------------------------------------------------------------- 1 | .note-editor { 2 | display: flex; 3 | flex-direction: column; 4 | flex: 1 0 auto; 5 | user-select: text; 6 | background-color: var(--background-color); 7 | } 8 | -------------------------------------------------------------------------------- /lib/note-info/references.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getNoteReferences } from '../utils/get-note-references'; 5 | 6 | import Reference from './reference'; 7 | 8 | import type * as S from '../state'; 9 | import type * as T from '../types'; 10 | 11 | type StateProps = { 12 | references: T.EntityId[]; 13 | }; 14 | 15 | type Props = StateProps; 16 | 17 | export const References: FunctionComponent = ({ references }) => { 18 | if (!references.length) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
24 |
25 |
Referenced In
26 | {references.map((noteId) => ( 27 | 28 | ))} 29 |
30 |
31 | ); 32 | }; 33 | 34 | const mapStateToProps: S.MapState = (state) => ({ 35 | references: getNoteReferences(state), 36 | }); 37 | 38 | export default connect(mapStateToProps)(References); 39 | -------------------------------------------------------------------------------- /lib/note-list/decorators.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { escapeRegExp } from 'lodash'; 3 | import replaceToArray from 'string-replace-to-array'; 4 | 5 | import { withoutTags } from '../utils/filter-notes'; 6 | 7 | export const decorateWith = (decorators, text) => 8 | decorators.length > 0 && text.length > 0 9 | ? decorators 10 | .reduce((output, { filter, replacer }) => { 11 | const searchText = 'string' === typeof filter && withoutTags(filter); 12 | const pattern = 13 | searchText && searchText.length > 0 14 | ? new RegExp(escapeRegExp(searchText), 'gi') 15 | : filter; 16 | 17 | return replaceToArray(output, pattern, replacer); 18 | }, text) 19 | .map((chunk, key) => 20 | chunk && 'string' !== typeof chunk 21 | ? React.cloneElement(chunk, { key }) 22 | : chunk 23 | ) 24 | : text; 25 | 26 | export const makeFilterDecorator = (filter) => ({ 27 | filter, 28 | replacer: (match) => { 29 | if (match.length) { 30 | return {match}; 31 | } 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /lib/note-list/get-note-title-and-preview.test.ts: -------------------------------------------------------------------------------- 1 | import { clearCache, withCache } from './get-note-title-and-preview'; 2 | 3 | describe('getNoteTitleAndPreview', () => { 4 | describe('withCache', () => { 5 | const getKey = (note) => note.mockKey; 6 | 7 | afterEach(() => { 8 | clearCache(); 9 | }); 10 | 11 | it('should only call getValue() when necessary', () => { 12 | const getValue = jest.fn(); 13 | 14 | withCache(getKey, getValue)({ id: 0, mockKey: 'foo' }); // should call 15 | withCache(getKey, getValue)({ id: 0, mockKey: 'foo' }); // shouldn't call 16 | expect(getValue).toHaveBeenCalledTimes(1); 17 | withCache(getKey, getValue)({ id: 0, mockKey: 'bar' }); // should call 18 | expect(getValue).toHaveBeenCalledTimes(2); 19 | }); 20 | 21 | it('should return the cached value', () => { 22 | const getValue = jest.fn().mockReturnValueOnce('mock value'); 23 | 24 | withCache(getKey, getValue)({ id: 0, mockKey: 'foo' }); 25 | const result = withCache(getKey, getValue)({ id: 0, mockKey: 'foo' }); 26 | expect(result).toBe('mock value'); 27 | }); 28 | 29 | it('should return an updated value when cache is invalidated', () => { 30 | const getValue = jest 31 | .fn() 32 | .mockReturnValueOnce('initial value') 33 | .mockReturnValueOnce('changed value'); 34 | 35 | withCache(getKey, getValue)({ id: 0, mockKey: 'foo' }); 36 | const result = withCache(getKey, getValue)({ id: 0, mockKey: 'bar' }); 37 | expect(result).toBe('changed value'); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/note-list/get-note-title-and-preview.ts: -------------------------------------------------------------------------------- 1 | import getNoteExcerpt from '../utils/note-utils'; 2 | 3 | /** @type {Map} stores a cache of computed note preview excerpts to prevent re-truncating note content */ 4 | const noteCache = new Map(); 5 | 6 | /** 7 | * Caches based on note id 8 | * 9 | * @param {Function} getKey Get the key used for invalidating the cache 10 | * @param {Function} getValue Get the value for the cache 11 | * @returns {Object} note title and preview excerpt 12 | */ 13 | export const withCache = (getKey, getValue) => (note) => { 14 | let cached = noteCache.get(note.id); 15 | const key = getKey(note); 16 | 17 | if ('undefined' === typeof cached || key !== cached.key) { 18 | const newCacheObj = { key, value: getValue(note) }; 19 | noteCache.set(note.id, newCacheObj); 20 | cached = newCacheObj; 21 | } 22 | return cached.value; 23 | }; 24 | 25 | export const clearCache = () => noteCache.clear(); 26 | 27 | const getNoteTitleAndPreview = withCache( 28 | (note) => note.data.content, 29 | getNoteExcerpt 30 | ); 31 | 32 | export default getNoteTitleAndPreview; 33 | -------------------------------------------------------------------------------- /lib/note-revisions/style.scss: -------------------------------------------------------------------------------- 1 | .note-revisions { 2 | background-color: var(--background-color); 3 | display: flex; 4 | flex-direction: column; 5 | flex: 1 1 auto; 6 | padding-top: 20px; 7 | 8 | .note-revisions-tag-list { 9 | display: flex; 10 | justify-content: flex-start; 11 | flex-wrap: wrap; 12 | line-height: 1.75em; 13 | white-space: nowrap; 14 | overflow: auto; 15 | max-height: calc(2.5 * 1.75em + 16px); // about 2.5 rows 16 | padding: 8px 12px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/search-field/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .search-field { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | width: 100%; 8 | padding: 6px 5px 5px 14px; 9 | border-bottom: 1px solid var(--secondary-color); 10 | height: $toolbar-height; 11 | background-color: var(--background-color); 12 | 13 | input { 14 | appearance: none; 15 | background-color: var(--background-color); 16 | border: 0; 17 | color: var(--primary-color); 18 | font-size: 16px; 19 | min-width: 0; // Firefox 20 | text-overflow: ellipsis !important; 21 | width: 100%; 22 | 23 | &:focus { 24 | outline: none; 25 | } 26 | } 27 | 28 | .icon-button { 29 | align-items: center; 30 | display: flex; 31 | flex: 0 0 auto; 32 | justify-content: center; 33 | } 34 | 35 | .icon-cross-small { 36 | transition: $anim-transition; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/search-results-bar/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .search-results { 4 | color: var(--primary-color); 5 | height: $toolbar-height; 6 | line-height: $toolbar-height; 7 | z-index: 100; 8 | border-top: 1px solid var(--secondary-color); 9 | background-color: var(--background-color); 10 | text-align: center; 11 | user-select: none; 12 | 13 | div { 14 | display: inline-block; 15 | } 16 | 17 | .search-results-next, 18 | .search-results-prev { 19 | float: right; 20 | padding: 0 6px; 21 | width: 42px; 22 | height: 100%; 23 | 24 | svg { 25 | fill: var(--accent-color); 26 | } 27 | } 28 | 29 | .search-results-next { 30 | margin-right: 6px; 31 | } 32 | 33 | .search-results-prev svg { 34 | transform: scaleX(-1); 35 | } 36 | 37 | @media only screen and (max-width: $single-column) { 38 | left: 0; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/state/actions.ts: -------------------------------------------------------------------------------- 1 | import * as analytics from './analytics/actions'; 2 | import * as data from './data/actions'; 3 | import * as electron from './electron/actions'; 4 | import * as settings from './settings/actions'; 5 | import * as simperium from './simperium/actions'; 6 | import * as ui from './ui/actions'; 7 | 8 | export default { 9 | analytics, 10 | data, 11 | electron, 12 | simperium, 13 | settings, 14 | ui, 15 | }; 16 | -------------------------------------------------------------------------------- /lib/state/analytics/actions.ts: -------------------------------------------------------------------------------- 1 | import * as A from '../action-types'; 2 | import * as T from '../../types'; 3 | 4 | export const withEvent = 5 | (eventName: string, eventProperties?: T.JSONSerializable) => (action) => ({ 6 | ...action, 7 | meta: { 8 | ...action.meta, 9 | analytics: [ 10 | ...(action.meta?.analytics ?? []), 11 | [eventName, eventProperties], 12 | ], 13 | }, 14 | }); 15 | 16 | export const recordEvent: A.ActionCreator = ( 17 | eventName: string, 18 | eventProperties?: T.JSONSerializable 19 | ) => 20 | withEvent( 21 | eventName, 22 | eventProperties 23 | )({ 24 | type: 'RECORD_EVENT', 25 | }); 26 | -------------------------------------------------------------------------------- /lib/state/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import * as A from '../action-types'; 4 | import * as S from '../'; 5 | 6 | /////////////////////////////////////// 7 | //// HELPERS 8 | /////////////////////////////////////// 9 | 10 | const getTheme = () => 11 | window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 12 | 13 | const getWidth = () => window.innerWidth; 14 | 15 | /////////////////////////////////////// 16 | //// REDUCERS 17 | /////////////////////////////////////// 18 | 19 | const systemTheme: A.Reducer<'light' | 'dark'> = ( 20 | state = getTheme(), 21 | action 22 | ) => (action.type === 'SYSTEM_THEME_UPDATE' ? action.prefers : state); 23 | 24 | const windowWidth: A.Reducer = (state = getWidth(), action) => 25 | action.type === 'WINDOW_RESIZE' ? action.innerWidth : state; 26 | 27 | /////////////////////////////////////// 28 | //// COMBINED 29 | /////////////////////////////////////// 30 | 31 | export const reducer = combineReducers({ 32 | windowWidth, 33 | systemTheme, 34 | }); 35 | 36 | export const middleware: S.Middleware = ({ dispatch }) => { 37 | window.addEventListener('resize', () => 38 | dispatch({ 39 | type: 'WINDOW_RESIZE', 40 | innerWidth: getWidth(), 41 | }) 42 | ); 43 | 44 | window.matchMedia('(prefers-color-scheme: dark)').addListener(() => 45 | dispatch({ 46 | type: 'SYSTEM_THEME_UPDATE', 47 | prefers: getTheme(), 48 | }) 49 | ); 50 | 51 | return (next) => next; 52 | }; 53 | -------------------------------------------------------------------------------- /lib/state/electron/actions.ts: -------------------------------------------------------------------------------- 1 | import * as A from '../action-types'; 2 | 3 | export const reallyCloseWindow: A.ActionCreator = () => ({ 4 | type: 'REALLY_CLOSE_WINDOW', 5 | }); 6 | -------------------------------------------------------------------------------- /lib/state/settings/actions.ts: -------------------------------------------------------------------------------- 1 | import * as A from '../action-types'; 2 | import * as T from '../../types'; 3 | 4 | export const activateTheme: A.ActionCreator = (theme: T.Theme) => ({ 5 | type: 'setTheme', 6 | theme, 7 | }); 8 | 9 | export const setNoteDisplay: A.ActionCreator = ( 10 | noteDisplay: T.ListDisplayMode 11 | ) => ({ 12 | type: 'setNoteDisplay', 13 | noteDisplay, 14 | }); 15 | 16 | export const setLineLength: A.ActionCreator = ( 17 | lineLength: T.LineLength 18 | ) => ({ 19 | type: 'setLineLength', 20 | lineLength, 21 | }); 22 | 23 | export const toggleKeyboardShortcuts: A.ActionCreator< 24 | A.ToggleKeyboardShortcuts 25 | > = () => ({ 26 | type: 'KEYBOARD_SHORTCUTS_TOGGLE', 27 | }); 28 | 29 | export const toggleSortOrder: A.ActionCreator = () => ({ 30 | type: 'TOGGLE_SORT_ORDER', 31 | }); 32 | 33 | export const setSortType: A.ActionCreator = ( 34 | sortType: T.SortType, 35 | sortReversed?: boolean 36 | ) => ({ 37 | type: 'setSortType', 38 | sortType, 39 | sortReversed, 40 | }); 41 | 42 | export const toggleSortTagsAlpha: A.ActionCreator< 43 | A.ToggleSortTagsAlpha 44 | > = () => ({ 45 | type: 'TOGGLE_SORT_TAGS_ALPHA', 46 | }); 47 | 48 | export const setAccountName: A.ActionCreator = ( 49 | accountName: string 50 | ) => ({ 51 | type: 'setAccountName', 52 | accountName, 53 | }); 54 | 55 | export const toggleFocusMode: A.ActionCreator = () => ({ 56 | type: 'TOGGLE_FOCUS_MODE', 57 | }); 58 | 59 | export const toggleSpellCheck: A.ActionCreator = () => ({ 60 | type: 'TOGGLE_SPELLCHECK', 61 | }); 62 | 63 | export const toggleAutoHideMenuBar: A.ActionCreator< 64 | A.ToggleAutoHideMenuBar 65 | > = () => ({ 66 | type: 'TOGGLE_AUTO_HIDE_MENU_BAR', 67 | }); 68 | -------------------------------------------------------------------------------- /lib/state/simperium/actions.ts: -------------------------------------------------------------------------------- 1 | import * as A from '../action-types'; 2 | import * as T from '../../types'; 3 | 4 | export const remoteNoteUpdate: A.ActionCreator = ( 5 | noteId: T.EntityId, 6 | note: T.Note 7 | ) => ({ 8 | type: 'REMOTE_NOTE_UPDATE', 9 | noteId, 10 | note, 11 | }); 12 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/connection-monitor.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'simperium'; 2 | 3 | import * as S from '../../'; 4 | 5 | export const start = (client: Client, { dispatch, getState }: S.Store) => { 6 | let lastMessageAt = -Infinity; 7 | 8 | client.on('message', () => { 9 | lastMessageAt = Date.now(); 10 | if (getState().simperium.connectionStatus !== 'green') { 11 | dispatch({ type: 'CHANGE_CONNECTION_STATUS', status: 'green' }); 12 | } 13 | }); 14 | 15 | setInterval(() => { 16 | const timeSinceLastMessage = Date.now() - lastMessageAt; 17 | const currentStatus = getState().simperium.connectionStatus; 18 | if (timeSinceLastMessage > 8000 && currentStatus === 'green') { 19 | dispatch({ type: 'CHANGE_CONNECTION_STATUS', status: 'red' }); 20 | } 21 | }, 1000); 22 | 23 | window.addEventListener('online', () => { 24 | if (getState().simperium.connectionStatus === 'offline') { 25 | dispatch({ type: 'CHANGE_CONNECTION_STATUS', status: 'red' }); 26 | } 27 | }); 28 | 29 | window.addEventListener('offline', () => { 30 | dispatch({ type: 'CHANGE_CONNECTION_STATUS', status: 'offline' }); 31 | }); 32 | 33 | client.on('disconnect', () => { 34 | dispatch({ 35 | type: 'CHANGE_CONNECTION_STATUS', 36 | status: navigator.onLine ? 'red' : 'offline', 37 | }); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/in-memory-bucket.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BucketObject, 3 | BucketStore, 4 | EntityCallback, 5 | EntitiesCallback, 6 | } from 'simperium'; 7 | import type * as T from '../../../types'; 8 | 9 | export class InMemoryBucket implements BucketStore { 10 | entities: Map; 11 | 12 | constructor() { 13 | this.entities = new Map(); 14 | } 15 | 16 | get(id: T.EntityId, callback: EntityCallback>) { 17 | callback(null, { id, data: this.entities.get(id) }); 18 | } 19 | 20 | find(query: {}, callback: EntitiesCallback>) { 21 | callback( 22 | null, 23 | [...this.entities].map(([id, data]) => ({ id, data })) 24 | ); 25 | } 26 | 27 | remove(id: T.EntityId, callback: (error: null) => void) { 28 | this.entities.delete(id); 29 | callback(null); 30 | } 31 | 32 | update( 33 | id: T.EntityId, 34 | data: U, 35 | isIndexing: boolean, 36 | callback: EntityCallback> 37 | ) { 38 | this.entities.set(id, data); 39 | callback(null, { id, data, isIndexing }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/in-memory-ghost.ts: -------------------------------------------------------------------------------- 1 | import { ChangeVersion, EntityId, GhostStore, Ghost } from 'simperium'; 2 | 3 | export class InMemoryGhost implements GhostStore { 4 | cv: ChangeVersion; 5 | entities: Map>; 6 | 7 | constructor() { 8 | this.cv = ''; 9 | this.entities = new Map(); 10 | } 11 | 12 | getChangeVersion(): Promise { 13 | return Promise.resolve(this.cv); 14 | } 15 | 16 | setChangeVersion(version: ChangeVersion): Promise { 17 | this.cv = version; 18 | return Promise.resolve(); 19 | } 20 | 21 | get(entityId: EntityId): Promise> { 22 | return Promise.resolve( 23 | this.entities.get(entityId) ?? ({ key: entityId, data: {} } as Ghost) 24 | ); 25 | } 26 | 27 | put(entityId: EntityId, version: number, data: U): Promise> { 28 | const ghost = { key: entityId, data, version }; 29 | this.entities.set(entityId, ghost); 30 | 31 | return Promise.resolve(ghost); 32 | } 33 | 34 | remove(entityId: EntityId): Promise> { 35 | const ghost = this.entities.get(entityId); 36 | this.entities.delete(entityId); 37 | 38 | return Promise.resolve(ghost); 39 | } 40 | 41 | eachGhost(iterator: (ghost: Ghost) => void) { 42 | this.entities.forEach((ghost) => iterator(ghost)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/note-bucket.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BucketObject, 3 | BucketStore, 4 | EntityCallback, 5 | EntitiesCallback, 6 | } from 'simperium'; 7 | import * as S from '../../'; 8 | import * as T from '../../../types'; 9 | 10 | export class NoteBucket implements BucketStore { 11 | store: S.Store; 12 | 13 | constructor(store: S.Store) { 14 | this.store = store; 15 | } 16 | 17 | get(noteId: T.EntityId, callback: EntityCallback>) { 18 | const note = this.store.getState().data.notes.get(noteId); 19 | 20 | callback(null, { id: noteId, data: note }); 21 | } 22 | 23 | find(query: {}, callback: EntitiesCallback>) { 24 | callback( 25 | null, 26 | [...this.store.getState().data.notes.entries()].map(([noteId, note]) => ({ 27 | id: noteId, 28 | data: note, 29 | })) 30 | ); 31 | } 32 | 33 | remove(noteId: T.EntityId, callback: (error: null) => void) { 34 | this.store.dispatch({ 35 | type: 'NOTE_BUCKET_REMOVE', 36 | noteId, 37 | }); 38 | callback(null); 39 | } 40 | 41 | update( 42 | noteId: T.EntityId, 43 | note: T.Note, 44 | isIndexing: boolean, 45 | callback: EntityCallback> 46 | ) { 47 | this.store.dispatch({ 48 | type: 'NOTE_BUCKET_UPDATE', 49 | noteId, 50 | note, 51 | isIndexing, 52 | }); 53 | callback(null, { id: noteId, data: note }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/preferences-bucket.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BucketObject, 3 | BucketStore, 4 | EntityCallback, 5 | EntitiesCallback, 6 | } from 'simperium'; 7 | import * as S from '../../'; 8 | import * as T from '../../../types'; 9 | 10 | export class PreferencesBucket implements BucketStore { 11 | store: S.Store; 12 | 13 | constructor(store: S.Store) { 14 | this.store = store; 15 | } 16 | 17 | get(id: T.EntityId, callback: EntityCallback>) { 18 | const data = this.store.getState().data.preferences.get(id); 19 | 20 | callback(null, { id, data }); 21 | } 22 | 23 | find(query: {}, callback: EntitiesCallback>) { 24 | callback( 25 | null, 26 | [...this.store.getState().data.preferences.entries()].map( 27 | ([id, data]) => ({ id, data }) 28 | ) 29 | ); 30 | } 31 | 32 | remove(id: T.EntityId, callback: (error: null) => void) { 33 | this.store.dispatch({ 34 | type: 'PREFERENCES_BUCKET_REMOVE', 35 | id, 36 | }); 37 | callback(null); 38 | } 39 | 40 | update( 41 | id: T.EntityId, 42 | data: T.Preferences, 43 | isIndexing: boolean, 44 | callback: EntityCallback> 45 | ) { 46 | this.store.dispatch({ 47 | type: 'PREFERENCES_BUCKET_UPDATE', 48 | id, 49 | data, 50 | isIndexing, 51 | }); 52 | callback(null, { id, data }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/tab-close-confirmation.ts: -------------------------------------------------------------------------------- 1 | import { getUnconfirmedChanges } from './unconfirmed-changes'; 2 | 3 | import type * as S from '../../'; 4 | 5 | export const confirmBeforeClosingTab = (store: S.Store) => { 6 | window.addEventListener('beforeunload', (event) => { 7 | const changes = getUnconfirmedChanges(store.getState()); 8 | if (changes.notes.length === 0) { 9 | return undefined; 10 | } 11 | 12 | event.preventDefault(); 13 | 14 | // this message is hidden by most browsers 15 | // and replaced by a generic message 16 | const message = 17 | 'There are unsynchronized changes - do you want to logout and lose those changes?'; 18 | event.returnValue = message; 19 | return message; 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/tag-bucket.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BucketObject, 3 | BucketStore, 4 | EntityCallback, 5 | EntitiesCallback, 6 | } from 'simperium'; 7 | import type * as S from '../../'; 8 | import type * as T from '../../../types'; 9 | 10 | export class TagBucket implements BucketStore { 11 | store: S.Store; 12 | 13 | constructor(store: S.Store) { 14 | this.store = store; 15 | } 16 | 17 | get(tagId: T.EntityId, callback: EntityCallback>) { 18 | const tag = this.store.getState().data.tags.get(tagId); 19 | 20 | callback(null, { id: tagId, data: tag }); 21 | } 22 | 23 | find(query: {}, callback: EntitiesCallback>) { 24 | callback( 25 | null, 26 | [...this.store.getState().data.tags.entries()].map(([tagHash, tag]) => ({ 27 | id: tagHash, 28 | data: tag, 29 | })) 30 | ); 31 | } 32 | 33 | remove(tagId: T.EntityId, callback: (error: null) => void) { 34 | this.store.dispatch({ 35 | type: 'TAG_BUCKET_REMOVE', 36 | tagHash: tagId, 37 | }); 38 | callback(null); 39 | } 40 | 41 | update( 42 | tagId: T.EntityId, 43 | tag: T.Tag, 44 | isIndexing: boolean, 45 | callback: EntityCallback> 46 | ) { 47 | this.store.dispatch({ 48 | type: 'TAG_BUCKET_UPDATE', 49 | tagHash: tagId, 50 | tag, 51 | isIndexing, 52 | }); 53 | callback(null, { id: tagId, data: tag }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/unconfirmed-changes.ts: -------------------------------------------------------------------------------- 1 | import { notesAreEqual } from '../../selectors'; 2 | 3 | import type * as S from '../../'; 4 | import type { EntityId } from 'simperium'; 5 | 6 | const getUnconfirmedNotes = (state: S.State): EntityId[] => { 7 | const notes: EntityId[] = []; 8 | 9 | state.data.notes.forEach((note, noteId) => { 10 | const ghost = state.simperium.ghosts[1].get('note')?.get(noteId); 11 | 12 | if (!ghost || !notesAreEqual(note, ghost.data)) { 13 | notes.push(noteId); 14 | } 15 | }); 16 | 17 | return notes; 18 | }; 19 | 20 | const getUnconfirmedPreferences = (state: S.State): EntityId[] => { 21 | return []; 22 | }; 23 | 24 | const getUnconfirmedTags = (state: S.State): EntityId[] => { 25 | return []; 26 | }; 27 | 28 | export const getUnconfirmedChanges = ( 29 | state: S.State 30 | ): Record<'notes' | 'preferences' | 'tags', EntityId[]> => { 31 | return { 32 | notes: getUnconfirmedNotes(state), 33 | preferences: getUnconfirmedPreferences(state), 34 | tags: getUnconfirmedTags(state), 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /lib/state/simperium/functions/username-monitor.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'simperium'; 2 | 3 | export const getAccountName = (client: Client): Promise => 4 | new Promise((resolve) => { 5 | const usernameMonitor = (message: string) => { 6 | if (!message.startsWith('0:auth:')) { 7 | return; 8 | } 9 | 10 | const [prefix, accountName] = message.split('0:auth:'); 11 | client.off('message', usernameMonitor); 12 | resolve(accountName); 13 | }; 14 | 15 | client.on('message', usernameMonitor); 16 | }); 17 | -------------------------------------------------------------------------------- /lib/state/ui/search-field-middleware.ts: -------------------------------------------------------------------------------- 1 | import * as A from '../action-types'; 2 | import * as S from '../'; 3 | 4 | const searchFields = new Set(); 5 | 6 | export const registerSearchField = (focus: Function) => searchFields.add(focus); 7 | 8 | export const middleware: S.Middleware = () => { 9 | return (next) => (action: A.ActionType) => { 10 | const result = next(action); 11 | 12 | switch (action.type) { 13 | case 'SEARCH': 14 | searchFields.forEach((focus) => focus()); 15 | break; 16 | 17 | case 'FOCUS_SEARCH_FIELD': 18 | searchFields.forEach((focus) => focus('select')); 19 | break; 20 | } 21 | 22 | return result; 23 | }; 24 | }; 25 | 26 | export default middleware; 27 | -------------------------------------------------------------------------------- /lib/tag-email-tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { showDialog } from '../state/ui/actions'; 5 | 6 | import * as S from '../state'; 7 | 8 | type DispatchProps = { 9 | openShareDialog: () => any; 10 | }; 11 | 12 | type Props = DispatchProps; 13 | 14 | export const EmailToolTip: FunctionComponent = ({ openShareDialog }) => ( 15 |
16 |
17 |
18 | Collaboration has moved. Press the Share icon in the toolbar to access 19 | the  20 | 21 | Collaborate screen 22 | 23 | . 24 |
25 |
26 | ); 27 | 28 | const mapDispatchToProps: S.MapDispatch = (dispatch) => ({ 29 | openShareDialog: () => dispatch(showDialog('SHARE')), 30 | }); 31 | 32 | export default connect(null, mapDispatchToProps)(EmailToolTip); 33 | -------------------------------------------------------------------------------- /lib/tag-email-tooltip/style.scss: -------------------------------------------------------------------------------- 1 | .tag-email-tooltip { 2 | position: absolute; 3 | bottom: 40px; 4 | padding: 5px 0; 5 | max-width: 360px; 6 | margin-left: 20px; 7 | margin-right: 20px; 8 | z-index: 100000; 9 | opacity: 0.99; 10 | } 11 | 12 | .tag-email-tooltip__arrow { 13 | position: absolute; 14 | width: 0; 15 | height: 0; 16 | border: solid transparent; 17 | opacity: 0.75; 18 | bottom: 0; 19 | left: 50px; 20 | margin-left: -5px; 21 | border-width: 5px 5px 0; 22 | border-top-color: #000; 23 | } 24 | 25 | .tag-email-tooltip__inside { 26 | padding: 6px 8px; 27 | color: #fff; 28 | text-align: center; 29 | border-radius: 3px; 30 | background-color: #000; 31 | opacity: 0.75; 32 | } 33 | -------------------------------------------------------------------------------- /lib/tag-field/style.scss: -------------------------------------------------------------------------------- 1 | .tag-editor { 2 | display: flex; 3 | justify-content: flex-start; 4 | flex-wrap: wrap; 5 | flex: 1 1 auto; 6 | line-height: 1.75em; 7 | white-space: nowrap; 8 | overflow: auto; 9 | max-height: calc(2.5 * 1.75em + 16px); // about 2.5 rows 10 | padding: 8px 12px; 11 | background-color: var(--background-color); 12 | 13 | &:focus { 14 | outline: none; 15 | } 16 | 17 | .hidden-tag { 18 | width: 0; 19 | height: 0; 20 | border: 0; 21 | padding: 0; 22 | opacity: 0; 23 | 24 | &:focus { 25 | outline: none; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/tag-input/style.scss: -------------------------------------------------------------------------------- 1 | @use '../../scss/variables' as *; 2 | 3 | .tag-input { 4 | cursor: text; 5 | position: relative; 6 | display: flex; 7 | flex: 1 0 auto; 8 | overflow: visible; 9 | min-height: 26px; 10 | color: var(--primary-color); 11 | 12 | input { 13 | width: 100%; 14 | height: 1.75em; 15 | min-width: 140px; 16 | outline: none; 17 | font: 14px/1.5 $sans; 18 | color: var(--primary-color); 19 | background: transparent; 20 | } 21 | } 22 | 23 | .tag-input__placeholder { 24 | position: absolute; 25 | display: block; 26 | background: transparent; 27 | user-select: none; 28 | color: var(--placeholder-color); 29 | } 30 | 31 | .tag-input__entry { 32 | min-width: 1px; // Needed for the caret to be visible in Safari 33 | 34 | & b, 35 | & strong { 36 | font-weight: normal; 37 | } 38 | 39 | & i, 40 | & em { 41 | font-style: normal; 42 | } 43 | 44 | &:focus { 45 | outline: none; 46 | } 47 | } 48 | 49 | .tag-input__suggestion { 50 | color: var(--foreground-color); 51 | } 52 | -------------------------------------------------------------------------------- /lib/tag-suggestions/style.scss: -------------------------------------------------------------------------------- 1 | .tag-suggestions { 2 | overflow-x: hidden; 3 | 4 | .tag-suggestions-list { 5 | list-style-type: none; 6 | padding: 0; 7 | margin: 0; 8 | border-bottom: 1px solid var(--secondary-color); 9 | 10 | .tag-suggestion-row { 11 | height: 36px; 12 | line-height: 36px; 13 | cursor: pointer; 14 | position: relative; 15 | padding: 0; 16 | } 17 | 18 | .tag-suggestion { 19 | border-color: var(--secondary-color); 20 | color: var(--primary-color); 21 | font-size: 16px; 22 | margin-left: 28px; 23 | overflow-x: hidden; 24 | text-overflow: ellipsis; 25 | } 26 | } 27 | 28 | .tag-suggestion-row:last-child .tag-suggestion { 29 | border-bottom: none; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/utils/crypto-random-string.ts: -------------------------------------------------------------------------------- 1 | import randomBytes from 'randombytes'; 2 | 3 | // Avoid using the npm `crypto-random-string` module, 4 | // since it requires the whole `crypto` module which will bloat 5 | // our bundle with large unused modules `elliptic` and `bn.js`. 6 | const cryptoRandomString = (len) => { 7 | if (!Number.isFinite(len)) { 8 | throw new TypeError('Expected a finite number'); 9 | } 10 | 11 | return randomBytes(Math.ceil(len / 2)) 12 | .toString('hex') 13 | .slice(0, len); 14 | }; 15 | 16 | export default cryptoRandomString; 17 | -------------------------------------------------------------------------------- /lib/utils/ensure-platform-support.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import BootWarning from '../components/boot-warning'; 5 | 6 | const hasLocalStorage = (): boolean => { 7 | try { 8 | localStorage.setItem('__localStorageSentinel__', 'present'); 9 | localStorage.removeItem('__localStorageSentinel__'); 10 | return true; 11 | } catch (e) { 12 | return false; 13 | } 14 | }; 15 | 16 | const deps = [['localStorage', hasLocalStorage()]] as const; 17 | 18 | const missingDeps = deps.filter(([, hasIt]) => !hasIt).map(([name]) => name); 19 | 20 | if (missingDeps.length) { 21 | render( 22 | 23 |

24 | Simplenote depends on a few web technologies to operate. Please make 25 | sure that you have all of the following enabled in your browser. 26 |

27 |
    28 | {deps.map(([name, hasIt]) => ( 29 |
  • 30 | {hasIt ? '✅' : '⚠️'} {name} - {hasIt ? 'working' : 'missing'} 31 |
  • 32 | ))} 33 |
34 |

35 | Many browsers disable some of these features in Private Mode. Simplenote 36 | does not currently support running in Private Mode. 37 |

38 |
, 39 | document.getElementById('root') 40 | ); 41 | throw new Error( 42 | `Simplenote is missing required dependencies: ${missingDeps.join(', ')}` 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /lib/utils/export/export-notes.ts: -------------------------------------------------------------------------------- 1 | import { partition, sortBy } from 'lodash'; 2 | 3 | import normalizeLineBreak from './normalize-line-break'; 4 | import isEmailTag from '../is-email-tag'; 5 | 6 | import * as T from '../../types'; 7 | import { ExportNote } from './types'; 8 | 9 | const exportNotes = (notes: Map) => { 10 | const activeNotes: ExportNote[] = []; 11 | const trashedNotes: ExportNote[] = []; 12 | [...notes.entries()].forEach((notePair) => { 13 | const [id, note] = notePair; 14 | const [collaboratorEmails, tags] = partition( 15 | sortBy(note.tags, (a) => a.toLocaleLowerCase()), 16 | isEmailTag 17 | ); 18 | const parsedNote = Object.assign( 19 | { 20 | id, 21 | content: normalizeLineBreak(note.content), 22 | creationDate: new Date(note.creationDate * 1000).toISOString(), 23 | lastModified: new Date(note.modificationDate * 1000).toISOString(), 24 | }, 25 | note.systemTags.includes('pinned') && { pinned: true }, 26 | note.systemTags.includes('markdown') && { markdown: true }, 27 | tags.length && { tags }, 28 | note.systemTags.includes('published') && 29 | note?.publishURL && { 30 | publicURL: `http://simp.ly/p/${note.publishURL}`, 31 | }, 32 | note.systemTags.includes('shared') && 33 | collaboratorEmails.length && { collaboratorEmails } 34 | ); 35 | note.deleted ? trashedNotes.push(parsedNote) : activeNotes.push(parsedNote); 36 | }); 37 | return Promise.resolve({ activeNotes, trashedNotes }); 38 | }; 39 | 40 | export default exportNotes; 41 | -------------------------------------------------------------------------------- /lib/utils/export/index.ts: -------------------------------------------------------------------------------- 1 | import saveAs from 'file-saver'; 2 | import { get } from 'lodash'; 3 | 4 | import exportNotes from './export-notes'; 5 | import exportToZip from './to-zip'; 6 | 7 | import * as T from '../../types'; 8 | 9 | const filename = 'notes.zip'; 10 | 11 | const exportZipArchive = (notes: Map) => { 12 | return exportNotes(notes) 13 | .then(exportToZip) 14 | .then((zip) => 15 | zip?.generateAsync({ 16 | compression: 'DEFLATE', 17 | platform: get(window, 'process.platform', 'DOS'), 18 | type: 'blob', 19 | }) 20 | ) 21 | .then((blob) => saveAs(blob, filename)) 22 | .catch(console.log); // eslint-disable-line no-console 23 | }; 24 | 25 | export default exportZipArchive; 26 | -------------------------------------------------------------------------------- /lib/utils/export/normalize-line-break.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalize line break characters to CRLF 3 | * @param content string Content to normalize 4 | */ 5 | const normalizeLineBreak = (content: string) => 6 | content.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); 7 | 8 | export default normalizeLineBreak; 9 | -------------------------------------------------------------------------------- /lib/utils/export/test/normalize-line-breaks.ts: -------------------------------------------------------------------------------- 1 | import normalizeLineBreak from '../normalize-line-break'; 2 | 3 | describe('Normalize line break', () => { 4 | const text = 'Line 1\nLine 2\r\nLine 3\n\r\nLine 5\r\n\nLine 5'; 5 | const normalizedText = 6 | 'Line 1\r\nLine 2\r\nLine 3\r\n\r\nLine 5\r\n\r\nLine 5'; 7 | 8 | it('should convert LF to CRLF', () => { 9 | expect(normalizeLineBreak(text)).toBe(normalizedText); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /lib/utils/export/types.ts: -------------------------------------------------------------------------------- 1 | import * as T from '../../types'; 2 | 3 | export type ExportNote = { 4 | content: string; 5 | collaboratorEmails: T.TagName[]; 6 | creationDate: T.SecondsEpoch; 7 | id: T.EntityId; 8 | markdown?: boolean; 9 | modificationDate: T.SecondsEpoch; 10 | pinned?: boolean; 11 | publicURL?: string; 12 | tags: T.TagName[]; 13 | }; 14 | 15 | export type GroupedExportNotes = { 16 | activeNotes: ExportNote[]; 17 | trashedNotes: ExportNote[]; 18 | }; 19 | -------------------------------------------------------------------------------- /lib/utils/filter-at-most.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the first maxResults matching items in the list 3 | * 4 | * This is like items.filter(predicate).slice(0,maxResults) 5 | * but it early-aborts as soon as we find our max results. 6 | * If we were filtering thousands of tags, for example, there'd 7 | * be no reason to iterate through all of them and only prune 8 | * the list after computing whether each one matches. 9 | * 10 | * @param items items to filter 11 | * @param predicate filtering function 12 | * @param maxResults maximum number of returned matching items 13 | */ 14 | export default function ( 15 | items: I[], 16 | predicate: (item: I) => boolean, 17 | maxResults: number 18 | ): I[] { 19 | const results = []; 20 | for (const item of items) { 21 | if (predicate(item)) { 22 | results.push(item); 23 | } 24 | 25 | if (results.length === maxResults) { 26 | break; 27 | } 28 | } 29 | return results; 30 | } 31 | -------------------------------------------------------------------------------- /lib/utils/filter-notes.ts: -------------------------------------------------------------------------------- 1 | const tagPattern = () => /(?:\btag:)([^\s,]+)/g; 2 | 3 | export const withoutTags = (s: string) => s.replace(tagPattern(), '').trim(); 4 | 5 | export const getTerms = (filterText: string): string[] => { 6 | if (!filterText) { 7 | return []; 8 | } 9 | 10 | const literalsPattern = /(?:")((?:"|[^"])+?)(?:")/g; 11 | const boundaryPattern = /[\b\s]/g; 12 | 13 | let match; 14 | let storedLastIndex = 0; 15 | let withoutLiterals = ''; 16 | 17 | const filter = withoutTags(filterText); 18 | 19 | const literals = []; 20 | while ((match = literalsPattern.exec(filter)) !== null) { 21 | literals.push(match[0].slice(1, -1)); 22 | 23 | // anything in between our last saved index and the current match index is a non-literal term 24 | // ex: for the search string [ "foo" bar "baz" ] we'll save "bar" as a non-literal here when we match "baz" 25 | withoutLiterals += filter.slice(storedLastIndex, match.index); 26 | 27 | // lastIndex is the end of the current match 28 | // -- where in the string to start scanning for the next match on the next loop iteration 29 | storedLastIndex = literalsPattern.lastIndex; 30 | } 31 | 32 | // save any search terms that occur after the last matched literal 33 | // i.e. between our last saved index and the end of the string 34 | if ( 35 | (storedLastIndex > 0 || literals.length === 0) && 36 | storedLastIndex < filter.length 37 | ) { 38 | withoutLiterals += filter.slice(storedLastIndex); 39 | } 40 | 41 | const terms = withoutLiterals 42 | .split(boundaryPattern) 43 | .map((a) => a.trim()) 44 | .filter((a) => a); 45 | 46 | return [...literals, ...terms]; 47 | }; 48 | -------------------------------------------------------------------------------- /lib/utils/get-note-references.ts: -------------------------------------------------------------------------------- 1 | import { number } from 'prop-types'; 2 | import type * as S from '../state'; 3 | import type * as T from '../types'; 4 | 5 | import getNoteTitleAndPreview from './note-utils'; 6 | 7 | const getNoteLink = (id: T.EntityId): string => `simplenote://note/${id}`; 8 | 9 | export const getNoteReferences = (state: S.State): T.EntityId[] => { 10 | const matches = new Set(); 11 | if (!state.ui.openedNote) { 12 | return []; 13 | } 14 | const noteLink = getNoteLink(state.ui.openedNote); 15 | state.data.notes.forEach((note, key) => { 16 | if (note.content.includes(noteLink)) { 17 | matches.add(key); 18 | } 19 | }); 20 | return [...matches.values()]; 21 | }; 22 | 23 | type noteReference = 24 | | { 25 | count: number; 26 | noteId: T.EntityId; 27 | modificationDate: T.SecondsEpoch; 28 | title: string; 29 | } 30 | | undefined; 31 | 32 | export const getNoteReference = ( 33 | state: S.State, 34 | noteId: T.EntityId 35 | ): noteReference => { 36 | const note = state.data.notes.get(noteId); 37 | if (!note || !state.ui.openedNote) { 38 | return; 39 | } 40 | const regExp = new RegExp(getNoteLink(state.ui.openedNote), 'gi'); 41 | const count = note?.content.match(regExp)?.length || 0; 42 | const { title } = getNoteTitleAndPreview(note); 43 | return { 44 | count, 45 | noteId, 46 | modificationDate: note?.modificationDate, 47 | title, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /lib/utils/import/README.MD: -------------------------------------------------------------------------------- 1 | # Simplenote Import 2 | 3 | This is the core importer for Simplenote. It expects an object of notes in the same format as the `import` module. It is aggressive about only allowing certain properties, and converts dates into timestamps where appropriate. Importing specific export files (Such as Evernote .enex) should use a separate conversion script and then pass the resulting object to `importNotes()`. 4 | 5 | Example object with allowed properties: 6 | 7 | ``` 8 | { 9 | "activeNotes": [ 10 | { 11 | "content": "Random thought: How much wood could a woodchuck chuck if a woodchuck could chuck wood?", 12 | "creationDate": "2013-10-16T20:17:41.760Z", 13 | "lastModified": "2018-10-10T22:07:54.128Z", 14 | "pinned": true, 15 | "tags": [ 16 | "Reminders" 17 | ] 18 | }, 19 | ], 20 | "trashedNotes": [ 21 | { 22 | "content": "Hello, world!", 23 | "creationDate": "2016-02-29T23:01:03.115Z", 24 | "lastModified": "2016-02-29T23:01:08.000Z" 25 | } 26 | ] 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /lib/utils/import/evernote/test.ts: -------------------------------------------------------------------------------- 1 | import EvernoteImporter from './'; 2 | 3 | describe('EvernoteImporter', () => { 4 | describe('getConvertedDate', () => { 5 | let dateNowSpy; 6 | let importer; 7 | 8 | beforeAll(() => { 9 | dateNowSpy = jest 10 | .spyOn(Date, 'now') 11 | .mockImplementation(() => 1542312140788); 12 | }); 13 | 14 | beforeEach(() => { 15 | importer = new EvernoteImporter({ noteBucket: {}, tagBucket: {} }); 16 | }); 17 | 18 | afterAll(() => { 19 | dateNowSpy.mockRestore(); 20 | }); 21 | 22 | it('should return a Unix timestamp, given an un-delimited ISO string', () => { 23 | const convertedDate = importer.getConvertedDate('20181008T172440Z'); 24 | expect(convertedDate).toBe(1539019480); 25 | }); 26 | 27 | it('should fall back to the current Unix timestamp', () => { 28 | const fallbackDate = importer.getConvertedDate('broken-timestamp'); 29 | expect(fallbackDate).toBe(1542312140.788); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /lib/utils/import/test.ts: -------------------------------------------------------------------------------- 1 | import CoreImporter from './'; 2 | 3 | describe('CoreImporter', () => { 4 | let importer; 5 | const addNote = jest.fn(); 6 | 7 | beforeEach(() => { 8 | importer = new CoreImporter(addNote); 9 | importer.emit = jest.fn(); 10 | }); 11 | 12 | describe('importNote', () => { 13 | it('should call addNote() with a note containing the required properties', () => { 14 | const note = {}; 15 | importer.importNote(note); 16 | 17 | const passedNote = addNote.mock.calls[0][0]; 18 | 19 | // Conforms to schema 20 | expect(passedNote.publishURL).toBe(''); 21 | expect(passedNote.shareURL).toBe(''); 22 | expect(passedNote.deleted).toBe(false); 23 | expect(passedNote.tags).toEqual([]); 24 | expect(passedNote.systemTags).toEqual([]); 25 | expect(passedNote.creationDate).toEqual(expect.any(Number)); 26 | expect(passedNote.modificationDate).toEqual(expect.any(Number)); 27 | expect(passedNote.content).toBe(''); 28 | }); 29 | }); 30 | 31 | describe('importNotes', () => { 32 | it('should emit error when no notes are passed', () => { 33 | importer.importNotes(); 34 | expect(importer.emit).toHaveBeenCalledWith( 35 | 'status', 36 | 'error', 37 | 'No notes to import.' 38 | ); 39 | }); 40 | 41 | it('should emit error when invalid object is passed', () => { 42 | const bogusNotes = { actveNotes: [] }; 43 | importer.importNotes(bogusNotes); 44 | expect(importer.emit).toHaveBeenCalledWith( 45 | 'status', 46 | 'error', 47 | 'Invalid import format: No active or trashed notes found.' 48 | ); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /lib/utils/is-dev-config/index.ts: -------------------------------------------------------------------------------- 1 | const isDevConfig = (development: boolean = false) => { 2 | const isDev = Boolean(development); 3 | const whichDB = isDev ? 'Development' : 'Production'; 4 | const shouldWarn = process.env.NODE_ENV === 'production' && development; 5 | const consoleMode = shouldWarn ? 'warn' : 'info'; 6 | console[consoleMode](`Simperium config: ${whichDB}`); // eslint-disable-line no-console 7 | 8 | return isDev; 9 | }; 10 | 11 | export default isDevConfig; 12 | -------------------------------------------------------------------------------- /lib/utils/is-dev-config/test.ts: -------------------------------------------------------------------------------- 1 | import isDevConfig from './'; 2 | 3 | describe('isDevConfig', () => { 4 | const unmockedConsole = global.console; 5 | 6 | beforeEach(() => { 7 | global.console = { 8 | info: jest.fn(), 9 | warn: jest.fn(), 10 | }; 11 | }); 12 | 13 | afterEach(() => { 14 | global.process.env.NODE_ENV = 'test'; 15 | global.console = unmockedConsole; 16 | }); 17 | 18 | it('should return a boolean of whether it is given a value or not', () => { 19 | expect(isDevConfig(true)).toBe(true); 20 | expect(isDevConfig()).toBe(false); 21 | }); 22 | 23 | it('should console.warn when NODE_ENV is production and Simperium is not', () => { 24 | global.process.env.NODE_ENV = 'production'; 25 | global.console.warn = jest.fn(); 26 | isDevConfig(true); 27 | expect(global.console.warn).toHaveBeenCalled(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/utils/is-email-tag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module utils/is-email-tag 3 | */ 4 | 5 | import type * as T from '../types'; 6 | 7 | /** 8 | * Naively matches what might appear to be an email address 9 | * 10 | * This is not spec-compliant but it does not need to 11 | * be. Users of the app should assume that any tag 12 | * name which has the general appearance of a valid 13 | * email address will be treated as if they are actual 14 | * email addresses and will be used for adding people 15 | * to a note as collaborators. 16 | * 17 | * Three main components are required: 18 | * 1. Mailbox piece _before_ the `@` 19 | * 2. `@` 20 | * 3. Host and domain piece ending in `[some host].[some TLD]` 21 | * 22 | * @type {RegExp} 23 | */ 24 | const naiveEmailPattern = /^(?:[^@]+)@(?:.+)(?:\.[^.]{2,})$/; 25 | 26 | /** 27 | * Indicates if a given tag name string 28 | * represents an email for collaboration 29 | * 30 | * @param {String} tagName name of tag which might be an email address 31 | * @returns {Boolean} whether or not the tag is considered an email address 32 | */ 33 | export const isEmailTag = (tagName: T.TagName) => 34 | naiveEmailPattern.test(tagName); 35 | 36 | export default isEmailTag; 37 | -------------------------------------------------------------------------------- /lib/utils/note-scroll-position.ts: -------------------------------------------------------------------------------- 1 | export type notePositions = { 2 | [key: string]: number; 3 | }; 4 | 5 | export const setNotePosition = (noteId: string, position: number) => { 6 | const positions = getAllPositions(); 7 | if (positions) { 8 | positions[noteId] = position; 9 | sessionStorage.setItem('note_positions', JSON.stringify(positions)); 10 | } 11 | }; 12 | 13 | export const getNotePosition = (noteId: string): number => { 14 | const positions = getAllPositions(); 15 | if (positions) { 16 | return positions[noteId]; 17 | } else { 18 | return 0; 19 | } 20 | }; 21 | 22 | export const clearNotePositions = () => { 23 | sessionStorage.removeItem('note_positions'); 24 | }; 25 | 26 | const getAllPositions = (): notePositions => { 27 | const notePositions = sessionStorage.getItem('note_positions'); 28 | let currentSavedPositions: notePositions; 29 | if (notePositions) { 30 | try { 31 | currentSavedPositions = JSON.parse(notePositions); 32 | return currentSavedPositions; 33 | } catch (e) { 34 | return {}; 35 | } 36 | } else { 37 | currentSavedPositions = {}; 38 | } 39 | return currentSavedPositions; 40 | }; 41 | -------------------------------------------------------------------------------- /lib/utils/platform.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/atom/electron/issues/22 2 | export const isElectron = !!window?.electron; 3 | 4 | export const isMac = isElectron 5 | ? window?.electron?.isMac 6 | : navigator.appVersion.indexOf('Mac') !== -1; 7 | 8 | export const CmdOrCtrl = isElectron && isMac ? 'Cmd' : 'Ctrl'; 9 | 10 | export const isSafari = /^((?!chrome|android).)*safari/i.test( 11 | window.navigator.userAgent 12 | ); 13 | 14 | export const isLinux = isElectron 15 | ? window?.electron?.isLinux 16 | : navigator.appVersion.indexOf('Linux') !== -1; 17 | -------------------------------------------------------------------------------- /lib/utils/render-note-to-html.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeHtml } from './sanitize-html'; 2 | 3 | const enableCheckboxes = { 4 | type: 'output', 5 | regex: '\n', 12 | replace: '>', 13 | }; 14 | 15 | export const renderNoteToHtml = (content: string) => { 16 | return import(/* webpackChunkName: 'showdown' */ 'showdown').then( 17 | ({ default: showdown }) => { 18 | showdown.extension('enableCheckboxes', enableCheckboxes); 19 | showdown.extension('removeLineBreaks', removeLineBreaks); 20 | const markdownConverter = new showdown.Converter({ 21 | extensions: ['enableCheckboxes', 'removeLineBreaks'], 22 | }); 23 | markdownConverter.setFlavor('github'); 24 | markdownConverter.setOption('ghMentions', false); 25 | markdownConverter.setOption('literalMidWordUnderscores', true); 26 | markdownConverter.setOption('simpleLineBreaks', false); // override GFM 27 | markdownConverter.setOption('smoothLivePreview', true); 28 | markdownConverter.setOption('splitAdjacentBlockquotes', true); 29 | markdownConverter.setOption('strikethrough', true); // ~~strikethrough~~ 30 | markdownConverter.setOption('tables', true); // table syntax 31 | 32 | const transformedContent = content.replace( 33 | /([ \t\u2000-\u200a]*)\u2022(\s)/gm, 34 | '$1-$2' 35 | ); // normalized bullets 36 | 37 | return sanitizeHtml(markdownConverter.makeHtml(transformedContent)); 38 | } 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /lib/utils/tag-hash.ts: -------------------------------------------------------------------------------- 1 | import type * as T from '../types'; 2 | 3 | export const MAX_TAG_HASH_LENGTH = 256; 4 | 5 | export const tagHashOf = (tagName: T.TagName): T.TagHash => { 6 | const normalized = tagName.normalize('NFC'); 7 | const lowercased = normalized.toLocaleLowerCase('en-US'); 8 | const encoded = encodeURIComponent(lowercased); 9 | 10 | return encoded.replace( 11 | /[!'()*\-_~.]/g, 12 | (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase() 13 | ) as T.TagHash; 14 | }; 15 | 16 | export const tagNameOf = (tagHash: T.TagHash): T.TagName => 17 | decodeURIComponent(tagHash) as T.TagName; 18 | 19 | export const withTag = (tags: T.TagName[], tag: T.TagName): T.TagName[] => { 20 | const hash = tagHashOf(tag); 21 | const tagAt = tags.findIndex((tagName) => tagHashOf(tagName) === hash); 22 | return tagAt > -1 ? tags : [...tags, tag]; 23 | }; 24 | 25 | export const withoutTag = (tags: T.TagName[], tag: T.TagName): T.TagName[] => { 26 | const hash = tagHashOf(tag); 27 | 28 | for (const tagName of tags) { 29 | if (tagHashOf(tagName) === hash) { 30 | return tags.filter((tagName) => tagHashOf(tagName) !== hash); 31 | } 32 | } 33 | 34 | return tags; 35 | }; 36 | -------------------------------------------------------------------------------- /lib/utils/task-transform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Regex to find valid checkboxes within a string 3 | * 4 | * Checkboxes are valid if they: 5 | * - exist as the first non-whitespace on a line 6 | * - take one of the following forms ( - [ ] | - [x] | - [X]) 7 | */ 8 | export const checkboxRegex: RegExp = /^(\s*)- \[( |x|X)\](\s)/gm; 9 | 10 | export const withCheckboxCharacters = (s: string): string => 11 | s.replace( 12 | checkboxRegex, 13 | (match, prespace, inside, postspace) => 14 | prespace + (inside === ' ' ? '\ue000' : '\ue001') + postspace 15 | ); 16 | 17 | export const withCheckboxSyntax = (s: string): string => 18 | s.replace(/\ue000|\ue001/g, (match) => 19 | match === '\ue000' ? '- [ ]' : '- [x]' 20 | ); 21 | -------------------------------------------------------------------------------- /lib/utils/test/is-email-tag.ts: -------------------------------------------------------------------------------- 1 | import isEmailTag from '../is-email-tag'; 2 | 3 | describe('Utils', () => { 4 | describe('#isEmailTag', () => { 5 | expect.extend({ 6 | toBeAnEmail(received) { 7 | const pass = isEmailTag(received); 8 | 9 | return { 10 | pass, 11 | message: () => 12 | `${this.utils.matcherHint( 13 | `${pass ? '.not' : ''}.toBeAnEmail`, 14 | '', 15 | '', 16 | { secondArgument: false } 17 | )}\n\n` + 18 | `Expected value to${pass ? ' not' : ''} be an email address\n` + 19 | ` ${this.utils.printReceived(received)}`, 20 | }; 21 | }, 22 | }); 23 | 24 | it('should not match when no `@` present', () => { 25 | ['test', 'test.at.test.com', 'tester.com'].forEach((tag) => 26 | expect(tag).not.toBeAnEmail() 27 | ); 28 | }); 29 | 30 | it('should not match when no domain is present', () => { 31 | ['test@', 'test@host', 'test.com@host.', 'test@host.test.'].forEach( 32 | (tag) => expect(tag).not.toBeAnEmail() 33 | ); 34 | }); 35 | 36 | it('should require at least a two-letter TLD', () => { 37 | expect('a@b.c').not.toBeAnEmail(); 38 | }); 39 | 40 | it('should match valid email addresses', () => { 41 | ['test@test.com', 'test@test.server.local.com', 'a@b.io'].forEach((tag) => 42 | expect(tag).toBeAnEmail() 43 | ); 44 | }); 45 | 46 | it('should match even with multiple `@`s', () => { 47 | expect('test@inbox@host.domain.com').toBeAnEmail(); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /lib/utils/url-utils.ts: -------------------------------------------------------------------------------- 1 | import { has } from 'lodash'; 2 | 3 | const allowedProtocols = ['http:', 'https:', 'mailto:']; 4 | 5 | const state = (() => { 6 | const getBoundElectronOpener = () => { 7 | const shell = window.require('electron').shell; 8 | return shell.openExternal.bind(shell); 9 | }; 10 | 11 | const isRunningElectron = window && has(window, 'process.versions.electron'); 12 | const openExternalUrl = isRunningElectron 13 | ? getBoundElectronOpener() 14 | : window.open.bind(window); 15 | 16 | return { isRunningElectron, openExternalUrl }; 17 | })(); 18 | 19 | export const viewExternalUrl = (url: string) => { 20 | try { 21 | const protocol = new URL(url).protocol; 22 | 23 | if (!allowedProtocols.some((allowed) => allowed === protocol)) { 24 | return; 25 | } 26 | } catch (e) { 27 | // Invalid Url 28 | return; 29 | } 30 | 31 | state.openExternalUrl(url); 32 | }; 33 | -------------------------------------------------------------------------------- /lib/utils/validate-password/index.ts: -------------------------------------------------------------------------------- 1 | export const validatePassword = function (password: string, email: string) { 2 | // does not equal username (i.e. email address) 3 | if (password === email) { 4 | return false; 5 | } 6 | 7 | // minimum of 8 characters; no tabs or newlines 8 | // (letters, numbers, special characters and spaces allowed) 9 | const re = /^[^\r\n\t]{8,}$/; 10 | return re.test(password); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/utils/validate-password/test.ts: -------------------------------------------------------------------------------- 1 | import { validatePassword } from './'; 2 | 3 | describe('validatePassword', () => { 4 | it('should return false for password that is too short', () => { 5 | expect(validatePassword('foo', 'bar@bang.com')).toBeFalsy(); 6 | }); 7 | 8 | it('should return false for password that equals email that would otherwise be valid', () => { 9 | expect(validatePassword('bar@bang.com', 'bar@bang.com')).toBeFalsy(); 10 | }); 11 | 12 | it('should return true for password that is more than 64 characters', () => { 13 | expect( 14 | validatePassword( 15 | 'y93HywVqX6sXbsyZiAAZ*xz8-X6uskm8ZjhLXvTTw2m8YxgvgujuWpEqUqqdT!H41111', 16 | 'bar@bang.com' 17 | ) 18 | ).toBeTruthy(); 19 | }); 20 | 21 | it('should return true for password that is numbers only', () => { 22 | expect( 23 | validatePassword( 24 | '1234567890123456789012345678901234567890123456789012345678901234', 25 | 'bar@bang.com' 26 | ) 27 | ).toBeTruthy(); 28 | }); 29 | 30 | it('should return true for password that contains a space', () => { 31 | expect( 32 | validatePassword( 33 | '12345678901234567890123456789012345678 0123456789012345678901234', 34 | 'bar@bang.com' 35 | ) 36 | ).toBeTruthy(); 37 | }); 38 | 39 | it('should return true for password that is valid', () => { 40 | expect( 41 | validatePassword( 42 | 'y93HywVqX6sXbsyZiAAZ*xz8-X6uskm8ZjhLXvTTw2m8YxgvgujuWpEqUqqdT!H4', 43 | 'bar@bang.com' 44 | ) 45 | ).toBeTruthy(); 46 | }); 47 | 48 | it('should allow all special characters', () => { 49 | expect( 50 | validatePassword('~ !@#$%^&*_-+=`|(){}[]:;"\',.?/', 'bar@bang.com') 51 | ).toBeTruthy(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /resources/appx/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/Square150x150Logo.png -------------------------------------------------------------------------------- /resources/appx/Square150x150Logo.targetsize-150_altform-unplated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/Square150x150Logo.targetsize-150_altform-unplated.png -------------------------------------------------------------------------------- /resources/appx/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/Square44x44Logo.png -------------------------------------------------------------------------------- /resources/appx/Square44x44Logo.targetsize-44_altform-unplated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/Square44x44Logo.targetsize-44_altform-unplated.png -------------------------------------------------------------------------------- /resources/appx/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/StoreLogo.png -------------------------------------------------------------------------------- /resources/appx/Wide310x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/appx/Wide310x150Logo.png -------------------------------------------------------------------------------- /resources/certificates/mac.p12.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/certificates/mac.p12.enc -------------------------------------------------------------------------------- /resources/certificates/win.p12.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/certificates/win.p12.enc -------------------------------------------------------------------------------- /resources/images/app-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/app-icon.icns -------------------------------------------------------------------------------- /resources/images/dmg-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/dmg-background.png -------------------------------------------------------------------------------- /resources/images/dmg-background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/dmg-background@2x.png -------------------------------------------------------------------------------- /resources/images/dmg-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/dmg-icon.icns -------------------------------------------------------------------------------- /resources/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/favicon.ico -------------------------------------------------------------------------------- /resources/images/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/icon_128x128.png -------------------------------------------------------------------------------- /resources/images/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/icon_16x16.png -------------------------------------------------------------------------------- /resources/images/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/icon_256x256.png -------------------------------------------------------------------------------- /resources/images/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/icon_32x32.png -------------------------------------------------------------------------------- /resources/images/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/icon_512x512.png -------------------------------------------------------------------------------- /resources/images/simplenote.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/images/simplenote.ico -------------------------------------------------------------------------------- /resources/macos/entitlements.mac.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-executable-page-protection 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | com.apple.security.cs.allow-dyld-environment-variables 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/macos/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-executable-page-protection 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | com.apple.security.cs.allow-dyld-environment-variables 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/secrets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/secrets/.gitkeep -------------------------------------------------------------------------------- /resources/secrets/config.json.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/simplenote-electron/6e0565dea8956b317c54f462ad4fae22d1e60709/resources/secrets/config.json.enc -------------------------------------------------------------------------------- /scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as *; 2 | 3 | @mixin react-transition-fade-in { 4 | &-enter { 5 | opacity: 0; 6 | } 7 | 8 | &-enter-done { 9 | opacity: 1; 10 | transition: opacity 0.2s ease-out; 11 | } 12 | } 13 | 14 | @mixin react-transition-fade-out { 15 | &-exit { 16 | opacity: 1; 17 | } 18 | 19 | &-exit-active { 20 | opacity: 0; 21 | transition: opacity 0.2s ease-out; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scss/_scrollbar.scss: -------------------------------------------------------------------------------- 1 | // Turn on custom scrollbar 2 | ::-webkit-scrollbar { 3 | width: 14px; 4 | background: transparent; 5 | } 6 | 7 | ::-webkit-scrollbar-corner { 8 | background: transparent; 9 | } 10 | 11 | ::-webkit-scrollbar-thumb { 12 | background: var(--secondary-color); 13 | border-radius: 100px; 14 | border: 4px solid var(--background-color); 15 | min-height: 24px; 16 | } 17 | 18 | // hover effect for scrollbar 'thumb' 19 | ::-webkit-scrollbar-thumb:hover { 20 | background-color: var(--foreground-color); 21 | } 22 | 23 | ::-webkit-scrollbar-thumb:active { 24 | background: var(--tertiary-accent-color); 25 | border-radius: 100px; 26 | } 27 | 28 | // Styles for Firefox not a lot of options we are able to set for custom scrollbars 29 | body { 30 | scrollbar-width: thin; 31 | scrollbar-color: var(--tertiary-accent-color) transparent; 32 | } 33 | -------------------------------------------------------------------------------- /scss/inputs.scss: -------------------------------------------------------------------------------- 1 | input, 2 | button, 3 | select, 4 | textarea { 5 | font-family: inherit; 6 | font-size: inherit; 7 | line-height: inherit; 8 | } 9 | 10 | input { 11 | -webkit-tap-highlight-color: transparent; 12 | 13 | &::placeholder { 14 | color: var(--placeholder-color); 15 | opacity: 1; 16 | } 17 | 18 | &::-ms-clear { 19 | display: none; 20 | } 21 | 22 | // Make sure inputs that are readonly (i.e. tag list) are not selectable 23 | &[readonly] { 24 | user-select: none; 25 | } 26 | } 27 | 28 | .transparent-input { 29 | color: inherit; 30 | background-color: transparent; 31 | } 32 | -------------------------------------------------------------------------------- /scss/print.scss: -------------------------------------------------------------------------------- 1 | @use '~@automattic/color-studio/dist/color-variables' as *; 2 | 3 | @media print { 4 | html { 5 | -ms-overflow-style: -ms-autohiding-scrollbar; 6 | } 7 | 8 | body { 9 | background-color: #fff; 10 | } 11 | 12 | .app-layout__note-column { 13 | border: 0; 14 | } 15 | 16 | .dev-badge, 17 | .app-layout__source-column, 18 | .tag-field, 19 | .note-toolbar-wrapper, 20 | .react-monaco-editor-container, 21 | .search-results { 22 | display: none; 23 | } 24 | 25 | /* Firefox only prints the first page if any divs are display: flex */ 26 | .app, 27 | .simplenote-app, 28 | .app-layout, 29 | .app-layout__note-column, 30 | .note-editor, 31 | .note-detail, 32 | .note-detail-textarea, 33 | .note-content-editor-shell, 34 | .note-detail-wrapper { 35 | display: block; 36 | overflow: auto; 37 | position: relative; 38 | } 39 | 40 | [class^='note-detail-'] { 41 | max-width: 100%; 42 | width: 100%; 43 | color: $studio-black; 44 | overflow-y: auto !important; 45 | } 46 | 47 | .note-content-plaintext { 48 | display: inherit; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scss/style.scss: -------------------------------------------------------------------------------- 1 | // Vendor 2 | @use 'normalize'; 3 | @use '~@automattic/color-studio/dist/color-variables'; 4 | 5 | // Internal 6 | @use 'variables'; 7 | @use 'mixins'; 8 | @use 'general'; 9 | @use 'scrollbar'; 10 | @use 'components'; 11 | 12 | // TODO: Tie these styles to their own components 13 | @use 'inputs'; 14 | @use 'buttons'; 15 | 16 | // TODO: Figure out a better way to manage these 17 | @use 'theme'; 18 | @use 'print'; 19 | -------------------------------------------------------------------------------- /setup-tests.js: -------------------------------------------------------------------------------- 1 | import mockIndexedDB from 'fake-indexeddb'; 2 | global.indexedDB = mockIndexedDB; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "allowJs": true, 8 | "jsx": "react", 9 | "noEmit": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["lib/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /vip/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplenote", 3 | "description": "Simplenote is an easy way to keep notes, lists, ideas and more. Your notes stay in sync with all your devices for free.", 4 | "version": "1.0.0", 5 | "license": "GPL-2.0", 6 | "scripts": { 7 | "start": "node ./webapp/index.js", 8 | "build": "exit 0" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/Automattic/simplenote-electron.git" 13 | }, 14 | "dependencies": { 15 | "@automattic/vip-go": "1.1.0", 16 | "express": "4.19.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vip/webapp/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const { server } = require('@automattic/vip-go'); 4 | 5 | const port = process.env.PORT || 4000; 6 | 7 | app.use(express.static('dist')); 8 | 9 | server(app, { PORT: port }).listen(); 10 | --------------------------------------------------------------------------------