diff --git a/.eslintrc b/.eslintrc index a5d7eb8ff..b572d609c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,7 @@ "parserOptions": { "project": "./app/assets/javascripts/tsconfig.json" }, - "ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js"], + "ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js", "__mocks__"], "rules": { "standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals "no-throw-literal": 0, @@ -19,8 +19,8 @@ "semi": 1, "camelcase": "warn", "sort-imports": "off", - "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks - "react-hooks/exhaustive-deps": "error", // Checks effect dependencies + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", "eol-last": "error", "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], "no-trailing-spaces": "error" diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 000000000..cc2afebb3 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,73 @@ +name: Beta + +on: + push: + branches: [ beta/* ] + workflow_dispatch: + +jobs: + tsc: + name: Check types & lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Install dependencies + run: yarn install --pure-lockfile + - name: Typescript + run: yarn tsc + - name: ESLint + run: yarn lint --quiet + + deploy: + runs-on: ubuntu-latest + needs: tsc + + steps: + - uses: actions/checkout@v2 + - name: Copy robots.txt + run: cp public/robots.txt.development public/robots.txt + - name: Publish to Registry + uses: elgohr/Publish-Docker-Github-Action@master + with: + name: standardnotes/web + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + tags: "beta,${{ github.sha }}" + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Download task definition + run: | + aws ecs describe-task-definition --task-definition app-beta-dev --query taskDefinition > task-definition.json + + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: app-beta-dev + image: "standardnotes/web:${{ github.sha }}" + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: app-beta-dev + cluster: dev + wait-for-service-stability: true + + notify_discord: + needs: deploy + + runs-on: ubuntu-latest + + steps: + - name: Run Discord Webhook + uses: johnnyhuy/actions-discord-git-webhook@main + with: + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index b73771dc2..cb2af7d02 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -73,15 +73,13 @@ jobs: cluster: dev wait-for-service-stability: true - notify_slack: + notify_discord: needs: deploy runs-on: ubuntu-latest steps: - - name: Notify slack - uses: pullreminders/slack-action@master - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + - name: Run Discord Webhook + uses: johnnyhuy/actions-discord-git-webhook@main with: - args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed \"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: \"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"\"}]}]}' + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index feac7cd51..8b01feb81 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -73,15 +73,13 @@ jobs: cluster: prod wait-for-service-stability: true - notify_slack: + notify_discord: needs: deploy runs-on: ubuntu-latest steps: - - name: Notify slack - uses: pullreminders/slack-action@master - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + - name: Run Discord Webhook + uses: johnnyhuy/actions-discord-git-webhook@main with: - args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed \"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: \"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"\"}]}]}' + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index baec9d89b..6db1cb540 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ yarn-error.log package-lock.json codeqldb + +coverage diff --git a/Gemfile.lock b/Gemfile.lock index e6dbdab88..37dd70bca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,7 +101,7 @@ GEM net-ssh (>= 2.6.5, < 6.0.0) net-ssh (5.2.0) newrelic_rpm (7.0.0) - nio4r (2.5.2) + nio4r (2.5.8) nokogiri (1.11.1) mini_portile2 (~> 2.5.0) racc (~> 1.4) @@ -111,7 +111,7 @@ GEM racc (~> 1.4) non-stupid-digest-assets (1.0.9) sprockets (>= 2.0) - puma (4.3.5) + puma (4.3.9) nio4r (~> 2.0) racc (1.5.2) rack (2.2.3) diff --git a/app/assets/icons/ic-add.svg b/app/assets/icons/ic-add.svg new file mode 100644 index 000000000..d92c119a2 --- /dev/null +++ b/app/assets/icons/ic-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-folder.svg b/app/assets/icons/ic-folder.svg new file mode 100644 index 000000000..db3aea31e --- /dev/null +++ b/app/assets/icons/ic-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-link-off.svg b/app/assets/icons/ic-link-off.svg new file mode 100644 index 000000000..3b701266c --- /dev/null +++ b/app/assets/icons/ic-link-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-list-bulleted.svg b/app/assets/icons/ic-list-bulleted.svg new file mode 100644 index 000000000..98c5a4333 --- /dev/null +++ b/app/assets/icons/ic-list-bulleted.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-menu-arrow-down-alt.svg b/app/assets/icons/ic-menu-arrow-down-alt.svg new file mode 100644 index 000000000..8e150eb15 --- /dev/null +++ b/app/assets/icons/ic-menu-arrow-down-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-menu-arrow-right.svg b/app/assets/icons/ic-menu-arrow-right.svg new file mode 100644 index 000000000..ba5890ea7 --- /dev/null +++ b/app/assets/icons/ic-menu-arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/__mocks__/@standardnotes/snjs.js b/app/assets/javascripts/__mocks__/@standardnotes/snjs.js new file mode 100644 index 000000000..a89d29416 --- /dev/null +++ b/app/assets/javascripts/__mocks__/@standardnotes/snjs.js @@ -0,0 +1,11 @@ +const { + ApplicationEvent, + ProtectionSessionDurations, + ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction, +} = require('@standardnotes/snjs'); + +module.exports = { + ApplicationEvent: ApplicationEvent, + ProtectionSessionDurations: ProtectionSessionDurations, + ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction, +}; diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 5a8b9b480..05b5dbd3d 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -10,6 +10,13 @@ declare global { _plans_url?: string; // eslint-disable-next-line camelcase _dashboard_url?: string; + // eslint-disable-next-line camelcase + _default_sync_server: string; + // eslint-disable-next-line camelcase + _enable_unfinished_features: boolean; + // eslint-disable-next-line camelcase + _websocket_url: string; + startApplication?: StartApplication; } } @@ -26,7 +33,6 @@ import { EditorGroupView, EditorView, TagsView, - NotesView, FooterView, ChallengeModal, } from '@/views'; @@ -45,7 +51,6 @@ import { import { ActionsMenu, - ComponentModal, EditorMenu, InputModal, MenuRow, @@ -65,7 +70,7 @@ import { StartApplication } from './startApplication'; import { Bridge } from './services/bridge'; import { SessionsModalDirective } from './components/SessionsModal'; import { NoAccountWarningDirective } from './components/NoAccountWarning'; -import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning'; +import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; import { SearchOptionsDirective } from './components/SearchOptions'; import { AccountMenuDirective } from './components/AccountMenu'; import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; @@ -81,7 +86,9 @@ import { PurchaseFlowDirective } from './purchaseFlow'; import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; import { ComponentViewDirective } from '@/components/ComponentView'; import { TagsListDirective } from '@/components/TagsList'; +import { NotesViewDirective } from './components/NotesView'; import { PinNoteButtonDirective } from '@/components/PinNoteButton'; +import { TagsSectionDirective } from './components/Tags/TagsSection'; function reloadHiddenFirefoxTab(): boolean { /** @@ -117,7 +124,7 @@ const startApplication: StartApplication = async function startApplication( SNLog.onLog = console.log; startErrorReporting(); - angular.module('app', ['ngSanitize']); + angular.module('app', []); // Config angular @@ -137,7 +144,6 @@ const startApplication: StartApplication = async function startApplication( .directive('editorGroupView', () => new EditorGroupView()) .directive('editorView', () => new EditorView()) .directive('tagsView', () => new TagsView()) - .directive('notesView', () => new NotesView()) .directive('footerView', () => new FooterView()); // Directives - Functional @@ -159,7 +165,6 @@ const startApplication: StartApplication = async function startApplication( .directive('accountSwitcher', () => new AccountSwitcher()) .directive('actionsMenu', () => new ActionsMenu()) .directive('challengeModal', () => new ChallengeModal()) - .directive('componentModal', () => new ComponentModal()) .directive('componentView', ComponentViewDirective) .directive('editorMenu', () => new EditorMenu()) .directive('inputModal', () => new InputModal()) @@ -174,7 +179,7 @@ const startApplication: StartApplication = async function startApplication( .directive('accountMenu', AccountMenuDirective) .directive('quickSettingsMenu', QuickSettingsMenuDirective) .directive('noAccountWarning', NoAccountWarningDirective) - .directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) + .directive('protectedNotePanel', ProtectedNoteOverlayDirective) .directive('searchOptions', SearchOptionsDirective) .directive('confirmSignout', ConfirmSignoutDirective) .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective) @@ -183,9 +188,11 @@ const startApplication: StartApplication = async function startApplication( .directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('tags', TagsListDirective) + .directive('tagsList', TagsListDirective) + .directive('tagsSection', TagsSectionDirective) .directive('preferences', PreferencesDirective) .directive('purchaseFlow', PurchaseFlowDirective) + .directive('notesView', NotesViewDirective) .directive('pinNoteButton', PinNoteButtonDirective); // Filters @@ -216,11 +223,11 @@ const startApplication: StartApplication = async function startApplication( if (IsWebPlatform) { startApplication( - (window as any)._default_sync_server as string, + window._default_sync_server, new BrowserBridge(AppVersion), - (window as any)._enable_unfinished_features as boolean, - (window as any)._websocket_url as string + window._enable_unfinished_features, + window._websocket_url ); } else { - (window as any).startApplication = startApplication; + window.startApplication = startApplication; } diff --git a/app/assets/javascripts/components/AccountMenu/DataBackup.tsx b/app/assets/javascripts/components/AccountMenu/DataBackup.tsx deleted file mode 100644 index 808a27616..000000000 --- a/app/assets/javascripts/components/AccountMenu/DataBackup.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { isDesktopApplication } from '@/utils'; -import { alertDialog } from '@Services/alertService'; -import { - STRING_IMPORT_SUCCESS, - STRING_INVALID_IMPORT_FILE, - STRING_UNSUPPORTED_BACKUP_FILE_VERSION, - StringImportError -} from '@/strings'; -import { BackupFile } from '@standardnotes/snjs'; -import { useRef, useState } from 'preact/hooks'; -import { WebApplication } from '@/ui_models/application'; -import { JSXInternal } from 'preact/src/jsx'; -import TargetedEvent = JSXInternal.TargetedEvent; -import { AppState } from '@/ui_models/app_state'; -import { observer } from 'mobx-react-lite'; - -type Props = { - application: WebApplication; - appState: AppState; -} - -const DataBackup = observer(({ - application, - appState - }: Props) => { - - const fileInputRef = useRef(null); - const [isImportDataLoading, setIsImportDataLoading] = useState(false); - - const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu; - - const downloadDataArchive = () => { - application.getArchiveService().downloadBackup(isBackupEncrypted); - }; - - const readFile = async (file: File): Promise => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => { - try { - const data = JSON.parse(e.target!.result as string); - resolve(data); - } catch (e) { - application.alertService.alert(STRING_INVALID_IMPORT_FILE); - } - }; - reader.readAsText(file); - }); - }; - - const performImport = async (data: BackupFile) => { - setIsImportDataLoading(true); - - const result = await application.importData(data); - - setIsImportDataLoading(false); - - if (!result) { - return; - } - - let statusText = STRING_IMPORT_SUCCESS; - if ('error' in result) { - statusText = result.error; - } else if (result.errorCount) { - statusText = StringImportError(result.errorCount); - } - void alertDialog({ - text: statusText - }); - }; - - const importFileSelected = async (event: TargetedEvent) => { - const { files } = (event.target as HTMLInputElement); - - if (!files) { - return; - } - const file = files[0]; - const data = await readFile(file); - if (!data) { - return; - } - - const version = data.version || data.keyParams?.version || data.auth_params?.version; - if (!version) { - await performImport(data); - return; - } - - if ( - application.protocolService.supportedVersions().includes(version) - ) { - await performImport(data); - } else { - setIsImportDataLoading(false); - void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION }); - } - }; - - // Whenever "Import Backup" is either clicked or key-pressed, proceed the import - const handleImportFile = (event: TargetedEvent | KeyboardEvent) => { - if (event instanceof KeyboardEvent) { - const { code } = event; - - // Process only when "Enter" or "Space" keys are pressed - if (code !== 'Enter' && code !== 'Space') { - return; - } - // Don't proceed the event's default action - // (like scrolling in case the "space" key is pressed) - event.preventDefault(); - } - - (fileInputRef.current as HTMLInputElement).click(); - }; - - return ( - <> - {isImportDataLoading ? ( -
- ) : ( -
-
Data Backups
-
Download a backup of all your data.
- {isEncryptionEnabled && ( -
-
- - -
-
- )} -
-
- - -
- {isDesktopApplication() && ( -

- Backups are automatically created on desktop and can be managed - via the "Backups" top-level menu. -

- )} -
-
- )} - - ); -}); - -export default DataBackup; diff --git a/app/assets/javascripts/components/AccountMenu/Encryption.tsx b/app/assets/javascripts/components/AccountMenu/Encryption.tsx deleted file mode 100644 index 1a98f2404..000000000 --- a/app/assets/javascripts/components/AccountMenu/Encryption.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { AppState } from '@/ui_models/app_state'; -import { observer } from 'mobx-react-lite'; - -type Props = { - appState: AppState; -} - -const Encryption = observer(({ appState }: Props) => { - const { isEncryptionEnabled, encryptionStatusString, notesAndTagsCount } = appState.accountMenu; - - const getEncryptionStatusForNotes = () => { - const length = notesAndTagsCount; - return `${length}/${length} notes and tags encrypted`; - }; - - return ( -
-
- Encryption -
- {isEncryptionEnabled && ( -
- {getEncryptionStatusForNotes()} -
- )} -

- {encryptionStatusString} -

-
- ); -}); - -export default Encryption; diff --git a/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx b/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx deleted file mode 100644 index 92784557e..000000000 --- a/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useState } from 'preact/hooks'; -import { storage, StorageKey } from '@Services/localStorage'; -import { disableErrorReporting, enableErrorReporting, errorReportingId } from '@Services/errorReporting'; -import { alertDialog } from '@Services/alertService'; -import { observer } from 'mobx-react-lite'; -import { AppState } from '@/ui_models/app_state'; - -type Props = { - appState: AppState; -} - -const ErrorReporting = observer(({ appState }: Props) => { - const [isErrorReportingEnabled] = useState(() => storage.get(StorageKey.DisableErrorReporting) === false); - const [errorReportingIdValue] = useState(() => errorReportingId()); - - const toggleErrorReportingEnabled = () => { - if (isErrorReportingEnabled) { - disableErrorReporting(); - } else { - enableErrorReporting(); - } - if (!appState.sync.inProgress) { - window.location.reload(); - } - }; - - const openErrorReportingDialog = () => { - alertDialog({ - title: 'Data sent during automatic error reporting', - text: ` - We use Bugsnag - to automatically report errors that occur while the app is running. See - - this article, paragraph 'Browser' under 'Sending diagnostic data', - - to see what data is included in error reports. -

- Error reports never include IP addresses and are fully - anonymized. We use error reports to be alerted when something in our - code is causing unexpected errors and crashes in your application - experience. - ` - }); - }; - - return ( -
-
Error Reporting
-
- Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'} -
-

- Help us improve Standard Notes by automatically submitting - anonymized error reports. -

- {errorReportingIdValue && ( - <> -

- Your random identifier is {errorReportingIdValue} -

-

- Disabling error reporting will remove that identifier from your - local storage, and a new identifier will be created should you - decide to enable error reporting again in the future. -

- - )} -
- -
- -
- ); -}); - -export default ErrorReporting; diff --git a/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx deleted file mode 100644 index cdfbbc046..000000000 --- a/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE, - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED, - STRING_NON_MATCHING_PASSCODES, - StringUtils, - Strings -} from '@/strings'; -import { WebApplication } from '@/ui_models/application'; -import { preventRefreshing } from '@/utils'; -import { JSXInternal } from 'preact/src/jsx'; -import TargetedEvent = JSXInternal.TargetedEvent; -import { alertDialog } from '@Services/alertService'; -import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; -import { ApplicationEvent } from '@standardnotes/snjs'; -import TargetedMouseEvent = JSXInternal.TargetedMouseEvent; -import { observer } from 'mobx-react-lite'; -import { AppState } from '@/ui_models/app_state'; - -type Props = { - application: WebApplication; - appState: AppState; -}; - -const PasscodeLock = observer(({ - application, - appState, - }: Props) => { - const keyStorageInfo = StringUtils.keyStorageInfo(application); - const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions(); - - const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu; - - const passcodeInputRef = useRef(null); - - const [passcode, setPasscode] = useState(undefined); - const [passcodeConfirmation, setPasscodeConfirmation] = useState(undefined); - const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState(null); - const [isPasscodeFocused, setIsPasscodeFocused] = useState(false); - const [showPasscodeForm, setShowPasscodeForm] = useState(false); - const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession()); - const [hasPasscode, setHasPasscode] = useState(application.hasPasscode()); - - const handleAddPassCode = () => { - setShowPasscodeForm(true); - setIsPasscodeFocused(true); - }; - - const changePasscodePressed = () => { - handleAddPassCode(); - }; - - const reloadAutoLockInterval = useCallback(async () => { - const interval = await application.getAutolockService().getAutoLockInterval(); - setSelectedAutoLockInterval(interval); - }, [application]); - - const refreshEncryptionStatus = useCallback(() => { - const hasUser = application.hasAccount(); - const hasPasscode = application.hasPasscode(); - - setHasPasscode(hasPasscode); - - const encryptionEnabled = hasUser || hasPasscode; - - const encryptionStatusString = hasUser - ? STRING_E2E_ENABLED - : hasPasscode - ? STRING_LOCAL_ENC_ENABLED - : STRING_ENC_NOT_ENABLED; - - setEncryptionStatusString(encryptionStatusString); - setIsEncryptionEnabled(encryptionEnabled); - setIsBackupEncrypted(encryptionEnabled); - }, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled]); - - const selectAutoLockInterval = async (interval: number) => { - if (!(await application.authorizeAutolockIntervalChange())) { - return; - } - await application.getAutolockService().setAutoLockInterval(interval); - reloadAutoLockInterval(); - }; - - const removePasscodePressed = async () => { - await preventRefreshing( - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, - async () => { - if (await application.removePasscode()) { - await application - .getAutolockService() - .deleteAutolockPreference(); - await reloadAutoLockInterval(); - refreshEncryptionStatus(); - } - } - ); - }; - - const handlePasscodeChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPasscode(value); - }; - - const handleConfirmPasscodeChange = (event: TargetedEvent) => { - const { value } = event.target as HTMLInputElement; - setPasscodeConfirmation(value); - }; - - const submitPasscodeForm = async (event: TargetedEvent | TargetedMouseEvent) => { - event.preventDefault(); - - if (!passcode || passcode.length === 0) { - await alertDialog({ - text: Strings.enterPasscode, - }); - } - - if (passcode !== passcodeConfirmation) { - await alertDialog({ - text: STRING_NON_MATCHING_PASSCODES - }); - setIsPasscodeFocused(true); - return; - } - - await preventRefreshing( - STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE, - async () => { - const successful = application.hasPasscode() - ? await application.changePasscode(passcode as string) - : await application.addPasscode(passcode as string); - - if (!successful) { - setIsPasscodeFocused(true); - } - } - ); - - setPasscode(undefined); - setPasscodeConfirmation(undefined); - setShowPasscodeForm(false); - - refreshEncryptionStatus(); - }; - - useEffect(() => { - refreshEncryptionStatus(); - }, [refreshEncryptionStatus]); - - // `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial - // value of `selectedAutoLockInterval` - useEffect(() => { - reloadAutoLockInterval(); - }, [reloadAutoLockInterval]); - - useEffect(() => { - if (isPasscodeFocused) { - passcodeInputRef.current!.focus(); - setIsPasscodeFocused(false); - } - }, [isPasscodeFocused]); - - // Add the required event observers - useEffect(() => { - const removeKeyStatusChangedObserver = application.addEventObserver( - async () => { - setCanAddPasscode(!application.isEphemeralSession()); - setHasPasscode(application.hasPasscode()); - setShowPasscodeForm(false); - }, - ApplicationEvent.KeyStatusChanged - ); - - return () => { - removeKeyStatusChangedObserver(); - }; - }, [application]); - - return ( -
-
Passcode Lock
- {!hasPasscode && ( -
- {canAddPasscode && ( - <> - {!showPasscodeForm && ( -
- -
- )} -

- Add a passcode to lock the application and - encrypt on-device key storage. -

- {keyStorageInfo && ( -

{keyStorageInfo}

- )} - - )} - {!canAddPasscode && ( -

- Adding a passcode is not supported in temporary sessions. Please sign - out, then sign back in with the "Stay signed in" option checked. -

- )} -
- )} - {showPasscodeForm && ( -
-
- - - - - - )} - {hasPasscode && !showPasscodeForm && ( - <> -
Passcode lock is enabled
-
-
Options
-
-
-
-
Autolock
- {passcodeAutoLockOptions.map(option => { - return ( - selectAutoLockInterval(option.value)}> - {option.label} - - ); - })} -
-
-
The autolock timer begins when the window or tab loses focus.
- -
- - )} -
- ); -}); - -export default PasscodeLock; diff --git a/app/assets/javascripts/components/AccountMenu/Protections.tsx b/app/assets/javascripts/components/AccountMenu/Protections.tsx deleted file mode 100644 index 8e7b1f229..000000000 --- a/app/assets/javascripts/components/AccountMenu/Protections.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { WebApplication } from '@/ui_models/application'; -import { FunctionalComponent } from 'preact'; -import { useCallback, useState } from 'preact/hooks'; -import { useEffect } from 'preact/hooks'; -import { ApplicationEvent } from '@standardnotes/snjs'; -import { isSameDay } from '@/utils'; - -type Props = { - application: WebApplication; -}; - -const Protections: FunctionalComponent = ({ application }) => { - const enableProtections = () => { - application.clearProtectionSession(); - }; - - const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources()); - - const getProtectionsDisabledUntil = useCallback((): string | null => { - const protectionExpiry = application.getProtectionSessionExpiryDate(); - const now = new Date(); - if (protectionExpiry > now) { - let f: Intl.DateTimeFormat; - if (isSameDay(protectionExpiry, now)) { - f = new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric' - }); - } else { - f = new Intl.DateTimeFormat(undefined, { - weekday: 'long', - day: 'numeric', - month: 'short', - hour: 'numeric', - minute: 'numeric' - }); - } - - return f.format(protectionExpiry); - } - return null; - }, [application]); - - const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); - - useEffect(() => { - const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( - async () => { - setProtectionsDisabledUntil(getProtectionsDisabledUntil()); - }, - ApplicationEvent.ProtectionSessionExpiryDateChanged - ); - - const removeKeyStatusChangedObserver = application.addEventObserver( - async () => { - setHasProtections(application.hasProtectionSources()); - }, - ApplicationEvent.KeyStatusChanged - ); - - return () => { - removeProtectionSessionExpiryDateChangedObserver(); - removeKeyStatusChangedObserver(); - }; - }, [application, getProtectionsDisabledUntil]); - - if (!hasProtections) { - return null; - } - - return ( -
-
Protections
- {protectionsDisabledUntil && ( -
- Protections are disabled until {protectionsDisabledUntil} -
- )} - {!protectionsDisabledUntil && ( -
- Protections are enabled -
- )} -

- Actions like viewing protected notes, exporting decrypted backups, - or revoking an active session, require additional authentication - like entering your account password or application passcode. -

- {protectionsDisabledUntil && ( -
- -
- )} -
- ); -}; - -export default Protections; diff --git a/app/assets/javascripts/components/ComponentView/IsExpired.tsx b/app/assets/javascripts/components/ComponentView/IsExpired.tsx index 7044aa11a..ce95e0750 100644 --- a/app/assets/javascripts/components/ComponentView/IsExpired.tsx +++ b/app/assets/javascripts/components/ComponentView/IsExpired.tsx @@ -5,11 +5,14 @@ interface IProps { expiredDate: string; componentName: string; featureStatus: FeatureStatus; - reloadStatus: () => void; manageSubscription: () => void; } -const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => { +const statusString = ( + featureStatus: FeatureStatus, + expiredDate: string, + componentName: string +) => { switch (featureStatus) { case FeatureStatus.InCurrentPlanButExpired: return `Your subscription expired on ${expiredDate}`; @@ -25,9 +28,8 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone export const IsExpired: FunctionalComponent = ({ expiredDate, featureStatus, - reloadStatus, componentName, - manageSubscription + manageSubscription, }) => { return (
@@ -50,11 +52,13 @@ export const IsExpired: FunctionalComponent = ({
-
manageSubscription()}> - -
-
reloadStatus()}> - +
manageSubscription()} + > +
diff --git a/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx b/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx index 3bec4eab7..b2c70b408 100644 --- a/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx +++ b/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx @@ -6,16 +6,16 @@ interface IProps { } export const IssueOnLoading: FunctionalComponent = ({ - componentName, - reloadIframe - }) => { + componentName, + reloadIframe, +}) => { return (
- There was an issue loading {componentName} + There was an issue loading {componentName}.
diff --git a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx index 7de8ef90b..a60ee0de3 100644 --- a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx +++ b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx @@ -1,14 +1,6 @@ import { FunctionalComponent } from 'preact'; -interface IProps { - isReloading: boolean; - reloadStatus: () => void; -} - -export const OfflineRestricted: FunctionalComponent = ({ - isReloading, - reloadStatus - }) => { +export const OfflineRestricted: FunctionalComponent = () => { return (
@@ -16,40 +8,29 @@ export const OfflineRestricted: FunctionalComponent = ({
- You have restricted this extension to be used offline only. + You have restricted this component to not use a hosted version.
- Offline extensions are not available in the Web app. + Locally-installed components are not available in the web + application.
- You can either: + To continue, choose from the following options:
  • - - 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. + Enable the Hosted option for this component by opening the + Preferences {'>'} General {'>'} Advanced Settings menu and{' '} + toggling 'Use hosted when local is unavailable' under this + component's options. Then press Reload.
  • +
  • Use the desktop application.
-
- {isReloading ? -
- : - - } -
diff --git a/app/assets/javascripts/components/ComponentView/index.tsx b/app/assets/javascripts/components/ComponentView/index.tsx index e78574a9b..e0ec55b9f 100644 --- a/app/assets/javascripts/components/ComponentView/index.tsx +++ b/app/assets/javascripts/components/ComponentView/index.tsx @@ -1,4 +1,12 @@ -import { ComponentAction, FeatureStatus, LiveItem, SNComponent, dateToLocalizedString } from '@standardnotes/snjs'; +import { + ComponentAction, + FeatureStatus, + SNComponent, + dateToLocalizedString, + ComponentViewer, + ComponentViewerEvent, + ComponentViewerError, +} from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { FunctionalComponent } from 'preact'; import { toDirective } from '@/components/utils'; @@ -11,15 +19,14 @@ 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'; import { openSubscriptionDashboard } from '@/hooks/manageSubscription'; interface IProps { application: WebApplication; appState: AppState; - componentUuid: string; + componentViewer: ComponentViewer; + requestReload?: (viewer: ComponentViewer) => void; onLoad?: (component: SNComponent) => void; - templateComponent?: SNComponent; manualDealloc?: boolean; } @@ -29,339 +36,237 @@ interface IProps { */ const MaxLoadThreshold = 4000; const VisibilityChangeKey = 'visibilitychange'; -const avoidFlickerTimeout = 7; +const MSToWaitAfterIframeLoadToAvoidFlicker = 35; export const ComponentView: FunctionalComponent = observer( - ({ - application, - onLoad, - componentUuid, - templateComponent - }) => { - const liveComponentRef = useRef | null>(null); + ({ application, onLoad, componentViewer, requestReload }) => { const iframeRef = useRef(null); + const excessiveLoadingTimeout = useRef< + ReturnType | undefined + >(undefined); - const [isIssueOnLoading, setIsIssueOnLoading] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isReloading, setIsReloading] = useState(false); - const [loadTimeout, setLoadTimeout] = useState | undefined>(undefined); - const [featureStatus, setFeatureStatus] = useState(FeatureStatus.Entitled); + const [hasIssueLoading, setHasIssueLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [featureStatus, setFeatureStatus] = useState( + componentViewer.getFeatureStatus() + ); 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 [error, setError] = useState( + undefined + ); + const [deprecationMessage, setDeprecationMessage] = useState< + string | undefined + >(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 component = componentViewer.component; const manageSubscription = useCallback(() => { openSubscriptionDashboard(application); }, [application]); - const reloadStatus = useCallback(() => { - if (!component) { - return; + useEffect(() => { + const loadTimeout = setTimeout(() => { + handleIframeTakingTooLongToLoad(); + }, MaxLoadThreshold); + + excessiveLoadingTimeout.current = loadTimeout; + + return () => { + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const reloadValidityStatus = useCallback(() => { + setFeatureStatus(componentViewer.getFeatureStatus()); + if (!componentViewer.lockReadonly) { + componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled); } + setIsComponentValid(componentViewer.shouldRender()); - const offlineRestricted = component.offlineOnly && !isDesktopApplication(); - const hasUrlError = function () { - if (isDesktopApplication()) { - return !component.local_url && !component.hasValidHostedUrl(); - } else { - return !component.hasValidHostedUrl(); - } - }(); - - setFeatureStatus(application.getFeatureStatus(component.identifier)); - - const readonlyState = application.componentManager.getReadonlyStateForComponent(component); - - if (!readonlyState.lockReadonly) { - application.componentManager.setReadonlyStateForComponent(component, featureStatus !== FeatureStatus.Entitled); - } - setIsComponentValid(!offlineRestricted && !hasUrlError); - - if (!isComponentValid) { + if (isLoading && !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, component, isComponentValid, featureStatus]); + setError(componentViewer.getError()); + setDeprecationMessage(component.deprecationMessage); + }, [ + componentViewer, + component.deprecationMessage, + featureStatus, + isComponentValid, + isLoading, + ]); + + useEffect(() => { + reloadValidityStatus(); + }, [reloadValidityStatus]); const dismissDeprecationMessage = () => { - setTimeout(() => { - setIsDeprecationMessageDismissed(true); - }); + setIsDeprecationMessageDismissed(true); }; const onVisibilityChange = useCallback(() => { if (document.visibilityState === 'hidden') { return; } - if (isIssueOnLoading) { - reloadIframe(); + if (hasIssueLoading) { + requestReload?.(componentViewer); } - }, [isIssueOnLoading]); + }, [hasIssueLoading, componentViewer, requestReload]); - const handleIframeLoadTimeout = useCallback(async () => { - if (isLoading) { - setIsLoading(false); - setIsIssueOnLoading(true); + const handleIframeTakingTooLongToLoad = useCallback(async () => { + setIsLoading(false); + setHasIssueLoading(true); - if (!didAttemptReload) { - setDidAttemptReload(true); - reloadIframe(); - } else { - document.addEventListener( - VisibilityChangeKey, - onVisibilityChange - ); - } + if (!didAttemptReload) { + setDidAttemptReload(true); + requestReload?.(componentViewer); + } 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) { - } - } - loadTimeout && 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]); + }, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]); useEffect(() => { - reloadStatus(); - if (!iframeRef.current) { return; } - iframeRef.current.onload = () => { - if (!component) { - return; + const iframe = iframeRef.current as HTMLIFrameElement; + iframe.onload = () => { + const contentWindow = iframe.contentWindow as Window; + + let hasDesktopError = false; + const canAccessWindowOrigin = isDesktopApplication(); + if (canAccessWindowOrigin) { + try { + if (!contentWindow.origin || contentWindow.origin === 'null') { + hasDesktopError = true; + } + } catch (e) { + console.error(e); + } } - const iframe = application.componentManager.iframeForComponent( - component.uuid - ); - if (!iframe) { - return; - } + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + + componentViewer.setWindow(contentWindow); setTimeout(() => { - loadComponent(); - reloadStatus(); - handleIframeLoad(iframe); - }); + setIsLoading(false); + setHasIssueLoading(hasDesktopError); + onLoad?.(component); + }, MSToWaitAfterIframeLoadToAvoidFlicker); }; - }, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]); - - const getUrl = () => { - const url = component ? application.componentManager.urlForComponent(component) : ''; - return url as string; - }; + }, [onLoad, component, componentViewer]); useEffect(() => { - if (componentUuid) { - liveComponentRef.current = new LiveItem(componentUuid, application); - } else { - application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent); - } + const removeFeaturesChangedObserver = componentViewer.addEventObserver( + (event) => { + if (event === ComponentViewerEvent.FeatureStatusUpdated) { + setFeatureStatus(componentViewer.getFeatureStatus()); + } + } + ); 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 - ); + removeFeaturesChangedObserver(); }; - }, [application, component, componentUuid, onVisibilityChange, templateComponent]); + }, [componentViewer]); 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) => { + const removeActionObserver = componentViewer.addActionObserver( + (action, data) => { switch (action) { - case (ComponentAction.SetSize): - application.componentManager.handleSetSizeEvent(component, data); - break; - case (ComponentAction.KeyDown): + case ComponentAction.KeyDown: application.io.handleComponentKeyDown(data.keyboardModifier); break; - case (ComponentAction.KeyUp): + case ComponentAction.KeyUp: application.io.handleComponentKeyUp(data.keyboardModifier); break; - case (ComponentAction.Click): + case ComponentAction.Click: application.getAppState().notes.setContextMenuOpen(false); break; default: return; } } - }); - + ); return () => { - unregisterComponentHandler(); + removeActionObserver(); }; - }, [application, component]); + }, [componentViewer, application]); useEffect(() => { - const unregisterDesktopObserver = application.getDesktopService() + const unregisterDesktopObserver = application + .getDesktopService() .registerUpdateObserver((component: SNComponent) => { if (component.uuid === component.uuid && component.active) { - reloadIframe(); + requestReload?.(componentViewer); } }); return () => { unregisterDesktopObserver(); }; - }, [application]); - - if (!component) { - return null; - } + }, [application, requestReload, componentViewer]); return ( <> - {isIssueOnLoading && ( + {hasIssueLoading && ( { + reloadValidityStatus(), requestReload?.(componentViewer); + }} /> )} + {featureStatus !== FeatureStatus.Entitled && ( )} - {isDeprecated && !isDeprecationMessageDismissed && ( + {deprecationMessage && !isDeprecationMessageDismissed && ( )} - {error == 'offline-restricted' && ( - + {error === ComponentViewerError.OfflineRestricted && ( + )} - {error == 'url-missing' && ( + {error === ComponentViewerError.MissingUrl && ( )} - {component.uuid && !isReloading && isComponentValid && ( + {component.uuid && isComponentValid && ( )} - {isLoading && ( -
- )} + {isLoading &&
} ); - }); + } +); export const ComponentViewDirective = toDirective(ComponentView, { onLoad: '=', - componentUuid: '=', - templateComponent: '=', - manualDealloc: '=' + componentViewer: '=', + requestReload: '=', + manualDealloc: '=', }); diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index d83e33ab6..96eee992f 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -24,8 +24,10 @@ import MarkdownIcon from '../../icons/ic-markdown.svg'; import CodeIcon from '../../icons/ic-code.svg'; import AccessibilityIcon from '../../icons/ic-accessibility.svg'; +import AddIcon from '../../icons/ic-add.svg'; import HelpIcon from '../../icons/ic-help.svg'; import KeyboardIcon from '../../icons/ic-keyboard.svg'; +import ListBulleted from '../../icons/ic-list-bulleted.svg'; import ListedIcon from '../../icons/ic-listed.svg'; import SecurityIcon from '../../icons/ic-security.svg'; import SettingsIcon from '../../icons/ic-settings.svg'; @@ -53,11 +55,17 @@ 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 LinkOffIcon from '../../icons/ic-link-off.svg'; + +import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg'; +import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg'; import { toDirective } from './utils'; import { FunctionalComponent } from 'preact'; const ICONS = { + 'menu-arrow-down-alt': MenuArrowDownAlt, + 'menu-arrow-right': MenuArrowRight, 'arrows-sort-up': ArrowsSortUpIcon, 'arrows-sort-down': ArrowsSortDownIcon, lock: LockIcon, @@ -94,8 +102,11 @@ const ICONS = { more: MoreIcon, tune: TuneIcon, accessibility: AccessibilityIcon, + add: AddIcon, help: HelpIcon, keyboard: KeyboardIcon, + 'list-bulleted': ListBulleted, + 'link-off': LinkOffIcon, listed: ListedIcon, security: SecurityIcon, settings: SettingsIcon, @@ -111,7 +122,7 @@ const ICONS = { 'menu-arrow-down': MenuArrowDownIcon, 'menu-close': MenuCloseIcon, window: WindowIcon, - 'premium-feature': PremiumFeatureIcon + 'premium-feature': PremiumFeatureIcon, }; export type IconType = keyof typeof ICONS; diff --git a/app/assets/javascripts/components/NoAccountWarning.tsx b/app/assets/javascripts/components/NoAccountWarning.tsx index a462b4920..e2fc6a115 100644 --- a/app/assets/javascripts/components/NoAccountWarning.tsx +++ b/app/assets/javascripts/components/NoAccountWarning.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite'; type Props = { appState: AppState }; -const NoAccountWarning = observer(({ appState }: Props) => { +export const NoAccountWarning = observer(({ appState }: Props) => { const canShow = appState.noAccountWarning.show; if (!canShow) { return null; diff --git a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx deleted file mode 100644 index e3e9ec291..000000000 --- a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AppState } from '@/ui_models/app_state'; -import { toDirective } from './utils'; - -type Props = { appState: AppState; onViewNote: () => void }; - -function NoProtectionsNoteWarning({ appState, onViewNote }: Props) { - return ( -
-

This note is protected

-

- Add a passcode or create an account to require authentication to view - this note. -

-
- - -
-
- ); -} - -export const NoProtectionsdNoteWarningDirective = toDirective( - NoProtectionsNoteWarning, - { - onViewNote: '&', - } -); diff --git a/app/assets/javascripts/components/NotesList.tsx b/app/assets/javascripts/components/NotesList.tsx new file mode 100644 index 000000000..e443393aa --- /dev/null +++ b/app/assets/javascripts/components/NotesList.tsx @@ -0,0 +1,107 @@ +import { KeyboardKey } from '@/services/ioService'; +import { AppState } from '@/ui_models/app_state'; +import { DisplayOptions } from '@/ui_models/app_state/notes_view_state'; +import { SNNote } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { NotesListItem } from './NotesListItem'; + +type Props = { + appState: AppState; + notes: SNNote[]; + selectedNotes: Record; + displayOptions: DisplayOptions; + paginate: () => void; +}; + +const FOCUSABLE_BUT_NOT_TABBABLE = -1; +const NOTES_LIST_SCROLL_THRESHOLD = 200; + +export const NotesList: FunctionComponent = observer( + ({ appState, notes, selectedNotes, displayOptions, paginate }) => { + const { selectPreviousNote, selectNextNote } = appState.notesView; + const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions; + + const tagsStringForNote = (note: SNNote): string => { + if (hideTags) { + return ''; + } + const selectedTag = appState.selectedTag; + if (!selectedTag) { + return ''; + } + const tags = appState.getNoteTags(note); + if (!selectedTag.isSmartTag && tags.length === 1) { + return ''; + } + return tags.map((tag) => `#${tag.title}`).join(' '); + }; + + const openNoteContextMenu = (posX: number, posY: number) => { + appState.notes.setContextMenuClickLocation({ + x: posX, + y: posY, + }); + appState.notes.reloadContextMenuLayout(); + appState.notes.setContextMenuOpen(true); + }; + + const onContextMenu = async (note: SNNote, posX: number, posY: number) => { + await appState.notes.selectNote(note.uuid, true); + if (selectedNotes[note.uuid]) { + openNoteContextMenu(posX, posY); + } + }; + + const onScroll = (e: Event) => { + const offset = NOTES_LIST_SCROLL_THRESHOLD; + const element = e.target as HTMLElement; + if ( + element.scrollTop + element.offsetHeight >= + element.scrollHeight - offset + ) { + paginate(); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === KeyboardKey.Up) { + e.preventDefault(); + selectPreviousNote(); + } else if (e.key === KeyboardKey.Down) { + e.preventDefault(); + selectNextNote(); + } + }; + + return ( +
+ {notes.map((note) => ( + { + appState.notes.selectNote(note.uuid, true); + }} + onContextMenu={(e: MouseEvent) => { + e.preventDefault(); + onContextMenu(note, e.clientX, e.clientY); + }} + /> + ))} +
+ ); + } +); diff --git a/app/assets/javascripts/components/NotesListItem.tsx b/app/assets/javascripts/components/NotesListItem.tsx new file mode 100644 index 000000000..d1da168ac --- /dev/null +++ b/app/assets/javascripts/components/NotesListItem.tsx @@ -0,0 +1,148 @@ +import { + CollectionSort, + sanitizeHtmlString, + SNNote, +} from '@standardnotes/snjs'; +import { FunctionComponent } from 'preact'; + +type Props = { + note: SNNote; + tags: string; + hideDate: boolean; + hidePreview: boolean; + hideTags: boolean; + onClick: () => void; + onContextMenu: (e: MouseEvent) => void; + selected: boolean; + sortedBy?: CollectionSort; +}; + +type NoteFlag = { + text: string; + class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'; +}; + +const flagsForNote = (note: SNNote) => { + const flags = [] as NoteFlag[]; + if (note.pinned) { + flags.push({ + text: 'Pinned', + class: 'info', + }); + } + if (note.archived) { + flags.push({ + text: 'Archived', + class: 'warning', + }); + } + if (note.locked) { + flags.push({ + text: 'Editing Disabled', + class: 'neutral', + }); + } + if (note.trashed) { + flags.push({ + text: 'Deleted', + class: 'danger', + }); + } + if (note.conflictOf) { + flags.push({ + text: 'Conflicted Copy', + class: 'danger', + }); + } + if (note.errorDecrypting) { + if (note.waitingForKey) { + flags.push({ + text: 'Waiting For Keys', + class: 'info', + }); + } else { + flags.push({ + text: 'Missing Keys', + class: 'danger', + }); + } + } + if (note.deleted) { + flags.push({ + text: 'Deletion Pending Sync', + class: 'danger', + }); + } + return flags; +}; + +export const NotesListItem: FunctionComponent = ({ + hideDate, + hidePreview, + hideTags, + note, + onClick, + onContextMenu, + selected, + sortedBy, + tags, +}) => { + const flags = flagsForNote(note); + const showModifiedDate = sortedBy === CollectionSort.UpdatedAt; + + return ( +
+ {flags && flags.length > 0 ? ( +
+ {flags.map((flag) => ( +
+
{flag.text}
+
+ ))} +
+ ) : null} +
{note.title}
+ {!hidePreview && !note.hidePreview && !note.protected ? ( +
+ {note.preview_html ? ( +
+ ) : null} + {!note.preview_html && note.preview_plain ? ( +
{note.preview_plain}
+ ) : null} + {!note.preview_html && !note.preview_plain ? ( +
{note.text}
+ ) : null} +
+ ) : null} + {!hideDate || note.protected ? ( +
+ {note.protected ? ( + Protected {hideDate ? '' : ' • '} + ) : null} + {!hideDate && showModifiedDate ? ( + Modified {note.updatedAtString || 'Now'} + ) : null} + {!hideDate && !showModifiedDate ? ( + {note.createdAtString || 'Now'} + ) : null} +
+ ) : null} + {!hideTags && ( +
+
{tags}
+
+ )} +
+ ); +}; diff --git a/app/assets/javascripts/components/NotesListOptionsMenu.tsx b/app/assets/javascripts/components/NotesListOptionsMenu.tsx index 861b96a51..ea59f76f0 100644 --- a/app/assets/javascripts/components/NotesListOptionsMenu.tsx +++ b/app/assets/javascripts/components/NotesListOptionsMenu.tsx @@ -10,11 +10,11 @@ import { toDirective, useCloseOnClickOutside } from './utils'; type Props = { application: WebApplication; - setShowMenuFalse: () => void; + closeDisplayOptionsMenu: () => void; }; export const NotesListOptionsMenu: FunctionComponent = observer( - ({ setShowMenuFalse, application }) => { + ({ closeDisplayOptionsMenu, 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 \ @@ -112,13 +112,13 @@ flex flex-col py-2 bottom-0 left-2 absolute'; useCloseOnClickOutside(menuRef as any, (open: boolean) => { if (!open) { - setShowMenuFalse(); + closeDisplayOptionsMenu(); } }); return (
- +
Sort by
@@ -246,7 +246,7 @@ flex flex-col py-2 bottom-0 left-2 absolute'; export const NotesListOptionsDirective = toDirective( NotesListOptionsMenu, { - setShowMenuFalse: '=', + closeDisplayOptionsMenu: '=', state: '&', } ); diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx new file mode 100644 index 000000000..004a54e1d --- /dev/null +++ b/app/assets/javascripts/components/NotesView.tsx @@ -0,0 +1,256 @@ +import { + PanelSide, + ResizeFinishCallback, +} from '@/directives/views/panelResizer'; +import { KeyboardKey, KeyboardModifier } from '@/services/ioService'; +import { WebApplication } from '@/ui_models/application'; +import { AppState } from '@/ui_models/app_state'; +import { PANEL_NAME_NOTES } from '@/views/constants'; +import { PrefKey } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import { NoAccountWarning } from './NoAccountWarning'; +import { NotesList } from './NotesList'; +import { NotesListOptionsMenu } from './NotesListOptionsMenu'; +import { PanelResizer } from './PanelResizer'; +import { SearchOptions } from './SearchOptions'; +import { toDirective } from './utils'; + +type Props = { + application: WebApplication; + appState: AppState; +}; + +const NotesView: FunctionComponent = observer( + ({ application, appState }) => { + const notesViewPanelRef = useRef(null); + + const { + completedFullSync, + createNewNote, + displayOptions, + noteFilterText, + optionsSubtitle, + panelTitle, + renderedNotes, + selectedNotes, + setNoteFilterText, + showDisplayOptionsMenu, + toggleDisplayOptionsMenu, + searchBarElement, + selectNextNote, + selectPreviousNote, + onFilterEnter, + handleFilterTextChanged, + onSearchInputBlur, + clearFilterText, + paginate, + } = appState.notesView; + + useEffect(() => { + handleFilterTextChanged(); + }, [noteFilterText, handleFilterTextChanged]); + + useEffect(() => { + /** + * In the browser we're not allowed to override cmd/ctrl + n, so we have to + * use Control modifier as well. These rules don't apply to desktop, but + * probably better to be consistent. + */ + const newNoteKeyObserver = application.io.addKeyObserver({ + key: 'n', + modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl], + onKeyDown: (event) => { + event.preventDefault(); + createNewNote(); + }, + }); + + const nextNoteKeyObserver = application.io.addKeyObserver({ + key: KeyboardKey.Down, + elements: [ + document.body, + ...(searchBarElement ? [searchBarElement] : []), + ], + onKeyDown: () => { + if (searchBarElement === document.activeElement) { + searchBarElement?.blur(); + } + selectNextNote(); + }, + }); + + const previousNoteKeyObserver = application.io.addKeyObserver({ + key: KeyboardKey.Up, + element: document.body, + onKeyDown: () => { + selectPreviousNote(); + }, + }); + + const searchKeyObserver = application.io.addKeyObserver({ + key: 'f', + modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift], + onKeyDown: () => { + if (searchBarElement) { + searchBarElement.focus(); + } + }, + }); + + return () => { + newNoteKeyObserver(); + nextNoteKeyObserver(); + previousNoteKeyObserver(); + searchKeyObserver(); + }; + }, [ + application.io, + createNewNote, + searchBarElement, + selectNextNote, + selectPreviousNote, + ]); + + const onNoteFilterTextChange = (e: Event) => { + setNoteFilterText((e.target as HTMLInputElement).value); + }; + + const onNoteFilterKeyUp = (e: KeyboardEvent) => { + if (e.key === KeyboardKey.Enter) { + onFilterEnter(); + } + }; + + const panelResizeFinishCallback: ResizeFinishCallback = ( + _w, + _l, + _mw, + isCollapsed + ) => { + appState.noteTags.reloadTagsContainerMaxWidth(); + appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed); + }; + + const panelWidthEventCallback = () => { + appState.noteTags.reloadTagsContainerMaxWidth(); + }; + + return ( +
+
+
+
+
+
{panelTitle}
+ +
+
+ onSearchInputBlur()} + /> + {noteFilterText ? ( + + ) : null} +
+ +
+
+ +
+
+
+
+
+ toggleDisplayOptionsMenu(!showDisplayOptionsMenu) + } + > +
+
Options
+
+
+
{optionsSubtitle}
+
+
+
+
+ {showDisplayOptionsMenu && ( + + toggleDisplayOptionsMenu(false) + } + /> + )} +
+
+ {completedFullSync && !renderedNotes.length ? ( +

No notes.

+ ) : null} + {!completedFullSync && !renderedNotes.length ? ( +

Loading notes...

+ ) : null} + {renderedNotes.length ? ( + + ) : null} +
+ {notesViewPanelRef.current && ( + + )} +
+ ); + } +); + +export const NotesViewDirective = toDirective(NotesView); diff --git a/app/assets/javascripts/components/PanelResizer.tsx b/app/assets/javascripts/components/PanelResizer.tsx new file mode 100644 index 000000000..22a73d92e --- /dev/null +++ b/app/assets/javascripts/components/PanelResizer.tsx @@ -0,0 +1,60 @@ +import { + PanelResizerProps, + PanelResizerState, +} from '@/ui_models/panel_resizer'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +export const PanelResizer: FunctionComponent = observer( + ({ + alwaysVisible, + application, + defaultWidth, + hoverable, + collapsable, + minWidth, + panel, + prefKey, + resizeFinishCallback, + side, + widthEventCallback, + }) => { + const [panelResizerState] = useState( + () => + new PanelResizerState({ + alwaysVisible, + application, + defaultWidth, + hoverable, + collapsable, + minWidth, + panel, + prefKey, + resizeFinishCallback, + side, + widthEventCallback, + }) + ); + const panelResizerRef = useRef(null); + + useEffect(() => { + if (panelResizerRef.current) { + panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2); + } + }, [panelResizerState]); + + return ( +
+ ); + } +); diff --git a/app/assets/javascripts/components/Premium/index.ts b/app/assets/javascripts/components/Premium/index.ts new file mode 100644 index 000000000..d24a2c7da --- /dev/null +++ b/app/assets/javascripts/components/Premium/index.ts @@ -0,0 +1 @@ +export { usePremiumModal, PremiumModalProvider } from './usePremiumModal'; diff --git a/app/assets/javascripts/components/Premium/usePremiumModal.tsx b/app/assets/javascripts/components/Premium/usePremiumModal.tsx new file mode 100644 index 000000000..d52b15823 --- /dev/null +++ b/app/assets/javascripts/components/Premium/usePremiumModal.tsx @@ -0,0 +1,49 @@ +import { FunctionalComponent } from 'preact'; +import { useCallback, useContext, useState } from 'preact/hooks'; +import { createContext } from 'react'; +import { PremiumFeaturesModal } from '../PremiumFeaturesModal'; + +type PremiumModalContextData = { + activate: (featureName: string) => void; +}; + +const PremiumModalContext = createContext(null); + +const PremiumModalProvider_ = PremiumModalContext.Provider; + +export const usePremiumModal = (): PremiumModalContextData => { + const value = useContext(PremiumModalContext); + + if (!value) { + throw new Error('invalid PremiumModal context'); + } + + return value; +}; + +export const PremiumModalProvider: FunctionalComponent = ({ children }) => { + const [featureName, setFeatureName] = useState(null); + + const activate = setFeatureName; + + const closeModal = useCallback(() => { + setFeatureName(null); + }, [setFeatureName]); + + const showModal = !!featureName; + + return ( + <> + {showModal && ( + + )} + + {children} + + + ); +}; diff --git a/app/assets/javascripts/components/ProtectedNoteOverlay.tsx b/app/assets/javascripts/components/ProtectedNoteOverlay.tsx new file mode 100644 index 000000000..a5b6be47a --- /dev/null +++ b/app/assets/javascripts/components/ProtectedNoteOverlay.tsx @@ -0,0 +1,51 @@ +import { AppState } from '@/ui_models/app_state'; +import { toDirective } from './utils'; + +type Props = { + appState: AppState; + onViewNote: () => void; + hasProtectionSources: boolean; +}; + +function ProtectedNoteOverlay({ + appState, + onViewNote, + hasProtectionSources, +}: Props) { + const instructionText = hasProtectionSources + ? 'Authenticate to view this note.' + : 'Add a passcode or create an account to require authentication to view this note.'; + + return ( +
+

This note is protected

+

{instructionText}

+
+ {!hasProtectionSources && ( + + )} + +
+
+ ); +} + +export const ProtectedNoteOverlayDirective = toDirective( + ProtectedNoteOverlay, + { + onViewNote: '&', + hasProtectionSources: '=', + } +); diff --git a/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx index 718e2b951..cfb696a9f 100644 --- a/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx +++ b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx @@ -1,8 +1,7 @@ import { WebApplication } from '@/ui_models/application'; -import { FeatureIdentifier } from '@standardnotes/features'; -import { FeatureStatus } from '@standardnotes/snjs'; +import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs'; import { FunctionComponent } from 'preact'; -import { useState } from 'preact/hooks'; +import { useCallback, useState } from 'preact/hooks'; import { JSXInternal } from 'preact/src/jsx'; import { Icon } from '../Icon'; import { PremiumFeaturesModal } from '../PremiumFeaturesModal'; @@ -10,46 +9,48 @@ import { Switch } from '../Switch'; type Props = { application: WebApplication; - closeQuickSettingsMenu: () => void; - focusModeEnabled: boolean; - setFocusModeEnabled: (enabled: boolean) => void; + onToggle: (value: boolean) => void; + onClose: () => void; + isEnabled: boolean; }; export const FocusModeSwitch: FunctionComponent = ({ application, - closeQuickSettingsMenu, - focusModeEnabled, - setFocusModeEnabled, + onToggle, + onClose, + isEnabled, }) => { const [showUpgradeModal, setShowUpgradeModal] = useState(false); - const isEntitledToFocusMode = + const isEntitled = application.getFeatureStatus(FeatureIdentifier.FocusMode) === FeatureStatus.Entitled; - const toggleFocusMode = ( - e: JSXInternal.TargetedMouseEvent - ) => { - e.preventDefault(); - if (isEntitledToFocusMode) { - setFocusModeEnabled(!focusModeEnabled); - closeQuickSettingsMenu(); - } else { - setShowUpgradeModal(true); - } - }; + const toggle = useCallback( + (e: JSXInternal.TargetedMouseEvent) => { + e.preventDefault(); + + if (isEntitled) { + onToggle(!isEnabled); + onClose(); + } else { + setShowUpgradeModal(true); + } + }, + [isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose] + ); return ( <>