diff --git a/.env.sample b/.env.sample index 5033e80ba..9909770d6 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/.eslintrc b/.eslintrc index 3841621ad..82a099e75 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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, diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 81941d543..5c87b578f 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -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 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 27bf89fae..b73771dc2 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -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 diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index a700f9017..feac7cd51 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7272384f3..9684431fb 100644 --- a/.gitignore +++ b/.gitignore @@ -41,11 +41,7 @@ dump.rdb .vscode -# Generated Files -/dist/javascripts -/dist/stylesheets -/dist/fonts -/dist/@types +/dist # Yarn yarn-error.log diff --git a/.gitmodules b/.gitmodules index adb7eaeaa..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 - diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..141e9a2a2 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16.11.1 diff --git a/Dockerfile b/Dockerfile index ef383b668..e787fcdbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index d2fe3cd95..e6dbdab88 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md index e623f0f1f..f77a05ac2 100644 --- a/README.md +++ b/README.md @@ -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 ``` --- diff --git a/app/assets/icons/ic-accessibility.svg b/app/assets/icons/ic-accessibility.svg index ffb780043..7d913389a 100644 --- a/app/assets/icons/ic-accessibility.svg +++ b/app/assets/icons/ic-accessibility.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-arrows-sort-down.svg b/app/assets/icons/ic-arrows-sort-down.svg new file mode 100644 index 000000000..b7793bb25 --- /dev/null +++ b/app/assets/icons/ic-arrows-sort-down.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/assets/icons/ic-arrows-sort-up.svg b/app/assets/icons/ic-arrows-sort-up.svg new file mode 100644 index 000000000..ddb85688f --- /dev/null +++ b/app/assets/icons/ic-arrows-sort-up.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/assets/icons/ic-chevron-down.svg b/app/assets/icons/ic-chevron-down.svg index 1c89552e6..190774efb 100644 --- a/app/assets/icons/ic-chevron-down.svg +++ b/app/assets/icons/ic-chevron-down.svg @@ -1,4 +1,4 @@ - - - \ No newline at end of file + + \ No newline at end of file diff --git a/app/assets/icons/ic-copy.svg b/app/assets/icons/ic-copy.svg index 694626a33..98e2c93e6 100644 --- a/app/assets/icons/ic-copy.svg +++ b/app/assets/icons/ic-copy.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-download.svg b/app/assets/icons/ic-download.svg index 923b753bd..e87e65165 100644 --- a/app/assets/icons/ic-download.svg +++ b/app/assets/icons/ic-download.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-help.svg b/app/assets/icons/ic-help.svg index eaed4c3f7..dfec7f52a 100644 --- a/app/assets/icons/ic-help.svg +++ b/app/assets/icons/ic-help.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-info.svg b/app/assets/icons/ic-info.svg index 14107de40..575677684 100644 --- a/app/assets/icons/ic-info.svg +++ b/app/assets/icons/ic-info.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-keyboard.svg b/app/assets/icons/ic-keyboard.svg index 8068326fd..3d37ab60a 100644 --- a/app/assets/icons/ic-keyboard.svg +++ b/app/assets/icons/ic-keyboard.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-listed.svg b/app/assets/icons/ic-listed.svg index 3bac23a5a..7179c323e 100644 --- a/app/assets/icons/ic-listed.svg +++ b/app/assets/icons/ic-listed.svg @@ -1,3 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-security.svg b/app/assets/icons/ic-security.svg index dfa4b37cc..06ab6033b 100644 --- a/app/assets/icons/ic-security.svg +++ b/app/assets/icons/ic-security.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-settings.svg b/app/assets/icons/ic-settings.svg index 2191bea9a..2d2a26528 100644 --- a/app/assets/icons/ic-settings.svg +++ b/app/assets/icons/ic-settings.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-star.svg b/app/assets/icons/ic-star.svg index f74b0d567..19072b969 100644 --- a/app/assets/icons/ic-star.svg +++ b/app/assets/icons/ic-star.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-themes.svg b/app/assets/icons/ic-themes.svg index 33abb061a..62ac3133b 100644 --- a/app/assets/icons/ic-themes.svg +++ b/app/assets/icons/ic-themes.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-user.svg b/app/assets/icons/ic-user.svg index 1bdfaf61f..ee303b0ee 100644 --- a/app/assets/icons/ic-user.svg +++ b/app/assets/icons/ic-user.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-window.svg b/app/assets/icons/ic-window.svg new file mode 100644 index 000000000..c94c68f8d --- /dev/null +++ b/app/assets/icons/ic-window.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 971f793eb..0550b3c55 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -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; diff --git a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx index 82865ede9..1a75878df 100644 --- a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx @@ -39,7 +39,7 @@ export const AdvancedOptions: FunctionComponent = observer( return ( <> - ) : ( - <> - - - - )} - - {user ? ( - <> -
- - - ) : null} + Account settings + + ) : ( + <> + { + setMenuPane(AccountMenuPane.Register); + }} + > + + Create free account + + { + setMenuPane(AccountMenuPane.SignIn); + }} + > + + Sign in + + + )} + { + appState.accountMenu.closeAccountMenu(); + appState.preferences.setCurrentPane('help-feedback'); + appState.preferences.openPreferences(); + }} + > +
+ + Help & feedback +
+ v{AppVersion} +
+ {user ? ( + <> + + { + appState.accountMenu.setSigningOut(true); + }} + > + + Sign out and clear local data + + + ) : null} + ); } diff --git a/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx index 95df944c4..cdfbbc046 100644 --- a/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx +++ b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx @@ -30,7 +30,7 @@ const PasscodeLock = observer(({ const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu; - const passcodeInputRef = useRef(); + const passcodeInputRef = useRef(null); const [passcode, setPasscode] = useState(undefined); const [passcodeConfirmation, setPasscodeConfirmation] = useState(undefined); @@ -155,7 +155,7 @@ const PasscodeLock = observer(({ useEffect(() => { if (isPasscodeFocused) { - passcodeInputRef.current.focus(); + passcodeInputRef.current!.focus(); setIsPasscodeFocused(false); } }, [isPasscodeFocused]); diff --git a/app/assets/javascripts/components/AccountMenu/SignIn.tsx b/app/assets/javascripts/components/AccountMenu/SignIn.tsx index 3b1ab702f..9c187890c 100644 --- a/app/assets/javascripts/components/AccountMenu/SignIn.tsx +++ b/app/assets/javascripts/components/AccountMenu/SignIn.tsx @@ -29,12 +29,12 @@ export const SignInPane: FunctionComponent = observer( const [showPassword, setShowPassword] = useState(false); const [shouldMergeLocal, setShouldMergeLocal] = useState(true); - const emailInputRef = useRef(); - const passwordInputRef = useRef(); + const emailInputRef = useRef(null); + const passwordInputRef = useRef(null); useEffect(() => { if (emailInputRef?.current) { - emailInputRef.current.focus(); + emailInputRef.current!.focus(); } }, []); @@ -73,8 +73,8 @@ export const SignInPane: FunctionComponent = 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 = observer( application.alertService.alert(err); } setPassword(''); - passwordInputRef?.current.blur(); + passwordInputRef?.current!.blur(); }) .finally(() => { setIsSigningIn(false); @@ -109,12 +109,12 @@ export const SignInPane: FunctionComponent = 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; } diff --git a/app/assets/javascripts/components/AccountMenu/index.tsx b/app/assets/javascripts/components/AccountMenu/index.tsx index 77da5608c..12f6a12b0 100644 --- a/app/assets/javascripts/components/AccountMenu/index.tsx +++ b/app/assets/javascripts/components/AccountMenu/index.tsx @@ -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 = observer( closeAccountMenu, } = appState.accountMenu; + const handleKeyDown: JSXInternal.KeyboardEventHandler = ( + 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 ( -
+
{ const { autocompleteTagHintFocused } = appState.noteTags; - const hintRef = useRef(); + const hintRef = useRef(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]); diff --git a/app/assets/javascripts/components/AutocompleteTagInput.tsx b/app/assets/javascripts/components/AutocompleteTagInput.tsx index 80c865f39..35ee99b6f 100644 --- a/app/assets/javascripts/components/AutocompleteTagInput.tsx +++ b/app/assets/javascripts/components/AutocompleteTagInput.tsx @@ -25,17 +25,17 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => { const [dropdownMaxHeight, setDropdownMaxHeight] = useState('auto'); - const containerRef = useRef(); - const inputRef = useRef(); + const containerRef = useRef(null); + const inputRef = useRef(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]); diff --git a/app/assets/javascripts/components/AutocompleteTagResult.tsx b/app/assets/javascripts/components/AutocompleteTagResult.tsx index 672d5e72c..dd5195bcb 100644 --- a/app/assets/javascripts/components/AutocompleteTagResult.tsx +++ b/app/assets/javascripts/components/AutocompleteTagResult.tsx @@ -19,7 +19,7 @@ export const AutocompleteTagResult = observer( focusedTagResultUuid, } = appState.noteTags; - const tagResultRef = useRef(); + const tagResultRef = useRef(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]); diff --git a/app/assets/javascripts/components/Button.tsx b/app/assets/javascripts/components/Button.tsx index 484983dde..53e4a79b7 100644 --- a/app/assets/javascripts/components/Button.tsx +++ b/app/assets/javascripts/components/Button.tsx @@ -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 | TargetedMouseEvent) => void; + onClick: ( + event: + | TargetedEvent + | TargetedMouseEvent + ) => void; disabled?: boolean; }> = ({ type, label, className = '', onClick, disabled = false }) => { const buttonClass = buttonClasses[type]; diff --git a/app/assets/javascripts/components/ComponentView/IsDeprecated.tsx b/app/assets/javascripts/components/ComponentView/IsDeprecated.tsx new file mode 100644 index 000000000..f65e80008 --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/IsDeprecated.tsx @@ -0,0 +1,32 @@ +import { FunctionalComponent } from 'preact'; + +interface IProps { + deprecationMessage: string | undefined; + dismissDeprecationMessage: () => void; +} + +export const IsDeprecated: FunctionalComponent = ({ + deprecationMessage, + dismissDeprecationMessage + }) => { + return ( +
+
+
+
+
+ {deprecationMessage || 'This extension is deprecated.'} +
+
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/app/assets/javascripts/components/ComponentView/IsExpired.tsx b/app/assets/javascripts/components/ComponentView/IsExpired.tsx new file mode 100644 index 000000000..31d1faa1f --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/IsExpired.tsx @@ -0,0 +1,57 @@ +import { FunctionalComponent } from 'preact'; + +interface IProps { + expiredDate: string; + reloadStatus: () => void; +} + +export const IsExpired: FunctionalComponent = ({ + expiredDate, + reloadStatus + }) => { + return ( +
+
+
+
+
+
+
+
+
+ + Your Extended subscription expired on {expiredDate} + +
+ Extensions are in a read-only state. +
+
+
+
+
+
+
reloadStatus()}> + +
+
+ +
+
+
+
+ ); +}; diff --git a/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx b/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx new file mode 100644 index 000000000..3bec4eab7 --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx @@ -0,0 +1,30 @@ +import { FunctionalComponent } from 'preact'; + +interface IProps { + componentName: string; + reloadIframe: () => void; +} + +export const IssueOnLoading: FunctionalComponent = ({ + componentName, + reloadIframe + }) => { + return ( +
+
+
+
+
+ There was an issue loading {componentName} +
+
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx new file mode 100644 index 000000000..7de8ef90b --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx @@ -0,0 +1,58 @@ +import { FunctionalComponent } from 'preact'; + +interface IProps { + isReloading: boolean; + reloadStatus: () => void; +} + +export const OfflineRestricted: FunctionalComponent = ({ + isReloading, + reloadStatus + }) => { + return ( +
+
+
+
+
+
+ You have restricted this extension to be used offline only. +
+
+ Offline extensions are not available in the Web app. +
+
+
+
+
+ You can either: +
+
    +
  • + + 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. + +
  • +
  • + Use the Desktop application. +
  • +
+
+
+
+ {isReloading ? +
+ : + + } +
+
+
+
+
+ ); +}; diff --git a/app/assets/javascripts/components/ComponentView/UrlMissing.tsx b/app/assets/javascripts/components/ComponentView/UrlMissing.tsx new file mode 100644 index 000000000..c2dd6072c --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/UrlMissing.tsx @@ -0,0 +1,26 @@ +import { FunctionalComponent } from 'preact'; + +interface IProps { + componentName: string; +} + +export const UrlMissing: FunctionalComponent = ({ componentName }) => { + return ( +
+
+
+
+
+ This extension is not installed correctly. +
+

Please uninstall {componentName}, then re-install it.

+

+ 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. +

+
+
+
+
+ ); +}; diff --git a/app/assets/javascripts/components/ComponentView/index.tsx b/app/assets/javascripts/components/ComponentView/index.tsx new file mode 100644 index 000000000..e1670d4ee --- /dev/null +++ b/app/assets/javascripts/components/ComponentView/index.tsx @@ -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 = observer( + ({ + application, + appState, + onLoad, + componentUuid, + templateComponent, + manualDealloc = false, + }) => { + const liveComponentRef = useRef | null>(null); + const iframeRef = useRef(null); + + const [isIssueOnLoading, setIsIssueOnLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isReloading, setIsReloading] = useState(false); + const [loadTimeout, setLoadTimeout] = useState(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(undefined); + const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false); + const [didAttemptReload, setDidAttemptReload] = useState(false); + const [component, setComponent] = useState(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 && ( + + )} + {isExpired && ( + + )} + {isDeprecated && !isDeprecationMessageDismissed && ( + + )} + {error == 'offline-restricted' && ( + + )} + {error == 'url-missing' && ( + + )} + {component.uuid && !isReloading && isComponentValid && ( + + )} + {isLoading && ( +
+ )} + + ); + }); + +export const ComponentViewDirective = toDirective(ComponentView, { + onLoad: '=', + componentUuid: '=', + templateComponent: '=', + manualDealloc: '=' +}); diff --git a/app/assets/javascripts/components/ConfirmSignoutModal.tsx b/app/assets/javascripts/components/ConfirmSignoutModal.tsx index 708ff1d95..ddb6e4056 100644 --- a/app/assets/javascripts/components/ConfirmSignoutModal.tsx +++ b/app/assets/javascripts/components/ConfirmSignoutModal.tsx @@ -25,7 +25,7 @@ export const ConfirmSignoutContainer = observer((props: Props) => { const ConfirmSignoutModal = observer(({ application, appState }: Props) => { const [deleteLocalBackups, setDeleteLocalBackups] = useState(false); - const cancelRef = useRef(); + const cancelRef = useRef(null); function closeDialog() { appState.accountMenu.setSigningOut(false); } diff --git a/app/assets/javascripts/components/ConfirmationDialog.tsx b/app/assets/javascripts/components/ConfirmationDialog.tsx index 78114ed66..03c632f8c 100644 --- a/app/assets/javascripts/components/ConfirmationDialog.tsx +++ b/app/assets/javascripts/components/ConfirmationDialog.tsx @@ -9,7 +9,7 @@ import { useRef } from 'preact/hooks'; export const ConfirmationDialog: FunctionComponent<{ title: string | ComponentChildren; }> = ({ title, children }) => { - const ldRef = useRef(); + const ldRef = useRef(null); return ( diff --git a/app/assets/javascripts/components/DecoratedInput.tsx b/app/assets/javascripts/components/DecoratedInput.tsx index fe3b67178..4008f60d5 100644 --- a/app/assets/javascripts/components/DecoratedInput.tsx +++ b/app/assets/javascripts/components/DecoratedInput.tsx @@ -31,10 +31,11 @@ export const DecoratedInput: FunctionalComponent = ({ '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 (
diff --git a/app/assets/javascripts/components/FloatingLabelInput.tsx b/app/assets/javascripts/components/FloatingLabelInput.tsx new file mode 100644 index 000000000..c8cda068d --- /dev/null +++ b/app/assets/javascripts/components/FloatingLabelInput.tsx @@ -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; + disabled?: boolean; + className?: string; + labelClassName?: string; + inputClassName?: string; + isInvalid?: boolean; +}; + +export const FloatingLabelInput: FunctionComponent = forwardRef( + ( + { + id, + type, + label, + disabled, + value, + isInvalid, + onChange, + className = '', + labelClassName = '', + inputClassName = '', + }: Props, + ref: Ref + ) => { + 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 ( +
+ + +
+ ); + } +); diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 051ac9c19..f21c0137f 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -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; diff --git a/app/assets/javascripts/components/Input.tsx b/app/assets/javascripts/components/Input.tsx index 39fac5e08..5be48f875 100644 --- a/app/assets/javascripts/components/Input.tsx +++ b/app/assets/javascripts/components/Input.tsx @@ -14,7 +14,7 @@ export const Input: FunctionalComponent = ({ 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 ( diff --git a/app/assets/javascripts/components/InputWithIcon.tsx b/app/assets/javascripts/components/InputWithIcon.tsx index 5277d0238..7f228df3b 100644 --- a/app/assets/javascripts/components/InputWithIcon.tsx +++ b/app/assets/javascripts/components/InputWithIcon.tsx @@ -42,7 +42,7 @@ export const InputWithIcon: FunctionComponent = forwardRef( disabled, toggle, placeholder, - }, + }: Props, ref: Ref ) => { const handleToggle = () => { @@ -51,7 +51,7 @@ export const InputWithIcon: FunctionComponent = forwardRef( return (
diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx index 9a31fd1e6..91dc12aba 100644 --- a/app/assets/javascripts/components/NoteTag.tsx +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -14,9 +14,9 @@ export const NoteTag = observer(({ appState, tag }: Props) => { const [showDeleteButton, setShowDeleteButton] = useState(false); const [tagClicked, setTagClicked] = useState(false); - const deleteTagRef = useRef(); + const deleteTagRef = useRef(null); - const tagRef = useRef(); + const tagRef = useRef(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]); diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx index 252516a98..1c4c367f5 100644 --- a/app/assets/javascripts/components/NotesContextMenu.tsx +++ b/app/assets/javascripts/components/NotesContextMenu.tsx @@ -17,14 +17,14 @@ const NotesContextMenu = observer(({ application, appState }: Props) => { contextMenuMaxHeight, } = appState.notes; - const contextMenuRef = useRef(); + const contextMenuRef = useRef(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) ); diff --git a/app/assets/javascripts/components/NotesListOptionsMenu.tsx b/app/assets/javascripts/components/NotesListOptionsMenu.tsx new file mode 100644 index 000000000..833701622 --- /dev/null +++ b/app/assets/javascripts/components/NotesListOptionsMenu.tsx @@ -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 = 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 ( +
+ +
+ Sort by +
+ +
+ Date modified + {sortBy === CollectionSort.UpdatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
+ Creation date + {sortBy === CollectionSort.CreatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
+ Title + {sortBy === CollectionSort.Title ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
+ View +
+ +
Show note preview
+
+ + Show date + + + Show tags + +
+
+ Other +
+ + Show pinned notes + + + Show protected notes + + + Show archived notes + + + Show trashed notes + +
+
+ ); + } +); + +export const NotesListOptionsDirective = toDirective( + NotesListOptionsMenu, + { + setShowMenuFalse: '=', + state: '&', + } +); diff --git a/app/assets/javascripts/components/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions.tsx index 3346ff1e4..597ab8ba7 100644 --- a/app/assets/javascripts/components/NotesOptions.tsx +++ b/app/assets/javascripts/components/NotesOptions.tsx @@ -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 = ({ ); +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 ( +
+ {typeof words === 'number' ? ( + <> +
+ {words} words · {characters} characters · {paragraphs} paragraphs +
+
+ Read time: {readTime} +
+ + ) : null} +
+ Last modified: {dateLastModified} +
+
+ Created: {dateCreated} +
+
+ Note ID: {note.uuid} +
+
+ ); +}; + 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('auto'); + const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState( + '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(); + const tagsButtonRef = useRef(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 ( + <> + { + await appState.notes.deleteNotesPermanently(); + }} + /> + + ); + } + return ( <> -
+
{appState.tags.tagsCount > 0 && ( { if (event.key === 'Escape') { setTagsMenuOpen(false); - tagsButtonRef.current.focus(); + tagsButtonRef.current!.focus(); } }} style={{ @@ -246,6 +363,22 @@ export const NotesOptions = observer( Unpin )} + + {unarchived && ( + ); +}; + +const QuickSettingsMenu: FunctionComponent = observer( + ({ application, appState }) => { + const { closeQuickSettingsMenu, shouldAnimateCloseMenu } = + appState.quickSettingsMenu; + const [themes, setThemes] = useState([]); + const [toggleableComponents, setToggleableComponents] = useState< + SNComponent[] + >([]); + const [themesMenuOpen, setThemesMenuOpen] = useState(false); + const [themesMenuPosition, setThemesMenuPosition] = useState({}); + const [defaultThemeOn, setDefaultThemeOn] = useState(false); + + const themesMenuRef = useRef(null); + const themesButtonRef = useRef(null); + const prefsButtonRef = useRef(null); + const quickSettingsMenuRef = useRef(null); + const defaultThemeButtonRef = useRef(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 = ( + event + ) => { + switch (event.key) { + case 'Escape': + setThemesMenuOpen(false); + themesButtonRef.current!.focus(); + break; + case 'ArrowRight': + if (!themesMenuOpen) { + toggleThemesMenu(); + } + } + }; + + const handleQuickSettingsKeyDown: JSXInternal.KeyboardEventHandler = + (event) => { + const items: NodeListOf = + 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 = ( + 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 ( +
+
+
+ Quick Settings +
+ + +
+ + Themes +
+ +
+ +
+ Themes +
+ + {themes.map((theme) => ( + + ))} +
+
+ + {toggleableComponents.map((component) => ( + { + toggleComponent(component); + }} + > +
+ + {component.name} +
+
+ ))} + +
+ +
+
+ ); + } +); + +export const QuickSettingsMenuDirective = + toDirective(QuickSettingsMenu); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index ddd320d93..b62989b7d 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -33,9 +33,9 @@ const SearchOptions = observer(({ appState }: Props) => { right: 0, }); const [maxWidth, setMaxWidth] = useState('auto'); - const buttonRef = useRef(); - const panelRef = useRef(); - const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); + const buttonRef = useRef(null); + const panelRef = useRef(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, diff --git a/app/assets/javascripts/components/SessionsModal.tsx b/app/assets/javascripts/components/SessionsModal.tsx index e06c7df7b..92a7f65d7 100644 --- a/app/assets/javascripts/components/SessionsModal.tsx +++ b/app/assets/javascripts/components/SessionsModal.tsx @@ -109,7 +109,7 @@ const SessionsModal: FunctionComponent<{ const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState(''); const closeRevokeSessionAlert = () => setRevokingSessionUuid(''); - const cancelRevokeRef = useRef(); + const cancelRevokeRef = useRef(null); const formatter = useMemo( () => diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index 49da00dc9..06b5a2881 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -13,6 +13,7 @@ export type SwitchProps = HTMLProps & { onChange: (checked: boolean) => void; className?: string; children?: ComponentChildren; + role?: string; }; export const Switch: FunctionalComponent = ( @@ -24,6 +25,7 @@ export const Switch: FunctionalComponent = ( return (