Merge branch 'release/3.9.0'

This commit is contained in:
Karol Sójko
2021-11-10 21:00:23 +01:00
161 changed files with 6929 additions and 5815 deletions

View File

@@ -7,13 +7,14 @@ RAILS_LOG_LEVEL=INFO
RAILS_SERVE_STATIC_FILES=true
SECRET_KEY_BASE=test
APP_HOST=http://localhost:3001
PURCHASE_URL=https://standardnotes.com/purchase
PLANS_URL=https://standardnotes.com/plans
DASHBOARD_URL=http://standardnotes.com/dashboard
EXTENSIONS_MANAGER_LOCATION=extensions/extensions-manager/dist/index.html
SF_DEFAULT_SERVER=http://localhost:3000
DEFAULT_SYNC_SERVER=http://localhost:3000
# Development options
DEV_DEFAULT_SYNC_SERVER=https://api.standardnotes.com
DEV_EXTENSIONS_MANAGER_LOCATION=public/extensions/extensions-manager/dist/index.html
ENABLE_UNFINISHED_FEATURES=false
DEV_WEBSOCKET_URL=wss://sockets-dev.standardnotes.com

View File

@@ -11,6 +11,7 @@
"parserOptions": {
"project": "./app/assets/javascripts/tsconfig.json"
},
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js"],
"rules": {
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
"no-throw-literal": 0,

View File

@@ -31,9 +31,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Initiate submodules
run: git submodule update --init
- name: Copy robots.txt
run: cp public/robots.txt.development public/robots.txt

View File

@@ -35,9 +35,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Initiate submodules
run: git submodule update --init
- name: Copy robots.txt
run: cp public/robots.txt.development public/robots.txt

View File

@@ -35,9 +35,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Initiate submodules
run: git submodule update --init
- name: Copy robots.txt
run: cp public/robots.txt.production public/robots.txt

6
.gitignore vendored
View File

@@ -41,11 +41,7 @@ dump.rdb
.vscode
# Generated Files
/dist/javascripts
/dist/stylesheets
/dist/fonts
/dist/@types
/dist
# Yarn
yarn-error.log

10
.gitmodules vendored
View File

@@ -1,10 +0,0 @@
[submodule "vendor/extensions/extensions-manager"]
path = vendor/extensions/extensions-manager
url = https://github.com/sn-extensions/extensions-manager.git
[submodule "app/extensions/extensions-manager"]
path = app/extensions/extensions-manager
url = https://github.com/sn-extensions/extensions-manager.git
[submodule "public/extensions/extensions-manager"]
path = public/extensions/extensions-manager
url = https://github.com/sn-extensions/extensions-manager.git

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
16.11.1

View File

@@ -1,11 +1,10 @@
FROM ruby:2.7.1-alpine3.12
FROM ruby:2.7.4-alpine3.14
RUN apk add --update --no-cache \
alpine-sdk \
nodejs-current \
python2 \
python3 \
git \
nodejs-npm \
yarn \
tzdata

View File

@@ -107,6 +107,8 @@ GEM
racc (~> 1.4)
nokogiri (1.11.1-x64-mingw32)
racc (~> 1.4)
nokogiri (1.11.1-x86_64-darwin)
racc (~> 1.4)
non-stupid-digest-assets (1.0.9)
sprockets (>= 2.0)
puma (4.3.5)
@@ -200,6 +202,7 @@ GEM
PLATFORMS
ruby
x64-mingw32
x86_64-darwin-18
DEPENDENCIES
byebug

View File

@@ -19,7 +19,7 @@ Standard Notes is a simple and private notes app available on most platforms, in
- Simple and easy to use
- Fast and encrypted cross-platform sync
- Free sync on unlimited devices
- Extensible with editors (such as Markdown and Code), themes, and components (like Folders and Autocomplete Tags). Learn more about [Extended](https://standardnotes.com/extensions).
- Extensible with editors (such as Markdown and Code), themes, and components. [Learn more](https://standardnotes.com/features).
- Open-source and the option to self-host your notes server. You can [host your own Standard Server](https://docs.standardnotes.com/self-hosting/getting-started) in a few easy steps.
- A strong focus on longevity and sustainability. [Learn more](https://standardnotes.com/longevity).
@@ -37,7 +37,7 @@ Standard Notes is a simple and private notes app available on most platforms, in
### Do More
If you're looking to power up your experience with extensions, and help support future development, [learn more about Extended](https://standardnotes.com/extensions). Extended offers:
If you're looking to power up your experience with extensions, and help support future development, [learn more about our paid plans](https://standardnotes.com/plans). Our paid plans offer:
- Powerful editors, including the Plus Editor, Simple Markdown, Advanced Markdown, Code Editor, Vim Editor, and the popular Simple Task Editor.
- Beautiful themes to help you find inspiration in any mood, like Midnight, Focused, Futura, Titanium, and Solarized Dark.
@@ -97,20 +97,10 @@ Then open your browser to `http://localhost:3001`.
---
**Extensions Manager and Batch Manager:**
The web app makes use of two optional native extensions, which, when running the app with Rails, can be configured to work as follows:
1. `git submodule update --init` (will load the submodules in the `public/extensions` folder)
1. Set the following environment variables in the .env file:
```
EXTENSIONS_MANAGER_LOCATION=extensions/extensions-manager/dist/index.html
```
You can also set the `SF_DEFAULT_SERVER` environment variable to set the default server for login and registration.
You can also set the `DEFAULT_SYNC_SERVER` environment variable to set the default server for login and registration.
```
SF_DEFAULT_SERVER=https://sync.myserver
DEFAULT_SYNC_SERVER=https://sync.myserver
```
---

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 7.50008H12.5V18.3334H10.8333V13.3334H9.16667V18.3334H7.5V7.50008H2.5V5.83342H17.5V7.50008ZM10 1.66675C10.442 1.66675 10.866 1.84234 11.1785 2.1549C11.4911 2.46746 11.6667 2.89139 11.6667 3.33342C11.6667 3.77544 11.4911 4.19937 11.1785 4.51193C10.866 4.82449 10.442 5.00008 10 5.00008C9.075 5.00008 8.33333 4.25008 8.33333 3.33342C8.33333 2.40841 9.075 1.66675 10 1.66675Z" fill="#72767E"/>
</svg>
<path
d="M17.5 7.50008H12.5V18.3334H10.8333V13.3334H9.16667V18.3334H7.5V7.50008H2.5V5.83342H17.5V7.50008ZM10 1.66675C10.442 1.66675 10.866 1.84234 11.1785 2.1549C11.4911 2.46746 11.6667 2.89139 11.6667 3.33342C11.6667 3.77544 11.4911 4.19937 11.1785 4.51193C10.866 4.82449 10.442 5.00008 10 5.00008C9.075 5.00008 8.33333 4.25008 8.33333 3.33342C8.33333 2.40841 9.075 1.66675 10 1.66675Z" />
</svg>

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50008 2.5L4.16675 5.83333H6.66675V11.6667H8.33342V5.83333H10.8334L7.50008 2.5Z" />
<path d="M13.3332 8.33325V14.1666H15.8332L12.4998 17.4999L9.1665 14.1666H11.6665V8.33325H13.3332Z" fill="#086DD6"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50008 2.5L4.16675 5.83333H6.66675V11.6667H8.33342V5.83333H10.8334L7.50008 2.5Z" fill="#086DD6"/>
<path d="M13.3332 8.33325V14.1666H15.8332L12.4998 17.4999L9.1665 14.1666H11.6665V8.33325H13.3332Z"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.17622 7.15015L10.0012 10.9751L13.8262 7.15015L15.0012 8.33348L10.0012 13.3335L5.00122 8.33348L6.17622 7.15015Z" fill="#72767E"/>
</svg>
<path
d="M6.17622 7.15015L10.0012 10.9751L13.8262 7.15015L15.0012 8.33348L10.0012 13.3335L5.00122 8.33348L6.17622 7.15015Z" />
</svg>

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 212 B

View File

@@ -1,3 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66724 3.66626C1.66724 2.56169 2.56267 1.66626 3.66724 1.66626H11.3339C12.4385 1.66626 13.3339 2.56169 13.3339 3.66626V13.3329H3.66724C2.56267 13.3329 1.66724 12.4375 1.66724 11.3329V3.66626ZM16.3339 6.66626C17.4385 6.66626 18.3339 7.56169 18.3339 8.66626V16.3329C18.3339 17.4375 17.4385 18.3329 16.3339 18.3329H8.66724C7.56267 18.3329 6.66724 17.4375 6.66724 16.3329V14.9996H15.0006V6.66626H16.3339ZM3.3339 3.33293V11.6663H11.6672V3.33293H3.3339Z" fill="#72767E"/>
<path d="M1.66724 3.66626C1.66724 2.56169 2.56267 1.66626 3.66724 1.66626H11.3339C12.4385 1.66626 13.3339 2.56169 13.3339 3.66626V13.3329H3.66724C2.56267 13.3329 1.66724 12.4375 1.66724 11.3329V3.66626ZM16.3339 6.66626C17.4385 6.66626 18.3339 7.56169 18.3339 8.66626V16.3329C18.3339 17.4375 17.4385 18.3329 16.3339 18.3329H8.66724C7.56267 18.3329 6.66724 17.4375 6.66724 16.3329V14.9996H15.0006V6.66626H16.3339ZM3.3339 3.33293V11.6663H11.6672V3.33293H3.3339Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -1,3 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3.5V9.5H12.17L10 11.67L7.83 9.5H9V3.5H11ZM13 1.5H7V7.5H3L10 14.5L17 7.5H13V1.5ZM17 16.5H3V18.5H17V16.5Z" fill="#72767E"/>
<path d="M11 3.5V9.5H12.17L10 11.67L7.83 9.5H9V3.5H11ZM13 1.5H7V7.5H3L10 14.5L17 7.5H13V1.5ZM17 16.5H3V18.5H17V16.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16675 15.0001H10.8334V13.3334H9.16675V15.0001ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10753 4.10753C2.54472 5.67033 1.66675 7.78995 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10753 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10753C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM10.0001 16.6668C6.32508 16.6668 3.33342 13.6751 3.33342 10.0001C3.33342 6.32508 6.32508 3.33342 10.0001 3.33342C13.6751 3.33342 16.6668 6.32508 16.6668 10.0001C16.6668 13.6751 13.6751 16.6668 10.0001 16.6668ZM10.0001 5.00008C9.11603 5.00008 8.26818 5.35127 7.64306 5.97639C7.01794 6.60151 6.66675 7.44936 6.66675 8.33342H8.33342C8.33342 7.89139 8.50901 7.46747 8.82157 7.1549C9.13413 6.84234 9.55806 6.66675 10.0001 6.66675C10.4421 6.66675 10.866 6.84234 11.1786 7.1549C11.4912 7.46747 11.6667 7.89139 11.6667 8.33342C11.6667 10.0001 9.16675 9.79175 9.16675 12.5001H10.8334C10.8334 10.6251 13.3334 10.4167 13.3334 8.33342C13.3334 7.44936 12.9822 6.60151 12.3571 5.97639C11.732 5.35127 10.8841 5.00008 10.0001 5.00008Z" fill="#72767E"/>
</svg>
<path
d="M9.16675 15.0001H10.8334V13.3334H9.16675V15.0001ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10753 4.10753C2.54472 5.67033 1.66675 7.78995 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10753 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10753C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM10.0001 16.6668C6.32508 16.6668 3.33342 13.6751 3.33342 10.0001C3.33342 6.32508 6.32508 3.33342 10.0001 3.33342C13.6751 3.33342 16.6668 6.32508 16.6668 10.0001C16.6668 13.6751 13.6751 16.6668 10.0001 16.6668ZM10.0001 5.00008C9.11603 5.00008 8.26818 5.35127 7.64306 5.97639C7.01794 6.60151 6.66675 7.44936 6.66675 8.33342H8.33342C8.33342 7.89139 8.50901 7.46747 8.82157 7.1549C9.13413 6.84234 9.55806 6.66675 10.0001 6.66675C10.4421 6.66675 10.866 6.84234 11.1786 7.1549C11.4912 7.46747 11.6667 7.89139 11.6667 8.33342C11.6667 10.0001 9.16675 9.79175 9.16675 12.5001H10.8334C10.8334 10.6251 13.3334 10.4167 13.3334 8.33342C13.3334 7.44936 12.9822 6.60151 12.3571 5.97639C11.732 5.35127 10.8841 5.00008 10.0001 5.00008Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16675 7.50008H10.8334V5.83342H9.16675V7.50008ZM10.0001 16.6667C6.32508 16.6667 3.33341 13.6751 3.33341 10.0001C3.33341 6.32508 6.32508 3.33341 10.0001 3.33341C13.6751 3.33341 16.6667 6.32508 16.6667 10.0001C16.6667 13.6751 13.6751 16.6667 10.0001 16.6667ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10752 4.10752C2.54472 5.67033 1.66675 7.78994 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10752 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10752C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM9.16675 14.1667H10.8334V9.16675H9.16675V14.1667Z" fill="#72767E"/>
</svg>
<path
d="M9.16675 7.50008H10.8334V5.83342H9.16675V7.50008ZM10.0001 16.6667C6.32508 16.6667 3.33341 13.6751 3.33341 10.0001C3.33341 6.32508 6.32508 3.33341 10.0001 3.33341C13.6751 3.33341 16.6667 6.32508 16.6667 10.0001C16.6667 13.6751 13.6751 16.6667 10.0001 16.6667ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10752 4.10752C2.54472 5.67033 1.66675 7.78994 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10752 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10752C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM9.16675 14.1667H10.8334V9.16675H9.16675V14.1667Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1001 B

After

Width:  |  Height:  |  Size: 992 B

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33341 4.16675C2.89139 4.16675 2.46746 4.34234 2.1549 4.6549C1.84234 4.96746 1.66675 5.39139 1.66675 5.83341V14.1667C1.66675 14.6088 1.84234 15.0327 2.1549 15.3453C2.46746 15.6578 2.89139 15.8334 3.33341 15.8334H16.6667C17.1088 15.8334 17.5327 15.6578 17.8453 15.3453C18.1578 15.0327 18.3334 14.6088 18.3334 14.1667V5.83341C18.3334 5.39139 18.1578 4.96746 17.8453 4.6549C17.5327 4.34234 17.1088 4.16675 16.6667 4.16675H3.33341ZM3.33341 5.83341H16.6667V14.1667H3.33341V5.83341ZM4.16675 6.66675V8.33342H5.83341V6.66675H4.16675ZM6.66675 6.66675V8.33342H8.33341V6.66675H6.66675ZM9.16675 6.66675V8.33342H10.8334V6.66675H9.16675ZM11.6667 6.66675V8.33342H13.3334V6.66675H11.6667ZM14.1667 6.66675V8.33342H15.8334V6.66675H14.1667ZM4.16675 9.16675V10.8334H5.83341V9.16675H4.16675ZM6.66675 9.16675V10.8334H8.33341V9.16675H6.66675ZM9.16675 9.16675V10.8334H10.8334V9.16675H9.16675ZM11.6667 9.16675V10.8334H13.3334V9.16675H11.6667ZM14.1667 9.16675V10.8334H15.8334V9.16675H14.1667ZM6.66675 11.6667V13.3334H13.3334V11.6667H6.66675Z" fill="#72767E"/>
</svg>
<path
d="M3.33341 4.16675C2.89139 4.16675 2.46746 4.34234 2.1549 4.6549C1.84234 4.96746 1.66675 5.39139 1.66675 5.83341V14.1667C1.66675 14.6088 1.84234 15.0327 2.1549 15.3453C2.46746 15.6578 2.89139 15.8334 3.33341 15.8334H16.6667C17.1088 15.8334 17.5327 15.6578 17.8453 15.3453C18.1578 15.0327 18.3334 14.6088 18.3334 14.1667V5.83341C18.3334 5.39139 18.1578 4.96746 17.8453 4.6549C17.5327 4.34234 17.1088 4.16675 16.6667 4.16675H3.33341ZM3.33341 5.83341H16.6667V14.1667H3.33341V5.83341ZM4.16675 6.66675V8.33342H5.83341V6.66675H4.16675ZM6.66675 6.66675V8.33342H8.33341V6.66675H6.66675ZM9.16675 6.66675V8.33342H10.8334V6.66675H9.16675ZM11.6667 6.66675V8.33342H13.3334V6.66675H11.6667ZM14.1667 6.66675V8.33342H15.8334V6.66675H14.1667ZM4.16675 9.16675V10.8334H5.83341V9.16675H4.16675ZM6.66675 9.16675V10.8334H8.33341V9.16675H6.66675ZM9.16675 9.16675V10.8334H10.8334V9.16675H9.16675ZM11.6667 9.16675V10.8334H13.3334V9.16675H11.6667ZM14.1667 9.16675V10.8334H15.8334V9.16675H14.1667ZM6.66675 11.6667V13.3334H13.3334V11.6667H6.66675Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,5 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.28571 3.6H15.7143C16.093 3.6 16.4 3.907 16.4 4.28571V15.7143C16.4 16.093 16.093 16.4 15.7143 16.4H4.28571C3.907 16.4 3.6 16.093 3.6 15.7143V4.28571C3.6 3.907 3.907 3.6 4.28571 3.6ZM2 4.28571C2 3.02335 3.02335 2 4.28571 2H15.7143C16.9767 2 18 3.02335 18 4.28571V15.7143C18 16.9767 16.9767 18 15.7143 18H4.28571C3.02335 18 2 16.9767 2 15.7143V4.28571ZM9.8045 6.31638C9.94745 6.27119 10.1703 6.24105 10.473 6.22599V6C10.0105 6.0226 9.35045 6.0339 8.49279 6.0339C7.59309 6.0339 6.92883 6.0226 6.5 6V6.22599C6.77748 6.24105 6.97928 6.27119 7.10541 6.31638C7.23994 6.36158 7.32823 6.44821 7.37027 6.57627C7.42072 6.70433 7.44595 6.91149 7.44595 7.19774V12.8023C7.44595 13.0885 7.42072 13.2957 7.37027 13.4237C7.32823 13.5518 7.23994 13.6384 7.10541 13.6836C6.97928 13.7288 6.77748 13.7589 6.5 13.774V14C7.40811 13.9774 8.77868 13.9661 10.6117 13.9661C11.9655 13.9661 12.9282 13.9774 13.5 14C13.4411 13.533 13.4117 12.9379 13.4117 12.2147C13.4117 11.8079 13.4243 11.4765 13.4495 11.2203H13.1595C13.0333 11.9812 12.7601 12.5913 12.3396 13.0508C11.9276 13.5104 11.4357 13.7401 10.864 13.7401H10.2459C10.0105 13.7401 9.83814 13.7213 9.72883 13.6836C9.62793 13.646 9.55646 13.5744 9.51441 13.4689C9.47237 13.3559 9.45135 13.1789 9.45135 12.9379V7.19774C9.45135 6.91149 9.47658 6.70433 9.52703 6.57627C9.57748 6.44821 9.66997 6.36158 9.8045 6.31638Z" fill="#72767E"/>
</svg>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M4.28571 3.6H15.7143C16.093 3.6 16.4 3.907 16.4 4.28571V15.7143C16.4 16.093 16.093 16.4 15.7143 16.4H4.28571C3.907 16.4 3.6 16.093 3.6 15.7143V4.28571C3.6 3.907 3.907 3.6 4.28571 3.6ZM2 4.28571C2 3.02335 3.02335 2 4.28571 2H15.7143C16.9767 2 18 3.02335 18 4.28571V15.7143C18 16.9767 16.9767 18 15.7143 18H4.28571C3.02335 18 2 16.9767 2 15.7143V4.28571ZM9.8045 6.31638C9.94745 6.27119 10.1703 6.24105 10.473 6.22599V6C10.0105 6.0226 9.35045 6.0339 8.49279 6.0339C7.59309 6.0339 6.92883 6.0226 6.5 6V6.22599C6.77748 6.24105 6.97928 6.27119 7.10541 6.31638C7.23994 6.36158 7.32823 6.44821 7.37027 6.57627C7.42072 6.70433 7.44595 6.91149 7.44595 7.19774V12.8023C7.44595 13.0885 7.42072 13.2957 7.37027 13.4237C7.32823 13.5518 7.23994 13.6384 7.10541 13.6836C6.97928 13.7288 6.77748 13.7589 6.5 13.774V14C7.40811 13.9774 8.77868 13.9661 10.6117 13.9661C11.9655 13.9661 12.9282 13.9774 13.5 14C13.4411 13.533 13.4117 12.9379 13.4117 12.2147C13.4117 11.8079 13.4243 11.4765 13.4495 11.2203H13.1595C13.0333 11.9812 12.7601 12.5913 12.3396 13.0508C11.9276 13.5104 11.4357 13.7401 10.864 13.7401H10.2459C10.0105 13.7401 9.83814 13.7213 9.72883 13.6836C9.62793 13.646 9.55646 13.5744 9.51441 13.4689C9.47237 13.3559 9.45135 13.1789 9.45135 12.9379V7.19774C9.45135 6.91149 9.47658 6.70433 9.52703 6.57627C9.57748 6.44821 9.66997 6.36158 9.8045 6.31638Z"
fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 10H15.4444C15.0322 12.9891 12.8933 15.6582 10 16.4873V10H4.55556V5.85455L10 3.59273V10ZM10 2L3 4.90909V9.27273C3 13.3091 5.98667 17.0764 10 18C14.0133 17.0764 17 13.3091 17 9.27273V4.90909L10 2Z" fill="#72767E"/>
</svg>
<path
d="M10 10H15.4444C15.0322 12.9891 12.8933 15.6582 10 16.4873V10H4.55556V5.85455L10 3.59273V10ZM10 2L3 4.90909V9.27273C3 13.3091 5.98667 17.0764 10 18C14.0133 17.0764 17 13.3091 17 9.27273V4.90909L10 2Z" />
</svg>

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0001 6.66675C10.8842 6.66675 11.732 7.01794 12.3571 7.64306C12.9823 8.26818 13.3334 9.11603 13.3334 10.0001C13.3334 10.8841 12.9823 11.732 12.3571 12.3571C11.732 12.9822 10.8842 13.3334 10.0001 13.3334C9.11606 13.3334 8.26821 12.9822 7.64309 12.3571C7.01797 11.732 6.66678 10.8841 6.66678 10.0001C6.66678 9.11603 7.01797 8.26818 7.64309 7.64306C8.26821 7.01794 9.11606 6.66675 10.0001 6.66675ZM10.0001 8.33342C9.55808 8.33342 9.13416 8.50901 8.8216 8.82157C8.50904 9.13413 8.33344 9.55805 8.33344 10.0001C8.33344 10.4421 8.50904 10.866 8.8216 11.1786C9.13416 11.4912 9.55808 11.6667 10.0001 11.6667C10.4421 11.6667 10.8661 11.4912 11.1786 11.1786C11.4912 10.866 11.6668 10.4421 11.6668 10.0001C11.6668 9.55805 11.4912 9.13413 11.1786 8.82157C10.8661 8.50901 10.4421 8.33342 10.0001 8.33342ZM8.33344 18.3334C8.12511 18.3334 7.95011 18.1834 7.91678 17.9834L7.60844 15.7751C7.08344 15.5667 6.63344 15.2834 6.20011 14.9501L4.12511 15.7917C3.94178 15.8584 3.71678 15.7917 3.61678 15.6084L1.95011 12.7251C1.84178 12.5417 1.89178 12.3167 2.05011 12.1917L3.80844 10.8084L3.75011 10.0001L3.80844 9.16675L2.05011 7.80841C1.89178 7.68341 1.84178 7.45841 1.95011 7.27508L3.61678 4.39175C3.71678 4.20841 3.94178 4.13341 4.12511 4.20842L6.20011 5.04175C6.63344 4.71675 7.08344 4.43341 7.60844 4.22508L7.91678 2.01675C7.95011 1.81675 8.12511 1.66675 8.33344 1.66675H11.6668C11.8751 1.66675 12.0501 1.81675 12.0834 2.01675L12.3918 4.22508C12.9168 4.43341 13.3668 4.71675 13.8001 5.04175L15.8751 4.20842C16.0584 4.13341 16.2834 4.20841 16.3834 4.39175L18.0501 7.27508C18.1584 7.45841 18.1084 7.68341 17.9501 7.80841L16.1918 9.16675L16.2501 10.0001L16.1918 10.8334L17.9501 12.1917C18.1084 12.3167 18.1584 12.5417 18.0501 12.7251L16.3834 15.6084C16.2834 15.7917 16.0584 15.8667 15.8751 15.7917L13.8001 14.9584C13.3668 15.2834 12.9168 15.5667 12.3918 15.7751L12.0834 17.9834C12.0501 18.1834 11.8751 18.3334 11.6668 18.3334H8.33344ZM9.37511 3.33341L9.06678 5.50841C8.06678 5.71675 7.18344 6.25008 6.54178 6.99175L4.53344 6.12508L3.90844 7.20841L5.66678 8.50008C5.33344 9.47508 5.33344 10.5334 5.66678 11.5001L3.90011 12.8001L4.52511 13.8834L6.55011 13.0167C7.19178 13.7501 8.06678 14.2834 9.05844 14.4834L9.36678 16.6667H10.6334L10.9418 14.4917C11.9334 14.2834 12.8084 13.7501 13.4501 13.0167L15.4751 13.8834L16.1001 12.8001L14.3334 11.5084C14.6668 10.5334 14.6668 9.47508 14.3334 8.50008L16.0918 7.20841L15.4668 6.12508L13.4584 6.99175C12.8168 6.25008 11.9334 5.71675 10.9334 5.51675L10.6251 3.33341H9.37511Z" fill="#72767E"/>
</svg>
<path
d="M10.0001 6.66675C10.8842 6.66675 11.732 7.01794 12.3571 7.64306C12.9823 8.26818 13.3334 9.11603 13.3334 10.0001C13.3334 10.8841 12.9823 11.732 12.3571 12.3571C11.732 12.9822 10.8842 13.3334 10.0001 13.3334C9.11606 13.3334 8.26821 12.9822 7.64309 12.3571C7.01797 11.732 6.66678 10.8841 6.66678 10.0001C6.66678 9.11603 7.01797 8.26818 7.64309 7.64306C8.26821 7.01794 9.11606 6.66675 10.0001 6.66675ZM10.0001 8.33342C9.55808 8.33342 9.13416 8.50901 8.8216 8.82157C8.50904 9.13413 8.33344 9.55805 8.33344 10.0001C8.33344 10.4421 8.50904 10.866 8.8216 11.1786C9.13416 11.4912 9.55808 11.6667 10.0001 11.6667C10.4421 11.6667 10.8661 11.4912 11.1786 11.1786C11.4912 10.866 11.6668 10.4421 11.6668 10.0001C11.6668 9.55805 11.4912 9.13413 11.1786 8.82157C10.8661 8.50901 10.4421 8.33342 10.0001 8.33342ZM8.33344 18.3334C8.12511 18.3334 7.95011 18.1834 7.91678 17.9834L7.60844 15.7751C7.08344 15.5667 6.63344 15.2834 6.20011 14.9501L4.12511 15.7917C3.94178 15.8584 3.71678 15.7917 3.61678 15.6084L1.95011 12.7251C1.84178 12.5417 1.89178 12.3167 2.05011 12.1917L3.80844 10.8084L3.75011 10.0001L3.80844 9.16675L2.05011 7.80841C1.89178 7.68341 1.84178 7.45841 1.95011 7.27508L3.61678 4.39175C3.71678 4.20841 3.94178 4.13341 4.12511 4.20842L6.20011 5.04175C6.63344 4.71675 7.08344 4.43341 7.60844 4.22508L7.91678 2.01675C7.95011 1.81675 8.12511 1.66675 8.33344 1.66675H11.6668C11.8751 1.66675 12.0501 1.81675 12.0834 2.01675L12.3918 4.22508C12.9168 4.43341 13.3668 4.71675 13.8001 5.04175L15.8751 4.20842C16.0584 4.13341 16.2834 4.20841 16.3834 4.39175L18.0501 7.27508C18.1584 7.45841 18.1084 7.68341 17.9501 7.80841L16.1918 9.16675L16.2501 10.0001L16.1918 10.8334L17.9501 12.1917C18.1084 12.3167 18.1584 12.5417 18.0501 12.7251L16.3834 15.6084C16.2834 15.7917 16.0584 15.8667 15.8751 15.7917L13.8001 14.9584C13.3668 15.2834 12.9168 15.5667 12.3918 15.7751L12.0834 17.9834C12.0501 18.1834 11.8751 18.3334 11.6668 18.3334H8.33344ZM9.37511 3.33341L9.06678 5.50841C8.06678 5.71675 7.18344 6.25008 6.54178 6.99175L4.53344 6.12508L3.90844 7.20841L5.66678 8.50008C5.33344 9.47508 5.33344 10.5334 5.66678 11.5001L3.90011 12.8001L4.52511 13.8834L6.55011 13.0167C7.19178 13.7501 8.06678 14.2834 9.05844 14.4834L9.36678 16.6667H10.6334L10.9418 14.4917C11.9334 14.2834 12.8084 13.7501 13.4501 13.0167L15.4751 13.8834L16.1001 12.8001L14.3334 11.5084C14.6668 10.5334 14.6668 9.47508 14.3334 8.50008L16.0918 7.20841L15.4668 6.12508L13.4584 6.99175C12.8168 6.25008 11.9334 5.71675 10.9334 5.51675L10.6251 3.33341H9.37511Z" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1035 12.8875C10.0399 12.8491 9.96028 12.8491 9.89672 12.8875L7.27153 14.4724C7.12022 14.5637 6.93347 14.4283 6.9733 14.2561L7.66462 11.2674C7.68131 11.1952 7.65676 11.1197 7.60082 11.0712L5.283 9.06056C5.14938 8.94465 5.22096 8.72509 5.39722 8.7102L8.45479 8.45191C8.52877 8.44566 8.5932 8.39895 8.62214 8.33058L9.8159 5.51022C9.88478 5.3475 10.1154 5.3475 10.1843 5.51022L11.378 8.33058C11.407 8.39895 11.4714 8.44566 11.5454 8.45191L14.6029 8.7102C14.7792 8.72509 14.8508 8.94465 14.7172 9.06057L12.3993 11.0712C12.3434 11.1197 12.3189 11.1952 12.3355 11.2674L13.0269 14.2561C13.0667 14.4283 12.8799 14.5637 12.7286 14.4724L10.1035 12.8875ZM17.9751 8.01046C18.1089 7.89462 18.0374 7.67496 17.8611 7.66001L12.4619 7.20194C12.388 7.19567 12.3236 7.149 12.2947 7.08071L10.1842 2.10122C10.1153 1.93862 9.88486 1.93862 9.81594 2.10122L7.70548 7.08071C7.67653 7.149 7.61216 7.19567 7.53824 7.20194L2.13848 7.66006C1.96228 7.67501 1.89074 7.89448 2.02429 8.01039L6.11748 11.5628C6.17343 11.6114 6.19795 11.6869 6.18122 11.759L4.95694 17.0392C4.91701 17.2114 5.10377 17.347 5.25512 17.2556L9.89674 14.4541C9.96029 14.4158 10.0399 14.4158 10.1034 14.4541L14.7443 17.2552C14.8957 17.3466 15.0826 17.2108 15.0424 17.0385L13.8108 11.7593C13.794 11.687 13.8185 11.6113 13.8747 11.5627L17.9751 8.01046Z" fill="#72767E"/>
</svg>
<path
d="M10.1035 12.8875C10.0399 12.8491 9.96028 12.8491 9.89672 12.8875L7.27153 14.4724C7.12022 14.5637 6.93347 14.4283 6.9733 14.2561L7.66462 11.2674C7.68131 11.1952 7.65676 11.1197 7.60082 11.0712L5.283 9.06056C5.14938 8.94465 5.22096 8.72509 5.39722 8.7102L8.45479 8.45191C8.52877 8.44566 8.5932 8.39895 8.62214 8.33058L9.8159 5.51022C9.88478 5.3475 10.1154 5.3475 10.1843 5.51022L11.378 8.33058C11.407 8.39895 11.4714 8.44566 11.5454 8.45191L14.6029 8.7102C14.7792 8.72509 14.8508 8.94465 14.7172 9.06057L12.3993 11.0712C12.3434 11.1197 12.3189 11.1952 12.3355 11.2674L13.0269 14.2561C13.0667 14.4283 12.8799 14.5637 12.7286 14.4724L10.1035 12.8875ZM17.9751 8.01046C18.1089 7.89462 18.0374 7.67496 17.8611 7.66001L12.4619 7.20194C12.388 7.19567 12.3236 7.149 12.2947 7.08071L10.1842 2.10122C10.1153 1.93862 9.88486 1.93862 9.81594 2.10122L7.70548 7.08071C7.67653 7.149 7.61216 7.19567 7.53824 7.20194L2.13848 7.66006C1.96228 7.67501 1.89074 7.89448 2.02429 8.01039L6.11748 11.5628C6.17343 11.6114 6.19795 11.6869 6.18122 11.759L4.95694 17.0392C4.91701 17.2114 5.10377 17.347 5.25512 17.2556L9.89674 14.4541C9.96029 14.4158 10.0399 14.4158 10.1034 14.4541L14.7443 17.2552C14.8957 17.3466 15.0826 17.2108 15.0424 17.0385L13.8108 11.7593C13.794 11.687 13.8185 11.6113 13.8747 11.5627L17.9751 8.01046Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18C8.94943 18 7.90914 17.7931 6.93853 17.391C5.96793 16.989 5.08601 16.3997 4.34315 15.6569C2.84285 14.1566 2 12.1217 2 10C2 7.87827 2.84285 5.84344 4.34315 4.34315C5.84344 2.84285 7.87827 2 10 2C14.4 2 18 5.2 18 9.2C18 10.473 17.4943 11.6939 16.5941 12.5941C15.6939 13.4943 14.473 14 13.2 14H11.76C11.52 14 11.36 14.16 11.36 14.4C11.36 14.48 11.44 14.56 11.44 14.64C11.76 15.04 11.92 15.52 11.92 16C12 17.12 11.12 18 10 18ZM10 3.6C8.30261 3.6 6.67475 4.27428 5.47452 5.47452C4.27428 6.67475 3.6 8.30261 3.6 10C3.6 11.6974 4.27428 13.3253 5.47452 14.5255C6.67475 15.7257 8.30261 16.4 10 16.4C10.24 16.4 10.4 16.24 10.4 16C10.4 15.84 10.32 15.76 10.32 15.68C10 15.28 9.84 14.88 9.84 14.4C9.84 13.28 10.72 12.4 11.84 12.4H13.2C14.0487 12.4 14.8626 12.0629 15.4627 11.4627C16.0629 10.8626 16.4 10.0487 16.4 9.2C16.4 6.08 13.52 3.6 10 3.6ZM5.6 8.4C6.24 8.4 6.8 8.96 6.8 9.6C6.8 10.24 6.24 10.8 5.6 10.8C4.96 10.8 4.4 10.24 4.4 9.6C4.4 8.96 4.96 8.4 5.6 8.4ZM8 5.2C8.64 5.2 9.2 5.76 9.2 6.4C9.2 7.04 8.64 7.6 8 7.6C7.36 7.6 6.8 7.04 6.8 6.4C6.8 5.76 7.36 5.2 8 5.2ZM12 5.2C12.64 5.2 13.2 5.76 13.2 6.4C13.2 7.04 12.64 7.6 12 7.6C11.36 7.6 10.8 7.04 10.8 6.4C10.8 5.76 11.36 5.2 12 5.2ZM14.4 8.4C15.04 8.4 15.6 8.96 15.6 9.6C15.6 10.24 15.04 10.8 14.4 10.8C13.76 10.8 13.2 10.24 13.2 9.6C13.2 8.96 13.76 8.4 14.4 8.4Z" fill="#72767E"/>
<path d="M10 18C8.94943 18 7.90914 17.7931 6.93853 17.391C5.96793 16.989 5.08601 16.3997 4.34315 15.6569C2.84285 14.1566 2 12.1217 2 10C2 7.87827 2.84285 5.84344 4.34315 4.34315C5.84344 2.84285 7.87827 2 10 2C14.4 2 18 5.2 18 9.2C18 10.473 17.4943 11.6939 16.5941 12.5941C15.6939 13.4943 14.473 14 13.2 14H11.76C11.52 14 11.36 14.16 11.36 14.4C11.36 14.48 11.44 14.56 11.44 14.64C11.76 15.04 11.92 15.52 11.92 16C12 17.12 11.12 18 10 18ZM10 3.6C8.30261 3.6 6.67475 4.27428 5.47452 5.47452C4.27428 6.67475 3.6 8.30261 3.6 10C3.6 11.6974 4.27428 13.3253 5.47452 14.5255C6.67475 15.7257 8.30261 16.4 10 16.4C10.24 16.4 10.4 16.24 10.4 16C10.4 15.84 10.32 15.76 10.32 15.68C10 15.28 9.84 14.88 9.84 14.4C9.84 13.28 10.72 12.4 11.84 12.4H13.2C14.0487 12.4 14.8626 12.0629 15.4627 11.4627C16.0629 10.8626 16.4 10.0487 16.4 9.2C16.4 6.08 13.52 3.6 10 3.6ZM5.6 8.4C6.24 8.4 6.8 8.96 6.8 9.6C6.8 10.24 6.24 10.8 5.6 10.8C4.96 10.8 4.4 10.24 4.4 9.6C4.4 8.96 4.96 8.4 5.6 8.4ZM8 5.2C8.64 5.2 9.2 5.76 9.2 6.4C9.2 7.04 8.64 7.6 8 7.6C7.36 7.6 6.8 7.04 6.8 6.4C6.8 5.76 7.36 5.2 8 5.2ZM12 5.2C12.64 5.2 13.2 5.76 13.2 6.4C13.2 7.04 12.64 7.6 12 7.6C11.36 7.6 10.8 7.04 10.8 6.4C10.8 5.76 11.36 5.2 12 5.2ZM14.4 8.4C15.04 8.4 15.6 8.96 15.6 9.6C15.6 10.24 15.04 10.8 14.4 10.8C13.76 10.8 13.2 10.24 13.2 9.6C13.2 8.96 13.76 8.4 14.4 8.4Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3C10.9283 3 11.8185 3.36875 12.4749 4.02513C13.1313 4.6815 13.5 5.57174 13.5 6.5C13.5 7.42826 13.1313 8.3185 12.4749 8.97487C11.8185 9.63125 10.9283 10 10 10C9.07174 10 8.1815 9.63125 7.52513 8.97487C6.86875 8.3185 6.5 7.42826 6.5 6.5C6.5 5.57174 6.86875 4.6815 7.52513 4.02513C8.1815 3.36875 9.07174 3 10 3ZM10 4.75C9.53587 4.75 9.09075 4.93437 8.76256 5.26256C8.43437 5.59075 8.25 6.03587 8.25 6.5C8.25 6.96413 8.43437 7.40925 8.76256 7.73744C9.09075 8.06563 9.53587 8.25 10 8.25C10.4641 8.25 10.9092 8.06563 11.2374 7.73744C11.5656 7.40925 11.75 6.96413 11.75 6.5C11.75 6.03587 11.5656 5.59075 11.2374 5.26256C10.9092 4.93437 10.4641 4.75 10 4.75ZM10 10.875C12.3363 10.875 17 12.0387 17 14.375V17H3V14.375C3 12.0387 7.66375 10.875 10 10.875ZM10 12.5375C7.40125 12.5375 4.6625 13.815 4.6625 14.375V15.3375H15.3375V14.375C15.3375 13.815 12.5988 12.5375 10 12.5375Z" fill="#72767E"/>
</svg>
<path
d="M10 3C10.9283 3 11.8185 3.36875 12.4749 4.02513C13.1313 4.6815 13.5 5.57174 13.5 6.5C13.5 7.42826 13.1313 8.3185 12.4749 8.97487C11.8185 9.63125 10.9283 10 10 10C9.07174 10 8.1815 9.63125 7.52513 8.97487C6.86875 8.3185 6.5 7.42826 6.5 6.5C6.5 5.57174 6.86875 4.6815 7.52513 4.02513C8.1815 3.36875 9.07174 3 10 3ZM10 4.75C9.53587 4.75 9.09075 4.93437 8.76256 5.26256C8.43437 5.59075 8.25 6.03587 8.25 6.5C8.25 6.96413 8.43437 7.40925 8.76256 7.73744C9.09075 8.06563 9.53587 8.25 10 8.25C10.4641 8.25 10.9092 8.06563 11.2374 7.73744C11.5656 7.40925 11.75 6.96413 11.75 6.5C11.75 6.03587 11.5656 5.59075 11.2374 5.26256C10.9092 4.93437 10.4641 4.75 10 4.75ZM10 10.875C12.3363 10.875 17 12.0387 17 14.375V17H3V14.375C3 12.0387 7.66375 10.875 10 10.875ZM10 12.5375C7.40125 12.5375 4.6625 13.815 4.6625 14.375V15.3375H15.3375V14.375C15.3375 13.815 12.5988 12.5375 10 12.5375Z" />
</svg>

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 968 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33325 3.33325H16.6666V16.6666H3.33325V3.33325ZM4.99992 6.66658V14.9999H14.9999V6.66658H4.99992Z"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -1,5 +1,18 @@
'use strict';
declare global {
interface Window {
// eslint-disable-next-line camelcase
_bugsnag_api_key?: string;
// eslint-disable-next-line camelcase
_purchase_url?: string;
// eslint-disable-next-line camelcase
_plans_url?: string;
// eslint-disable-next-line camelcase
_dashboard_url?: string;
}
}
import { SNLog } from '@standardnotes/snjs';
import angular from 'angular';
import { configRoutes } from './routes';
@@ -33,7 +46,6 @@ import {
import {
ActionsMenu,
ComponentModal,
ComponentView,
EditorMenu,
InputModal,
MenuRow,
@@ -64,6 +76,10 @@ import { IconDirective } from './components/Icon';
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
import { PreferencesDirective } from './preferences';
import { AppVersion, IsWebPlatform } from '@/version';
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
import { PurchaseFlowDirective } from './purchaseFlow';
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu';
import { ComponentViewDirective } from '@/components/ComponentView';
function reloadHiddenFirefoxTab(): boolean {
/**
@@ -142,7 +158,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('actionsMenu', () => new ActionsMenu())
.directive('challengeModal', () => new ChallengeModal())
.directive('componentModal', () => new ComponentModal())
.directive('componentView', () => new ComponentView())
.directive('componentView', ComponentViewDirective)
.directive('editorMenu', () => new EditorMenu())
.directive('inputModal', () => new InputModal())
.directive('menuRow', () => new MenuRow())
@@ -154,6 +170,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('syncResolutionMenu', () => new SyncResolutionMenu())
.directive('sessionsModal', SessionsModalDirective)
.directive('accountMenu', AccountMenuDirective)
.directive('quickSettingsMenu', QuickSettingsMenuDirective)
.directive('noAccountWarning', NoAccountWarningDirective)
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
.directive('searchOptions', SearchOptionsDirective)
@@ -161,9 +178,11 @@ const startApplication: StartApplication = async function startApplication(
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
.directive('notesContextMenu', NotesContextMenuDirective)
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
.directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('preferences', PreferencesDirective);
.directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective);
// Filters
angular.module('app').filter('trusted', ['$sce', trusted]);
@@ -196,7 +215,7 @@ if (IsWebPlatform) {
(window as any)._default_sync_server as string,
new BrowserBridge(AppVersion),
(window as any)._enable_unfinished_features as boolean,
(window as any)._websocket_url as string,
(window as any)._websocket_url as string
);
} else {
(window as any).startApplication = startApplication;

View File

@@ -39,7 +39,7 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
return (
<>
<button
className="sn-dropdown-item font-bold"
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
onClick={toggleShowAdvanced}
>
<div className="flex items-center">

View File

@@ -45,7 +45,7 @@ const Authentication = observer(({ application, appState }: Props) => {
useEffect(() => {
if (isEmailFocused) {
emailInputRef.current.focus();
emailInputRef.current!.focus();
setIsEmailFocused(false);
}
}, [isEmailFocused]);
@@ -64,9 +64,9 @@ const Authentication = observer(({ application, appState }: Props) => {
application.setCustomHost(value);
};
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
const passwordConfirmationInputRef = useRef<HTMLInputElement>();
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
const passwordConfirmationInputRef = useRef<HTMLInputElement>(null);
const handleSignInClick = () => {
setShowSignIn(true);
@@ -79,8 +79,8 @@ const Authentication = observer(({ application, appState }: Props) => {
};
const blurAuthFields = () => {
emailInputRef.current.blur();
passwordInputRef.current.blur();
emailInputRef.current!.blur();
passwordInputRef.current!.blur();
passwordConfirmationInputRef.current?.blur();
};
@@ -277,7 +277,7 @@ const Authentication = observer(({ application, appState }: Props) => {
onKeyDown={handleKeyPressKeyDown}
value={passwordConfirmation}
onChange={handlePasswordConfirmationChange}
ref={passwordConfirmationInputRef}
ref={passwordConfirmationInputRef!}
/>
)}
<div className="sk-panel-row" />

View File

@@ -3,7 +3,7 @@ import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { StateUpdater, useRef, useState } from 'preact/hooks';
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { Button } from '../Button';
import { Checkbox } from '../Checkbox';
@@ -29,7 +29,11 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
const [isEphemeral, setIsEphemeral] = useState(false);
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
const passwordInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
passwordInputRef?.current?.focus();
}, []);
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
@@ -55,7 +59,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
e.preventDefault();
if (!password) {
passwordInputRef?.current.focus();
passwordInputRef?.current!.focus();
return;
}
@@ -85,7 +89,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
.alert(STRING_NON_MATCHING_PASSWORDS)
.finally(() => {
setConfirmPassword('');
passwordInputRef?.current.focus();
passwordInputRef?.current!.focus();
});
}
};

View File

@@ -31,12 +31,12 @@ export const CreateAccount: FunctionComponent<Props> = observer(
}) => {
const [showPassword, setShowPassword] = useState(false);
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (emailInputRef.current) {
emailInputRef.current.focus();
emailInputRef.current!.focus();
}
}, []);
@@ -62,12 +62,12 @@ export const CreateAccount: FunctionComponent<Props> = observer(
e.preventDefault();
if (!email || email.length === 0) {
emailInputRef?.current.focus();
emailInputRef?.current!.focus();
return;
}
if (!password || password.length === 0) {
passwordInputRef?.current.focus();
passwordInputRef?.current!.focus();
return;
}

View File

@@ -24,7 +24,7 @@ const DataBackup = observer(({
appState
}: Props) => {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;

View File

@@ -8,6 +8,9 @@ import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { FunctionComponent } from 'preact';
import { AppVersion } from '@/version';
import { Menu } from '../menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../menu/MenuItem';
type Props = {
appState: AppState;
@@ -16,7 +19,7 @@ type Props = {
closeMenu: () => void;
};
const iconClassName = 'color-grey-1 mr-2';
const iconClassName = 'color-neutral mr-2';
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, closeMenu }) => {
@@ -57,14 +60,15 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<div className="flex items-center justify-between px-3 mt-1 mb-3">
<div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-grey-1" />
<Icon type="close" className="color-neutral" />
</div>
</div>
{user ? (
<>
<div className="px-3 mb-2 color-foreground text-sm">
<div className="px-3 mb-3 color-foreground text-sm">
<div>You're signed in as:</div>
<div className="font-bold">{user.email}</div>
<div className="my-0.5 font-bold">{user.email}</div>
<span className="color-neutral">{application.getHost()}</span>
</div>
<div className="flex items-center justify-between px-3 mb-2">
{isSyncingInProgress ? (
@@ -101,65 +105,71 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
</>
)}
<div className="h-1px my-2 bg-border"></div>
{user ? (
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('account');
appState.preferences.openPreferences();
}}
>
<Icon type="user" className={iconClassName} />
Account settings
</button>
) : (
<>
<button
className="sn-dropdown-item"
<Menu a11yLabel="General account menu" closeMenu={closeMenu}>
{user ? (
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
setMenuPane(AccountMenuPane.Register);
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('account');
appState.preferences.openPreferences();
}}
>
<Icon type="user" className={iconClassName} />
Create free account
</button>
<button
className="sn-dropdown-item"
onClick={() => {
setMenuPane(AccountMenuPane.SignIn);
}}
>
<Icon type="signIn" className={iconClassName} />
Sign in
</button>
</>
)}
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('help-feedback');
appState.preferences.openPreferences();
}}
>
<Icon type="help" className={iconClassName} />
Help &amp; feedback
</button>
{user ? (
<>
<div className="h-1px my-2 bg-border"></div>
<button
className="sn-dropdown-item"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
>
<Icon type="signOut" className={iconClassName} />
Sign out and clear local data
</button>
</>
) : null}
Account settings
</MenuItem>
) : (
<>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
setMenuPane(AccountMenuPane.Register);
}}
>
<Icon type="user" className={iconClassName} />
Create free account
</MenuItem>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
setMenuPane(AccountMenuPane.SignIn);
}}
>
<Icon type="signIn" className={iconClassName} />
Sign in
</MenuItem>
</>
)}
<MenuItem
className="justify-between"
type={MenuItemType.IconButton}
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('help-feedback');
appState.preferences.openPreferences();
}}
>
<div className="flex items-center">
<Icon type="help" className={iconClassName} />
Help &amp; feedback
</div>
<span className="color-neutral">v{AppVersion}</span>
</MenuItem>
{user ? (
<>
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
>
<Icon type="signOut" className={iconClassName} />
Sign out and clear local data
</MenuItem>
</>
) : null}
</Menu>
</>
);
}

View File

@@ -30,7 +30,7 @@ const PasscodeLock = observer(({
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu;
const passcodeInputRef = useRef<HTMLInputElement>();
const passcodeInputRef = useRef<HTMLInputElement>(null);
const [passcode, setPasscode] = useState<string | undefined>(undefined);
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string | undefined>(undefined);
@@ -155,7 +155,7 @@ const PasscodeLock = observer(({
useEffect(() => {
if (isPasscodeFocused) {
passcodeInputRef.current.focus();
passcodeInputRef.current!.focus();
setIsPasscodeFocused(false);
}
}, [isPasscodeFocused]);

View File

@@ -29,12 +29,12 @@ export const SignInPane: FunctionComponent<Props> = observer(
const [showPassword, setShowPassword] = useState(false);
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (emailInputRef?.current) {
emailInputRef.current.focus();
emailInputRef.current!.focus();
}
}, []);
@@ -73,8 +73,8 @@ export const SignInPane: FunctionComponent<Props> = observer(
const signIn = () => {
setIsSigningIn(true);
emailInputRef?.current.blur();
passwordInputRef?.current.blur();
emailInputRef?.current!.blur();
passwordInputRef?.current!.blur();
application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
@@ -92,7 +92,7 @@ export const SignInPane: FunctionComponent<Props> = observer(
application.alertService.alert(err);
}
setPassword('');
passwordInputRef?.current.blur();
passwordInputRef?.current!.blur();
})
.finally(() => {
setIsSigningIn(false);
@@ -109,12 +109,12 @@ export const SignInPane: FunctionComponent<Props> = observer(
e.preventDefault();
if (!email || email.length === 0) {
emailInputRef?.current.focus();
emailInputRef?.current!.focus();
return;
}
if (!password || password.length === 0) {
passwordInputRef?.current.focus();
passwordInputRef?.current!.focus();
return;
}

View File

@@ -9,6 +9,7 @@ import { SignInPane } from './SignIn';
import { CreateAccount } from './CreateAccount';
import { ConfirmSignoutContainer } from '../ConfirmSignoutModal';
import { ConfirmPassword } from './ConfirmPassword';
import { JSXInternal } from 'preact/src/jsx';
export enum AccountMenuPane {
GeneralMenu,
@@ -87,14 +88,31 @@ const AccountMenu: FunctionComponent<Props> = observer(
closeAccountMenu,
} = appState.accountMenu;
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
event
) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu();
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register);
} else {
setCurrentPane(AccountMenuPane.GeneralMenu);
}
break;
}
};
return (
<div className="sn-component">
<div className='sn-component'>
<div
className={`sn-account-menu sn-dropdown ${
className={`sn-menu-border sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu
? 'slide-up-animation'
: 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
onKeyDown={handleKeyDown}
>
<MenuPaneSelector
appState={appState}

View File

@@ -12,7 +12,7 @@ export const AutocompleteTagHint = observer(
({ appState, closeOnBlur }: Props) => {
const { autocompleteTagHintFocused } = appState.noteTags;
const hintRef = useRef<HTMLButtonElement>();
const hintRef = useRef<HTMLButtonElement>(null);
const { autocompleteSearchQuery, autocompleteTagResults } =
appState.noteTags;
@@ -45,7 +45,7 @@ export const AutocompleteTagHint = observer(
useEffect(() => {
if (autocompleteTagHintFocused) {
hintRef.current.focus();
hintRef.current!.focus();
}
}, [appState.noteTags, autocompleteTagHintFocused]);

View File

@@ -25,17 +25,17 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
const [dropdownMaxHeight, setDropdownMaxHeight] =
useState<number | 'auto'>('auto');
const containerRef = useRef<HTMLDivElement>();
const inputRef = useRef<HTMLInputElement>();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
const [closeOnBlur] = useCloseOnBlur(containerRef as any, (visible: boolean) => {
setDropdownVisible(visible);
appState.noteTags.clearAutocompleteSearch();
});
const showDropdown = () => {
const { clientHeight } = document.documentElement;
const inputRect = inputRef.current.getBoundingClientRect();
const inputRect = inputRef.current!.getBoundingClientRect();
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
setDropdownVisible(true);
};
@@ -93,7 +93,7 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
useEffect(() => {
if (autocompleteInputFocused) {
inputRef.current.focus();
inputRef.current!.focus();
}
}, [appState.noteTags, autocompleteInputFocused]);

View File

@@ -19,7 +19,7 @@ export const AutocompleteTagResult = observer(
focusedTagResultUuid,
} = appState.noteTags;
const tagResultRef = useRef<HTMLButtonElement>();
const tagResultRef = useRef<HTMLButtonElement>(null);
const onTagOptionClick = async (tag: SNTag) => {
await appState.noteTags.addTagToActiveNote(tag);
@@ -68,7 +68,7 @@ export const AutocompleteTagResult = observer(
useEffect(() => {
if (focusedTagResultUuid === tagResult.uuid) {
tagResultRef.current.focus();
tagResultRef.current!.focus();
appState.noteTags.setFocusedTagResultUuid(undefined);
}
}, [appState.noteTags, focusedTagResultUuid, tagResult]);

View File

@@ -9,16 +9,20 @@ const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`;
type ButtonType = 'normal' | 'primary' | 'danger';
const buttonClasses: { [type in ButtonType]: string } = {
normal: `${baseClass} bg-default color-text border-solid border-gray-300 border-1 focus:bg-contrast hover:bg-contrast`,
normal: `${baseClass} bg-default color-text border-solid border-main border-1 focus:bg-contrast hover:bg-contrast`,
primary: `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 focus:brightness-130`,
danger: `${baseClass} bg-default color-danger border-solid border-gray-300 border-1 focus:bg-contrast hover:bg-contrast`,
danger: `${baseClass} bg-default color-danger border-solid border-main border-1 focus:bg-contrast hover:bg-contrast`,
};
export const Button: FunctionComponent<{
className?: string;
type: ButtonType;
label: string;
onClick: (event: TargetedEvent<HTMLFormElement> | TargetedMouseEvent<HTMLButtonElement>) => void;
onClick: (
event:
| TargetedEvent<HTMLFormElement>
| TargetedMouseEvent<HTMLButtonElement>
) => void;
disabled?: boolean;
}> = ({ type, label, className = '', onClick, disabled = false }) => {
const buttonClass = buttonClasses[type];

View File

@@ -0,0 +1,32 @@
import { FunctionalComponent } from 'preact';
interface IProps {
deprecationMessage: string | undefined;
dismissDeprecationMessage: () => void;
}
export const IsDeprecated: FunctionalComponent<IProps> = ({
deprecationMessage,
dismissDeprecationMessage
}) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-label warning'}>
{deprecationMessage || 'This extension is deprecated.'}
</div>
</div>
</div>
<div className={'right'}>
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
<button className={'sn-button small info'}>
Dismiss
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { FunctionalComponent } from 'preact';
interface IProps {
expiredDate: string;
reloadStatus: () => void;
}
export const IsExpired: FunctionalComponent<IProps> = ({
expiredDate,
reloadStatus
}) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-app-bar-item-column'}>
<div className={'sk-circle danger small'} />
</div>
<div className={'sk-app-bar-item-column'}>
<div>
<a
className={'sk-label sk-base'}
href={'https://dashboard.standardnotes.com'}
rel={'noopener'}
target={'_blank'}
>
Your Extended subscription expired on {expiredDate}
</a>
<div className={'sk-p'}>
Extensions are in a read-only state.
</div>
</div>
</div>
</div>
</div>
<div className={'right'}>
<div className={'sk-app-bar-item'} onClick={() => reloadStatus()}>
<button className={'sn-button small info'}>Reload</button>
</div>
<div className={'sk-app-bar-item'}>
<div className={'sk-app-bar-item-column'}>
<a
className={'sn-button small warning'}
href={'https://standardnotes.com/help/41/my-extensions-appear-as-expired-even-though-my-subscription-is-still-valid'}
rel={'noopener'}
target={'_blank'}
>
Help
</a>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,30 @@
import { FunctionalComponent } from 'preact';
interface IProps {
componentName: string;
reloadIframe: () => void;
}
export const IssueOnLoading: FunctionalComponent<IProps> = ({
componentName,
reloadIframe
}) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-label.warning'}>
There was an issue loading {componentName}
</div>
</div>
</div>
<div className={'right'}>
<div className={'sk-app-bar-item'} onClick={reloadIframe}>
<button className={'sn-button small info'}>Reload</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,58 @@
import { FunctionalComponent } from 'preact';
interface IProps {
isReloading: boolean;
reloadStatus: () => void;
}
export const OfflineRestricted: FunctionalComponent<IProps> = ({
isReloading,
reloadStatus
}) => {
return (
<div className={'sn-component'}>
<div className={'sk-panel static'}>
<div className={'sk-panel-content'}>
<div className={'sk-panel-section stretch'}>
<div className={'sk-panel-column'} />
<div className={'sk-h1 sk-bold'}>
You have restricted this extension to be used offline only.
</div>
<div className={'sk-subtitle'}>
Offline extensions are not available in the Web app.
</div>
<div className={'sk-panel-row'} />
<div className={'sk-panel-row'}>
<div className={'sk-panel-column'}>
<div className={'sk-p'}>
You can either:
</div>
<ul>
<li className={'sk-p'}>
<span className={'font-bold'}>
Enable the Hosted option for this extension by opening the 'Extensions' menu and{' '}
toggling 'Use hosted when local is unavailable' under this extension's options.{' '}
Then press Reload below.
</span>
</li>
<li className={'sk-p'}>
<span className={'font-bold'}>Use the Desktop application.</span>
</li>
</ul>
</div>
</div>
<div className={'sk-panel-row'}>
{isReloading ?
<div className={'sk-spinner info small'} />
:
<button className={'sn-button small info'} onClick={() => reloadStatus()}>
Reload
</button>
}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { FunctionalComponent } from 'preact';
interface IProps {
componentName: string;
}
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
return (
<div className={'sn-component'}>
<div className={'sk-panel static'}>
<div className={'sk-panel-content'}>
<div className={'sk-panel-section stretch'}>
<div className={'sk-panel-section-title'}>
This extension is not installed correctly.
</div>
<p>Please uninstall {componentName}, then re-install it.</p>
<p>
This issue can occur if you access Standard Notes using an older version of the app.{' '}
Ensure you are running at least version 2.1 on all platforms.
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,360 @@
import { ComponentAction, LiveItem, SNComponent } from '@node_modules/@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { toDirective } from '@/components/utils';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite';
import { isDesktopApplication } from '@/utils';
import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted';
import { UrlMissing } from '@/components/ComponentView/UrlMissing';
import { IsDeprecated } from '@/components/ComponentView/IsDeprecated';
import { IsExpired } from '@/components/ComponentView/IsExpired';
import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading';
import { AppState } from '@/ui_models/app_state';
import { ComponentArea } from '@node_modules/@standardnotes/features';
interface IProps {
application: WebApplication;
appState: AppState;
componentUuid: string;
onLoad?: (component: SNComponent) => void;
templateComponent?: SNComponent;
manualDealloc?: boolean;
}
/**
* The maximum amount of time we'll wait for a component
* to load before displaying error
*/
const MaxLoadThreshold = 4000;
const VisibilityChangeKey = 'visibilitychange';
const avoidFlickerTimeout = 7;
export const ComponentView: FunctionalComponent<IProps> = observer(
({
application,
appState,
onLoad,
componentUuid,
templateComponent,
manualDealloc = false,
}) => {
const liveComponentRef = useRef<LiveItem<SNComponent> | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isIssueOnLoading, setIsIssueOnLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [loadTimeout, setLoadTimeout] = useState<number | undefined>(undefined);
const [isExpired, setIsExpired] = useState(false);
const [isComponentValid, setIsComponentValid] = useState(true);
const [error, setError] = useState<'offline-restricted' | 'url-missing' | undefined>(undefined);
const [isDeprecated, setIsDeprecated] = useState(false);
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined);
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false);
const [didAttemptReload, setDidAttemptReload] = useState(false);
const [component, setComponent] = useState<SNComponent | undefined>(undefined);
const getComponent = useCallback((): SNComponent => {
return (templateComponent || liveComponentRef.current?.item) as SNComponent;
}, [templateComponent]);
const reloadIframe = () => {
setTimeout(() => {
setIsReloading(true);
setTimeout(() => {
setIsReloading(false);
});
});
};
const reloadStatus = useCallback(() => {
if (!component) {
return;
}
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
const hasUrlError = function () {
if (isDesktopApplication()) {
return !component.local_url && !component.hasValidHostedUrl();
} else {
return !component.hasValidHostedUrl();
}
}();
setIsExpired(component.valid_until && component.valid_until <= new Date());
const readonlyState = application.componentManager!.getReadonlyStateForComponent(component);
if (!readonlyState.lockReadonly) {
application.componentManager!.setReadonlyStateForComponent(component, isExpired);
}
setIsComponentValid(!offlineRestricted && !hasUrlError);
if (!isComponentValid) {
setIsLoading(false);
}
if (offlineRestricted) {
setError('offline-restricted');
} else if (hasUrlError) {
setError('url-missing');
} else {
setError(undefined);
}
setIsDeprecated(component.isDeprecated);
setDeprecationMessage(component.package_info.deprecation_message);
}, [application.componentManager, component, isComponentValid, isExpired]);
const dismissDeprecationMessage = () => {
setTimeout(() => {
setIsDeprecationMessageDismissed(true);
});
};
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return;
}
if (isIssueOnLoading) {
reloadIframe();
}
}, [isIssueOnLoading]);
const handleIframeLoadTimeout = useCallback(async () => {
if (isLoading) {
setIsLoading(false);
setIsIssueOnLoading(true);
if (!didAttemptReload) {
setDidAttemptReload(true);
reloadIframe();
} else {
document.addEventListener(
VisibilityChangeKey,
onVisibilityChange
);
}
}
}, [didAttemptReload, isLoading, onVisibilityChange]);
const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => {
if (!component) {
return;
}
let desktopError = false;
if (isDesktopApplication()) {
try {
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') {
desktopError = true;
}
// eslint-disable-next-line no-empty
} catch (e) {
}
}
clearTimeout(loadTimeout);
await application.componentManager!.registerComponentWindow(
component,
iframe.contentWindow!
);
setTimeout(() => {
setIsLoading(false);
setIsIssueOnLoading(desktopError ? true : false);
onLoad?.(component!);
}, avoidFlickerTimeout);
}, [application.componentManager, component, loadTimeout, onLoad]);
const loadComponent = useCallback(() => {
if (!component) {
throw Error('Component view is missing component');
}
if (!component.active && !component.isEditor() && component.area !== ComponentArea.Modal) {
/** Editors don't need to be active to be displayed */
throw Error('Component view component must be active');
}
setIsLoading(true);
if (loadTimeout) {
clearTimeout(loadTimeout);
}
const timeoutHandler = setTimeout(() => {
handleIframeLoadTimeout();
}, MaxLoadThreshold);
setLoadTimeout(timeoutHandler);
}, [component, handleIframeLoadTimeout, loadTimeout]);
useEffect(() => {
if (!iframeRef.current) {
return;
}
iframeRef.current.onload = () => {
if (!component) {
return;
}
const iframe = application.componentManager!.iframeForComponent(
component.uuid
);
if (!iframe) {
return;
}
setTimeout(() => {
loadComponent();
reloadStatus();
handleIframeLoad(iframe);
});
};
}, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const expiredDate = isExpired ? component.dateToLocalizedString(component.valid_until) : '';
const getUrl = () => {
const url = component ? application.componentManager!.urlForComponent(component) : '';
return url as string;
};
useEffect(() => {
if (componentUuid) {
liveComponentRef.current = new LiveItem(componentUuid, application);
} else {
application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent);
}
return () => {
if (application.componentManager) {
/** Component manager Can be destroyed already via locking */
if (component) {
application.componentManager.onComponentIframeDestroyed(component.uuid);
}
if (templateComponent) {
application.componentManager.removeTemporaryTemplateComponent(templateComponent);
}
}
if (liveComponentRef.current) {
liveComponentRef.current.deinit();
}
document.removeEventListener(
VisibilityChangeKey,
onVisibilityChange
);
};
}, [appState, application, component, componentUuid, onVisibilityChange, reloadStatus, templateComponent]);
useEffect(() => {
// Set/update `component` based on `componentUuid` prop.
// It's a hint that the props were changed and we should rerender this component (and particularly, the iframe).
if (!component || component.uuid && componentUuid && component.uuid !== componentUuid) {
const latestComponentValue = getComponent();
setComponent(latestComponentValue);
}
}, [component, componentUuid, getComponent]);
useEffect(() => {
if (!component) {
return;
}
const unregisterComponentHandler = application.componentManager!.registerHandler({
identifier: 'component-view-' + Math.random(),
areas: [component.area],
actionHandler: (component, action, data) => {
switch (action) {
case (ComponentAction.SetSize):
application.componentManager!.handleSetSizeEvent(component, data);
break;
case (ComponentAction.KeyDown):
application.io.handleComponentKeyDown(data.keyboardModifier);
break;
case (ComponentAction.KeyUp):
application.io.handleComponentKeyUp(data.keyboardModifier);
break;
case (ComponentAction.Click):
application.getAppState().notes.setContextMenuOpen(false);
break;
default:
return;
}
}
});
return () => {
unregisterComponentHandler();
};
}, [application, component]);
useEffect(() => {
const unregisterDesktopObserver = application.getDesktopService()
.registerUpdateObserver((component: SNComponent) => {
if (component.uuid === component.uuid && component.active) {
reloadIframe();
}
});
return () => {
unregisterDesktopObserver();
};
}, [application]);
if (!component) {
return null;
}
return (
<>
{isIssueOnLoading && (
<IssueOnLoading
componentName={component.name}
reloadIframe={reloadIframe}
/>
)}
{isExpired && (
<IsExpired expiredDate={expiredDate} reloadStatus={reloadStatus} />
)}
{isDeprecated && !isDeprecationMessageDismissed && (
<IsDeprecated
deprecationMessage={deprecationMessage}
dismissDeprecationMessage={dismissDeprecationMessage}
/>
)}
{error == 'offline-restricted' && (
<OfflineRestricted isReloading={isReloading} reloadStatus={reloadStatus} />
)}
{error == 'url-missing' && (
<UrlMissing componentName={component.name} />
)}
{component.uuid && !isReloading && isComponentValid && (
<iframe
ref={iframeRef}
data-component-id={component.uuid}
frameBorder={0}
data-attr-id={`component-iframe-${component.uuid}`}
src={getUrl()}
sandbox='allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads'
>
Loading
</iframe>
)}
{isLoading && (
<div className={'loading-overlay'} />
)}
</>
);
});
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
onLoad: '=',
componentUuid: '=',
templateComponent: '=',
manualDealloc: '='
});

View File

@@ -25,7 +25,7 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
const cancelRef = useRef<HTMLButtonElement>();
const cancelRef = useRef<HTMLButtonElement>(null);
function closeDialog() {
appState.accountMenu.setSigningOut(false);
}

View File

@@ -9,7 +9,7 @@ import { useRef } from 'preact/hooks';
export const ConfirmationDialog: FunctionComponent<{
title: string | ComponentChildren;
}> = ({ title, children }) => {
const ldRef = useRef<HTMLButtonElement>();
const ldRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog leastDestructiveRef={ldRef}>

View File

@@ -31,10 +31,11 @@ export const DecoratedInput: FunctionalComponent<Props> = ({
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center bg-contrast';
const stateClasses = disabled
? 'no-border'
: 'border-solid border-1 border-gray-300';
: 'border-solid border-1 border-main';
const classes = `${baseClasses} ${stateClasses} ${className}`;
const inputBaseClasses = 'w-full no-border color-text focus:shadow-none bg-contrast';
const inputBaseClasses =
'w-full no-border color-text focus:shadow-none bg-contrast';
const inputStateClasses = disabled ? 'overflow-ellipsis' : '';
return (
<div className={`${classes} focus-within:ring-info`}>

View File

@@ -0,0 +1,75 @@
import { FunctionComponent, Ref } from 'preact';
import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat';
import { useState } from 'preact/hooks';
type Props = {
id: string;
type: 'text' | 'email' | 'password'; // Have no use cases for other types so far
label: string;
value: string;
onChange: JSXInternal.GenericEventHandler<HTMLInputElement>;
disabled?: boolean;
className?: string;
labelClassName?: string;
inputClassName?: string;
isInvalid?: boolean;
};
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
(
{
id,
type,
label,
disabled,
value,
isInvalid,
onChange,
className = '',
labelClassName = '',
inputClassName = '',
}: Props,
ref: Ref<HTMLInputElement>
) => {
const [focused, setFocused] = useState(false);
const BASE_CLASSNAME = `relative bg-default`;
const LABEL_CLASSNAME = `hidden absolute ${
!focused ? 'color-neutral' : 'color-info'
} ${focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''} ${
isInvalid ? 'color-dark-red' : ''
} ${labelClassName}`;
const INPUT_CLASSNAME = `w-full h-full ${
focused || value ? 'pt-6 pb-2' : 'py-2.5'
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
isInvalid ? 'border-dark-red placeholder-dark-red' : ''
} ${inputClassName}`;
const handleFocus = () => setFocused(true);
const handleBlur = () => setFocused(false);
return (
<div className={`${BASE_CLASSNAME} ${className}`}>
<label htmlFor={id} className={LABEL_CLASSNAME}>
{label}
</label>
<input
id={id}
className={INPUT_CLASSNAME}
placeholder={!focused ? label : ''}
type={type}
value={value}
onChange={onChange}
onFocus={handleFocus}
onBlur={handleBlur}
ref={ref}
disabled={disabled}
/>
</div>
);
}
);

View File

@@ -48,11 +48,16 @@ import ServerIcon from '../../icons/ic-server.svg';
import EyeIcon from '../../icons/ic-eye.svg';
import EyeOffIcon from '../../icons/ic-eye-off.svg';
import LockIcon from '../../icons/ic-lock.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import WindowIcon from '../../icons/ic-window.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon,
eye: EyeIcon,
'eye-off': EyeOffIcon,
@@ -102,6 +107,7 @@ const ICONS = {
'check-bold': CheckBoldIcon,
'account-circle': AccountCircleIcon,
'menu-arrow-down': MenuArrowDownIcon,
window: WindowIcon
};
export type IconType = keyof typeof ICONS;

View File

@@ -14,7 +14,7 @@ export const Input: FunctionalComponent<Props> = ({
const base = `rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast`;
const stateClasses = disabled
? 'no-border'
: 'border-solid border-1 border-gray-300';
: 'border-solid border-1 border-main';
const classes = `${base} ${stateClasses} ${className}`;
return (
<input type="text" className={classes} disabled={disabled} value={text} />

View File

@@ -42,7 +42,7 @@ export const InputWithIcon: FunctionComponent<Props> = forwardRef(
disabled,
toggle,
placeholder,
},
}: Props,
ref: Ref<HTMLInputElement>
) => {
const handleToggle = () => {
@@ -51,7 +51,7 @@ export const InputWithIcon: FunctionComponent<Props> = forwardRef(
return (
<div
className={`flex items-stretch position-relative bg-default border-1 border-solid border-neutral rounded focus-within:ring-info overflow-hidden ${
className={`flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
disabled ? DISABLED_CLASSNAME : ''
} ${className}`}
>

View File

@@ -14,9 +14,9 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
const [showDeleteButton, setShowDeleteButton] = useState(false);
const [tagClicked, setTagClicked] = useState(false);
const deleteTagRef = useRef<HTMLButtonElement>();
const deleteTagRef = useRef<HTMLButtonElement>(null);
const tagRef = useRef<HTMLButtonElement>();
const tagRef = useRef<HTMLButtonElement>(null);
const deleteTag = () => {
appState.noteTags.focusPreviousTag(tag);
@@ -84,7 +84,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
useEffect(() => {
if (focusedTagUuid === tag.uuid) {
tagRef.current.focus();
tagRef.current!.focus();
}
}, [appState.noteTags, focusedTagUuid, tag]);

View File

@@ -17,14 +17,14 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
contextMenuMaxHeight,
} = appState.notes;
const contextMenuRef = useRef<HTMLDivElement>();
const contextMenuRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(
contextMenuRef,
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
);
useCloseOnClickOutside(
contextMenuRef,
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
);

View File

@@ -0,0 +1,244 @@
import { WebApplication } from '@/ui_models/application';
import { CollectionSort, PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { Icon } from './Icon';
import { Menu } from './menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
import { toDirective } from './utils';
type Props = {
application: WebApplication;
setShowMenuFalse: () => void;
};
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ setShowMenuFalse, application }) => {
const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \
flex flex-col py-2 bottom-0 left-2 absolute';
const [sortBy, setSortBy] = useState(() =>
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
);
const [sortReverse, setSortReverse] = useState(() =>
application.getPreference(PrefKey.SortNotesReverse, false)
);
const [hidePreview, setHidePreview] = useState(() =>
application.getPreference(PrefKey.NotesHideNotePreview, false)
);
const [hideDate, setHideDate] = useState(() =>
application.getPreference(PrefKey.NotesHideDate, false)
);
const [hideTags, setHideTags] = useState(() =>
application.getPreference(PrefKey.NotesHideTags, true)
);
const [hidePinned, setHidePinned] = useState(() =>
application.getPreference(PrefKey.NotesHidePinned, false)
);
const [showArchived, setShowArchived] = useState(() =>
application.getPreference(PrefKey.NotesShowArchived, false)
);
const [showTrashed, setShowTrashed] = useState(() =>
application.getPreference(PrefKey.NotesShowTrashed, false)
);
const [hideProtected, setHideProtected] = useState(() =>
application.getPreference(PrefKey.NotesHideProtected, false)
);
const toggleSortReverse = () => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse);
setSortReverse(!sortReverse);
};
const toggleSortBy = (sort: CollectionSort) => {
if (sortBy === sort) {
toggleSortReverse();
} else {
setSortBy(sort);
application.setPreference(PrefKey.SortNotesBy, sort);
}
};
const toggleSortByDateModified = () => {
toggleSortBy(CollectionSort.UpdatedAt);
};
const toggleSortByCreationDate = () => {
toggleSortBy(CollectionSort.CreatedAt);
};
const toggleSortByTitle = () => {
toggleSortBy(CollectionSort.Title);
};
const toggleHidePreview = () => {
setHidePreview(!hidePreview);
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview);
};
const toggleHideDate = () => {
setHideDate(!hideDate);
application.setPreference(PrefKey.NotesHideDate, !hideDate);
};
const toggleHideTags = () => {
setHideTags(!hideTags);
application.setPreference(PrefKey.NotesHideTags, !hideTags);
};
const toggleHidePinned = () => {
setHidePinned(!hidePinned);
application.setPreference(PrefKey.NotesHidePinned, !hidePinned);
};
const toggleShowArchived = () => {
setShowArchived(!showArchived);
application.setPreference(PrefKey.NotesShowArchived, !showArchived);
};
const toggleShowTrashed = () => {
setShowTrashed(!showTrashed);
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed);
};
const toggleHideProtected = () => {
setHideProtected(!hideProtected);
application.setPreference(PrefKey.NotesHideProtected, !hideProtected);
};
return (
<div className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={setShowMenuFalse}>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">
Sort by
</div>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt}
>
<div className="flex flex-grow items-center justify-between">
<span>Date modified</span>
{sortBy === CollectionSort.UpdatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt}
>
<div className="flex flex-grow items-center justify-between">
<span>Creation date</span>
{sortBy === CollectionSort.CreatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title}
>
<div className="flex flex-grow items-center justify-between">
<span>Title</span>
{sortBy === CollectionSort.Title ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">
View
</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePreview}
onChange={toggleHidePreview}
>
<div className="flex flex-col max-w-3/4">Show note preview</div>
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideDate}
onChange={toggleHideDate}
>
Show date
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideTags}
onChange={toggleHideTags}
>
Show tags
</MenuItem>
<div className="h-1px my-2 bg-border"></div>
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">
Other
</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePinned}
onChange={toggleHidePinned}
>
Show pinned notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideProtected}
onChange={toggleHideProtected}
>
Show protected notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showArchived}
onChange={toggleShowArchived}
>
Show archived notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showTrashed}
onChange={toggleShowTrashed}
>
Show trashed notes
</MenuItem>
</Menu>
</div>
);
}
);
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
setShowMenuFalse: '=',
state: '&',
}
);

View File

@@ -2,7 +2,7 @@ import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon';
import { Switch } from './Switch';
import { observer } from 'mobx-react-lite';
import { useRef, useState, useEffect } from 'preact/hooks';
import { useRef, useState, useEffect, useMemo } from 'preact/hooks';
import {
Disclosure,
DisclosureButton,
@@ -11,6 +11,7 @@ import {
import { SNNote } from '@standardnotes/snjs/dist/@types';
import { WebApplication } from '@/ui_models/application';
import { KeyboardModifier } from '@/services/ioService';
import { FunctionComponent } from 'preact';
type Props = {
application: WebApplication;
@@ -20,9 +21,9 @@ type Props = {
};
type DeletePermanentlyButtonProps = {
closeOnBlur: Props["closeOnBlur"];
closeOnBlur: Props['closeOnBlur'];
onClick: () => void;
}
};
const DeletePermanentlyButton = ({
closeOnBlur,
@@ -34,6 +35,87 @@ const DeletePermanentlyButton = ({
</button>
);
const countNoteAttributes = (text: string) => {
try {
JSON.parse(text);
return {
characters: 'N/A',
words: 'N/A',
paragraphs: 'N/A',
};
} catch {
const characters = text.length;
const words = text.match(/[\w'-]+\b/g)?.length;
const paragraphs = text.replace(/\n$/gm, '').split(/\n/).length;
return {
characters,
words,
paragraphs,
};
}
};
const calculateReadTime = (words: number) => {
const timeToRead = Math.round(words / 200);
if (timeToRead === 0) {
return '< 1 minute';
} else {
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`;
}
};
const formatDate = (date: Date | undefined) => {
if (!date) return;
return `${date.toDateString()} ${date.toLocaleTimeString()}`;
};
const NoteAttributes: FunctionComponent<{ note: SNNote }> = ({ note }) => {
const { words, characters, paragraphs } = useMemo(
() => countNoteAttributes(note.text),
[note.text]
);
const readTime = useMemo(
() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'),
[words]
);
const dateLastModified = useMemo(
() => formatDate(note.serverUpdatedAt),
[note.serverUpdatedAt]
);
const dateCreated = useMemo(
() => formatDate(note.created_at),
[note.created_at]
);
return (
<div className="px-3 pt-1.5 pb-1 text-xs color-neutral font-medium">
{typeof words === 'number' ? (
<>
<div className="mb-1">
{words} words · {characters} characters · {paragraphs} paragraphs
</div>
<div className="mb-1">
<span className="font-semibold">Read time:</span> {readTime}
</div>
</>
) : null}
<div className="mb-1">
<span className="font-semibold">Last modified:</span> {dateLastModified}
</div>
<div className="mb-1">
<span className="font-semibold">Created:</span> {dateCreated}
</div>
<div>
<span className="font-semibold">Note ID:</span> {note.uuid}
</div>
</div>
);
};
export const NotesOptions = observer(
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => {
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
@@ -45,8 +127,9 @@ export const NotesOptions = observer(
top: 0,
right: 0,
});
const [tagsMenuMaxHeight, setTagsMenuMaxHeight] =
useState<number | 'auto'>('auto');
const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState<number | 'auto'>(
'auto'
);
const [altKeyDown, setAltKeyDown] = useState(false);
const toggleOn = (condition: (note: SNNote) => boolean) => {
@@ -67,8 +150,9 @@ export const NotesOptions = observer(
const notTrashed = notes.some((note) => !note.trashed);
const pinned = notes.some((note) => note.pinned);
const unpinned = notes.some((note) => !note.pinned);
const errored = notes.some((note) => note.errorDecrypting);
const tagsButtonRef = useRef<HTMLButtonElement>();
const tagsButtonRef = useRef<HTMLButtonElement>(null);
const iconClass = 'color-neutral mr-2';
@@ -86,7 +170,7 @@ export const NotesOptions = observer(
},
onKeyUp: () => {
setAltKeyDown(false);
}
},
});
return () => {
@@ -100,7 +184,7 @@ export const NotesOptions = observer(
).fontSize;
const maxTagsMenuSize = parseFloat(defaultFontSize) * 30;
const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = tagsButtonRef.current.getBoundingClientRect();
const buttonRect = tagsButtonRef.current!.getBoundingClientRect();
const footerHeight = 32;
if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) {
@@ -122,6 +206,39 @@ export const NotesOptions = observer(
setTagsMenuOpen(!tagsMenuOpen);
};
const downloadSelectedItems = () => {
notes.forEach((note) => {
const editor = application.componentManager.editorForNote(note);
const format = editor?.package_info?.file_type || 'txt';
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text)
);
downloadAnchor.setAttribute('download', `${note.title}.${format}`);
downloadAnchor.click();
});
};
const duplicateSelectedItems = () => {
notes.forEach((note) => {
application.duplicateItem(note);
});
};
if (errored) {
return (
<>
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await appState.notes.deleteNotesPermanently();
}}
/>
</>
);
}
return (
<>
<Switch
@@ -163,7 +280,7 @@ export const NotesOptions = observer(
Protect
</span>
</Switch>
<div className="h-1px my-2 bg-border"></div>
<div className="min-h-1px my-2 bg-border"></div>
{appState.tags.tagsCount > 0 && (
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
<DisclosureButton
@@ -186,7 +303,7 @@ export const NotesOptions = observer(
onKeyDown={(event) => {
if (event.key === 'Escape') {
setTagsMenuOpen(false);
tagsButtonRef.current.focus();
tagsButtonRef.current!.focus();
}
}}
style={{
@@ -246,6 +363,22 @@ export const NotesOptions = observer(
Unpin
</button>
)}
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={downloadSelectedItems}
>
<Icon type="download" className={iconClass} />
Export
</button>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={duplicateSelectedItems}
>
<Icon type="copy" className={iconClass} />
Duplicate
</button>
{unarchived && (
<button
onBlur={closeOnBlur}
@@ -327,6 +460,12 @@ export const NotesOptions = observer(
</button>
</>
)}
{notes.length === 1 ? (
<>
<div className="min-h-1px my-2 bg-border"></div>
<NoteAttributes note={notes[0]} />
</>
) : null}
</>
);
}

View File

@@ -24,9 +24,9 @@ export const NotesOptionsPanel = observer(({ application, appState }: Props) =>
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>();
const panelRef = useRef<HTMLDivElement>();
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
const onSubmenuChange = (open: boolean) => {
@@ -37,7 +37,7 @@ export const NotesOptionsPanel = observer(({ application, appState }: Props) =>
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current.getBoundingClientRect();
const rect = buttonRef.current!.getBoundingClientRect();
const { clientHeight } = document.documentElement;
const footerHeight = 32;
setMaxHeight(clientHeight - rect.bottom - footerHeight - 2);
@@ -65,7 +65,7 @@ export const NotesOptionsPanel = observer(({ application, appState }: Props) =>
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current.focus();
buttonRef.current!.focus();
}
}}
ref={panelRef}

View File

@@ -23,7 +23,7 @@ export const OtherSessionsSignOutContainer = observer((props: Props) => {
const ConfirmOtherSessionsSignOut = observer(
({ application, appState }: Props) => {
const cancelRef = useRef<HTMLButtonElement>();
const cancelRef = useRef<HTMLButtonElement>(null);
function closeDialog() {
appState.accountMenu.setOtherSessionsSignOut(false);
}

View File

@@ -0,0 +1,376 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import {
ContentType,
SNTheme,
ComponentArea,
SNComponent,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { Icon } from './Icon';
import { Switch } from './Switch';
import { toDirective, useCloseOnBlur } from './utils';
const MENU_CLASSNAME =
'sn-menu-border sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto';
type ThemeButtonProps = {
theme: SNTheme;
application: WebApplication;
onBlur: (event: { relatedTarget: EventTarget | null }) => void;
};
type MenuProps = {
appState: AppState;
application: WebApplication;
};
const ThemeButton: FunctionComponent<ThemeButtonProps> = ({
application,
theme,
onBlur,
}) => {
const toggleTheme = (e: any) => {
e.preventDefault();
if (theme.isLayerable() || !theme.active) {
application.toggleComponent(theme);
}
};
return (
<button
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${
theme.isLayerable() ? `justify-start` : `justify-between`
}`}
onClick={toggleTheme}
onBlur={onBlur}
>
{theme.isLayerable() ? (
<>
<Switch
className="px-0 mr-2"
checked={theme.active}
onChange={toggleTheme}
/>
{theme.package_info.name}
</>
) : (
<>
<div className="flex items-center">
<div
className={`pseudo-radio-btn ${
theme.active ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
<span className={theme.active ? 'font-semibold' : undefined}>
{theme.package_info.name}
</span>
</div>
<div
className="w-5 h-5 rounded-full"
style={{
backgroundColor: theme.package_info?.dock_icon?.background_color,
}}
></div>
</>
)}
</button>
);
};
const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
({ application, appState }) => {
const { closeQuickSettingsMenu, shouldAnimateCloseMenu } =
appState.quickSettingsMenu;
const [themes, setThemes] = useState<SNTheme[]>([]);
const [toggleableComponents, setToggleableComponents] = useState<
SNComponent[]
>([]);
const [themesMenuOpen, setThemesMenuOpen] = useState(false);
const [themesMenuPosition, setThemesMenuPosition] = useState({});
const [defaultThemeOn, setDefaultThemeOn] = useState(false);
const themesMenuRef = useRef<HTMLDivElement>(null);
const themesButtonRef = useRef<HTMLButtonElement>(null);
const prefsButtonRef = useRef<HTMLButtonElement>(null);
const quickSettingsMenuRef = useRef<HTMLDivElement>(null);
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null);
const reloadThemes = useCallback(() => {
application.streamItems(ContentType.Theme, () => {
const themes = application.getDisplayableItems(
ContentType.Theme
) as SNTheme[];
setThemes(
themes.sort((a, b) => {
const aIsLayerable = a.isLayerable();
const bIsLayerable = b.isLayerable();
if (aIsLayerable && !bIsLayerable) {
return 1;
} else if (!aIsLayerable && bIsLayerable) {
return -1;
} else {
return a.package_info.name.toLowerCase() <
b.package_info.name.toLowerCase()
? -1
: 1;
}
})
);
setDefaultThemeOn(
!themes.find((theme) => theme.active && !theme.isLayerable())
);
});
}, [application]);
const reloadToggleableComponents = useCallback(() => {
application.streamItems(ContentType.Component, () => {
const toggleableComponents = (
application.getDisplayableItems(
ContentType.Component
) as SNComponent[]
).filter((component) =>
[ComponentArea.EditorStack, ComponentArea.TagsList].includes(
component.area
)
);
setToggleableComponents(toggleableComponents);
});
}, [application]);
useEffect(() => {
reloadThemes();
}, [reloadThemes]);
useEffect(() => {
reloadToggleableComponents();
}, [reloadToggleableComponents]);
useEffect(() => {
if (themesMenuOpen) {
defaultThemeButtonRef.current!.focus();
}
}, [themesMenuOpen]);
useEffect(() => {
prefsButtonRef.current!.focus();
}, []);
const [closeOnBlur] = useCloseOnBlur(
themesMenuRef as any,
setThemesMenuOpen
);
const toggleThemesMenu = () => {
if (!themesMenuOpen) {
const themesButtonRect =
themesButtonRef.current!.getBoundingClientRect();
setThemesMenuPosition({
left: themesButtonRect.right,
bottom:
document.documentElement.clientHeight - themesButtonRect.bottom,
});
setThemesMenuOpen(true);
} else {
setThemesMenuOpen(false);
}
};
const openPreferences = () => {
closeQuickSettingsMenu();
appState.preferences.openPreferences();
};
const toggleComponent = (component: SNComponent) => {
application.toggleComponent(component);
};
const handleBtnKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (
event
) => {
switch (event.key) {
case 'Escape':
setThemesMenuOpen(false);
themesButtonRef.current!.focus();
break;
case 'ArrowRight':
if (!themesMenuOpen) {
toggleThemesMenu();
}
}
};
const handleQuickSettingsKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> =
(event) => {
const items: NodeListOf<HTMLButtonElement> =
quickSettingsMenuRef.current!.querySelectorAll(':scope > button');
const currentFocusedIndex = Array.from(items).findIndex(
(btn) => btn === document.activeElement
);
if (!themesMenuOpen) {
switch (event.key) {
case 'Escape':
closeQuickSettingsMenu();
break;
case 'ArrowDown':
if (items[currentFocusedIndex + 1]) {
items[currentFocusedIndex + 1].focus();
} else {
items[0].focus();
}
break;
case 'ArrowUp':
if (items[currentFocusedIndex - 1]) {
items[currentFocusedIndex - 1].focus();
} else {
items[items.length - 1].focus();
}
break;
}
}
};
const handlePanelKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (
event
) => {
const themes = themesMenuRef.current!.querySelectorAll('button');
const currentFocusedIndex = Array.from(themes).findIndex(
(themeBtn) => themeBtn === document.activeElement
);
switch (event.key) {
case 'Escape':
case 'ArrowLeft':
event.stopPropagation();
setThemesMenuOpen(false);
themesButtonRef.current!.focus();
break;
case 'ArrowDown':
if (themes[currentFocusedIndex + 1]) {
themes[currentFocusedIndex + 1].focus();
} else {
themes[0].focus();
}
break;
case 'ArrowUp':
if (themes[currentFocusedIndex - 1]) {
themes[currentFocusedIndex - 1].focus();
} else {
themes[themes.length - 1].focus();
}
break;
}
};
const toggleDefaultTheme = () => {
const activeTheme = themes.find(
(theme) => theme.active && !theme.isLayerable()
);
if (activeTheme) application.toggleComponent(activeTheme);
};
return (
<div className="sn-component">
<div
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
shouldAnimateCloseMenu
? 'slide-up-animation'
: 'sn-dropdown--animated'
}`}
ref={quickSettingsMenuRef}
onKeyDown={handleQuickSettingsKeyDown}
>
<div className="px-3 mt-1 mb-2 font-semibold color-text uppercase">
Quick Settings
</div>
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
<DisclosureButton
onKeyDown={handleBtnKeyDown}
onBlur={closeOnBlur}
ref={themesButtonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="themes" className="color-neutral mr-2" />
Themes
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
onBlur={closeOnBlur}
ref={themesMenuRef}
onKeyDown={handlePanelKeyDown}
style={{
...themesMenuPosition,
}}
className={`${MENU_CLASSNAME} fixed sn-dropdown--animated`}
>
<div className="px-3 my-1 font-semibold color-text uppercase">
Themes
</div>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
onClick={toggleDefaultTheme}
onBlur={closeOnBlur}
ref={defaultThemeButtonRef}
>
<div
className={`pseudo-radio-btn ${
defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
Default
</button>
{themes.map((theme) => (
<ThemeButton
theme={theme}
application={application}
key={theme.uuid}
onBlur={closeOnBlur}
/>
))}
</DisclosurePanel>
</Disclosure>
{toggleableComponents.map((component) => (
<Switch
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
checked={component.active}
onChange={() => {
toggleComponent(component);
}}
>
<div className="flex items-center">
<Icon type="window" className="color-neutral mr-2" />
{component.name}
</div>
</Switch>
))}
<div className="h-1px my-2 bg-border"></div>
<button
class="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
onClick={openPreferences}
ref={prefsButtonRef}
>
<Icon type="more" className="color-neutral mr-2" />
Open Preferences
</button>
</div>
</div>
);
}
);
export const QuickSettingsMenuDirective =
toDirective<MenuProps>(QuickSettingsMenu);

View File

@@ -33,9 +33,9 @@ const SearchOptions = observer(({ appState }: Props) => {
right: 0,
});
const [maxWidth, setMaxWidth] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>();
const panelRef = useRef<HTMLDivElement>();
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
async function toggleIncludeProtectedContents() {
setLockCloseOnBlur(true);
@@ -47,7 +47,7 @@ const SearchOptions = observer(({ appState }: Props) => {
}
const updateWidthAndPosition = () => {
const rect = buttonRef.current.getBoundingClientRect();
const rect = buttonRef.current!.getBoundingClientRect();
setMaxWidth(rect.right - 16);
setPosition({
top: rect.bottom,

View File

@@ -109,7 +109,7 @@ const SessionsModal: FunctionComponent<{
const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState('');
const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
const cancelRevokeRef = useRef<HTMLButtonElement>();
const cancelRevokeRef = useRef<HTMLButtonElement>(null);
const formatter = useMemo(
() =>

View File

@@ -13,6 +13,7 @@ export type SwitchProps = HTMLProps<HTMLInputElement> & {
onChange: (checked: boolean) => void;
className?: string;
children?: ComponentChildren;
role?: string;
};
export const Switch: FunctionalComponent<SwitchProps> = (
@@ -24,6 +25,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
return (
<label
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
{...(props.role ? { role: props.role } : {})}
>
{props.children}
<CustomCheckboxContainer

View File

@@ -0,0 +1,132 @@
import {
JSX,
FunctionComponent,
Ref,
ComponentChildren,
VNode,
RefCallback,
ComponentChild,
} from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat';
import { MenuItem, MenuItemListElement } from './MenuItem';
type MenuProps = {
className?: string;
style?: string | JSX.CSSProperties | undefined;
a11yLabel: string;
children: ComponentChildren;
closeMenu: () => void;
};
export const Menu: FunctionComponent<MenuProps> = forwardRef(
(
{ children, className = '', style, a11yLabel, closeMenu }: MenuProps,
ref: Ref<HTMLMenuElement>
) => {
const [currentIndex, setCurrentIndex] = useState(0);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (
event
) => {
switch (event.key) {
case 'Home':
setCurrentIndex(0);
break;
case 'End':
setCurrentIndex(
menuItemRefs.current!.length ? menuItemRefs.current!.length - 1 : 0
);
break;
case 'ArrowDown':
setCurrentIndex((index) => {
if (index + 1 < menuItemRefs.current!.length) {
return index + 1;
} else {
return 0;
}
});
break;
case 'ArrowUp':
setCurrentIndex((index) => {
if (index - 1 > -1) {
return index - 1;
} else {
return menuItemRefs.current!.length - 1;
}
});
break;
case 'Escape':
closeMenu();
break;
}
};
useEffect(() => {
if (menuItemRefs.current[currentIndex]) {
menuItemRefs.current[currentIndex]?.focus();
}
}, [currentIndex]);
const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => {
if (instance && instance.children) {
Array.from(instance.children).forEach((child) => {
if (
child.getAttribute('role')?.includes('menuitem') &&
!menuItemRefs.current!.includes(child as HTMLButtonElement)
) {
menuItemRefs.current!.push(child as HTMLButtonElement);
}
});
}
};
const mapMenuItems = (
child: ComponentChild,
index: number,
array: ComponentChild[]
) => {
if (!child) return;
const _child = child as VNode<unknown>;
const isFirstMenuItem =
index ===
array.findIndex((child) => (child as VNode<unknown>).type === MenuItem);
const hasMultipleItems = Array.isArray(_child.props.children)
? Array.from(_child.props.children as ComponentChild[]).some(
(child) => (child as VNode<unknown>).type === MenuItem
)
: false;
const items = hasMultipleItems
? [...(_child.props.children as ComponentChild[])]
: [_child];
return items.map((child) => {
return (
<MenuItemListElement
isFirstMenuItem={isFirstMenuItem}
ref={pushRefToArray}
>
{child}
</MenuItemListElement>
);
});
};
return (
<menu
className={`m-0 p-0 list-style-none ${className}`}
onKeyDown={handleKeyDown}
ref={ref}
style={style}
aria-label={a11yLabel}
>
{Array.isArray(children) ? children.map(mapMenuItems) : null}
</menu>
);
}
);

View File

@@ -0,0 +1,111 @@
import {
ComponentChild,
ComponentChildren,
FunctionComponent,
VNode,
} from 'preact';
import { forwardRef, Ref } from 'preact/compat';
import { JSXInternal } from 'preact/src/jsx';
import { Icon, IconType } from '../Icon';
import { Switch, SwitchProps } from '../Switch';
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}
type MenuItemProps = {
type: MenuItemType;
children: ComponentChildren;
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
onChange?: SwitchProps['onChange'];
className?: string;
checked?: boolean;
icon?: IconType;
iconClassName?: string;
tabIndex?: number;
};
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
(
{
children,
onClick,
onChange,
className = '',
type,
checked,
icon,
iconClassName,
tabIndex,
}: MenuItemProps,
ref: Ref<HTMLButtonElement>
) => {
return type === MenuItemType.SwitchButton &&
typeof onChange === 'function' ? (
<Switch
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={checked}
onChange={onChange}
role="menuitemcheckbox"
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
>
{children}
</Switch>
) : (
<button
ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onClick={onClick}
{...(type === MenuItemType.RadioButton
? { 'aria-checked': checked }
: {})}
>
{type === MenuItemType.IconButton && icon ? (
<Icon type={icon} className={iconClassName} />
) : null}
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
<div
className={`pseudo-radio-btn ${
checked ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
) : null}
{children}
</button>
);
}
);
export const MenuItemSeparator: FunctionComponent = () => (
<div role="separator" className="h-1px my-2 bg-border"></div>
);
type ListElementProps = {
isFirstMenuItem: boolean;
children: ComponentChildren;
};
export const MenuItemListElement: FunctionComponent<ListElementProps> =
forwardRef(({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
const child = children as VNode<unknown>;
return (
<li className="list-style-none" role="none" ref={ref}>
{{
...child,
props: {
...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem
? {
tabIndex: isFirstMenuItem ? 0 : -1,
}
: {}),
},
}}
</li>
);
});

View File

@@ -0,0 +1,44 @@
import { FunctionalComponent } from 'preact';
import { useRef, useState } from 'preact/hooks';
import ArrowDown from '../../../svg/arrow-down.svg';
import { Title } from '@/preferences/components';
type Props = {
title: string | JSX.Element;
className?: string;
}
export const AccordionItem: FunctionalComponent<Props> = ({
title,
className = '',
children
}) => {
const elementRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={className}>
<div
className={'relative flex cursor-pointer hover:underline'}
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<Title>{title}</Title>
<ArrowDown
className={'sn-accordion-arrow-icon absolute right-0'}
width={20}
height={20}
data-is-expanded={isExpanded}
/>
</div>
<div
className={'accordion-contents-container cursor-auto'}
data-is-expanded={isExpanded}
ref={elementRef}
>
{children}
</div>
</div>
);
};

View File

@@ -2,9 +2,9 @@ import { FunctionalComponent } from 'preact';
type Props = {
classes?: string;
}
};
export const HorizontalSeparator: FunctionalComponent<Props> = ({
classes = ''
classes = '',
}) => {
return <hr className={`h-1px w-full bg-border no-border ${classes}`} />;
};

View File

@@ -5,10 +5,9 @@ import {
AlertDialogLabel,
} from '@node_modules/@reach/alert-dialog';
import { useRef } from '@node_modules/preact/hooks';
import { IconButton } from '@/components/IconButton';
export const ModalDialog: FunctionComponent = ({ children }) => {
const ldRef = useRef<HTMLButtonElement>();
const ldRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog leastDestructiveRef={ldRef}>
@@ -19,7 +18,7 @@ export const ModalDialog: FunctionComponent = ({ children }) => {
<div tabIndex={-1} className="sn-component">
<div
tabIndex={0}
className="w-160 bg-default rounded shadow-overlay focus:padded-ring-info"
className="sk-panel w-160 bg-default rounded shadow-overlay focus:padded-ring-info"
>
{children}
</div>
@@ -30,17 +29,20 @@ export const ModalDialog: FunctionComponent = ({ children }) => {
export const ModalDialogLabel: FunctionComponent<{
closeDialog: () => void;
}> = ({ children, closeDialog }) => (
<AlertDialogLabel className="">
<div className="px-4 py-4 flex flex-row items-center">
<div className="flex-grow color-text text-lg font-bold">{children}</div>
<IconButton
focusable={true}
title="Close"
className="color-neutral h-5 w-5"
icon="close"
onClick={() => closeDialog()}
/>
className?: string;
}> = ({ children, closeDialog, className }) => (
<AlertDialogLabel className={className}>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex-grow color-text text-base font-medium">
{children}
</div>
<div
tabIndex={0}
className="font-bold color-info cursor-pointer"
onClick={closeDialog}
>
Close
</div>
</div>
<hr className="h-1px bg-border no-border m-0" />
</AlertDialogLabel>
@@ -55,17 +57,20 @@ export const ModalDialogDescription: FunctionComponent<{ className?: string }> =
</AlertDialogDescription>
);
export const ModalDialogButtons: FunctionComponent = ({ children }) => (
export const ModalDialogButtons: FunctionComponent<{ className?: string }> = ({
children,
className,
}) => (
<>
<hr className="h-1px bg-border no-border m-0" />
<div className="px-4 py-4 flex flex-row justify-end items-center">
<div className={`px-4 py-4 flex flex-row items-center ${className}`}>
{children != undefined && Array.isArray(children)
? children.map((child, idx, arr) => (
<>
{child}
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
</>
))
<>
{child}
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
</>
))
: children}
</div>
</>

View File

@@ -1,4 +1,5 @@
import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { useEffect } from 'react';
@@ -8,7 +9,7 @@ import { useEffect } from 'react';
* monitored.
*/
export function useCloseOnBlur(
container: { current: HTMLDivElement },
container: { current?: HTMLDivElement },
setOpen: (open: boolean) => void
): [
(event: { relatedTarget: EventTarget | null }) => void,
@@ -70,6 +71,9 @@ export function toDirective<Props>(
$onChanges() {
render(h(component, $scope), $element[0]);
},
$onDestroy() {
unmountComponentAtNode($element[0]);
},
};
},
],

View File

@@ -1,294 +0,0 @@
import { RootScopeMessages } from './../../messages';
import { WebApplication } from '@/ui_models/application';
import { SNComponent, ComponentAction, LiveItem } from '@standardnotes/snjs';
import { WebDirective } from './../../types';
import template from '%/directives/component-view.pug';
import { isDesktopApplication } from '../../utils';
/**
* The maximum amount of time we'll wait for a component
* to load before displaying error
*/
const MaxLoadThreshold = 4000;
const VisibilityChangeKey = 'visibilitychange';
interface ComponentViewScope {
componentUuid: string
onLoad?: (component: SNComponent) => void
application: WebApplication
}
class ComponentViewCtrl implements ComponentViewScope {
/** @scope */
onLoad?: (component: SNComponent) => void
componentUuid!: string
templateComponent!: SNComponent
application!: WebApplication
liveComponent!: LiveItem<SNComponent>
private $rootScope: ng.IRootScopeService
private $timeout: ng.ITimeoutService
private componentValid = true
private cleanUpOn: () => void
private unregisterComponentHandler!: () => void
private unregisterDesktopObserver!: () => void
private issueLoading = false
private isDeprecated = false
private deprecationMessage: string | undefined = undefined
private deprecationMessageDismissed = false
public reloading = false
private expired = false
private loading = false
private didAttemptReload = false
public error: 'offline-restricted' | 'url-missing' | undefined
private loadTimeout: any
/* @ngInject */
constructor(
$scope: ng.IScope,
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService,
) {
this.$rootScope = $rootScope;
this.$timeout = $timeout;
this.cleanUpOn = $scope.$on('ext-reload-complete', () => {
this.reloadStatus(false);
});
/** To allow for registering events */
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
$onDestroy() {
if(this.application.componentManager) {
/** Component manager Can be destroyed already via locking */
this.application.componentManager.onComponentIframeDestroyed(this.component.uuid);
if(this.templateComponent) {
this.application.componentManager.removeTemporaryTemplateComponent(this.templateComponent);
}
}
if(this.liveComponent) {
this.liveComponent.deinit();
}
this.cleanUpOn();
(this.cleanUpOn as any) = undefined;
this.unregisterComponentHandler();
(this.unregisterComponentHandler as any) = undefined;
this.unregisterDesktopObserver();
(this.unregisterDesktopObserver as any) = undefined;
(this.templateComponent as any) = undefined;
(this.liveComponent as any) = undefined;
(this.application as any) = undefined;
(this.onVisibilityChange as any) = undefined;
this.onLoad = undefined;
document.removeEventListener(
VisibilityChangeKey,
this.onVisibilityChange
);
}
$onInit() {
if(this.componentUuid) {
this.liveComponent = new LiveItem(this.componentUuid, this.application);
} else {
this.application.componentManager.addTemporaryTemplateComponent(this.templateComponent);
}
this.registerComponentHandlers();
this.registerPackageUpdateObserver();
}
get component() {
return this.templateComponent || this.liveComponent?.item;
}
/** @template */
public onIframeInit() {
/** Perform in timeout required so that dynamic iframe id is set (based on ctrl values) */
this.$timeout(() => {
this.loadComponent();
});
}
private loadComponent() {
if (!this.component) {
throw Error('Component view is missing component');
}
if (!this.component.active && !this.component.isEditor()) {
/** Editors don't need to be active to be displayed */
throw Error('Component view component must be active');
}
const iframe = this.application.componentManager!.iframeForComponent(
this.component.uuid
);
if (!iframe) {
return;
}
this.loading = true;
if (this.loadTimeout) {
this.$timeout.cancel(this.loadTimeout);
}
this.loadTimeout = this.$timeout(() => {
this.handleIframeLoadTimeout();
}, MaxLoadThreshold);
iframe.onload = () => {
this.reloadStatus();
this.handleIframeLoad(iframe);
};
}
private registerPackageUpdateObserver() {
this.unregisterDesktopObserver = this.application.getDesktopService()
.registerUpdateObserver((component: SNComponent) => {
if (component.uuid === this.component.uuid && component.active) {
this.reloadIframe();
}
});
}
private registerComponentHandlers() {
this.unregisterComponentHandler = this.application.componentManager!.registerHandler({
identifier: 'component-view-' + Math.random(),
areas: [this.component.area],
actionHandler: (component, action, data) => {
switch (action) {
case (ComponentAction.SetSize):
this.application.componentManager!.handleSetSizeEvent(component, data);
break;
case (ComponentAction.KeyDown):
this.application.io.handleComponentKeyDown(data.keyboardModifier);
break;
case (ComponentAction.KeyUp):
this.application.io.handleComponentKeyUp(data.keyboardModifier);
break;
case (ComponentAction.Click):
this.application.getAppState().notes.setContextMenuOpen(false);
break;
default:
return;
}
}
});
}
private reloadIframe() {
this.$timeout(() => {
this.reloading = true;
this.$timeout(() => {
this.reloading = false;
});
});
}
private dismissDeprecationMessage() {
this.$timeout(() => {
this.deprecationMessageDismissed = true;
});
}
private onVisibilityChange() {
if (document.visibilityState === 'hidden') {
return;
}
if (this.issueLoading) {
this.reloadIframe();
}
}
public reloadStatus(doManualReload = true) {
const component = this.component;
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
const hasUrlError = function () {
if (isDesktopApplication()) {
return !component.local_url && !component.hasValidHostedUrl();
} else {
return !component.hasValidHostedUrl();
}
}();
this.expired = component.valid_until && component.valid_until <= new Date();
const readonlyState = this.application.componentManager!
.getReadonlyStateForComponent(component);
if (!readonlyState.lockReadonly) {
this.application.componentManager!
.setReadonlyStateForComponent(component, this.expired);
}
this.componentValid = !offlineRestricted && !hasUrlError;
if (!this.componentValid) {
this.loading = false;
}
if (offlineRestricted) {
this.error = 'offline-restricted';
} else if (hasUrlError) {
this.error = 'url-missing';
} else {
this.error = undefined;
}
if (this.expired && doManualReload) {
this.$rootScope.$broadcast(RootScopeMessages.ReloadExtendedData);
}
this.isDeprecated = component.isDeprecated;
this.deprecationMessage = component.package_info.deprecation_message;
}
private async handleIframeLoadTimeout() {
if (this.loading) {
this.loading = false;
this.issueLoading = true;
if (!this.didAttemptReload) {
this.didAttemptReload = true;
this.reloadIframe();
} else {
document.addEventListener(
VisibilityChangeKey,
this.onVisibilityChange
);
}
}
}
private async handleIframeLoad(iframe: HTMLIFrameElement) {
let desktopError = false;
if (isDesktopApplication()) {
try {
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') {
desktopError = true;
}
// eslint-disable-next-line no-empty
} catch (e) { }
}
this.$timeout.cancel(this.loadTimeout);
await this.application.componentManager!.registerComponentWindow(
this.component,
iframe.contentWindow!
);
const avoidFlickerTimeout = 7;
this.$timeout(() => {
this.loading = false;
// eslint-disable-next-line no-unneeded-ternary
this.issueLoading = desktopError ? true : false;
this.onLoad && this.onLoad(this.component!);
}, avoidFlickerTimeout);
}
/** @template */
public getUrl() {
const url = this.application.componentManager!.urlForComponent(this.component);
return url;
}
}
export class ComponentView extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.scope = {
componentUuid: '=',
templateComponent: '=?',
onLoad: '=?',
application: '='
};
this.controller = ComponentViewCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
}
}

View File

@@ -1,6 +1,5 @@
export { ActionsMenu } from './actionsMenu';
export { ComponentModal } from './componentModal';
export { ComponentView } from './componentView';
export { EditorMenu } from './editorMenu';
export { InputModal } from './inputModal';
export { MenuRow } from './menuRow';

View File

@@ -20,5 +20,5 @@ export enum HtmlInputTypes {
Text = 'text',
Time = 'time',
Url = 'url',
Week = 'week'
Week = 'week',
}

View File

@@ -1,4 +1,3 @@
export enum RootScopeMessages {
ReloadExtendedData = 'reload-ext-data',
NewUpdateAvailable = 'new-update-available'
}

View File

@@ -1,12 +1,16 @@
import { IconType } from '@/components/Icon';
import { makeAutoObservable, observable } from 'mobx';
import { action, makeAutoObservable, observable } from 'mobx';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import { ContentType, SNComponent } from '@node_modules/@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { FeatureIdentifier } from '@node_modules/@standardnotes/features/dist/Domain/Feature/FeatureIdentifier';
import { ComponentArea } from '@standardnotes/snjs';
const PREFERENCE_IDS = [
'general',
'account',
'appearance',
'security',
'extensions',
'listed',
'shortcuts',
'accessibility',
@@ -16,20 +20,23 @@ const PREFERENCE_IDS = [
export type PreferenceId = typeof PREFERENCE_IDS[number];
interface PreferencesMenuItem {
readonly id: PreferenceId;
readonly id: PreferenceId | FeatureIdentifier;
readonly icon: IconType;
readonly label: string;
}
interface SelectableMenuItem extends PreferencesMenuItem {
selected: boolean;
}
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'extensions', label: 'Extensions', icon: 'tune' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
@@ -38,46 +45,118 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
];
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
];
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'general';
private _selectedPane: PreferenceId | FeatureIdentifier = 'account';
private _extensionPanes: SNComponent[] = [];
private _menu: PreferencesMenuItem[];
private _extensionLatestVersions: ExtensionsLatestVersions =
new ExtensionsLatestVersions(new Map());
constructor(
private readonly _enableUnfinishedFeatures: boolean,
private application: WebApplication,
private readonly _enableUnfinishedFeatures: boolean
) {
this._menu = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS;
makeAutoObservable<PreferencesMenu, '_selectedPane' | '_twoFactorAuth'>(
this,
{
_twoFactorAuth: observable,
_selectedPane: observable,
this._menu = this._enableUnfinishedFeatures
? PREFERENCES_MENU_ITEMS
: READY_PREFERENCES_MENU_ITEMS;
this.loadExtensionsPanes();
this.loadLatestVersions();
makeAutoObservable<
PreferencesMenu,
| '_selectedPane'
| '_twoFactorAuth'
| '_extensionPanes'
| '_extensionLatestVersions'
| 'loadLatestVersions'
>(this, {
_twoFactorAuth: observable,
_selectedPane: observable,
_extensionPanes: observable.ref,
_extensionLatestVersions: observable.ref,
loadLatestVersions: action,
});
}
private loadLatestVersions(): void {
ExtensionsLatestVersions.load(this.application).then((versions) => {
this._extensionLatestVersions = versions;
});
}
get extensionsLatestVersions(): ExtensionsLatestVersions {
return this._extensionLatestVersions;
}
loadExtensionsPanes(): void {
const excludedComponents = [
FeatureIdentifier.TwoFactorAuthManager,
'org.standardnotes.batch-manager',
'org.standardnotes.extensions-manager',
];
this._extensionPanes = (
this.application.getItems([
ContentType.ActionsExtension,
ContentType.Component,
ContentType.Theme,
]) as SNComponent[]
).filter(
(extension) =>
extension.area === ComponentArea.Modal &&
!excludedComponents.includes(extension.package_info.identifier)
);
}
get menuItems(): SelectableMenuItem[] {
const menuItems = this._menu.map((preference) => ({
...preference,
selected: preference.id === this._selectedPane,
}));
const extensionsMenuItems: SelectableMenuItem[] = this._extensionPanes.map(
(extension) => {
return {
icon: 'window',
id: extension.package_info.identifier,
label: extension.name,
selected: extension.package_info.identifier === this._selectedPane,
};
}
);
return menuItems.concat(extensionsMenuItems);
}
get menuItems(): (PreferencesMenuItem & {
selected: boolean;
})[] {
return this._menu.map((p) => ({
...p,
selected: p.id === this._selectedPane,
}));
get selectedMenuItem(): PreferencesMenuItem | undefined {
return this._menu.find((item) => item.id === this._selectedPane);
}
get selectedPaneId(): PreferenceId {
return (
this._menu.find((item) => item.id === this._selectedPane)?.id ?? 'general'
get selectedExtension(): SNComponent | undefined {
return this._extensionPanes.find(
(extension) => extension.package_info.identifier === this._selectedPane
);
}
selectPane(key: PreferenceId): void {
get selectedPaneId(): PreferenceId | FeatureIdentifier {
if (this.selectedMenuItem != undefined) {
return this.selectedMenuItem.id;
}
if (this.selectedExtension != undefined) {
return this.selectedExtension.package_info.identifier;
}
return 'account';
}
selectPane(key: PreferenceId | FeatureIdentifier): void {
this._selectedPane = key;
}
}

View File

@@ -9,13 +9,14 @@ import {
Security,
} from './panes';
import { observer } from 'mobx-react-lite';
import { PreferencesMenu } from './PreferencesMenu';
import { PreferencesMenuView } from './PreferencesMenuView';
import { WebApplication } from '@/ui_models/application';
import { MfaProps } from './panes/two-factor-auth/MfaProps';
import { AppState } from '@/ui_models/app_state';
import { useEffect, useMemo } from 'preact/hooks';
import { Extensions } from './panes/Extensions';
import { ExtensionPane } from './panes/ExtensionPane';
interface PreferencesProps extends MfaProps {
application: WebApplication;
@@ -25,44 +26,72 @@ interface PreferencesProps extends MfaProps {
const PaneSelector: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
> = observer((props) => {
switch (props.menu.selectedPaneId) {
case 'general':
return (
<General appState={props.appState} application={props.application} />
);
case 'account':
return (
<AccountPreferences
application={props.application}
appState={props.appState}
/>
);
case 'appearance':
return null;
case 'security':
return (
<Security
mfaProvider={props.mfaProvider}
userProvider={props.userProvider}
appState={props.appState}
application={props.application}
/>
);
case 'extensions':
return <Extensions application={props.application} />;
case 'listed':
return <Listed application={props.application} />;
case 'shortcuts':
return null;
case 'accessibility':
return null;
case 'get-free-month':
return null;
case 'help-feedback':
return <HelpAndFeedback />;
}
});
> = observer(
({
menu,
appState,
application,
mfaProvider,
userProvider
}) => {
switch (menu.selectedPaneId) {
case 'general':
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
);
case 'account':
return (
<AccountPreferences
application={application}
appState={appState}
/>
);
case 'appearance':
return null;
case 'security':
return (
<Security
mfaProvider={mfaProvider}
userProvider={userProvider}
appState={appState}
application={application}
/>
);
case 'listed':
return <Listed application={application} />;
case 'shortcuts':
return null;
case 'accessibility':
return null;
case 'get-free-month':
return null;
case 'help-feedback':
return <HelpAndFeedback />;
default:
if (menu.selectedExtension != undefined) {
return (
<ExtensionPane
application={application}
appState={appState}
extension={menu.selectedExtension}
preferencesMenu={menu}
/>
);
} else {
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
);
}
}
});
const PreferencesCanvas: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
@@ -75,9 +104,9 @@ const PreferencesCanvas: FunctionComponent<
export const PreferencesView: FunctionComponent<PreferencesProps> = observer(
(props) => {
const menu = useMemo(() => new PreferencesMenu(props.appState.enableUnfinishedFeatures), [
props.appState.enableUnfinishedFeatures
]);
const menu = useMemo(
() => new PreferencesMenu(props.application, props.appState.enableUnfinishedFeatures),
[props.appState.enableUnfinishedFeatures, props.application]);
useEffect(() => {
menu.selectPane(props.appState.preferences.currentPane);

View File

@@ -4,18 +4,28 @@ export const Title: FunctionComponent = ({ children }) => (
<h2 className="text-base m-0 mb-1">{children}</h2>
);
export const Subtitle: FunctionComponent<{ className?: string }> = ({ children, className = "" }) => (
export const Subtitle: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => (
<h4 className={`font-medium text-sm m-0 mb-1 ${className}`}>{children}</h4>
);
export const SubtitleLight: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => (
<h4 className={`font-normal text-sm m-0 mb-1 ${className}`}>{children}</h4>
);
export const Text: FunctionComponent<{ className?: string }> = ({
children,
className = '',
}) => <p className={`${className} text-xs`}>{children}</p>;
const buttonClasses = `block bg-default color-text rounded border-solid \
border-1 border-gray-300 px-4 py-1.75 font-bold text-sm fit-content \
focus:bg-contrast hover:bg-contrast`;
border-1 px-4 py-1.75 font-bold text-sm fit-content \
focus:bg-contrast hover:bg-contrast border-main`;
export const LinkButton: FunctionComponent<{
label: string;

View File

@@ -7,18 +7,18 @@ const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
}) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col mb-3">
<div className="bg-default border-1 border-solid rounded border-main px-6 py-6 flex flex-col mb-3">
{Array.isArray(children)
? children
.filter(
(child) => child != undefined && child !== '' && child !== false
)
.map((child, i, arr) => (
<>
{child}
<HorizontalLine index={i} length={arr.length} />
</>
))
.filter(
(child) => child != undefined && child !== '' && child !== false
)
.map((child, i, arr) => (
<>
{child}
<HorizontalLine index={i} length={arr.length} />
</>
))
: children}
</div>
);

View File

@@ -1,5 +1,11 @@
import { FunctionComponent } from 'preact';
export const PreferencesSegment: FunctionComponent = ({ children }) => (
<div className="flex flex-col">{children}</div>
type Props = {
classes?: string;
}
export const PreferencesSegment: FunctionComponent<Props> = ({
children,
classes = ''
}) => (
<div className={`flex flex-col ${classes}`}>{children}</div>
);

View File

@@ -3,7 +3,7 @@ import {
SubscriptionWrapper,
Credentials,
SignOutWrapper,
Authentication,
Authentication
} from '@/preferences/panes/account';
import { PreferencesPane } from '@/preferences/components';
import { observer } from 'mobx-react-lite';
@@ -22,7 +22,7 @@ export const AccountPreferences = observer(
return (
<PreferencesPane>
<Authentication application={application} appState={appState} />
{appState.enableUnfinishedFeatures && <SubscriptionWrapper application={application} />}
<SubscriptionWrapper application={application} />
<SignOutWrapper application={application} appState={appState} />
</PreferencesPane>
);
@@ -32,7 +32,7 @@ export const AccountPreferences = observer(
<PreferencesPane>
<Credentials application={application} appState={appState} />
<Sync application={application} />
{appState.enableUnfinishedFeatures && <SubscriptionWrapper application={application} />}
<SubscriptionWrapper application={application} />
<SignOutWrapper application={application} appState={appState} />
</PreferencesPane>
);

View File

@@ -0,0 +1,47 @@
import { PreferencesGroup, PreferencesSegment } from "@/preferences/components";
import { WebApplication } from "@/ui_models/application";
import { SNComponent } from "@standardnotes/snjs/dist/@types";
import { observer } from "mobx-react-lite";
import { FunctionComponent } from "preact";
import { ExtensionItem } from "./extensions-segments";
import { ComponentView } from '@/components/ComponentView';
import { AppState } from '@/ui_models/app_state';
import { PreferencesMenu } from '@/preferences/PreferencesMenu';
interface IProps {
application: WebApplication;
appState: AppState;
extension: SNComponent;
preferencesMenu: PreferencesMenu;
}
export const ExtensionPane: FunctionComponent<IProps> = observer(
({ extension, application, appState, preferencesMenu }) => {
const latestVersion = preferencesMenu.extensionsLatestVersions.getVersion(extension);
return (
<div className="preferences-extension-pane color-foreground flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center">
<div className="w-200 max-w-200 flex flex-col">
<PreferencesGroup>
<ExtensionItem
application={application}
extension={extension}
first={false}
uninstall={() => application.deleteItem(extension).then(() => preferencesMenu.loadExtensionsPanes())}
toggleActivate={() => application.toggleComponent(extension).then(() => preferencesMenu.loadExtensionsPanes())}
latestVersion={latestVersion}
/>
<PreferencesSegment>
<ComponentView
application={application}
appState={appState}
componentUuid={extension.uuid}
/>
</PreferencesSegment>
</PreferencesGroup>
</div>
</div>
</div>
);
});

View File

@@ -5,59 +5,35 @@ import { WebApplication } from '@/ui_models/application';
import { FunctionComponent } from 'preact';
import {
Title,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
} from '../components';
import { ConfirmCustomExtension, ExtensionItem } from './extensions-segments';
import { ConfirmCustomExtension, ExtensionItem, ExtensionsLatestVersions } from './extensions-segments';
import { useEffect, useRef, useState } from 'preact/hooks';
import { FeatureDescription } from '@standardnotes/features';
import { observer } from 'mobx-react-lite';
const loadExtensions = (application: WebApplication) => application.getItems([
ContentType.ActionsExtension,
ContentType.Component,
ContentType.Theme,
]) as SNComponent[];
function collectFeatures(features: FeatureDescription[] | undefined, versionMap: Map<string, string>) {
if (features == undefined) return;
for (const feature of features) {
versionMap.set(feature.identifier, feature.version);
}
}
const loadLatestVersions = (application: WebApplication) => application.getAvailableSubscriptions()
.then(subscriptions => {
const versionMap: Map<string, string> = new Map();
collectFeatures(subscriptions?.CORE_PLAN?.features, versionMap);
collectFeatures(subscriptions?.PLUS_PLAN?.features, versionMap);
collectFeatures(subscriptions?.PRO_PLAN?.features, versionMap);
return versionMap;
});
], true) as SNComponent[];
export const Extensions: FunctionComponent<{
application: WebApplication
}> = ({ application }) => {
extensionsLatestVersions: ExtensionsLatestVersions,
}> = observer(({ application, extensionsLatestVersions }) => {
const [customUrl, setCustomUrl] = useState('');
const [confirmableExtension, setConfirmableExtension] = useState<SNComponent | undefined>(undefined);
const [extensions, setExtensions] = useState(loadExtensions(application));
const [latestVersions, setLatestVersions] = useState<Map<string, string> | undefined>(undefined);
const confirmableEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
if (confirmableExtension) {
confirmableEnd.current.scrollIntoView({ behavior: 'smooth' });
confirmableEnd.current!.scrollIntoView({ behavior: 'smooth' });
}
}, [confirmableExtension, confirmableEnd]);
useEffect(() => {
if (!latestVersions) {
loadLatestVersions(application).then(versions => setLatestVersions(versions));
}
}, [latestVersions, application]);
const uninstallExtension = async (extension: SNComponent) => {
await application.deleteItem(extension);
setExtensions(loadExtensions(application));
@@ -88,57 +64,60 @@ export const Extensions: FunctionComponent<{
setExtensions(loadExtensions(application));
};
const visibleExtensions = extensions
.filter((extension) => {
return extension.package_info != undefined && !['modal', 'rooms'].includes(extension.area);
});
return (
<PreferencesPane>
{extensions.length > 0 &&
<PreferencesGroup>
{
extensions
.filter(extension => extension.package_info.identifier !== 'org.standardnotes.extensions-manager')
.sort((e1, e2) => e1.name.toLowerCase().localeCompare(e2.name.toLowerCase()))
.map((extension, i) => (
<ExtensionItem
application={application}
extension={extension}
latestVersion={latestVersions?.get(extension.package_info.identifier)}
first={i === 0}
uninstall={uninstallExtension}
toggleActivate={toggleActivateExtension} />
))
}
</PreferencesGroup>
<div>
{visibleExtensions.length > 0 &&
<div>
{
visibleExtensions
.sort((e1, e2) => e1.name.toLowerCase().localeCompare(e2.name.toLowerCase()))
.map((extension, i) => (
<ExtensionItem
application={application}
extension={extension}
latestVersion={extensionsLatestVersions.getVersion(extension)}
first={i === 0}
uninstall={uninstallExtension}
toggleActivate={toggleActivateExtension} />
))
}
</div>
}
<PreferencesGroup>
<div>
{!confirmableExtension &&
<PreferencesSegment>
<Title>Install Custom Extension</Title>
<div className="min-h-2" />
<DecoratedInput
placeholder={'Enter Extension URL'}
text={customUrl}
onChange={(value) => { setCustomUrl(value); }}
/>
<div className="min-h-2" />
<Button
className="min-w-20"
type="normal"
label="Install"
onClick={() => submitExtensionUrl(customUrl)}
/>
</PreferencesSegment>
<PreferencesSegment>
<Title>Install Custom Extension</Title>
<div className="min-h-2" />
<DecoratedInput
placeholder={'Enter Extension URL'}
text={customUrl}
onChange={(value) => { setCustomUrl(value); }}
/>
<div className="min-h-2" />
<Button
className="min-w-20"
type="normal"
label="Install"
onClick={() => submitExtensionUrl(customUrl)}
/>
</PreferencesSegment>
}
{confirmableExtension &&
<PreferencesSegment>
<ConfirmCustomExtension
component={confirmableExtension}
callback={handleConfirmExtensionSubmit}
/>
<div ref={confirmableEnd} />
</PreferencesSegment>
<PreferencesSegment>
<ConfirmCustomExtension
component={confirmableExtension}
callback={handleConfirmExtensionSubmit}
/>
<div ref={confirmableEnd} />
</PreferencesSegment>
}
</PreferencesGroup>
</PreferencesPane>
</div>
</div>
);
};
});

View File

@@ -3,16 +3,27 @@ import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { ErrorReporting, Tools, Defaults } from './general-segments';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import { Advanced } from '@/preferences/panes/account';
import { observer } from 'mobx-react-lite';
interface GeneralProps {
appState: AppState;
application: WebApplication;
extensionsLatestVersions: ExtensionsLatestVersions,
}
export const General: FunctionComponent<GeneralProps> = (props) => (
<PreferencesPane>
<Tools application={props.application} />
<Defaults application={props.application} />
<ErrorReporting appState={props.appState} />
</PreferencesPane>
export const General: FunctionComponent<GeneralProps> = observer(
({
appState,
application,
extensionsLatestVersions
}) => (
<PreferencesPane>
<Tools application={application} />
<Defaults application={application} />
<ErrorReporting appState={appState} />
<Advanced application={application} appState={appState} extensionsLatestVersions={extensionsLatestVersions} />
</PreferencesPane>
)
);

View File

@@ -80,16 +80,21 @@ export const HelpAndFeedback: FunctionComponent = () => (
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Slack group</Title>
<Title>Community groups</Title>
<Text>
Want to meet other passionate note-takers and privacy enthusiasts?
Want to share your feedback with us? Join the Standard Notes Slack
group for discussions on security, themes, editors and more.
Want to share your feedback with us? Join the Standard Notes community
groups for discussions on security, themes, editors and more.
</Text>
<LinkButton
className="mt-3"
link="https://standardnotes.com/slack"
label="Join our Slack group"
label="Join our Slack"
/>
<LinkButton
className="mt-3"
link="https://standardnotes.com/discord"
label="Join our Discord"
/>
</PreferencesSegment>
</PreferencesGroup>

View File

@@ -0,0 +1,36 @@
import { FunctionalComponent } from 'preact';
import { PreferencesGroup, PreferencesSegment, Title } from '@/preferences/components';
import { OfflineSubscription } from '@/preferences/panes/account/offlineSubscription';
import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
import { Extensions } from '@/preferences/panes/Extensions';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { AccordionItem } from '@/components/shared/AccordionItem';
interface IProps {
application: WebApplication;
appState: AppState;
extensionsLatestVersions: ExtensionsLatestVersions;
}
export const Advanced: FunctionalComponent<IProps> = observer(
({ application, appState, extensionsLatestVersions }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced Settings'}>
<div className='flex flex-row items-center'>
<div className='flex-grow flex flex-col'>
<OfflineSubscription application={application} appState={appState} />
<HorizontalSeparator classes="mt-8 mb-8" />
<Extensions application={application} extensionsLatestVersions={extensionsLatestVersions} />
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -33,16 +33,14 @@ export const Credentials: FunctionComponent<Props> = observer(({ application, ap
<Text>
You're signed in as <span className='font-bold'>{user?.email}</span>
</Text>
{appState.enableUnfinishedFeatures && (
<Button
className='min-w-20 mt-3'
type='normal'
label='Change email'
onClick={() => {
setIsChangeEmailDialogOpen(true);
}}
/>
)}
<Button
className='min-w-20 mt-3'
type='normal'
label='Change email'
onClick={() => {
setIsChangeEmailDialogOpen(true);
}}
/>
<HorizontalSeparator classes='mt-5 mb-3' />
<div className={'text-input mt-2'}>
Password

View File

@@ -1,37 +1,47 @@
import { DecoratedInput } from '@/components/DecoratedInput';
import { StateUpdater } from 'preact/hooks';
import { FunctionalComponent } from 'preact';
import { HtmlInputTypes } from '@/enums';
type Props = {
setNewEmail: StateUpdater<string>
setCurrentPassword: StateUpdater<string>
}
setNewEmail: StateUpdater<string>;
setCurrentPassword: StateUpdater<string>;
};
const labelClassName = `block mb-1`;
const inputClassName = 'sk-input contrast';
export const ChangeEmailForm: FunctionalComponent<Props> = ({
setNewEmail,
setCurrentPassword
setCurrentPassword,
}) => {
return (
(
<>
<div className={'mt-2 mb-3'}>
<DecoratedInput
onChange={(newEmail) => {
setNewEmail(newEmail);
}}
placeholder={'New Email'}
/>
</div>
<div className={'mt-2 mb-3'}>
<DecoratedInput
type={HtmlInputTypes.Password}
onChange={(currentPassword) => {
setCurrentPassword(currentPassword);
}}
placeholder={'Current Password'}
/>
</div>
</>
)
<div className="w-full flex flex-col">
<div className="mt-2 mb-3">
<label className={labelClassName} htmlFor="change-email-email-input">
New Email:
</label>
<input
id="change-email-email-input"
className={inputClassName}
type="email"
onChange={({ target }) => {
setNewEmail((target as HTMLInputElement).value);
}}
/>
</div>
<div className="mb-2">
<label className={labelClassName} htmlFor="change-email-password-input">
Current Password:
</label>
<input
id="change-email-password-input"
className={inputClassName}
type="password"
onChange={({ target }) => {
setCurrentPassword((target as HTMLInputElement).value);
}}
/>
</div>
</div>
);
};

View File

@@ -2,11 +2,11 @@ import { FunctionalComponent } from 'preact';
export const ChangeEmailSuccess: FunctionalComponent = () => {
return (
<>
<div className={'sk-label sk-bold info'}>Your email has been successfully changed.</div>
<div>
<div className={'sk-label sk-bold info mt-2'}>Your email has been successfully changed.</div>
<p className={'sk-p'}>
Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum compatibility.
</p>
</>
</div>
);
};

View File

@@ -154,10 +154,13 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>
<ModalDialogLabel
closeDialog={handleDialogClose}
className="sk-panel-header px-4.5"
>
Change Email
</ModalDialogLabel>
<ModalDialogDescription>
<ModalDialogDescription className="px-4.5">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm
setNewEmail={setNewEmail}
@@ -166,15 +169,7 @@ export const ChangeEmail: FunctionalComponent<Props> = ({
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</ModalDialogDescription>
<ModalDialogButtons>
{currentStep === Steps.InitialStep && (
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={handleDialogClose}
/>
)}
<ModalDialogButtons className="px-4.5">
<Button
className="min-w-20"
type="primary"

View File

@@ -1,45 +0,0 @@
import { DecoratedInput } from '@/components/DecoratedInput';
import { StateUpdater } from 'preact/hooks';
import { FunctionalComponent } from 'preact';
import { HtmlInputTypes } from '@/enums';
type Props = {
setCurrentPassword: StateUpdater<string>
setNewPassword: StateUpdater<string>
setNewPasswordConfirmation: StateUpdater<string>
}
export const ChangePasswordForm: FunctionalComponent<Props> = ({
setCurrentPassword,
setNewPassword,
setNewPasswordConfirmation
}) => {
return (
(
<>
<div className={'mt-2 mb-3'}>
<DecoratedInput
type={HtmlInputTypes.Password}
onChange={(currentPassword) => {
setCurrentPassword(currentPassword);
}}
placeholder={'Current Password'}
/>
</div>
<div className={'mt-2 mb-3'}>
<DecoratedInput
type={HtmlInputTypes.Password}
placeholder={'New Password'}
onChange={newPassword => setNewPassword(newPassword)}
/>
</div>
<div className={'mt-2 mb-3'}>
<DecoratedInput
type={HtmlInputTypes.Password}
placeholder={'Confirm New Password'}
onChange={newPasswordConfirmation => setNewPasswordConfirmation(newPasswordConfirmation)}
/>
</div>
</>
)
);
};

View File

@@ -1,12 +0,0 @@
import { FunctionalComponent } from 'preact';
export const ChangePasswordSuccess: FunctionalComponent = () => {
return (
<>
<div className={'sk-label sk-bold info'}>Your password has been successfully changed.</div>
<p className={'sk-p'}>
Please ensure you are running the latest version of Standard Notes on all platforms to ensure maximum compatibility.
</p>
</>
);
};

View File

@@ -1,189 +0,0 @@
import { useState } from '@node_modules/preact/hooks';
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/components/shared/ModalDialog';
import { Button } from '@/components/Button';
import { FunctionalComponent } from 'preact';
import { WebApplication } from '@/ui_models/application';
import { ChangePasswordSuccess } from '@/preferences/panes/account/changePassword/ChangePasswordSuccess';
import { ChangePasswordForm } from '@/preferences/panes/account/changePassword/ChangePasswordForm';
import { useBeforeUnload } from '@/hooks/useBeforeUnload';
enum SubmitButtonTitles {
Default = 'Continue',
GeneratingKeys = 'Generating Keys...',
Finish = 'Finish',
}
enum Steps {
InitialStep,
FinishStep,
}
type Props = {
onCloseDialog: () => void;
application: WebApplication;
};
export const ChangePassword: FunctionalComponent<Props> = ({
onCloseDialog,
application,
}) => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
const [isContinuing, setIsContinuing] = useState(false);
const [lockContinue, setLockContinue] = useState(false);
const [submitButtonTitle, setSubmitButtonTitle] = useState(
SubmitButtonTitles.Default
);
const [currentStep, setCurrentStep] = useState(Steps.InitialStep);
useBeforeUnload();
const applicationAlertService = application.alertService;
const validateCurrentPassword = async () => {
if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert('Please enter your current password.');
return false;
}
if (!newPassword || newPassword.length === 0) {
applicationAlertService.alert('Please enter a new password.');
return false;
}
if (newPassword !== newPasswordConfirmation) {
applicationAlertService.alert(
'Your new password does not match its confirmation.'
);
return false;
}
if (!application.getUser()?.email) {
applicationAlertService.alert(
"We don't have your email stored. Please sign out then sign back in to fix this issue."
);
return false;
}
/** Validate current password */
const success = await application.validateAccountPassword(currentPassword);
if (!success) {
applicationAlertService.alert(
'The current password you entered is not correct. Please try again.'
);
}
return success;
};
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default);
setIsContinuing(false);
};
const processPasswordChange = async () => {
await application.downloadBackup();
setLockContinue(true);
const response = await application.changePassword(
currentPassword,
newPassword
);
const success = !response.error;
setLockContinue(false);
return success;
};
const dismiss = () => {
if (lockContinue) {
applicationAlertService.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
onCloseDialog();
}
};
const handleSubmit = async () => {
if (lockContinue || isContinuing) {
return;
}
if (currentStep === Steps.FinishStep) {
dismiss();
return;
}
setIsContinuing(true);
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys);
const valid = await validateCurrentPassword();
if (!valid) {
resetProgressState();
return;
}
const success = await processPasswordChange();
if (!success) {
resetProgressState();
return;
}
setIsContinuing(false);
setSubmitButtonTitle(SubmitButtonTitles.Finish);
setCurrentStep(Steps.FinishStep);
};
const handleDialogClose = () => {
if (lockContinue) {
applicationAlertService.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
onCloseDialog();
}
};
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>
Change Password
</ModalDialogLabel>
<ModalDialogDescription>
{currentStep === Steps.InitialStep && (
<ChangePasswordForm
setCurrentPassword={setCurrentPassword}
setNewPassword={setNewPassword}
setNewPasswordConfirmation={setNewPasswordConfirmation}
/>
)}
{currentStep === Steps.FinishStep && <ChangePasswordSuccess />}
</ModalDialogDescription>
<ModalDialogButtons>
{currentStep === Steps.InitialStep && (
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={handleDialogClose}
/>
)}
<Button
className="min-w-20"
type="primary"
label={submitButtonTitle}
onClick={handleSubmit}
/>
</ModalDialogButtons>
</ModalDialog>
</div>
);
};

View File

@@ -3,3 +3,4 @@ export { Sync } from './Sync';
export { Credentials } from './Credentials';
export { SignOutWrapper } from './SignOutView';
export { Authentication } from './Authentication';
export { Advanced } from './Advanced';

View File

@@ -0,0 +1,124 @@
import { FunctionalComponent } from 'preact';
import { Subtitle } from '@/preferences/components';
import { DecoratedInput } from '@/components/DecoratedInput';
import { Button } from '@/components/Button';
import { JSXInternal } from '@node_modules/preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { useEffect, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/strings';
import { ButtonType } from '@standardnotes/snjs';
interface IProps {
application: WebApplication;
appState: AppState;
}
export const OfflineSubscription: FunctionalComponent<IProps> = observer(({ application, appState }) => {
const [activationCode, setActivationCode] = useState('');
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false);
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false);
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] = useState(false);
useEffect(() => {
if (application.getIsOfflineActivationCodeStoredPreviously()) {
setHasUserPreviouslyStoredCode(true);
}
}, [application]);
const shouldShowOfflineSubscription = () => {
return !application.hasAccount() || application.isCustomServerHostUsed();
};
const handleSubscriptionCodeSubmit = async (event: TargetedEvent<HTMLFormElement, Event>) => {
event.preventDefault();
const result = await application.setOfflineFeatures(activationCode);
if (result?.error) {
await application.alertService.alert(result.error);
} else {
setIsSuccessfullyActivated(true);
setHasUserPreviouslyStoredCode(true);
setIsSuccessfullyRemoved(false);
}
};
const handleRemoveOfflineKey = async () => {
await application.removeOfflineActivationCode();
setIsSuccessfullyActivated(false);
setHasUserPreviouslyStoredCode(false);
setActivationCode('');
setIsSuccessfullyRemoved(true);
};
if (!shouldShowOfflineSubscription()) {
return null;
}
const handleRemoveClick = async () => {
application.alertService.confirm(
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
'Remove offline key?',
'Remove Offline Key',
ButtonType.Danger,
'Cancel'
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await handleRemoveOfflineKey();
}
})
.catch((err: string) => {
application.alertService.alert(err);
});
};
return (
<div className='flex items-center justify-between'>
<div className='flex flex-col mt-3 w-full'>
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
<form onSubmit={handleSubscriptionCodeSubmit}>
<div className={'mt-2'}>
{!hasUserPreviouslyStoredCode && (
<DecoratedInput
onChange={(code) => setActivationCode(code)}
placeholder={'Offline Subscription Code'}
text={activationCode}
disabled={isSuccessfullyActivated}
className={'mb-3'}
/>
)}
</div>
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
<div className={'mt-3 mb-3 info font-bold'}>
Successfully {isSuccessfullyActivated ? 'Activated' : 'Removed'}!
</div>
)}
{hasUserPreviouslyStoredCode && (
<Button
type='danger'
label='Remove offline key'
onClick={() => {
handleRemoveClick();
}}
/>
)}
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
<Button
label={'Submit'}
type='primary'
disabled={activationCode === ''}
onClick={(event) =>
handleSubscriptionCodeSubmit(event as TargetedEvent<HTMLFormElement>)
}
/>
)}
</form>
</div>
</div>
);
});

View File

@@ -3,7 +3,7 @@ import { LinkButton, Text } from '@/preferences/components';
import { Button } from '@/components/Button';
import { WebApplication } from "@/ui_models/application";
import { useState } from "preact/hooks";
import { isDesktopApplication } from "@/utils";
import { loadPurchaseFlowUrl } from "@/purchaseFlow/PurchaseFlowWrapper";
export const NoSubscription: FunctionalComponent<{
application: WebApplication;
@@ -15,12 +15,7 @@ export const NoSubscription: FunctionalComponent<{
const errorMessage = 'There was an error when attempting to redirect you to the subscription page.';
setIsLoadingPurchaseFlow(true);
try {
const url = await application.getPurchaseFlowUrl();
if (url) {
const currentUrl = window.location.href;
const successUrl = isDesktopApplication() ? `standardnotes://${currentUrl}` : currentUrl;
window.location.assign(`${url}&success_url=${successUrl}`);
} else {
if (!await loadPurchaseFlowUrl(application)) {
setPurchaseFlowError(errorMessage);
}
} catch (e) {
@@ -47,7 +42,7 @@ export const NoSubscription: FunctionalComponent<{
<LinkButton
className="min-w-20 mt-3 mr-3"
label="Learn More"
link="https://standardnotes.com/plans"
link={window._plans_url as string}
/>
{application.hasAccount() &&
<Button

View File

@@ -11,6 +11,7 @@ import { NoSubscription } from './NoSubscription';
import { Text } from '@/preferences/components';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { ApplicationEvent } from '@standardnotes/snjs';
type Props = {
application: WebApplication;
@@ -58,6 +59,20 @@ export const Subscription: FunctionComponent<Props> = observer(({
}
}, [getSubscription, getSubscriptions]);
useEffect(() => {
const removeUserRoleObserver = application.addEventObserver(
async () => {
await getSubscription();
await getSubscriptions();
},
ApplicationEvent.UserRolesChanged
);
return () => {
removeUserRoleObserver();
};
}, [application, getSubscription, getSubscriptions]);
useEffect(() => {
if (application.hasAccount()) {
getSubscriptionInfo();
@@ -77,7 +92,7 @@ export const Subscription: FunctionComponent<Props> = observer(({
) : loading ? (
<Text>Loading subscription information...</Text>
) : userSubscription && userSubscription.endsAt > now ? (
<SubscriptionInformation subscriptionState={subscriptionState} />
<SubscriptionInformation subscriptionState={subscriptionState} application={application} />
) : (
<NoSubscription application={application} />
)}

View File

@@ -2,17 +2,22 @@ import { observer } from 'mobx-react-lite';
import { SubscriptionState } from './subscription_state';
import { Text } from '@/preferences/components';
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { convertTimestampToMilliseconds } from '@standardnotes/snjs';
type Props = {
subscriptionState: SubscriptionState;
application?: WebApplication;
};
const StatusText = observer(({ subscriptionState }: Props) => {
const { userSubscription, userSubscriptionName } = subscriptionState;
const expirationDate = new Date(userSubscription!.endsAt / 1000).toLocaleString();
const expirationDate = new Date(
convertTimestampToMilliseconds(userSubscription!.endsAt)
).toLocaleString();
return userSubscription!.cancelled ? (
<Text>
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
@@ -20,63 +25,45 @@ const StatusText = observer(({ subscriptionState }: Props) => {
</span>{' '}
subscription has been{' '}
<span className="font-bold">
canceled but will remain valid until{' '}
{expirationDate}
canceled but will remain valid until {expirationDate}
</span>
. You may resubscribe below if you wish.
</Text>
) : (
<Text>
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription will be{' '}
<span className="font-bold">
renewed on {expirationDate}
</span>
.
<span className="font-bold">renewed on {expirationDate}</span>.
</Text>
);
});
const PrimaryButton = observer(({ subscriptionState }: Props) => {
const { userSubscription } = subscriptionState;
return (
<Button
className="min-w-20 mt-3"
type="primary"
label={userSubscription!.cancelled ? "Renew subscription" : "Cancel subscription"}
onClick={() => null}
/>
);
});
export const SubscriptionInformation = observer(
({ subscriptionState }: Props) => (
<>
<StatusText
subscriptionState={subscriptionState}
/>
<div className="flex">
({ subscriptionState, application }: Props) => {
const openSubscriptionDashboard = async () => {
const token = await application?.getNewSubscriptionToken();
if (!token) {
return;
}
window.location.assign(
`${window._dashboard_url}?subscription_token=${token}`
);
};
return (
<>
<StatusText subscriptionState={subscriptionState} />
<Button
className="min-w-20 mt-3 mr-3"
type="normal"
label="Refresh"
onClick={() => null}
label="Manage subscription"
onClick={openSubscriptionDashboard}
/>
<Button
className="min-w-20 mt-3 mr-3"
type="normal"
label="Change plan"
onClick={() => null}
/>
<PrimaryButton
subscriptionState={subscriptionState}
/>
</div>
</>
)
</>
);
}
);

View File

@@ -1,11 +1,12 @@
import { FunctionComponent } from "preact";
import { SNComponent } from "@standardnotes/snjs";
import { PreferencesSegment, Subtitle, Title } from "@/preferences/components";
import { ComponentArea } from "@standardnotes/features";
import { PreferencesSegment, SubtitleLight, Title } from "@/preferences/components";
import { Switch } from "@/components/Switch";
import { WebApplication } from "@/ui_models/application";
import { useEffect, useRef, useState } from "preact/hooks";
import { useState } from "preact/hooks";
import { Button } from "@/components/Button";
import { RenameExtension } from "./RenameExtension";
const ExtensionVersions: FunctionComponent<{
installedVersion: string,
@@ -13,7 +14,7 @@ const ExtensionVersions: FunctionComponent<{
}> = ({ installedVersion, latestVersion }) => {
return (
<>
<Subtitle>Installed version <b>{installedVersion}</b> {latestVersion && <>(latest is <b>{latestVersion}</b>)</>}</Subtitle>
<SubtitleLight>Installed version <b>{installedVersion}</b> {latestVersion && <>(latest is <b>{latestVersion}</b>)</>}</SubtitleLight>
</>
);
};
@@ -23,7 +24,7 @@ const AutoUpdateLocal: FunctionComponent<{
toggleAutoupdate: () => void
}> = ({ autoupdateDisabled, toggleAutoupdate }) => (
<div className="flex flex-row">
<Subtitle className="flex-grow">Autoupdate local installation</Subtitle>
<SubtitleLight className="flex-grow">Autoupdate local installation</SubtitleLight>
<Switch onChange={toggleAutoupdate} checked={!autoupdateDisabled} />
</div>
);
@@ -32,166 +33,111 @@ const UseHosted: FunctionComponent<{
offlineOnly: boolean, toggleOfllineOnly: () => void
}> = ({ offlineOnly, toggleOfllineOnly }) => (
<div className="flex flex-row">
<Subtitle className="flex-grow">Use hosted when local is unavailable</Subtitle>
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
</div>
);
const RenameExtension: FunctionComponent<{
extensionName: string, changeName: (newName: string) => void
}> = ({ extensionName, changeName }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isRenaming) {
inputRef.current.focus();
}
}, [inputRef, isRenaming]);
const startRenaming = () => {
setNewExtensionName(extensionName);
setIsRenaming(true);
};
const cancelRename = () => {
setNewExtensionName(extensionName);
setIsRenaming(false);
};
const confirmRename = () => {
if (newExtensionName == undefined || newExtensionName === '') {
return;
}
changeName(newExtensionName);
setIsRenaming(false);
};
return (
<div className="flex flex-row mr-3 items-center">
<input
ref={inputRef}
disabled={!isRenaming}
autocomplete='off'
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
type="text"
value={newExtensionName}
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
/>
<div className="min-w-3" />
{isRenaming ?
<>
<a className="pt-1 cursor-pointer" onClick={confirmRename}>Confirm</a>
<div className="min-w-3" />
<a className="pt-1 cursor-pointer" onClick={cancelRename}>Cancel</a>
</> :
<a className="pt-1 cursor-pointer" onClick={startRenaming}>Rename</a>
}
</div>
);
};
export const ExtensionItem: FunctionComponent<{
export interface ExtensionItemProps {
application: WebApplication,
extension: SNComponent,
first: boolean,
latestVersion: string | undefined,
uninstall: (extension: SNComponent) => void,
toggleActivate: (extension: SNComponent) => void,
}> = ({ application, extension, first, uninstall, toggleActivate, latestVersion }) => {
toggleActivate?: (extension: SNComponent) => void,
}
export const ExtensionItem: FunctionComponent<ExtensionItemProps> =
({ application, extension, first, uninstall, toggleActivate, latestVersion }) => {
const [autoupdateDisabled, setAutoupdateDisabled] = useState(extension.autoupdateDisabled ?? false);
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false);
const [extensionName, setExtensionName] = useState(extension.name);
const toggleAutoupdate = () => {
const newAutoupdateValue = !autoupdateDisabled;
setAutoupdateDisabled(newAutoupdateValue);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.autoupdateDisabled = newAutoupdateValue;
})
.then((item) => {
const component = (item as SNComponent);
setAutoupdateDisabled(component.autoupdateDisabled);
})
.catch(e => {
console.error(e);
});
const toggleAutoupdate = () => {
const newAutoupdateValue = !autoupdateDisabled;
setAutoupdateDisabled(newAutoupdateValue);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.autoupdateDisabled = newAutoupdateValue;
})
.then((item) => {
const component = (item as SNComponent);
setAutoupdateDisabled(component.autoupdateDisabled);
})
.catch(e => {
console.error(e);
});
};
const toggleOffllineOnly = () => {
const newOfflineOnly = !offlineOnly;
setOfflineOnly(newOfflineOnly);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.offlineOnly = newOfflineOnly;
})
.then((item) => {
const component = (item as SNComponent);
setOfflineOnly(component.offlineOnly);
})
.catch(e => {
console.error(e);
});
};
const changeExtensionName = (newName: string) => {
setExtensionName(newName);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.name = newName;
})
.then((item) => {
const component = (item as SNComponent);
setExtensionName(component.name);
});
};
const localInstallable = extension.package_info.download_url;
const isExternal = !extension.package_info.identifier.startsWith('org.standardnotes.');
const installedVersion = extension.package_info.version;
const isToggleable = [ComponentArea.EditorStack, ComponentArea.TagsList].includes(extension.area);
return (
<PreferencesSegment classes={'mb-5'}>
{first && <>
<Title>Extensions</Title>
<div className="w-full min-h-3" />
</>}
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
<div className="min-h-2" />
<ExtensionVersions installedVersion={installedVersion} latestVersion={latestVersion} />
{localInstallable && <AutoUpdateLocal autoupdateDisabled={autoupdateDisabled} toggleAutoupdate={toggleAutoupdate} />}
{localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
{(isToggleable || isExternal) &&
<>
<div className="min-h-2" />
<div className="flex flex-row">
{isToggleable && (
<>
{(extension.active ? (
<Button className="min-w-20" type="normal" label="Deactivate" onClick={() => toggleActivate!(extension)} />
) : (
<Button className="min-w-20" type="normal" label="Activate" onClick={() => toggleActivate!(extension)} />
))}
<div className="min-w-3" />
</>
)}
{isExternal && <Button className="min-w-20" type="normal" label="Uninstall" onClick={() => uninstall(extension)} />}
</div>
</>
}
</PreferencesSegment >
);
};
const toggleOffllineOnly = () => {
const newOfflineOnly = !offlineOnly;
setOfflineOnly(newOfflineOnly);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.offlineOnly = newOfflineOnly;
})
.then((item) => {
const component = (item as SNComponent);
setOfflineOnly(component.offlineOnly);
})
.catch(e => {
console.error(e);
});
};
const changeExtensionName = (newName: string) => {
setExtensionName(newName);
application
.changeAndSaveItem(extension.uuid, (m: any) => {
if (m.content == undefined) m.content = {};
m.content.name = newName;
})
.then((item) => {
const component = (item as SNComponent);
setExtensionName(component.name);
});
};
const localInstallable = extension.package_info.download_url;
const isExternal = !extension.package_info.identifier.startsWith('org.standardnotes.');
const installedVersion = extension.package_info.version;
const isEditorOrTags = ['editor-stack', 'tags-list'].includes(extension.area);
return (
<PreferencesSegment>
{first && <>
<Title>Extensions</Title>
<div className="w-full min-h-3" />
</>}
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
<div className="min-h-2" />
<ExtensionVersions installedVersion={installedVersion} latestVersion={latestVersion} />
{localInstallable && <AutoUpdateLocal autoupdateDisabled={autoupdateDisabled} toggleAutoupdate={toggleAutoupdate} />}
{localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
{isEditorOrTags || isExternal &&
<>
<div className="min-h-2" />
<div className="flex flex-row">
{isEditorOrTags && (
<>
{extension.active ?
<Button className="min-w-20" type="normal" label="Deactivate" onClick={() => toggleActivate(extension)} /> :
<Button className="min-w-20" type="normal" label="Activate" onClick={() => toggleActivate(extension)} />
}
<div className="min-w-3" />
</>
)}
{isExternal && <Button className="min-w-20" type="normal" label="Uninstall" onClick={() => uninstall(extension)} />}
</div>
</>
}
</PreferencesSegment >
);
};

View File

@@ -0,0 +1,35 @@
import { WebApplication } from "@/ui_models/application";
import { FeatureDescription } from "@standardnotes/features";
import { SNComponent } from "@standardnotes/snjs/dist/@types";
import { makeAutoObservable, observable } from "mobx";
export class ExtensionsLatestVersions {
static async load(application: WebApplication): Promise<ExtensionsLatestVersions> {
const map = await application.getAvailableSubscriptions()
.then(subscriptions => {
const versionMap: Map<string, string> = new Map();
collectFeatures(subscriptions?.CORE_PLAN?.features as FeatureDescription[], versionMap);
collectFeatures(subscriptions?.PLUS_PLAN?.features as FeatureDescription[], versionMap);
collectFeatures(subscriptions?.PRO_PLAN?.features as FeatureDescription[], versionMap);
return versionMap;
});
return new ExtensionsLatestVersions(map);
}
constructor(private readonly latestVersionsMap: Map<string, string>) {
makeAutoObservable<ExtensionsLatestVersions, 'latestVersionsMap'>(
this, { latestVersionsMap: observable.ref });
}
getVersion(extension: SNComponent): string | undefined {
return this.latestVersionsMap.get(extension.package_info.identifier);
}
}
function collectFeatures(features: FeatureDescription[] | undefined, versionMap: Map<string, string>) {
if (features == undefined) return;
for (const feature of features) {
versionMap.set(feature.identifier, feature.version);
}
}

View File

@@ -0,0 +1,58 @@
import { FunctionComponent } from "preact";
import { useState, useRef, useEffect } from "preact/hooks";
export const RenameExtension: FunctionComponent<{
extensionName: string, changeName: (newName: string) => void
}> = ({ extensionName, changeName }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isRenaming) {
inputRef.current!.focus();
}
}, [inputRef, isRenaming]);
const startRenaming = () => {
setNewExtensionName(extensionName);
setIsRenaming(true);
};
const cancelRename = () => {
setNewExtensionName(extensionName);
setIsRenaming(false);
};
const confirmRename = () => {
if (!newExtensionName) {
return;
}
changeName(newExtensionName);
setIsRenaming(false);
};
return (
<div className="flex flex-row mr-3 items-center">
<input
ref={inputRef}
disabled={!isRenaming}
autocomplete='off'
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
type="text"
value={newExtensionName}
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
/>
<div className="min-w-3" />
{isRenaming ?
<>
<a className="pt-1 cursor-pointer" onClick={confirmRename}>Confirm</a>
<div className="min-w-3" />
<a className="pt-1 cursor-pointer" onClick={cancelRename}>Cancel</a>
</> :
<a className="pt-1 cursor-pointer" onClick={startRenaming}>Rename</a>
}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More