From a15014f0033c77bcfe3d8857708b8a0789fbcc69 Mon Sep 17 00:00:00 2001 From: Mo Date: Mon, 13 Dec 2021 11:16:14 -0600 Subject: [PATCH 01/18] Component view refactor (#770) * refactor: simplify component-view lifecycle callbacks * fix: reintroduce exhaustive-deps --- .eslintrc | 4 +- .../ComponentView/IssueOnLoading.tsx | 8 +- .../ComponentView/OfflineRestricted.tsx | 38 +- .../components/ComponentView/index.tsx | 362 +++++++++--------- .../javascripts/views/editor/editor_view.ts | 5 +- 5 files changed, 201 insertions(+), 216 deletions(-) diff --git a/.eslintrc b/.eslintrc index a5d7eb8ff..21f273635 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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/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..6882b0220 100644 --- a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx +++ b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx @@ -6,9 +6,9 @@ interface IProps { } export const OfflineRestricted: FunctionalComponent = ({ - isReloading, - reloadStatus - }) => { + isReloading, + reloadStatus, +}) => { return (
@@ -16,39 +16,37 @@ export const OfflineRestricted: FunctionalComponent = ({
- You have restricted this extension to be used offline only. + You have restricted this component to be used offline only.
- Offline extensions are not available in the Web app. + Offline components are not available in the web application.
-
- You can either: -
+
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. + Enable the Hosted option for this component by opening + Preferences {'>'} General {'>'} Advanced Settings menu and{' '} + toggling 'Use hosted when local is unavailable' under this + components's options. Then press Reload below.
  • +
  • Use the desktop application.
- {isReloading ? + {isReloading ? (
- : - - } + )}
diff --git a/app/assets/javascripts/components/ComponentView/index.tsx b/app/assets/javascripts/components/ComponentView/index.tsx index e78574a9b..3c6a98a6f 100644 --- a/app/assets/javascripts/components/ComponentView/index.tsx +++ b/app/assets/javascripts/components/ComponentView/index.tsx @@ -1,4 +1,10 @@ -import { ComponentAction, FeatureStatus, LiveItem, SNComponent, dateToLocalizedString } from '@standardnotes/snjs'; +import { + ComponentAction, + FeatureStatus, + SNComponent, + dateToLocalizedString, + ApplicationEvent, +} from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { FunctionalComponent } from 'preact'; import { toDirective } from '@/components/utils'; @@ -11,7 +17,6 @@ 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 { @@ -32,31 +37,37 @@ const VisibilityChangeKey = 'visibilitychange'; const avoidFlickerTimeout = 7; export const ComponentView: FunctionalComponent = observer( - ({ - application, - onLoad, - componentUuid, - templateComponent - }) => { - const liveComponentRef = useRef | null>(null); + ({ application, onLoad, componentUuid, templateComponent }) => { const iframeRef = useRef(null); + const excessiveLoadingTimeout = useRef< + ReturnType | undefined + >(undefined); - const [isIssueOnLoading, setIsIssueOnLoading] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [hasIssueLoading, setHasIssueLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [isReloading, setIsReloading] = useState(false); - const [loadTimeout, setLoadTimeout] = useState | undefined>(undefined); - const [featureStatus, setFeatureStatus] = useState(FeatureStatus.Entitled); + const [component] = useState( + application.findItem(componentUuid) as SNComponent + ); + const [featureStatus, setFeatureStatus] = useState( + application.getFeatureStatus(component.identifier) + ); const [isComponentValid, setIsComponentValid] = useState(true); - const [error, setError] = useState<'offline-restricted' | 'url-missing' | undefined>(undefined); + 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 [deprecationMessage, setDeprecationMessage] = useState< + string | undefined + >(undefined); + const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = + useState(false); const [didAttemptReload, setDidAttemptReload] = useState(false); - const [component, setComponent] = useState(undefined); + const [contentWindow, setContentWindow] = useState(null); - const getComponent = useCallback((): SNComponent => { - return (templateComponent || liveComponentRef.current?.item) as SNComponent; - }, [templateComponent]); + const manageSubscription = useCallback(() => { + openSubscriptionDashboard(application); + }, [application]); const reloadIframe = () => { setTimeout(() => { @@ -67,30 +78,37 @@ export const ComponentView: FunctionalComponent = observer( }); }; - const manageSubscription = useCallback(() => { - openSubscriptionDashboard(application); - }, [application]); + useEffect(() => { + const loadTimeout = setTimeout(() => { + handleIframeTakingTooLongToLoad(); + }, MaxLoadThreshold); + excessiveLoadingTimeout.current = loadTimeout; + return () => { + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const reloadStatus = useCallback(() => { - if (!component) { - return; - } - - const offlineRestricted = component.offlineOnly && !isDesktopApplication(); - const hasUrlError = function () { + const reloadValidityStatus = useCallback(() => { + 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); + const readonlyState = + application.componentManager.getReadonlyStateForComponent(component); if (!readonlyState.lockReadonly) { - application.componentManager.setReadonlyStateForComponent(component, featureStatus !== FeatureStatus.Entitled); + application.componentManager.setReadonlyStateForComponent( + component, + featureStatus !== FeatureStatus.Entitled + ); } setIsComponentValid(!offlineRestricted && !hasUrlError); @@ -107,195 +125,165 @@ export const ComponentView: FunctionalComponent = observer( } setIsDeprecated(component.isDeprecated); setDeprecationMessage(component.package_info.deprecation_message); - }, [application, component, isComponentValid, featureStatus]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + reloadValidityStatus(); + }, [reloadValidityStatus]); const dismissDeprecationMessage = () => { - setTimeout(() => { - setIsDeprecationMessageDismissed(true); - }); + setIsDeprecationMessageDismissed(true); }; const onVisibilityChange = useCallback(() => { if (document.visibilityState === 'hidden') { return; } - if (isIssueOnLoading) { + if (hasIssueLoading) { reloadIframe(); } - }, [isIssueOnLoading]); + }, [hasIssueLoading]); - 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); + reloadIframe(); + } else { + document.addEventListener(VisibilityChangeKey, onVisibilityChange); } - }, [didAttemptReload, isLoading, onVisibilityChange]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => { - if (!component) { - return; - } - - let desktopError = false; - if (isDesktopApplication()) { + let hasDesktopError = false; + const canAccessWindowOrigin = isDesktopApplication(); + if (canAccessWindowOrigin) { try { - /** Accessing iframe.contentWindow.origin only allowed in desktop app. */ - if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') { - desktopError = true; + const contentWindow = iframe.contentWindow as Window; + if (!contentWindow.origin || contentWindow.origin === 'null') { + hasDesktopError = true; } // eslint-disable-next-line no-empty - } catch (e) { - } + } catch (e) {} } - loadTimeout && clearTimeout(loadTimeout); - await application.componentManager.registerComponentWindow( - component, - iframe.contentWindow! - ); - + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + setContentWindow(iframe.contentWindow); setTimeout(() => { setIsLoading(false); - setIsIssueOnLoading(desktopError ? true : false); - onLoad?.(component!); + setHasIssueLoading(hasDesktopError); + 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]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { - reloadStatus(); + if (contentWindow) { + application.componentManager.registerComponentWindow( + component, + contentWindow + ); + } + return () => { + application.componentManager.onComponentIframeDestroyed(component.uuid); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contentWindow]); + useEffect(() => { if (!iframeRef.current) { + setContentWindow(null); return; } iframeRef.current.onload = () => { - if (!component) { - return; - } - const iframe = application.componentManager.iframeForComponent( component.uuid ); - if (!iframe) { - return; + if (iframe) { + setTimeout(() => { + handleIframeLoad(iframe); + }); } - - setTimeout(() => { - loadComponent(); - reloadStatus(); - handleIframeLoad(iframe); - }); }; - }, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]); - - const getUrl = () => { - const url = component ? application.componentManager.urlForComponent(component) : ''; - return url as string; - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [iframeRef.current]); useEffect(() => { - if (componentUuid) { - liveComponentRef.current = new LiveItem(componentUuid, application); - } else { - application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent); + const removeFeaturesChangedObserver = application.addEventObserver( + async () => { + setFeatureStatus(application.getFeatureStatus(component.identifier)); + }, + ApplicationEvent.FeaturesUpdated + ); + + return () => { + removeFeaturesChangedObserver(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!componentUuid) { + 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 (templateComponent) { + /** componentManager can be destroyed already via locking */ + application.componentManager?.removeTemporaryTemplateComponent( + templateComponent + ); } - if (liveComponentRef.current) { - liveComponentRef.current.deinit(); - } - - document.removeEventListener( - VisibilityChangeKey, - onVisibilityChange - ); + document.removeEventListener(VisibilityChangeKey, onVisibilityChange); }; - }, [application, component, componentUuid, onVisibilityChange, templateComponent]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); 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; - } - } - }); + 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]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [component]); useEffect(() => { - const unregisterDesktopObserver = application.getDesktopService() + const unregisterDesktopObserver = application + .getDesktopService() .registerUpdateObserver((component: SNComponent) => { if (component.uuid === component.uuid && component.active) { reloadIframe(); @@ -307,13 +295,9 @@ export const ComponentView: FunctionalComponent = observer( }; }, [application]); - if (!component) { - return null; - } - return ( <> - {isIssueOnLoading && ( + {hasIssueLoading && ( = observer( {featureStatus !== FeatureStatus.Entitled && ( @@ -335,7 +319,10 @@ export const ComponentView: FunctionalComponent = observer( /> )} {error == 'offline-restricted' && ( - + )} {error == 'url-missing' && ( @@ -346,22 +333,21 @@ export const ComponentView: FunctionalComponent = observer( data-component-id={component.uuid} frameBorder={0} data-attr-id={`component-iframe-${component.uuid}`} - src={getUrl()} - sandbox='allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads' + src={application.componentManager.urlForComponent(component) || ''} + sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads" > Loading )} - {isLoading && ( -
- )} + {isLoading &&
} ); - }); + } +); export const ComponentViewDirective = toDirective(ComponentView, { onLoad: '=', componentUuid: '=', templateComponent: '=', - manualDealloc: '=' + manualDealloc: '=', }); diff --git a/app/assets/javascripts/views/editor/editor_view.ts b/app/assets/javascripts/views/editor/editor_view.ts index d032bf04f..cbb33e677 100644 --- a/app/assets/javascripts/views/editor/editor_view.ts +++ b/app/assets/javascripts/views/editor/editor_view.ts @@ -174,7 +174,8 @@ class EditorViewCtrl extends PureViewCtrl { this.editorValues.text = note.text; } - const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadSource.Constructor && note.dirty; + const isTemplateNoteInsertedToBeInteractableWithEditor = + source === PayloadSource.Constructor && note.dirty; if (isTemplateNoteInsertedToBeInteractableWithEditor) { return; } @@ -396,7 +397,7 @@ class EditorViewCtrl extends PureViewCtrl { this.reloadFont(); } else if (component.area === ComponentArea.Editor) { const currentEditor = this.state.editorComponent; - if (currentEditor && component !== currentEditor) { + if (currentEditor && component.uuid !== currentEditor.uuid) { await this.disassociateComponentWithCurrentNote(currentEditor); } const prefersPlain = this.note.prefersPlainEditor; From 8db549f6f6a474e73b57bb31d8d9c8dc95bd0a85 Mon Sep 17 00:00:00 2001 From: Vardan Hakobyan Date: Tue, 14 Dec 2021 19:14:01 +0400 Subject: [PATCH 02/18] feat: handle unprotected session expiration (#747) * feat: hide note contents if the protection expires when the protected note is open and wasn't edited for a while * feat: handle session expiration for opened protected note for both plain advanced editors * fix: if after canceling session expiry modal only one unprotected note stays selected, show its contents in the editor * refactor: handle session expiration for opened protected note (move the logic to web client) * feat: handle the case of selecting "Don't remember" option in session expiry dialog * test (WIP): add unit tests for protecting opened note after the session has expired * test: add remaining unit tests * refactor: move the opened note protection logic to "editor_view" * refactor: reviewer comments - don't rely on user signed-in/out status to require authentication for protected note - remove unnecessary async/awaits - better wording on ui * refactor: reviewer's comments: - use snjs method to check if "Don't remember" option is selected in authentication modal - move the constant to snjs - fix eslint error * refactor: avoid `any` type for `appEvent` payload * test: add unit tests * chore: update function name * refactor: use simpler protection session event types * refactor: protected access terminology * refactor: start counting idle timer after every edit (instead of counting by interval in spite of edits) * test: unit tests * style: don't give extra brightness to the "View Note"/"Authenticate" button on hover/focus * chore: bump snjs version Co-authored-by: Mo Bitar --- .eslintrc | 2 +- .gitignore | 2 + .../__mocks__/@standardnotes/snjs.js | 11 + .../components/AccountMenu/DataBackup.tsx | 180 ------------ .../components/AccountMenu/Encryption.tsx | 33 --- .../components/AccountMenu/ErrorReporting.tsx | 80 ------ .../components/AccountMenu/PasscodeLock.tsx | 272 ------------------ .../components/AccountMenu/Protections.tsx | 100 ------- .../components/NoProtectionsNoteWarning.tsx | 43 ++- .../javascripts/components/SearchOptions.tsx | 3 +- app/assets/javascripts/components/utils.ts | 3 +- app/assets/javascripts/jest.config.js | 6 +- .../preferences/panes/account/Sync.tsx | 2 +- .../panes/security-segments/Protections.tsx | 64 +++-- .../two-factor-auth/AuthAppInfoPopup.tsx | 3 +- .../app_state/search_options_state.ts | 23 +- .../views/abstract/pure_view_ctrl.ts | 6 +- .../views/application/application_view.ts | 25 +- .../views/challenge_modal/challenge_modal.tsx | 15 +- app/assets/javascripts/views/constants.ts | 6 +- .../javascripts/views/editor/editor-view.pug | 1 + .../views/editor/editor_view.test.ts | 196 +++++++++++++ .../javascripts/views/editor/editor_view.ts | 75 ++++- app/assets/stylesheets/_sn.scss | 9 + package.json | 5 +- yarn.lock | 8 +- 26 files changed, 418 insertions(+), 755 deletions(-) create mode 100644 app/assets/javascripts/__mocks__/@standardnotes/snjs.js delete mode 100644 app/assets/javascripts/components/AccountMenu/DataBackup.tsx delete mode 100644 app/assets/javascripts/components/AccountMenu/Encryption.tsx delete mode 100644 app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx delete mode 100644 app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx delete mode 100644 app/assets/javascripts/components/AccountMenu/Protections.tsx create mode 100644 app/assets/javascripts/views/editor/editor_view.test.ts diff --git a/.eslintrc b/.eslintrc index 21f273635..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, 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/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/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/NoProtectionsNoteWarning.tsx b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx index e3e9ec291..243896f4f 100644 --- a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx +++ b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx @@ -1,27 +1,41 @@ import { AppState } from '@/ui_models/app_state'; import { toDirective } from './utils'; -type Props = { appState: AppState; onViewNote: () => void }; +type Props = { + appState: AppState; + onViewNote: () => void; + requireAuthenticationForProtectedNote: boolean; +}; + +function NoProtectionsNoteWarning({ + appState, + onViewNote, + requireAuthenticationForProtectedNote, +}: Props) { + const instructionText = requireAuthenticationForProtectedNote + ? 'Authenticate to view this note.' + : 'Add a passcode or create an account to require authentication to view this note.'; -function NoProtectionsNoteWarning({ appState, onViewNote }: Props) { return (

This note is protected

-

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

+

{instructionText}

+ {!requireAuthenticationForProtectedNote && ( + + )} -
@@ -32,5 +46,6 @@ export const NoProtectionsdNoteWarningDirective = toDirective( NoProtectionsNoteWarning, { onViewNote: '&', + requireAuthenticationForProtectedNote: '=', } ); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index b62989b7d..a67dfcc60 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,7 +1,7 @@ import { AppState } from '@/ui_models/app_state'; import { Icon } from './Icon'; import { toDirective, useCloseOnBlur } from './utils'; -import { useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; import { @@ -11,7 +11,6 @@ import { } from '@reach/disclosure'; import { Switch } from './Switch'; import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; type Props = { appState: AppState; diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts index 652ee79e7..3e4f9b631 100644 --- a/app/assets/javascripts/components/utils.ts +++ b/app/assets/javascripts/components/utils.ts @@ -1,7 +1,6 @@ import { FunctionComponent, h, render } from 'preact'; import { unmountComponentAtNode } from 'preact/compat'; -import { StateUpdater, useCallback, useState } from 'preact/hooks'; -import { useEffect } from 'react'; +import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks'; /** * @returns a callback that will close a dropdown if none of its children has diff --git a/app/assets/javascripts/jest.config.js b/app/assets/javascripts/jest.config.js index e985f0c59..b0733b204 100644 --- a/app/assets/javascripts/jest.config.js +++ b/app/assets/javascripts/jest.config.js @@ -1,10 +1,13 @@ -const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper; +const pathsToModuleNameMapper = + require('ts-jest/utils').pathsToModuleNameMapper; const tsConfig = require('./tsconfig.json'); const pathsFromTsconfig = tsConfig.compilerOptions.paths; module.exports = { + restoreMocks: true, clearMocks: true, + resetMocks: true, moduleNameMapper: { ...pathsToModuleNameMapper(pathsFromTsconfig, { prefix: '', @@ -14,7 +17,6 @@ module.exports = { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', }, globals: { - window: {}, __VERSION__: '1.0.0', __DESKTOP__: false, __WEB__: true, diff --git a/app/assets/javascripts/preferences/panes/account/Sync.tsx b/app/assets/javascripts/preferences/panes/account/Sync.tsx index e8b465b39..b7d6147bd 100644 --- a/app/assets/javascripts/preferences/panes/account/Sync.tsx +++ b/app/assets/javascripts/preferences/panes/account/Sync.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/Button'; import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'; import { STRING_GENERIC_SYNC_ERROR } from '@/strings'; import { useState } from '@node_modules/preact/hooks'; -import { observer } from '@node_modules/mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import { WebApplication } from '@/ui_models/application'; import { FunctionComponent } from 'preact'; diff --git a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx index 2cf2caabb..f0e564274 100644 --- a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx +++ b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx @@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks'; import { useEffect } from 'preact/hooks'; import { ApplicationEvent } from '@standardnotes/snjs'; import { isSameDay } from '@/utils'; -import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components'; +import { + PreferencesGroup, + PreferencesSegment, + Title, + Text, +} from '@/preferences/components'; import { Button } from '@/components/Button'; type Props = { @@ -16,7 +21,9 @@ export const Protections: FunctionalComponent = ({ application }) => { application.clearProtectionSession(); }; - const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources()); + const [hasProtections, setHasProtections] = useState(() => + application.hasProtectionSources() + ); const getProtectionsDisabledUntil = useCallback((): string | null => { const protectionExpiry = application.getProtectionSessionExpiryDate(); @@ -26,7 +33,7 @@ export const Protections: FunctionalComponent = ({ application }) => { if (isSameDay(protectionExpiry, now)) { f = new Intl.DateTimeFormat(undefined, { hour: 'numeric', - minute: 'numeric' + minute: 'numeric', }); } else { f = new Intl.DateTimeFormat(undefined, { @@ -34,7 +41,7 @@ export const Protections: FunctionalComponent = ({ application }) => { day: 'numeric', month: 'short', hour: 'numeric', - minute: 'numeric' + minute: 'numeric', }); } @@ -43,14 +50,23 @@ export const Protections: FunctionalComponent = ({ application }) => { return null; }, [application]); - const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); + const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState( + getProtectionsDisabledUntil() + ); useEffect(() => { - const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( + const removeUnprotectedSessionBeginObserver = application.addEventObserver( async () => { setProtectionsDisabledUntil(getProtectionsDisabledUntil()); }, - ApplicationEvent.ProtectionSessionExpiryDateChanged + ApplicationEvent.UnprotectedSessionBegan + ); + + const removeUnprotectedSessionEndObserver = application.addEventObserver( + async () => { + setProtectionsDisabledUntil(getProtectionsDisabledUntil()); + }, + ApplicationEvent.UnprotectedSessionExpired ); const removeKeyStatusChangedObserver = application.addEventObserver( @@ -61,7 +77,8 @@ export const Protections: FunctionalComponent = ({ application }) => { ); return () => { - removeProtectionSessionExpiryDateChangedObserver(); + removeUnprotectedSessionBeginObserver(); + removeUnprotectedSessionEndObserver(); removeKeyStatusChangedObserver(); }; }, [application, getProtectionsDisabledUntil]); @@ -74,19 +91,28 @@ export const Protections: FunctionalComponent = ({ application }) => { Protections - {protectionsDisabledUntil - ? Protections are disabled until {protectionsDisabledUntil}. - : Protections are enabled. - } + {protectionsDisabledUntil ? ( + + Unprotected access expires at {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. + Actions like viewing or searching protected notes, exporting decrypted + backups, or revoking an active session require additional + authentication such as entering your account password or application + passcode. - {protectionsDisabledUntil && - + +
+ {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 new file mode 100644 index 000000000..1a98f2404 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Encryption.tsx @@ -0,0 +1,33 @@ +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 new file mode 100644 index 000000000..92784557e --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/ErrorReporting.tsx @@ -0,0 +1,80 @@ +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 new file mode 100644 index 000000000..cdfbbc046 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/PasscodeLock.tsx @@ -0,0 +1,272 @@ +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 new file mode 100644 index 000000000..8e7b1f229 --- /dev/null +++ b/app/assets/javascripts/components/AccountMenu/Protections.tsx @@ -0,0 +1,100 @@ +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/NoProtectionsNoteWarning.tsx b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx index 243896f4f..e3e9ec291 100644 --- a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx +++ b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx @@ -1,41 +1,27 @@ import { AppState } from '@/ui_models/app_state'; import { toDirective } from './utils'; -type Props = { - appState: AppState; - onViewNote: () => void; - requireAuthenticationForProtectedNote: boolean; -}; - -function NoProtectionsNoteWarning({ - appState, - onViewNote, - requireAuthenticationForProtectedNote, -}: Props) { - const instructionText = requireAuthenticationForProtectedNote - ? 'Authenticate to view this note.' - : 'Add a passcode or create an account to require authentication to view this note.'; +type Props = { appState: AppState; onViewNote: () => void }; +function NoProtectionsNoteWarning({ appState, onViewNote }: Props) { return (

This note is protected

-

{instructionText}

+

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

- {!requireAuthenticationForProtectedNote && ( - - )} +
@@ -46,6 +32,5 @@ export const NoProtectionsdNoteWarningDirective = toDirective( NoProtectionsNoteWarning, { onViewNote: '&', - requireAuthenticationForProtectedNote: '=', } ); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index a67dfcc60..b62989b7d 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,7 +1,7 @@ import { AppState } from '@/ui_models/app_state'; import { Icon } from './Icon'; import { toDirective, useCloseOnBlur } from './utils'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; import { @@ -11,6 +11,7 @@ import { } from '@reach/disclosure'; import { Switch } from './Switch'; import { observer } from 'mobx-react-lite'; +import { useEffect } from 'react'; type Props = { appState: AppState; diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts index 3e4f9b631..652ee79e7 100644 --- a/app/assets/javascripts/components/utils.ts +++ b/app/assets/javascripts/components/utils.ts @@ -1,6 +1,7 @@ import { FunctionComponent, h, render } from 'preact'; import { unmountComponentAtNode } from 'preact/compat'; -import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks'; +import { StateUpdater, useCallback, useState } from 'preact/hooks'; +import { useEffect } from 'react'; /** * @returns a callback that will close a dropdown if none of its children has diff --git a/app/assets/javascripts/jest.config.js b/app/assets/javascripts/jest.config.js index b0733b204..e985f0c59 100644 --- a/app/assets/javascripts/jest.config.js +++ b/app/assets/javascripts/jest.config.js @@ -1,13 +1,10 @@ -const pathsToModuleNameMapper = - require('ts-jest/utils').pathsToModuleNameMapper; +const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper; const tsConfig = require('./tsconfig.json'); const pathsFromTsconfig = tsConfig.compilerOptions.paths; module.exports = { - restoreMocks: true, clearMocks: true, - resetMocks: true, moduleNameMapper: { ...pathsToModuleNameMapper(pathsFromTsconfig, { prefix: '', @@ -17,6 +14,7 @@ module.exports = { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', }, globals: { + window: {}, __VERSION__: '1.0.0', __DESKTOP__: false, __WEB__: true, diff --git a/app/assets/javascripts/preferences/panes/account/Sync.tsx b/app/assets/javascripts/preferences/panes/account/Sync.tsx index b7d6147bd..e8b465b39 100644 --- a/app/assets/javascripts/preferences/panes/account/Sync.tsx +++ b/app/assets/javascripts/preferences/panes/account/Sync.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/Button'; import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'; import { STRING_GENERIC_SYNC_ERROR } from '@/strings'; import { useState } from '@node_modules/preact/hooks'; -import { observer } from 'mobx-react-lite'; +import { observer } from '@node_modules/mobx-react-lite'; import { WebApplication } from '@/ui_models/application'; import { FunctionComponent } from 'preact'; diff --git a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx index f0e564274..2cf2caabb 100644 --- a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx +++ b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx @@ -4,12 +4,7 @@ import { useCallback, useState } from 'preact/hooks'; import { useEffect } from 'preact/hooks'; import { ApplicationEvent } from '@standardnotes/snjs'; import { isSameDay } from '@/utils'; -import { - PreferencesGroup, - PreferencesSegment, - Title, - Text, -} from '@/preferences/components'; +import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components'; import { Button } from '@/components/Button'; type Props = { @@ -21,9 +16,7 @@ export const Protections: FunctionalComponent = ({ application }) => { application.clearProtectionSession(); }; - const [hasProtections, setHasProtections] = useState(() => - application.hasProtectionSources() - ); + const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources()); const getProtectionsDisabledUntil = useCallback((): string | null => { const protectionExpiry = application.getProtectionSessionExpiryDate(); @@ -33,7 +26,7 @@ export const Protections: FunctionalComponent = ({ application }) => { if (isSameDay(protectionExpiry, now)) { f = new Intl.DateTimeFormat(undefined, { hour: 'numeric', - minute: 'numeric', + minute: 'numeric' }); } else { f = new Intl.DateTimeFormat(undefined, { @@ -41,7 +34,7 @@ export const Protections: FunctionalComponent = ({ application }) => { day: 'numeric', month: 'short', hour: 'numeric', - minute: 'numeric', + minute: 'numeric' }); } @@ -50,23 +43,14 @@ export const Protections: FunctionalComponent = ({ application }) => { return null; }, [application]); - const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState( - getProtectionsDisabledUntil() - ); + const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); useEffect(() => { - const removeUnprotectedSessionBeginObserver = application.addEventObserver( + const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( async () => { setProtectionsDisabledUntil(getProtectionsDisabledUntil()); }, - ApplicationEvent.UnprotectedSessionBegan - ); - - const removeUnprotectedSessionEndObserver = application.addEventObserver( - async () => { - setProtectionsDisabledUntil(getProtectionsDisabledUntil()); - }, - ApplicationEvent.UnprotectedSessionExpired + ApplicationEvent.ProtectionSessionExpiryDateChanged ); const removeKeyStatusChangedObserver = application.addEventObserver( @@ -77,8 +61,7 @@ export const Protections: FunctionalComponent = ({ application }) => { ); return () => { - removeUnprotectedSessionBeginObserver(); - removeUnprotectedSessionEndObserver(); + removeProtectionSessionExpiryDateChangedObserver(); removeKeyStatusChangedObserver(); }; }, [application, getProtectionsDisabledUntil]); @@ -91,28 +74,19 @@ export const Protections: FunctionalComponent = ({ application }) => { Protections - {protectionsDisabledUntil ? ( - - Unprotected access expires at {protectionsDisabledUntil}. - - ) : ( - Protections are enabled. - )} + {protectionsDisabledUntil + ? Protections are disabled until {protectionsDisabledUntil}. + : Protections are enabled. + } - Actions like viewing or searching protected notes, exporting decrypted - backups, or revoking an active session require additional - authentication such as entering your account password or application - passcode. + 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 && ( - - -
- {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/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/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/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index b62989b7d..a67dfcc60 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,7 +1,7 @@ import { AppState } from '@/ui_models/app_state'; import { Icon } from './Icon'; import { toDirective, useCloseOnBlur } from './utils'; -import { useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; import { @@ -11,7 +11,6 @@ import { } from '@reach/disclosure'; import { Switch } from './Switch'; import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; type Props = { appState: AppState; diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts index 652ee79e7..3e4f9b631 100644 --- a/app/assets/javascripts/components/utils.ts +++ b/app/assets/javascripts/components/utils.ts @@ -1,7 +1,6 @@ import { FunctionComponent, h, render } from 'preact'; import { unmountComponentAtNode } from 'preact/compat'; -import { StateUpdater, useCallback, useState } from 'preact/hooks'; -import { useEffect } from 'react'; +import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks'; /** * @returns a callback that will close a dropdown if none of its children has diff --git a/app/assets/javascripts/jest.config.js b/app/assets/javascripts/jest.config.js index e985f0c59..b0733b204 100644 --- a/app/assets/javascripts/jest.config.js +++ b/app/assets/javascripts/jest.config.js @@ -1,10 +1,13 @@ -const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper; +const pathsToModuleNameMapper = + require('ts-jest/utils').pathsToModuleNameMapper; const tsConfig = require('./tsconfig.json'); const pathsFromTsconfig = tsConfig.compilerOptions.paths; module.exports = { + restoreMocks: true, clearMocks: true, + resetMocks: true, moduleNameMapper: { ...pathsToModuleNameMapper(pathsFromTsconfig, { prefix: '', @@ -14,7 +17,6 @@ module.exports = { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', }, globals: { - window: {}, __VERSION__: '1.0.0', __DESKTOP__: false, __WEB__: true, diff --git a/app/assets/javascripts/preferences/panes/account/Sync.tsx b/app/assets/javascripts/preferences/panes/account/Sync.tsx index e8b465b39..b7d6147bd 100644 --- a/app/assets/javascripts/preferences/panes/account/Sync.tsx +++ b/app/assets/javascripts/preferences/panes/account/Sync.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/Button'; import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'; import { STRING_GENERIC_SYNC_ERROR } from '@/strings'; import { useState } from '@node_modules/preact/hooks'; -import { observer } from '@node_modules/mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import { WebApplication } from '@/ui_models/application'; import { FunctionComponent } from 'preact'; diff --git a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx index 2cf2caabb..f0e564274 100644 --- a/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx +++ b/app/assets/javascripts/preferences/panes/security-segments/Protections.tsx @@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks'; import { useEffect } from 'preact/hooks'; import { ApplicationEvent } from '@standardnotes/snjs'; import { isSameDay } from '@/utils'; -import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components'; +import { + PreferencesGroup, + PreferencesSegment, + Title, + Text, +} from '@/preferences/components'; import { Button } from '@/components/Button'; type Props = { @@ -16,7 +21,9 @@ export const Protections: FunctionalComponent = ({ application }) => { application.clearProtectionSession(); }; - const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources()); + const [hasProtections, setHasProtections] = useState(() => + application.hasProtectionSources() + ); const getProtectionsDisabledUntil = useCallback((): string | null => { const protectionExpiry = application.getProtectionSessionExpiryDate(); @@ -26,7 +33,7 @@ export const Protections: FunctionalComponent = ({ application }) => { if (isSameDay(protectionExpiry, now)) { f = new Intl.DateTimeFormat(undefined, { hour: 'numeric', - minute: 'numeric' + minute: 'numeric', }); } else { f = new Intl.DateTimeFormat(undefined, { @@ -34,7 +41,7 @@ export const Protections: FunctionalComponent = ({ application }) => { day: 'numeric', month: 'short', hour: 'numeric', - minute: 'numeric' + minute: 'numeric', }); } @@ -43,14 +50,23 @@ export const Protections: FunctionalComponent = ({ application }) => { return null; }, [application]); - const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil()); + const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState( + getProtectionsDisabledUntil() + ); useEffect(() => { - const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver( + const removeUnprotectedSessionBeginObserver = application.addEventObserver( async () => { setProtectionsDisabledUntil(getProtectionsDisabledUntil()); }, - ApplicationEvent.ProtectionSessionExpiryDateChanged + ApplicationEvent.UnprotectedSessionBegan + ); + + const removeUnprotectedSessionEndObserver = application.addEventObserver( + async () => { + setProtectionsDisabledUntil(getProtectionsDisabledUntil()); + }, + ApplicationEvent.UnprotectedSessionExpired ); const removeKeyStatusChangedObserver = application.addEventObserver( @@ -61,7 +77,8 @@ export const Protections: FunctionalComponent = ({ application }) => { ); return () => { - removeProtectionSessionExpiryDateChangedObserver(); + removeUnprotectedSessionBeginObserver(); + removeUnprotectedSessionEndObserver(); removeKeyStatusChangedObserver(); }; }, [application, getProtectionsDisabledUntil]); @@ -74,19 +91,28 @@ export const Protections: FunctionalComponent = ({ application }) => { Protections - {protectionsDisabledUntil - ? Protections are disabled until {protectionsDisabledUntil}. - : Protections are enabled. - } + {protectionsDisabledUntil ? ( + + Unprotected access expires at {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. + Actions like viewing or searching protected notes, exporting decrypted + backups, or revoking an active session require additional + authentication such as entering your account password or application + passcode. - {protectionsDisabledUntil && - +
+
+ 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/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index a67dfcc60..df71dbfd2 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -17,7 +17,7 @@ type Props = { application: WebApplication; }; -const SearchOptions = observer(({ appState }: Props) => { +export const SearchOptions = observer(({ appState }: Props) => { const { searchOptions } = appState; const { diff --git a/app/assets/javascripts/directives/views/panelResizer.ts b/app/assets/javascripts/directives/views/panelResizer.ts index 99d054ea5..739225dc6 100644 --- a/app/assets/javascripts/directives/views/panelResizer.ts +++ b/app/assets/javascripts/directives/views/panelResizer.ts @@ -3,14 +3,14 @@ import angular from 'angular'; import template from '%/directives/panel-resizer.pug'; import { debounce } from '@/utils'; -enum PanelSide { +export enum PanelSide { Right = 'right', - Left = 'left' + Left = 'left', } enum MouseEventType { Move = 'mousemove', Down = 'mousedown', - Up = 'mouseup' + Up = 'mouseup', } enum CssClass { Hoverable = 'hoverable', @@ -22,64 +22,63 @@ enum CssClass { } const WINDOW_EVENT_RESIZE = 'resize'; -type ResizeFinishCallback = ( +export type ResizeFinishCallback = ( lastWidth: number, lastLeft: number, isMaxWidth: boolean, isCollapsed: boolean -) => void +) => void; interface PanelResizerScope { - alwaysVisible: boolean - collapsable: boolean - control: PanelPuppet - defaultWidth: number - hoverable: boolean - index: number - minWidth: number - onResizeFinish: () => ResizeFinishCallback - onWidthEvent?: () => void - panelId: string - property: PanelSide + alwaysVisible: boolean; + collapsable: boolean; + control: PanelPuppet; + defaultWidth: number; + hoverable: boolean; + index: number; + minWidth: number; + onResizeFinish: () => ResizeFinishCallback; + onWidthEvent?: () => void; + panelId: string; + property: PanelSide; } class PanelResizerCtrl implements PanelResizerScope { - /** @scope */ - alwaysVisible!: boolean - collapsable!: boolean - control!: PanelPuppet - defaultWidth!: number - hoverable!: boolean - index!: number - minWidth!: number - onResizeFinish!: () => ResizeFinishCallback - onWidthEvent?: () => () => void - panelId!: string - property!: PanelSide + alwaysVisible!: boolean; + collapsable!: boolean; + control!: PanelPuppet; + defaultWidth!: number; + hoverable!: boolean; + index!: number; + minWidth!: number; + onResizeFinish!: () => ResizeFinishCallback; + onWidthEvent?: () => () => void; + panelId!: string; + property!: PanelSide; - $compile: ng.ICompileService - $element: JQLite - $timeout: ng.ITimeoutService - panel!: HTMLElement - resizerColumn!: HTMLElement - currentMinWidth = 0 - pressed = false - startWidth = 0 - lastDownX = 0 - collapsed = false - lastWidth = 0 - startLeft = 0 - lastLeft = 0 - appFrame?: DOMRect - widthBeforeLastDblClick = 0 - overlay?: JQLite + $compile: ng.ICompileService; + $element: JQLite; + $timeout: ng.ITimeoutService; + panel!: HTMLElement; + resizerColumn!: HTMLElement; + currentMinWidth = 0; + pressed = false; + startWidth = 0; + lastDownX = 0; + collapsed = false; + lastWidth = 0; + startLeft = 0; + lastLeft = 0; + appFrame?: DOMRect; + widthBeforeLastDblClick = 0; + overlay?: JQLite; /* @ngInject */ constructor( $compile: ng.ICompileService, $element: JQLite, - $timeout: ng.ITimeoutService, + $timeout: ng.ITimeoutService ) { this.$compile = $compile; this.$element = $element; @@ -109,7 +108,10 @@ class PanelResizerCtrl implements PanelResizerScope { window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize); document.removeEventListener(MouseEventType.Move, this.onMouseMove); document.removeEventListener(MouseEventType.Up, this.onMouseUp); - this.resizerColumn.removeEventListener(MouseEventType.Down, this.onMouseDown); + this.resizerColumn.removeEventListener( + MouseEventType.Down, + this.onMouseDown + ); (this.handleResize as any) = undefined; (this.onMouseMove as any) = undefined; (this.onMouseUp as any) = undefined; @@ -140,7 +142,7 @@ class PanelResizerCtrl implements PanelResizerScope { return; } this.resizerColumn = this.$element[0]; - this.currentMinWidth = this.minWidth || (this.resizerColumn.offsetWidth + 2); + this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2; this.pressed = false; this.startWidth = this.panel.scrollWidth; this.lastDownX = 0; @@ -313,7 +315,8 @@ class PanelResizerCtrl implements PanelResizerScope { width = parentRect.width; } - const maxWidth = this.appFrame!.width - this.panel.getBoundingClientRect().x; + const maxWidth = + this.appFrame!.width - this.panel.getBoundingClientRect().x; if (width > maxWidth) { width = maxWidth; } @@ -356,7 +359,9 @@ class PanelResizerCtrl implements PanelResizerScope { if (this.overlay) { return; } - this.overlay = this.$compile(`
`)(this as any); + this.overlay = this.$compile(`
`)( + this as any + ); angular.element(document.body).prepend(this.overlay); } @@ -395,7 +400,7 @@ export class PanelResizer extends WebDirective { onResizeFinish: '&', onWidthEvent: '&', panelId: '=', - property: '=' + property: '=', }; } } diff --git a/app/assets/javascripts/services/ioService.ts b/app/assets/javascripts/services/ioService.ts index e1c775047..5883ba1aa 100644 --- a/app/assets/javascripts/services/ioService.ts +++ b/app/assets/javascripts/services/ioService.ts @@ -4,6 +4,7 @@ export enum KeyboardKey { Backspace = 'Backspace', Up = 'ArrowUp', Down = 'ArrowDown', + Enter = 'Enter', } export enum KeyboardModifier { @@ -51,7 +52,9 @@ export class IOService { (this.handleWindowBlur as unknown) = undefined; } - private addActiveModifier = (modifier: KeyboardModifier | undefined): void => { + private addActiveModifier = ( + modifier: KeyboardModifier | undefined + ): void => { if (!modifier) { return; } @@ -73,14 +76,16 @@ export class IOService { break; } } - } + }; - private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => { + private removeActiveModifier = ( + modifier: KeyboardModifier | undefined + ): void => { if (!modifier) { return; } this.activeModifiers.delete(modifier); - } + }; handleKeyDown = (event: KeyboardEvent): void => { for (const modifier of this.modifiersForEvent(event)) { @@ -91,7 +96,7 @@ export class IOService { handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => { this.addActiveModifier(modifier); - } + }; handleKeyUp = (event: KeyboardEvent): void => { for (const modifier of this.modifiersForEvent(event)) { @@ -102,7 +107,7 @@ export class IOService { handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => { this.removeActiveModifier(modifier); - } + }; handleWindowBlur = (): void => { for (const modifier of this.activeModifiers) { diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 7ee09f8d8..98e88e81b 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -23,6 +23,7 @@ import { } from 'mobx'; import { ActionsMenuState } from './actions_menu_state'; import { NotesState } from './notes_state'; +import { NotesViewState } from './notes_view_state'; import { NoteTagsState } from './note_tags_state'; import { NoAccountWarningState } from './no_account_warning_state'; import { PreferencesState } from './preferences_state'; @@ -86,6 +87,7 @@ export class AppState { readonly searchOptions: SearchOptionsState; readonly notes: NotesState; readonly tags: TagsState; + readonly notesView: NotesViewState; isSessionsModalVisible = false; private appEventObserverRemovers: (() => void)[] = []; @@ -127,6 +129,11 @@ export class AppState { this.appEventObserverRemovers ); this.purchaseFlow = new PurchaseFlowState(application); + this.notesView = new NotesViewState( + application, + this, + this.appEventObserverRemovers + ); this.addAppEventObserver(); this.streamNotesAndTags(); this.onVisibilityChange = () => { diff --git a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts new file mode 100644 index 000000000..62a5f6047 --- /dev/null +++ b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts @@ -0,0 +1,541 @@ +import { + ApplicationEvent, + CollectionSort, + ContentType, + findInArray, + NotesDisplayCriteria, + PrefKey, + SNNote, + SNTag, + UuidString, +} from '@standardnotes/snjs'; +import { + action, + autorun, + computed, + makeObservable, + observable, + reaction, +} from 'mobx'; +import { AppState, AppStateEvent } from '.'; +import { WebApplication } from '../application'; + +const MIN_NOTE_CELL_HEIGHT = 51.0; +const DEFAULT_LIST_NUM_NOTES = 20; +const ELEMENT_ID_SEARCH_BAR = 'search-bar'; +const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable'; + +export type DisplayOptions = { + sortBy: CollectionSort; + sortReverse: boolean; + hidePinned: boolean; + showArchived: boolean; + showTrashed: boolean; + hideProtected: boolean; + hideTags: boolean; + hideNotePreview: boolean; + hideDate: boolean; +}; + +export class NotesViewState { + completedFullSync = false; + noteFilterText = ''; + notes: SNNote[] = []; + notesToDisplay = 0; + pageSize = 0; + panelTitle = 'All Notes'; + renderedNotes: SNNote[] = []; + searchSubmitted = false; + selectedNotes: Record = {}; + showDisplayOptionsMenu = false; + displayOptions = { + sortBy: CollectionSort.CreatedAt, + sortReverse: false, + hidePinned: false, + showArchived: false, + showTrashed: false, + hideProtected: false, + hideTags: true, + hideDate: false, + hideNotePreview: false, + }; + + constructor( + private application: WebApplication, + private appState: AppState, + appObservers: (() => void)[] + ) { + this.resetPagination(); + + appObservers.push( + application.streamItems(ContentType.Note, () => { + this.reloadNotes(); + const activeNote = this.appState.notes.activeEditor?.note; + if (this.application.getAppState().notes.selectedNotesCount < 2) { + if (activeNote) { + const discarded = activeNote.deleted || activeNote.trashed; + if ( + discarded && + !this.appState?.selectedTag?.isTrashTag && + !this.appState?.searchOptions.includeTrashed + ) { + this.selectNextOrCreateNew(); + } else if (!this.selectedNotes[activeNote.uuid]) { + this.selectNote(activeNote); + } + } else { + this.selectFirstNote(); + } + } + }), + application.streamItems([ContentType.Tag], async (items) => { + const tags = items as SNTag[]; + /** A tag could have changed its relationships, so we need to reload the filter */ + this.reloadNotesDisplayOptions(); + this.reloadNotes(); + if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) { + /** Tag title could have changed */ + this.reloadPanelTitle(); + } + }), + application.addEventObserver(async () => { + this.reloadPreferences(); + }, ApplicationEvent.PreferencesChanged), + application.addEventObserver(async () => { + this.appState.closeAllEditors(); + this.selectFirstNote(); + this.setCompletedFullSync(false); + }, ApplicationEvent.SignedIn), + application.addEventObserver(async () => { + this.reloadNotes(); + if ( + this.notes.length === 0 && + this.appState.selectedTag?.isAllTag && + this.noteFilterText === '' + ) { + this.createPlaceholderNote(); + } + this.setCompletedFullSync(true); + }, ApplicationEvent.CompletedFullSync), + autorun(() => { + if (appState.notes.selectedNotes) { + this.syncSelectedNotes(); + } + }), + reaction( + () => [ + appState.searchOptions.includeProtectedContents, + appState.searchOptions.includeArchived, + appState.searchOptions.includeTrashed, + ], + () => { + this.reloadNotesDisplayOptions(); + this.reloadNotes(); + } + ), + appState.addObserver(async (eventName) => { + if (eventName === AppStateEvent.TagChanged) { + this.handleTagChange(); + } else if (eventName === AppStateEvent.ActiveEditorChanged) { + this.handleEditorChange(); + } else if (eventName === AppStateEvent.EditorFocused) { + this.toggleDisplayOptionsMenu(false); + } + }) + ); + + makeObservable(this, { + completedFullSync: observable, + displayOptions: observable.struct, + noteFilterText: observable, + notes: observable, + notesToDisplay: observable, + panelTitle: observable, + renderedNotes: observable, + selectedNotes: observable, + showDisplayOptionsMenu: observable, + + reloadNotes: action, + reloadPanelTitle: action, + reloadPreferences: action, + resetPagination: action, + setCompletedFullSync: action, + setNoteFilterText: action, + syncSelectedNotes: action, + toggleDisplayOptionsMenu: action, + onFilterEnter: action, + handleFilterTextChanged: action, + + optionsSubtitle: computed, + }); + + window.onresize = () => { + this.resetPagination(true); + }; + } + + setCompletedFullSync = (completed: boolean) => { + this.completedFullSync = completed; + }; + + toggleDisplayOptionsMenu = (enabled: boolean) => { + this.showDisplayOptionsMenu = enabled; + }; + + get searchBarElement() { + return document.getElementById(ELEMENT_ID_SEARCH_BAR); + } + + get isFiltering(): boolean { + return !!this.noteFilterText && this.noteFilterText.length > 0; + } + + get activeEditorNote() { + return this.appState.notes.activeEditor?.note; + } + + reloadPanelTitle = () => { + let title = this.panelTitle; + if (this.isFiltering) { + const resultCount = this.notes.length; + title = `${resultCount} search results`; + } else if (this.appState.selectedTag) { + title = `${this.appState.selectedTag.title}`; + } + this.panelTitle = title; + }; + + reloadNotes = () => { + const tag = this.appState.selectedTag; + if (!tag) { + return; + } + const notes = this.application.getDisplayableItems( + ContentType.Note + ) as SNNote[]; + const renderedNotes = notes.slice(0, this.notesToDisplay); + + this.notes = notes; + this.renderedNotes = renderedNotes; + this.reloadPanelTitle(); + }; + + reloadNotesDisplayOptions = () => { + const tag = this.appState.selectedTag; + + const searchText = this.noteFilterText.toLowerCase(); + const isSearching = searchText.length; + let includeArchived: boolean; + let includeTrashed: boolean; + + if (isSearching) { + includeArchived = this.appState.searchOptions.includeArchived; + includeTrashed = this.appState.searchOptions.includeTrashed; + } else { + includeArchived = this.displayOptions.showArchived ?? false; + includeTrashed = this.displayOptions.showTrashed ?? false; + } + + const criteria = NotesDisplayCriteria.Create({ + sortProperty: this.displayOptions.sortBy as CollectionSort, + sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc', + tags: tag ? [tag] : [], + includeArchived, + includeTrashed, + includePinned: !this.displayOptions.hidePinned, + includeProtected: !this.displayOptions.hideProtected, + searchQuery: { + query: searchText, + includeProtectedNoteText: + this.appState.searchOptions.includeProtectedContents, + }, + }); + this.application.setNotesDisplayCriteria(criteria); + }; + + reloadPreferences = () => { + const freshDisplayOptions = {} as DisplayOptions; + const currentSortBy = this.displayOptions.sortBy; + let sortBy = this.application.getPreference( + PrefKey.SortNotesBy, + CollectionSort.CreatedAt + ); + if ( + sortBy === CollectionSort.UpdatedAt || + (sortBy as string) === 'client_updated_at' + ) { + /** Use UserUpdatedAt instead */ + sortBy = CollectionSort.UpdatedAt; + } + freshDisplayOptions.sortBy = sortBy; + freshDisplayOptions.sortReverse = this.application.getPreference( + PrefKey.SortNotesReverse, + false + ); + freshDisplayOptions.showArchived = this.application.getPreference( + PrefKey.NotesShowArchived, + false + ); + freshDisplayOptions.showTrashed = this.application.getPreference( + PrefKey.NotesShowTrashed, + false + ) as boolean; + freshDisplayOptions.hidePinned = this.application.getPreference( + PrefKey.NotesHidePinned, + false + ); + freshDisplayOptions.hideProtected = this.application.getPreference( + PrefKey.NotesHideProtected, + false + ); + freshDisplayOptions.hideNotePreview = this.application.getPreference( + PrefKey.NotesHideNotePreview, + false + ); + freshDisplayOptions.hideDate = this.application.getPreference( + PrefKey.NotesHideDate, + false + ); + freshDisplayOptions.hideTags = this.application.getPreference( + PrefKey.NotesHideTags, + true + ); + const displayOptionsChanged = + freshDisplayOptions.sortBy !== this.displayOptions.sortBy || + freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse || + freshDisplayOptions.hidePinned !== this.displayOptions.hidePinned || + freshDisplayOptions.showArchived !== this.displayOptions.showArchived || + freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed || + freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected || + freshDisplayOptions.hideTags !== this.displayOptions.hideTags; + this.displayOptions = freshDisplayOptions; + if (displayOptionsChanged) { + this.reloadNotesDisplayOptions(); + } + this.reloadNotes(); + if (freshDisplayOptions.sortBy !== currentSortBy) { + this.selectFirstNote(); + } + }; + + createNewNote = async (focusNewNote = true) => { + this.appState.notes.unselectNotes(); + let title = `Note ${this.notes.length + 1}`; + if (this.isFiltering) { + title = this.noteFilterText; + } + await this.appState.createEditor(title); + this.reloadNotes(); + this.appState.noteTags.reloadTags(); + const noteTitleEditorElement = document.getElementById('note-title-editor'); + if (focusNewNote) { + noteTitleEditorElement?.focus(); + } + }; + + createPlaceholderNote = () => { + const selectedTag = this.appState.selectedTag; + if (selectedTag && selectedTag.isSmartTag && !selectedTag.isAllTag) { + return; + } + return this.createNewNote(false); + }; + + get optionsSubtitle(): string { + let base = ''; + if (this.displayOptions.sortBy === CollectionSort.CreatedAt) { + base += ' Date Added'; + } else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) { + base += ' Date Modified'; + } else if (this.displayOptions.sortBy === CollectionSort.Title) { + base += ' Title'; + } + if (this.displayOptions.showArchived) { + base += ' | + Archived'; + } + if (this.displayOptions.showTrashed) { + base += ' | + Trashed'; + } + if (this.displayOptions.hidePinned) { + base += ' | – Pinned'; + } + if (this.displayOptions.hideProtected) { + base += ' | – Protected'; + } + if (this.displayOptions.sortReverse) { + base += ' | Reversed'; + } + return base; + } + + paginate = () => { + this.notesToDisplay += this.pageSize; + this.reloadNotes(); + if (this.searchSubmitted) { + this.application.getDesktopService().searchText(this.noteFilterText); + } + }; + + resetPagination = (keepCurrentIfLarger = false) => { + const clientHeight = document.documentElement.clientHeight; + this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT); + if (this.pageSize === 0) { + this.pageSize = DEFAULT_LIST_NUM_NOTES; + } + if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) { + return; + } + this.notesToDisplay = this.pageSize; + }; + + getFirstNonProtectedNote = () => { + return this.notes.find((note) => !note.protected); + }; + + get notesListScrollContainer() { + return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER); + } + + selectNote = async ( + note: SNNote, + userTriggered?: boolean, + scrollIntoView = true + ): Promise => { + await this.appState.notes.selectNote(note.uuid, userTriggered); + if (scrollIntoView) { + const noteElement = document.getElementById(`note-${note.uuid}`); + noteElement?.scrollIntoView({ + behavior: 'smooth', + }); + } + }; + + selectFirstNote = () => { + const note = this.getFirstNonProtectedNote(); + if (note) { + this.selectNote(note, false, false); + this.resetScrollPosition(); + } + }; + + selectNextNote = () => { + const displayableNotes = this.notes; + const currentIndex = displayableNotes.findIndex((candidate) => { + return candidate.uuid === this.activeEditorNote?.uuid; + }); + if (currentIndex + 1 < displayableNotes.length) { + const nextNote = displayableNotes[currentIndex + 1]; + this.selectNote(nextNote); + const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`); + nextNoteElement?.focus(); + } + }; + + selectNextOrCreateNew = () => { + const note = this.getFirstNonProtectedNote(); + if (note) { + this.selectNote(note, false, false); + } else { + this.appState.closeActiveEditor(); + } + }; + + selectPreviousNote = () => { + const displayableNotes = this.notes; + if (this.activeEditorNote) { + const currentIndex = displayableNotes.indexOf(this.activeEditorNote); + if (currentIndex - 1 >= 0) { + const previousNote = displayableNotes[currentIndex - 1]; + this.selectNote(previousNote); + const previousNoteElement = document.getElementById( + `note-${previousNote.uuid}` + ); + previousNoteElement?.focus(); + return true; + } else { + return false; + } + } + }; + + setNoteFilterText = (text: string) => { + this.noteFilterText = text; + }; + + syncSelectedNotes = () => { + this.selectedNotes = this.appState.notes.selectedNotes; + }; + + handleEditorChange = async () => { + const activeNote = this.appState.getActiveEditor()?.note; + if (activeNote && activeNote.conflictOf) { + this.application.changeAndSaveItem(activeNote.uuid, (mutator) => { + mutator.conflictOf = undefined; + }); + } + if (this.isFiltering) { + this.application.getDesktopService().searchText(this.noteFilterText); + } + }; + + resetScrollPosition = () => { + if (this.notesListScrollContainer) { + this.notesListScrollContainer.scrollTop = 0; + this.notesListScrollContainer.scrollLeft = 0; + } + }; + + handleTagChange = () => { + this.resetScrollPosition(); + this.toggleDisplayOptionsMenu(false); + this.setNoteFilterText(''); + this.application.getDesktopService().searchText(); + this.resetPagination(); + + /* Capture db load state before beginning reloadNotes, + since this status may change during reload */ + const dbLoaded = this.application.isDatabaseLoaded(); + this.reloadNotesDisplayOptions(); + this.reloadNotes(); + + if (this.notes.length > 0) { + this.selectFirstNote(); + } else if (dbLoaded) { + if ( + this.activeEditorNote && + !this.notes.includes(this.activeEditorNote) + ) { + this.appState.closeActiveEditor(); + } + } + }; + + onFilterEnter = () => { + /** + * For Desktop, performing a search right away causes + * input to lose focus. We wait until user explicity hits + * enter before highlighting desktop search results. + */ + this.searchSubmitted = true; + this.application.getDesktopService().searchText(this.noteFilterText); + }; + + handleFilterTextChanged = () => { + if (this.searchSubmitted) { + this.searchSubmitted = false; + } + this.reloadNotesDisplayOptions(); + this.reloadNotes(); + }; + + onSearchInputBlur = () => { + this.appState.searchOptions.refreshIncludeProtectedContents(); + }; + + clearFilterText = () => { + this.setNoteFilterText(''); + this.onFilterEnter(); + this.handleFilterTextChanged(); + this.resetPagination(); + }; +} diff --git a/app/assets/javascripts/ui_models/panel_resizer.ts b/app/assets/javascripts/ui_models/panel_resizer.ts new file mode 100644 index 000000000..18276952e --- /dev/null +++ b/app/assets/javascripts/ui_models/panel_resizer.ts @@ -0,0 +1,291 @@ +import { + PanelSide, + ResizeFinishCallback, +} from '@/directives/views/panelResizer'; +import { debounce } from '@/utils'; +import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'; +import { action, computed, makeObservable, observable } from 'mobx'; +import { WebApplication } from './application'; + +export type PanelResizerProps = { + alwaysVisible?: boolean; + application: WebApplication; + collapsable: boolean; + defaultWidth?: number; + hoverable?: boolean; + minWidth?: number; + panel: HTMLDivElement; + prefKey: PrefKey; + resizeFinishCallback?: ResizeFinishCallback; + side: PanelSide; + widthEventCallback?: () => void; +}; + +export class PanelResizerState { + private application: WebApplication; + alwaysVisible: boolean; + collapsable: boolean; + collapsed = false; + currentMinWidth = 0; + defaultWidth: number; + hoverable: boolean; + lastDownX = 0; + lastLeft = 0; + lastWidth = 0; + panel: HTMLDivElement; + pressed = false; + prefKey: PrefKey; + resizeFinishCallback?: ResizeFinishCallback; + side: PanelSide; + startLeft = 0; + startWidth = 0; + widthBeforeLastDblClick = 0; + widthEventCallback?: () => void; + + constructor({ + alwaysVisible, + application, + defaultWidth, + hoverable, + collapsable, + minWidth, + panel, + prefKey, + resizeFinishCallback, + side, + widthEventCallback, + }: PanelResizerProps) { + this.alwaysVisible = alwaysVisible ?? false; + this.application = application; + this.collapsable = collapsable ?? false; + this.collapsed = false; + this.currentMinWidth = minWidth ?? 0; + this.defaultWidth = defaultWidth ?? 0; + this.hoverable = hoverable ?? true; + this.lastDownX = 0; + this.lastLeft = this.startLeft; + this.lastWidth = this.startWidth; + this.panel = panel; + this.prefKey = prefKey; + this.pressed = false; + this.side = side; + this.startLeft = this.panel.offsetLeft; + this.startWidth = this.panel.scrollWidth; + this.widthBeforeLastDblClick = 0; + this.widthEventCallback = widthEventCallback; + this.resizeFinishCallback = resizeFinishCallback; + + application.addEventObserver(async () => { + const changedWidth = application.getPreference(prefKey) as number; + if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true); + }, ApplicationEvent.PreferencesChanged); + + makeObservable(this, { + pressed: observable, + collapsed: observable, + + onMouseUp: action, + onMouseDown: action, + onDblClick: action, + handleWidthEvent: action, + handleLeftEvent: action, + setWidth: action, + setMinWidth: action, + reloadDefaultValues: action, + + appFrame: computed, + }); + + document.addEventListener('mouseup', this.onMouseUp.bind(this)); + document.addEventListener('mousemove', this.onMouseMove.bind(this)); + if (this.side === PanelSide.Right) { + window.addEventListener( + 'resize', + debounce(this.handleResize.bind(this), 250) + ); + } + } + + get appFrame() { + return document.getElementById('app')?.getBoundingClientRect() as DOMRect; + } + + getParentRect() { + return (this.panel.parentNode as HTMLElement).getBoundingClientRect(); + } + + isAtMaxWidth = () => { + return ( + Math.round(this.lastWidth + this.lastLeft) === + Math.round(this.getParentRect().width) + ); + }; + + isCollapsed() { + return this.lastWidth <= this.currentMinWidth; + } + + reloadDefaultValues = () => { + this.startWidth = this.isAtMaxWidth() + ? this.getParentRect().width + : this.panel.scrollWidth; + this.lastWidth = this.startWidth; + }; + + finishSettingWidth = () => { + if (!this.collapsable) { + return; + } + + this.collapsed = this.isCollapsed(); + }; + + setWidth = (width: number, finish = false) => { + if (width < this.currentMinWidth) { + width = this.currentMinWidth; + } + const parentRect = this.getParentRect(); + if (width > parentRect.width) { + width = parentRect.width; + } + + const maxWidth = this.appFrame.width - this.panel.getBoundingClientRect().x; + if (width > maxWidth) { + width = maxWidth; + } + + if (Math.round(width + this.lastLeft) === Math.round(parentRect.width)) { + this.panel.style.width = `calc(100% - ${this.lastLeft}px)`; + } else { + this.panel.style.width = width + 'px'; + } + + this.lastWidth = width; + + if (finish) { + this.finishSettingWidth(); + if (this.resizeFinishCallback) { + this.resizeFinishCallback( + this.lastWidth, + this.lastLeft, + this.isAtMaxWidth(), + this.isCollapsed() + ); + } + } + + this.application.setPreference(this.prefKey, this.lastWidth); + }; + + setLeft = (left: number) => { + this.panel.style.left = left + 'px'; + this.lastLeft = left; + }; + + onDblClick = () => { + const collapsed = this.isCollapsed(); + if (collapsed) { + this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth); + } else { + this.widthBeforeLastDblClick = this.lastWidth; + this.setWidth(this.currentMinWidth); + } + this.application.setPreference(this.prefKey, this.lastWidth); + this.finishSettingWidth(); + if (this.resizeFinishCallback) { + this.resizeFinishCallback( + this.lastWidth, + this.lastLeft, + this.isAtMaxWidth(), + this.isCollapsed() + ); + } + }; + + handleWidthEvent(event?: MouseEvent) { + if (this.widthEventCallback) { + this.widthEventCallback(); + } + let x; + if (event) { + x = event.clientX; + } else { + /** Coming from resize event */ + x = 0; + this.lastDownX = 0; + } + const deltaX = x - this.lastDownX; + const newWidth = this.startWidth + deltaX; + this.setWidth(newWidth, false); + } + + handleLeftEvent(event: MouseEvent) { + const panelRect = this.panel.getBoundingClientRect(); + const x = event.clientX || panelRect.x; + let deltaX = x - this.lastDownX; + let newLeft = this.startLeft + deltaX; + if (newLeft < 0) { + newLeft = 0; + deltaX = -this.startLeft; + } + const parentRect = this.getParentRect(); + let newWidth = this.startWidth - deltaX; + if (newWidth < this.currentMinWidth) { + newWidth = this.currentMinWidth; + } + if (newWidth > parentRect.width) { + newWidth = parentRect.width; + } + if (newLeft + newWidth > parentRect.width) { + newLeft = parentRect.width - newWidth; + } + this.setLeft(newLeft); + this.setWidth(newWidth, false); + } + + handleResize = () => { + this.reloadDefaultValues(); + this.handleWidthEvent(); + this.finishSettingWidth(); + }; + + onMouseDown = (event: MouseEvent) => { + this.pressed = true; + this.lastDownX = event.clientX; + this.startWidth = this.panel.scrollWidth; + this.startLeft = this.panel.offsetLeft; + }; + + onMouseUp = () => { + if (!this.pressed) { + return; + } + this.pressed = false; + const isMaxWidth = this.isAtMaxWidth(); + if (this.resizeFinishCallback) { + this.resizeFinishCallback( + this.lastWidth, + this.lastLeft, + isMaxWidth, + this.isCollapsed() + ); + } + this.finishSettingWidth(); + }; + + onMouseMove(event: MouseEvent) { + if (!this.pressed) { + return; + } + event.preventDefault(); + if (this.side === PanelSide.Left) { + this.handleLeftEvent(event); + } else { + this.handleWidthEvent(event); + } + } + + setMinWidth = (minWidth?: number) => { + this.currentMinWidth = minWidth ?? this.currentMinWidth; + }; +} diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index 785ce2df5..66d15f53b 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -6,7 +6,10 @@ ng-if='!self.state.needsUnlock && self.state.ready' ) tags-view(application='self.application') - notes-view(application='self.application') + notes-view( + application='self.application' + app-state='self.appState' + ) editor-group-view.flex-grow(application='self.application') footer-view( diff --git a/app/assets/javascripts/views/index.ts b/app/assets/javascripts/views/index.ts index fc5b9525f..a68bf862d 100644 --- a/app/assets/javascripts/views/index.ts +++ b/app/assets/javascripts/views/index.ts @@ -4,6 +4,5 @@ export { ApplicationView } from './application/application_view'; export { EditorGroupView } from './editor_group/editor_group_view'; export { EditorView } from './editor/editor_view'; export { FooterView } from './footer/footer_view'; -export { NotesView } from './notes/notes_view'; export { TagsView } from './tags/tags_view'; export { ChallengeModal } from './challenge_modal/challenge_modal'; diff --git a/app/assets/javascripts/views/notes/notes-view.pug b/app/assets/javascripts/views/notes/notes-view.pug deleted file mode 100644 index d373eb36a..000000000 --- a/app/assets/javascripts/views/notes/notes-view.pug +++ /dev/null @@ -1,115 +0,0 @@ -#notes-column.sn-component.section.notes(aria-label='Notes') - .content - #notes-title-bar.section-title-bar - .p-4 - .section-title-bar-header - .sk-h2.font-semibold.title {{self.state.panelTitle}} - .sk-button.contrast.wide( - ng-click='self.createNewNote()', - title='Create a new note in the selected tag' - aria-label="Create new note" - ) - .sk-label - i.icon.ion-plus.add-button - .filter-section(role='search') - input#search-bar.filter-bar( - type="text" - ng-ref='self.searchBarInput' - ng-focus='self.onSearchInputFocus()' - ng-blur='self.onSearchInputBlur()', - ng-change='self.filterTextChanged()', - ng-keyup='$event.keyCode == 13 && self.onFilterEnter();', - ng-model='self.state.noteFilter.text', - placeholder='Search', - select-on-focus='true', - title='Searches notes in the currently selected tag' - ) - #search-clear-button( - ng-click='self.clearFilterText();', - ng-show='self.state.noteFilter.text' - aria-role="button" - ) ✕ - search-options( - class="ml-2" - app-state='self.appState' - ) - no-account-warning( - application='self.application' - app-state='self.appState' - ) - #notes-menu-bar.sn-component - .sk-app-bar.no-edges - .left - .sk-app-bar-item( - ng-class="{'selected' : self.state.mutable.showMenu}", - ng-click='self.state.mutable.showMenu = !self.state.mutable.showMenu' - ) - .sk-app-bar-item-column - .sk-label - | Options - .sk-app-bar-item-column - .sk-sublabel {{self.optionsSubtitle()}} - notes-list-options-menu( - ng-if='self.state.mutable.showMenu' - app-state='self.appState' - application='self.application' - set-show-menu-false='self.setShowMenuFalse' - ) - p.empty-notes-list.faded( - ng-if="self.state.completedFullSync && !self.state.renderedNotes.length" - ) No notes. - p.empty-notes-list.faded( - ng-if="!self.state.completedFullSync && !self.state.renderedNotes.length" - ) Loading notes… - .scrollable(ng-if="self.state.renderedNotes.length") - #notes-scrollable.infinite-scroll( - can-load='true', - infinite-scroll='self.paginate()', - threshold='200' - ) - .note( - ng-attr-id='note-{{note.uuid}}' - ng-repeat='note in self.state.renderedNotes track by note.uuid' - ng-class="{'selected' : self.isNoteSelected(note.uuid) }" - ng-click='self.selectNote(note, true)' - ) - .note-flags.flex.flex-wrap(ng-show='self.noteFlags[note.uuid].length > 0') - .flag(ng-class='flag.class', ng-repeat='flag in self.noteFlags[note.uuid]') - .label {{flag.text}} - .name(ng-show='note.title') - | {{note.title}} - .note-preview( - ng-if=` - !self.state.hideNotePreview && - !note.hidePreview && - !note.protected` - ) - .html-preview( - ng-bind-html='note.preview_html', - ng-show='note.preview_html' - ) - .plain-preview( - ng-show='!note.preview_html && note.preview_plain' - ) {{note.preview_plain}} - .default-preview( - ng-show='!note.preview_html && !note.preview_plain' - ) {{note.text}} - .bottom-info.faded(ng-show='!self.state.hideDate || note.protected') - span(ng-if="note.protected") - | Protected{{self.state.hideDate ? '' : ' • '}} - span(ng-show="!self.state.hideDate && self.state.sortBy == 'userModifiedDate'") - | Modified {{note.updatedAtString || 'Now'}} - span(ng-show="!self.state.hideDate && self.state.sortBy != 'userModifiedDate'") - | {{note.createdAtString || 'Now'}} - .tags-string(ng-if='!self.state.hideTags && self.state.renderedNotesTags[$index]') - .faded {{self.state.renderedNotesTags[$index]}} - - panel-resizer( - collapsable="true" - control="self.panelPuppet" - default-width="300" - hoverable="true" - on-resize-finish="self.onPanelResize" - on-width-event="self.onPanelWidthEvent" - panel-id="'notes-column'" - ) diff --git a/app/assets/javascripts/views/notes/notes_view.ts b/app/assets/javascripts/views/notes/notes_view.ts deleted file mode 100644 index d8b69852b..000000000 --- a/app/assets/javascripts/views/notes/notes_view.ts +++ /dev/null @@ -1,955 +0,0 @@ -import { PanelPuppet, WebDirective } from './../../types'; -import template from './notes-view.pug'; -import { - ApplicationEvent, - ContentType, - removeFromArray, - SNNote, - SNTag, - PrefKey, - findInArray, - CollectionSort, - UuidString, - NotesDisplayCriteria -} from '@standardnotes/snjs'; -import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; -import { AppStateEvent } from '@/ui_models/app_state'; -import { KeyboardKey, KeyboardModifier } from '@/services/ioService'; -import { - PANEL_NAME_NOTES -} from '@/views/constants'; - -type NotesCtrlState = { - panelTitle: string - notes: SNNote[] - renderedNotes: SNNote[] - renderedNotesTags: string[], - selectedNotes: Record, - sortBy?: string - sortReverse?: boolean - showArchived?: boolean - hidePinned?: boolean - hideNotePreview?: boolean - hideDate?: boolean - hideTags: boolean - noteFilter: { - text: string; - } - searchOptions: { - includeProtectedContents: boolean; - includeArchived: boolean; - includeTrashed: boolean; - } - mutable: { showMenu: boolean } - completedFullSync: boolean - [PrefKey.TagsPanelWidth]?: number - [PrefKey.NotesPanelWidth]?: number - [PrefKey.EditorWidth]?: number - [PrefKey.EditorLeft]?: number - [PrefKey.EditorMonospaceEnabled]?: boolean - [PrefKey.EditorSpellcheck]?: boolean - [PrefKey.EditorResizersEnabled]?: boolean - [PrefKey.NotesShowTrashed]?: boolean - [PrefKey.NotesHideProtected]?: boolean -} - -type NoteFlag = { - text: string - class: 'info' | 'neutral' | 'warning' | 'success' | 'danger' -} - -/** - * This is the height of a note cell with nothing but the title, - * which *is* a display option - */ -const MIN_NOTE_CELL_HEIGHT = 51.0; -const DEFAULT_LIST_NUM_NOTES = 20; -const ELEMENT_ID_SEARCH_BAR = 'search-bar'; -const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable'; - -class NotesViewCtrl extends PureViewCtrl { - - private panelPuppet?: PanelPuppet - private reloadNotesPromise?: any - private notesToDisplay = 0 - private pageSize = 0 - private searchSubmitted = false - private newNoteKeyObserver: any - private nextNoteKeyObserver: any - private previousNoteKeyObserver: any - private searchKeyObserver: any - private noteFlags: Partial> = {} - private removeObservers: Array<() => void> = []; - private rightClickListeners: Map void> = new Map(); - - /* @ngInject */ - constructor($timeout: ng.ITimeoutService,) { - super($timeout); - this.resetPagination(); - } - - $onInit() { - super.$onInit(); - this.panelPuppet = { - onReady: () => this.reloadPanelWidth() - }; - this.onWindowResize = this.onWindowResize.bind(this); - this.onPanelResize = this.onPanelResize.bind(this); - this.onPanelWidthEvent = this.onPanelWidthEvent.bind(this); - this.setShowMenuFalse = this.setShowMenuFalse.bind(this); - window.addEventListener('resize', this.onWindowResize, true); - this.registerKeyboardShortcuts(); - this.autorun(async () => { - const { - includeProtectedContents, - includeArchived, - includeTrashed, - } = this.appState.searchOptions; - await this.setState({ - searchOptions: { - includeProtectedContents, - includeArchived, - includeTrashed, - } - }); - if (this.state.noteFilter.text) { - this.reloadNotesDisplayOptions(); - this.reloadNotes(); - } - }); - this.autorun(() => { - this.setState({ - selectedNotes: this.appState.notes.selectedNotes, - }); - }); - } - - onWindowResize() { - this.resetPagination(true); - } - - deinit() { - for (const remove of this.removeObservers) remove(); - this.removeObservers.length = 0; - this.removeRightClickListeners(); - this.panelPuppet!.onReady = undefined; - this.panelPuppet = undefined; - window.removeEventListener('resize', this.onWindowResize, true); - (this.onWindowResize as any) = undefined; - (this.onPanelResize as any) = undefined; - (this.onPanelWidthEvent as any) = undefined; - this.newNoteKeyObserver(); - this.nextNoteKeyObserver(); - this.previousNoteKeyObserver(); - this.searchKeyObserver(); - this.newNoteKeyObserver = undefined; - this.nextNoteKeyObserver = undefined; - this.previousNoteKeyObserver = undefined; - this.searchKeyObserver = undefined; - super.deinit(); - } - - async setNotesState(state: Partial) { - return this.setState(state); - } - - getInitialState(): NotesCtrlState { - return { - notes: [], - renderedNotes: [], - renderedNotesTags: [], - selectedNotes: {}, - mutable: { showMenu: false }, - noteFilter: { - text: '', - }, - searchOptions: { - includeArchived: false, - includeProtectedContents: false, - includeTrashed: false, - }, - panelTitle: '', - completedFullSync: false, - hideTags: true - }; - } - - async onAppLaunch() { - super.onAppLaunch(); - this.streamNotesAndTags(); - this.reloadPreferences(); - } - - /** @override */ - onAppStateEvent(eventName: AppStateEvent, data?: any) { - if (eventName === AppStateEvent.TagChanged) { - this.handleTagChange(this.selectedTag!); - } else if (eventName === AppStateEvent.ActiveEditorChanged) { - this.handleEditorChange(); - } else if (eventName === AppStateEvent.EditorFocused) { - this.setShowMenuFalse(); - } - } - - private get activeEditorNote() { - return this.appState.notes.activeEditor?.note; - } - - /** @template */ - public isNoteSelected(uuid: UuidString) { - return !!this.state.selectedNotes[uuid]; - } - - public get editorNotes() { - return this.appState.getEditors().map((editor) => editor.note); - } - - /** @override */ - async onAppEvent(eventName: ApplicationEvent) { - switch (eventName) { - case ApplicationEvent.PreferencesChanged: - this.reloadPreferences(); - break; - case ApplicationEvent.SignedIn: - this.appState.closeAllEditors(); - this.selectFirstNote(); - this.setState({ - completedFullSync: false, - }); - break; - case ApplicationEvent.CompletedFullSync: - this.getMostValidNotes().then((notes) => { - if (notes.length === 0 && this.selectedTag?.isAllTag && this.state.noteFilter.text === '') { - this.createPlaceholderNote(); - } - }); - this.setState({ - completedFullSync: true, - }); - break; - } - } - - /** - * Access the current state notes without awaiting any potential reloads - * that may be in progress. This is the sync alternative to `async getMostValidNotes` - */ - private getPossiblyStaleNotes() { - return this.state.notes; - } - - /** - * Access the current state notes after waiting for any pending reloads. - * This returns the most up to date notes, but is the asyncronous counterpart - * to `getPossiblyStaleNotes` - */ - private async getMostValidNotes() { - await this.reloadNotesPromise; - return this.getPossiblyStaleNotes(); - } - - /** - * Triggered programatically to create a new placeholder note - * when conditions allow for it. This is as opposed to creating a new note - * as part of user interaction (pressing the + button). - */ - private async createPlaceholderNote() { - const selectedTag = this.selectedTag!; - if (selectedTag.isSmartTag && !selectedTag.isAllTag) { - return; - } - return this.createNewNote(false); - } - - streamNotesAndTags() { - this.removeObservers.push(this.application.streamItems( - [ContentType.Note], - async (items) => { - const notes = items as SNNote[]; - /** Note has changed values, reset its flags */ - for (const note of notes) { - if (note.deleted) { - continue; - } - this.loadFlagsForNote(note); - } - /** If a note changes, it will be queried against the existing filter; - * we dont need to reload display options */ - await this.reloadNotes(); - const activeNote = this.activeEditorNote; - if (this.application.getAppState().notes.selectedNotesCount < 2) { - if (activeNote) { - const discarded = activeNote.deleted || activeNote.trashed; - if ( - discarded && - !this.appState?.selectedTag?.isTrashTag && - !this.appState?.searchOptions.includeTrashed - ) { - this.selectNextOrCreateNew(); - } else if (!this.state.selectedNotes[activeNote.uuid]) { - this.selectNote(activeNote); - } - } else { - this.selectFirstNote(); - } - } - } - )); - - this.removeObservers.push(this.application.streamItems( - [ContentType.Tag], - async (items) => { - const tags = items as SNTag[]; - /** A tag could have changed its relationships, so we need to reload the filter */ - this.reloadNotesDisplayOptions(); - await this.reloadNotes(); - if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) { - /** Tag title could have changed */ - this.reloadPanelTitle(); - } - } - )); - } - - private async openNotesContextMenu(e: MouseEvent, note: SNNote) { - e.preventDefault(); - if (!this.state.selectedNotes[note.uuid]) { - await this.selectNote(note, true); - } - if (this.state.selectedNotes[note.uuid]) { - this.appState.notes.setContextMenuClickLocation({ - x: e.clientX, - y: e.clientY, - }); - this.appState.notes.reloadContextMenuLayout(); - this.appState.notes.setContextMenuOpen(true); - } - } - - private removeRightClickListeners() { - for (const [noteUuid, listener] of this.rightClickListeners.entries()) { - document - .getElementById(`note-${noteUuid}`) - ?.removeEventListener('contextmenu', listener); - } - this.rightClickListeners.clear(); - } - - private addRightClickListeners() { - for (const [noteUuid, listener] of this.rightClickListeners.entries()) { - if (!this.state.renderedNotes.find(note => note.uuid === noteUuid)) { - document - .getElementById(`note-${noteUuid}`) - ?.removeEventListener('contextmenu', listener); - this.rightClickListeners.delete(noteUuid); - } - } - for (const note of this.state.renderedNotes) { - if (!this.rightClickListeners.has(note.uuid)) { - const listener = async (e: MouseEvent): Promise => { - return await this.openNotesContextMenu(e, note); - }; - document - .getElementById(`note-${note.uuid}`) - ?.addEventListener('contextmenu', listener); - this.rightClickListeners.set(note.uuid, listener); - } - } - } - - async selectNote(note: SNNote, userTriggered?: boolean): Promise { - await this.appState.notes.selectNote(note.uuid, userTriggered); - } - - async createNewNote(focusNewNote = true) { - this.appState.notes.unselectNotes(); - let title = `Note ${this.state.notes.length + 1}`; - if (this.isFiltering()) { - title = this.state.noteFilter.text; - } - await this.appState.createEditor(title); - await this.flushUI(); - await this.reloadNotes(); - await this.appState.noteTags.reloadTags(); - const noteTitleEditorElement = document.getElementById('note-title-editor'); - if (focusNewNote) { - noteTitleEditorElement?.focus(); - } - } - - async handleTagChange(tag: SNTag) { - this.resetScrollPosition(); - this.setShowMenuFalse(); - await this.setNoteFilterText(''); - this.application.getDesktopService().searchText(); - this.resetPagination(); - - /* Capture db load state before beginning reloadNotes, - since this status may change during reload */ - const dbLoaded = this.application.isDatabaseLoaded(); - this.reloadNotesDisplayOptions(); - await this.reloadNotes(); - - if (this.state.notes.length > 0) { - this.selectFirstNote(); - } else if (dbLoaded) { - if ( - this.activeEditorNote && - !this.state.notes.includes(this.activeEditorNote!) - ) { - this.appState.closeActiveEditor(); - } - } - } - - resetScrollPosition() { - const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER); - if (scrollable) { - scrollable.scrollTop = 0; - scrollable.scrollLeft = 0; - } - } - - async removeNoteFromList(note: SNNote) { - const notes = this.state.notes; - removeFromArray(notes, note); - await this.setNotesState({ - notes: notes, - renderedNotes: notes.slice(0, this.notesToDisplay) - }); - } - - private async reloadNotes() { - this.reloadNotesPromise = this.performReloadNotes(); - return this.reloadNotesPromise; - } - - /** - * Note that reloading display options destroys the current index and rebuilds it, - * so call sparingly. The runtime complexity of destroying and building - * an index is roughly O(n^2). - */ - private reloadNotesDisplayOptions() { - const tag = this.appState.selectedTag; - - const searchText = this.state.noteFilter.text.toLowerCase(); - const isSearching = searchText.length; - let includeArchived: boolean; - let includeTrashed: boolean; - - if (isSearching) { - includeArchived = this.state.searchOptions.includeArchived; - includeTrashed = this.state.searchOptions.includeTrashed; - } else { - includeArchived = this.state.showArchived ?? false; - includeTrashed = this.state.showTrashed ?? false; - } - - const criteria = NotesDisplayCriteria.Create({ - sortProperty: this.state.sortBy as CollectionSort, - sortDirection: this.state.sortReverse ? 'asc' : 'dsc', - tags: tag ? [tag] : [], - includeArchived, - includeTrashed, - includePinned: !this.state.hidePinned, - includeProtected: !this.state.hideProtected, - searchQuery: { - query: searchText, - includeProtectedNoteText: this.state.searchOptions.includeProtectedContents - } - }); - this.application.setNotesDisplayCriteria(criteria); - } - - private get selectedTag() { - return this.application.getAppState().getSelectedTag(); - } - - private async performReloadNotes() { - const tag = this.appState.selectedTag!; - if (!tag) { - return; - } - const notes = this.application.getDisplayableItems( - ContentType.Note - ) as SNNote[]; - const renderedNotes = notes.slice(0, this.notesToDisplay); - const renderedNotesTags = this.notesTagsList(renderedNotes); - - await this.setNotesState({ - notes, - renderedNotesTags, - renderedNotes, - }); - this.reloadPanelTitle(); - this.addRightClickListeners(); - } - - private notesTagsList(notes: SNNote[]): string[] { - if (this.state.hideTags) { - return []; - } else { - const selectedTag = this.appState.selectedTag; - if (!selectedTag) { - return []; - } else if (selectedTag?.isSmartTag) { - return notes.map((note) => - this.appState - .getNoteTags(note) - .map((tag) => '#' + tag.title) - .join(' ') - ); - } else { - /** - * Displaying a normal tag, hide the note's tag when there's only one - */ - return notes.map((note) => { - const tags = this.appState.getNoteTags(note); - if (tags.length === 1) return ''; - return tags.map((tag) => '#' + tag.title).join(' '); - }); - } - } - } - - setShowMenuFalse() { - this.setNotesState({ - mutable: { - ...this.state.mutable, - showMenu: false - } - }); - } - - async handleEditorChange() { - const activeNote = this.appState.getActiveEditor()?.note; - if (activeNote && activeNote.conflictOf) { - this.application.changeAndSaveItem(activeNote.uuid, (mutator) => { - mutator.conflictOf = undefined; - }); - } - if (this.isFiltering()) { - this.application.getDesktopService().searchText(this.state.noteFilter.text); - } - } - - async reloadPreferences() { - const viewOptions = {} as NotesCtrlState; - const prevSortValue = this.state.sortBy; - let sortBy = this.application.getPreference( - PrefKey.SortNotesBy, - CollectionSort.CreatedAt - ); - if ( - sortBy === CollectionSort.UpdatedAt || - (sortBy as string) === "client_updated_at" - ) { - /** Use UserUpdatedAt instead */ - sortBy = CollectionSort.UpdatedAt; - } - viewOptions.sortBy = sortBy; - viewOptions.sortReverse = this.application.getPreference( - PrefKey.SortNotesReverse, - false - ); - viewOptions.showArchived = this.application.getPreference( - PrefKey.NotesShowArchived, - false - ); - viewOptions.showTrashed = this.application.getPreference( - PrefKey.NotesShowTrashed, - false - ) as boolean; - viewOptions.hidePinned = this.application.getPreference( - PrefKey.NotesHidePinned, - false - ); - viewOptions.hideProtected = this.application.getPreference( - PrefKey.NotesHideProtected, - false - ); - viewOptions.hideNotePreview = this.application.getPreference( - PrefKey.NotesHideNotePreview, - false - ); - viewOptions.hideDate = this.application.getPreference( - PrefKey.NotesHideDate, - false - ); - viewOptions.hideTags = this.application.getPreference( - PrefKey.NotesHideTags, - true, - ); - const state = this.state; - const displayOptionsChanged = ( - viewOptions.sortBy !== state.sortBy || - viewOptions.sortReverse !== state.sortReverse || - viewOptions.hidePinned !== state.hidePinned || - viewOptions.showArchived !== state.showArchived || - viewOptions.showTrashed !== state.showTrashed || - viewOptions.hideProtected !== state.hideProtected || - viewOptions.hideTags !== state.hideTags - ); - await this.setNotesState({ - ...viewOptions - }); - this.reloadPanelWidth(); - if (displayOptionsChanged) { - this.reloadNotesDisplayOptions(); - } - await this.reloadNotes(); - if (prevSortValue && prevSortValue !== sortBy) { - this.selectFirstNote(); - } - } - - reloadPanelWidth() { - const width = this.application.getPreference( - PrefKey.NotesPanelWidth - ); - if (width && this.panelPuppet!.ready) { - this.panelPuppet!.setWidth!(width); - if (this.panelPuppet!.isCollapsed!()) { - this.application.getAppState().panelDidResize( - PANEL_NAME_NOTES, - this.panelPuppet!.isCollapsed!() - ); - } - } - } - - onPanelResize( - newWidth: number, - newLeft: number, - __: boolean, - isCollapsed: boolean - ) { - this.appState.noteTags.reloadTagsContainerMaxWidth(); - this.application.setPreference( - PrefKey.NotesPanelWidth, - newWidth - ); - this.application.getAppState().panelDidResize( - PANEL_NAME_NOTES, - isCollapsed - ); - } - - onPanelWidthEvent(): void { - this.appState.noteTags.reloadTagsContainerMaxWidth(); - } - - paginate() { - this.notesToDisplay += this.pageSize; - this.reloadNotes(); - if (this.searchSubmitted) { - this.application.getDesktopService().searchText(this.state.noteFilter.text); - } - } - - resetPagination(keepCurrentIfLarger = false) { - const clientHeight = document.documentElement.clientHeight; - this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT); - if (this.pageSize === 0) { - this.pageSize = DEFAULT_LIST_NUM_NOTES; - } - if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) { - return; - } - this.notesToDisplay = this.pageSize; - } - - reloadPanelTitle() { - let title; - if (this.isFiltering()) { - const resultCount = this.state.notes.length; - title = `${resultCount} search results`; - } else if (this.appState.selectedTag) { - title = `${this.appState.selectedTag.title}`; - } - this.setNotesState({ - panelTitle: title - }); - } - - optionsSubtitle() { - let base = ""; - if (this.state.sortBy === CollectionSort.CreatedAt) { - base += " Date Added"; - } else if (this.state.sortBy === CollectionSort.UpdatedAt) { - base += " Date Modified"; - } else if (this.state.sortBy === CollectionSort.Title) { - base += " Title"; - } - if (this.state.showArchived) { - base += " | + Archived"; - } - if (this.state.showTrashed) { - base += " | + Trashed"; - } - if (this.state.hidePinned) { - base += " | – Pinned"; - } - if (this.state.hideProtected) { - base += " | – Protected"; - } - if (this.state.sortReverse) { - base += " | Reversed"; - } - return base; - } - - loadFlagsForNote(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' - }); - } - this.noteFlags[note.uuid] = flags; - } - - getFirstNonProtectedNote() { - return this.state.notes.find(note => !note.protected); - } - - selectFirstNote() { - const note = this.getFirstNonProtectedNote(); - if (note) { - this.selectNote(note); - } - } - - selectNextNote() { - const displayableNotes = this.state.notes; - const currentIndex = displayableNotes.findIndex((candidate) => { - return candidate.uuid === this.activeEditorNote?.uuid; - }); - if (currentIndex + 1 < displayableNotes.length) { - const nextNote = displayableNotes[currentIndex + 1]; - this.selectNote(nextNote); - const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`); - nextNoteElement?.focus(); - } - } - - selectNextOrCreateNew() { - const note = this.getFirstNonProtectedNote(); - if (note) { - this.selectNote(note); - } else { - this.appState.closeActiveEditor(); - } - } - - selectPreviousNote() { - const displayableNotes = this.state.notes; - const currentIndex = displayableNotes.indexOf(this.activeEditorNote!); - if (currentIndex - 1 >= 0) { - const previousNote = displayableNotes[currentIndex - 1]; - this.selectNote(previousNote); - const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`); - previousNoteElement?.focus(); - return true; - } else { - return false; - } - } - - isFiltering() { - return this.state.noteFilter.text && - this.state.noteFilter.text.length > 0; - } - - async setNoteFilterText(text: string) { - await this.setNotesState({ - noteFilter: { - ...this.state.noteFilter, - text: text - } - }); - } - - async clearFilterText() { - await this.setNoteFilterText(''); - this.onFilterEnter(); - this.filterTextChanged(); - this.resetPagination(); - } - - async filterTextChanged() { - if (this.searchSubmitted) { - this.searchSubmitted = false; - } - this.reloadNotesDisplayOptions(); - await this.reloadNotes(); - } - - async onSearchInputBlur() { - this.appState.searchOptions.refreshIncludeProtectedContents(); - } - - onFilterEnter() { - /** - * For Desktop, performing a search right away causes - * input to lose focus. We wait until user explicity hits - * enter before highlighting desktop search results. - */ - this.searchSubmitted = true; - this.application.getDesktopService().searchText(this.state.noteFilter.text); - } - - selectedMenuItem() { - this.setShowMenuFalse(); - } - - togglePrefKey(key: PrefKey) { - this.application.setPreference( - key, - !this.state[key] - ); - } - - selectedSortByCreated() { - this.setSortBy(CollectionSort.CreatedAt); - } - - selectedSortByUpdated() { - this.setSortBy(CollectionSort.UpdatedAt); - } - - selectedSortByTitle() { - this.setSortBy(CollectionSort.Title); - } - - toggleReverseSort() { - this.selectedMenuItem(); - this.application.setPreference( - PrefKey.SortNotesReverse, - !this.state.sortReverse - ); - } - - setSortBy(type: CollectionSort) { - this.application.setPreference( - PrefKey.SortNotesBy, - type - ); - } - - getSearchBar() { - return document.getElementById(ELEMENT_ID_SEARCH_BAR)!; - } - - registerKeyboardShortcuts() { - /** - * 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. - */ - this.newNoteKeyObserver = this.application.io.addKeyObserver({ - key: 'n', - modifiers: [ - KeyboardModifier.Meta, - KeyboardModifier.Ctrl - ], - onKeyDown: (event) => { - event.preventDefault(); - this.createNewNote(); - } - }); - - this.nextNoteKeyObserver = this.application.io.addKeyObserver({ - key: KeyboardKey.Down, - elements: [ - document.body, - this.getSearchBar() - ], - onKeyDown: () => { - const searchBar = this.getSearchBar(); - if (searchBar === document.activeElement) { - searchBar.blur(); - } - this.selectNextNote(); - } - }); - - this.previousNoteKeyObserver = this.application.io.addKeyObserver({ - key: KeyboardKey.Up, - element: document.body, - onKeyDown: () => { - this.selectPreviousNote(); - } - }); - - this.searchKeyObserver = this.application.io.addKeyObserver({ - key: "f", - modifiers: [ - KeyboardModifier.Meta, - KeyboardModifier.Shift - ], - onKeyDown: () => { - const searchBar = this.getSearchBar(); - if (searchBar) { searchBar.focus(); } - } - }); - } -} - -export class NotesView extends WebDirective { - constructor() { - super(); - this.template = template; - this.replace = true; - this.controller = NotesViewCtrl; - this.controllerAs = 'self'; - this.bindToController = true; - this.scope = { - application: '=' - }; - } -} diff --git a/app/assets/stylesheets/_focused.scss b/app/assets/stylesheets/_focused.scss index edcbf0f45..d16d4866a 100644 --- a/app/assets/stylesheets/_focused.scss +++ b/app/assets/stylesheets/_focused.scss @@ -36,7 +36,7 @@ } .section.tags, - .section.notes { + notes-view { will-change: opacity; animation: fade-out 1.25s forwards; transition: width 1.25s; @@ -50,7 +50,7 @@ width: 0px !important; } - .section.notes:hover { + notes-view:hover { flex: initial; width: 0px !important; } @@ -58,7 +58,7 @@ .disable-focus-mode { .section.tags, - .section.notes { + notes-view { transition: width 1.25s; will-change: opacity; animation: fade-in 1.25s forwards; diff --git a/app/assets/stylesheets/_main.scss b/app/assets/stylesheets/_main.scss index a6b059eb2..635816c3e 100644 --- a/app/assets/stylesheets/_main.scss +++ b/app/assets/stylesheets/_main.scss @@ -136,7 +136,7 @@ $footer-height: 2rem; overflow: hidden; position: relative; - panel-resizer { + panel-resizer, .panel-resizer { top: 0; right: 0; z-index: $z-index-panel-resizer; diff --git a/app/assets/stylesheets/_notes.scss b/app/assets/stylesheets/_notes.scss index 0ccf25e65..6ec937804 100644 --- a/app/assets/stylesheets/_notes.scss +++ b/app/assets/stylesheets/_notes.scss @@ -1,11 +1,16 @@ +notes-view { + width: 350px; +} + #notes-column, .notes { + width: 100%; + border-left: 1px solid var(--sn-stylekit-border-color); border-right: 1px solid var(--sn-stylekit-border-color); font-size: var(--sn-stylekit-font-size-h2); - width: 350px; flex-grow: 0; user-select: none; @@ -71,6 +76,8 @@ } #search-clear-button { + padding: 0; + border: none; border-radius: 50%; width: 17px; height: 17px; From 94b9ff5032586b0ee53500b87d68b236debcb0ef Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Tue, 21 Dec 2021 23:38:52 +0530 Subject: [PATCH 13/18] fix: Account switcher icon proportions (#783) --- app/assets/javascripts/views/footer/footer-view.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/views/footer/footer-view.pug b/app/assets/javascripts/views/footer/footer-view.pug index f4e95fbe3..31c860b78 100644 --- a/app/assets/javascripts/views/footer/footer-view.pug +++ b/app/assets/javascripts/views/footer/footer-view.pug @@ -77,8 +77,8 @@ ng-if='ctrl.state.hasAccountSwitcher' ng-click='ctrl.openAccountSwitcher()', ) - #account-switcher-icon(ng-class='{"alone": !ctrl.state.hasPasscode}') - svg.info.ionicon + #account-switcher-icon.flex.items-center(ng-class='{"alone": !ctrl.state.hasPasscode}') + svg.info.ionicon.w-5.h-5 use(href="#layers-sharp") .sk-app-bar-item.border(ng-if='ctrl.state.hasPasscode') #lock-item.sk-app-bar-item( From 237cd91acd78967929bc77258e0e46eb8e9877ef Mon Sep 17 00:00:00 2001 From: Laurent Senta Date: Thu, 23 Dec 2021 14:34:02 +0100 Subject: [PATCH 14/18] feat: implement tags folder as experimental feature (#788) * feat: add tag folders support basics * feat: add draggability to tags * feat: add drag & drop draft * feat: fold folders * fix: do not select on fold / unfold tags * style: clean the isValidTag call * feat: add native folder toggle * feat: add touch mobile support * ui: add nicer design & icons * style: render full-width tag items * feat: nicer looking dropzone * style: fix arguments * fix: tag template rendering in list items * feat: tag can be dragged over the whole item * fix: cancel / reset title after save * fix: disable drag completely when needed * fix: invalid tag parents * feat: add paying feature * feat: with paid feature tooltip * feat: tag has a plus feature * feat: add premium modal * style: simplif code * refactor: extract feature_state & simplif code * fix: icons and icons svg * style: remove comment * feat: tag folders naming * feat: use the feature notification * fix: tag folders copy * style: variable names * style: remove & clean comments * refactor: remove is-mobile library * feat: tags folder experimental (#10) * feat: hide native folders behind experimental flag * fix: better tags resizing * fix: merge global window * style: rename params * refactor: remove level of indirection in feature toggle * feat: recursively add tags to note on create (#9) * fix: use add tags folder hierarchy & isTemplateItem (#13) * fix: use new snjs add tag hierarchy * fix: use new snjs isTemplateItem * feat: tags folder premium (#774) * feat: upgrade premium in tags section refactor: move TagsSection to react feat: show premium on Tag section feat: keep drag and drop features always active fix: drag & drop tweak with premium feat: premium messages fix: remove fill in svg icons fix: change tag list color (temporary) style: remove dead code refactor: clarify names and modules fix: draggable behind feature toggle feat: add button in TagSection & design * feat: fix features loading with app state (#775) * fix: distinguish between app launch and start * fix: update state for footer too * fix: wait for application launch event Co-authored-by: Laurent Senta * feat: tags folder with folder text design (#776) * feat: add folder text * fix: sn stylekit colors * fix: root drop zone * chore: upgrade stylekit * fix: hide dropzone when feature is disabled * chore: bump versions now that they are released Co-authored-by: Mo * feat: tags folder design review (#785) * fix: upgrade design after review * fix: tweak dropzone * fix: sync after assign parent * fix: tsc error on build * fix: vertical center the fold arrows * fix: define our own hoist for react-dnd * feat: hide fold when there are no folders * fix: show children usability + resize UI * fix: use old colors for now, theme compat * fix: tweak alignment and add title * fix: meta offset with folders * fix: tweak tag size * fix: observable setup * fix: use link-off icon on dropzone * fix: more tweak on text sizes Co-authored-by: Mo --- app/assets/icons/ic-add.svg | 3 + app/assets/icons/ic-folder.svg | 3 + app/assets/icons/ic-link-off.svg | 3 + app/assets/icons/ic-list-bulleted.svg | 3 + app/assets/icons/ic-menu-arrow-down-alt.svg | 3 + app/assets/icons/ic-menu-arrow-right.svg | 3 + app/assets/javascripts/app.ts | 19 +- app/assets/javascripts/components/Icon.tsx | 13 +- .../javascripts/components/Premium/index.ts | 1 + .../components/Premium/usePremiumModal.tsx | 49 ++++ .../QuickSettingsMenu/FocusModeSwitch.tsx | 46 ++-- .../QuickSettingsMenu/QuickSettingsMenu.tsx | 10 +- .../components/RootTagDropZone.tsx | 64 +++++ app/assets/javascripts/components/Switch.tsx | 13 +- .../components/Tags/TagsSection.tsx | 108 +++++++++ .../javascripts/components/TagsList.tsx | 65 +++-- .../javascripts/components/TagsListItem.tsx | 228 ++++++++++++++---- .../purchaseFlow/PurchaseFlowView.tsx | 2 +- .../typings/hoist-non-react-statics.d.ts | 67 +++++ .../ui_models/app_state/app_state.ts | 18 +- .../ui_models/app_state/features_state.ts | 87 +++++++ .../ui_models/app_state/note_tags_state.ts | 11 +- .../ui_models/app_state/tags_state.ts | 77 +++++- app/assets/javascripts/ui_models/editor.ts | 50 ++-- .../javascripts/{utils.ts => utils/index.ts} | 1 + app/assets/javascripts/utils/isMobile.ts | 28 +++ .../views/application/application-view.pug | 4 +- .../views/application/application_view.ts | 79 +++--- .../javascripts/views/tags/tags-view.pug | 6 +- app/assets/stylesheets/_sn.scss | 5 +- app/assets/stylesheets/_tags.scss | 97 ++++++-- package.json | 8 +- yarn.lock | 95 +++++++- 33 files changed, 1048 insertions(+), 221 deletions(-) create mode 100644 app/assets/icons/ic-add.svg create mode 100644 app/assets/icons/ic-folder.svg create mode 100644 app/assets/icons/ic-link-off.svg create mode 100644 app/assets/icons/ic-list-bulleted.svg create mode 100644 app/assets/icons/ic-menu-arrow-down-alt.svg create mode 100644 app/assets/icons/ic-menu-arrow-right.svg create mode 100644 app/assets/javascripts/components/Premium/index.ts create mode 100644 app/assets/javascripts/components/Premium/usePremiumModal.tsx create mode 100644 app/assets/javascripts/components/RootTagDropZone.tsx create mode 100644 app/assets/javascripts/components/Tags/TagsSection.tsx create mode 100644 app/assets/javascripts/typings/hoist-non-react-statics.d.ts create mode 100644 app/assets/javascripts/ui_models/app_state/features_state.ts rename app/assets/javascripts/{utils.ts => utils/index.ts} (99%) create mode 100644 app/assets/javascripts/utils/isMobile.ts 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/app.ts b/app/assets/javascripts/app.ts index 6202a9353..0392d0c76 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; } } @@ -82,6 +89,7 @@ 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 { /** @@ -182,7 +190,8 @@ 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) @@ -216,11 +225,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/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/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/QuickSettingsMenu/FocusModeSwitch.tsx b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx index 718e2b951..4786dd00e 100644 --- a/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx +++ b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx @@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application'; import { FeatureIdentifier } from '@standardnotes/features'; import { FeatureStatus } 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 +10,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 ( <>
-
-
reloadStatus()}> - +
manageSubscription()} + > +
diff --git a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx b/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx index 6882b0220..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,38 +8,29 @@ export const OfflineRestricted: FunctionalComponent = ({
- You have restricted this component to be used offline only. + You have restricted this component to not use a hosted version.
- Offline components are not available in the web application. + 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 component by opening + 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 - components's options. Then press Reload below. + 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 3c6a98a6f..e0ec55b9f 100644 --- a/app/assets/javascripts/components/ComponentView/index.tsx +++ b/app/assets/javascripts/components/ComponentView/index.tsx @@ -3,7 +3,9 @@ import { FeatureStatus, SNComponent, dateToLocalizedString, - ApplicationEvent, + ComponentViewer, + ComponentViewerEvent, + ComponentViewerError, } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { FunctionalComponent } from 'preact'; @@ -22,9 +24,9 @@ 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; } @@ -34,10 +36,10 @@ interface IProps { */ const MaxLoadThreshold = 4000; const VisibilityChangeKey = 'visibilitychange'; -const avoidFlickerTimeout = 7; +const MSToWaitAfterIframeLoadToAvoidFlicker = 35; export const ComponentView: FunctionalComponent = observer( - ({ application, onLoad, componentUuid, templateComponent }) => { + ({ application, onLoad, componentViewer, requestReload }) => { const iframeRef = useRef(null); const excessiveLoadingTimeout = useRef< ReturnType | undefined @@ -45,44 +47,33 @@ export const ComponentView: FunctionalComponent = observer( const [hasIssueLoading, setHasIssueLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [isReloading, setIsReloading] = useState(false); - const [component] = useState( - application.findItem(componentUuid) as SNComponent - ); const [featureStatus, setFeatureStatus] = useState( - application.getFeatureStatus(component.identifier) + componentViewer.getFeatureStatus() ); const [isComponentValid, setIsComponentValid] = useState(true); - const [error, setError] = useState< - 'offline-restricted' | 'url-missing' | undefined - >(undefined); - const [isDeprecated, setIsDeprecated] = 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 [contentWindow, setContentWindow] = useState(null); + + const component = componentViewer.component; const manageSubscription = useCallback(() => { openSubscriptionDashboard(application); }, [application]); - const reloadIframe = () => { - setTimeout(() => { - setIsReloading(true); - setTimeout(() => { - setIsReloading(false); - }); - }); - }; - useEffect(() => { const loadTimeout = setTimeout(() => { handleIframeTakingTooLongToLoad(); }, MaxLoadThreshold); + excessiveLoadingTimeout.current = loadTimeout; + return () => { excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current); @@ -91,42 +82,25 @@ export const ComponentView: FunctionalComponent = observer( }, []); const reloadValidityStatus = useCallback(() => { - const offlineRestricted = - component.offlineOnly && !isDesktopApplication(); - const hasUrlError = (function () { - if (isDesktopApplication()) { - return !component.local_url && !component.hasValidHostedUrl(); - } else { - return !component.hasValidHostedUrl(); - } - })(); - - const readonlyState = - application.componentManager.getReadonlyStateForComponent(component); - - if (!readonlyState.lockReadonly) { - application.componentManager.setReadonlyStateForComponent( - component, - featureStatus !== FeatureStatus.Entitled - ); + setFeatureStatus(componentViewer.getFeatureStatus()); + if (!componentViewer.lockReadonly) { + componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled); } - setIsComponentValid(!offlineRestricted && !hasUrlError); + setIsComponentValid(componentViewer.shouldRender()); - 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); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + setError(componentViewer.getError()); + setDeprecationMessage(component.deprecationMessage); + }, [ + componentViewer, + component.deprecationMessage, + featureStatus, + isComponentValid, + isLoading, + ]); useEffect(() => { reloadValidityStatus(); @@ -141,9 +115,9 @@ export const ComponentView: FunctionalComponent = observer( return; } if (hasIssueLoading) { - reloadIframe(); + requestReload?.(componentViewer); } - }, [hasIssueLoading]); + }, [hasIssueLoading, componentViewer, requestReload]); const handleIframeTakingTooLongToLoad = useCallback(async () => { setIsLoading(false); @@ -151,188 +125,133 @@ export const ComponentView: FunctionalComponent = observer( if (!didAttemptReload) { setDidAttemptReload(true); - reloadIframe(); + requestReload?.(componentViewer); } else { document.addEventListener(VisibilityChangeKey, onVisibilityChange); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => { - let hasDesktopError = false; - const canAccessWindowOrigin = isDesktopApplication(); - if (canAccessWindowOrigin) { - try { - const contentWindow = iframe.contentWindow as Window; - if (!contentWindow.origin || contentWindow.origin === 'null') { - hasDesktopError = true; - } - // eslint-disable-next-line no-empty - } catch (e) {} - } - excessiveLoadingTimeout.current && - clearTimeout(excessiveLoadingTimeout.current); - setContentWindow(iframe.contentWindow); - setTimeout(() => { - setIsLoading(false); - setHasIssueLoading(hasDesktopError); - onLoad?.(component); - }, avoidFlickerTimeout); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (contentWindow) { - application.componentManager.registerComponentWindow( - component, - contentWindow - ); - } - return () => { - application.componentManager.onComponentIframeDestroyed(component.uuid); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contentWindow]); + }, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]); useEffect(() => { if (!iframeRef.current) { - setContentWindow(null); return; } - iframeRef.current.onload = () => { - const iframe = application.componentManager.iframeForComponent( - component.uuid - ); - if (iframe) { - setTimeout(() => { - handleIframeLoad(iframe); - }); + 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); + } } + + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + + componentViewer.setWindow(contentWindow); + + setTimeout(() => { + setIsLoading(false); + setHasIssueLoading(hasDesktopError); + onLoad?.(component); + }, MSToWaitAfterIframeLoadToAvoidFlicker); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [iframeRef.current]); + }, [onLoad, component, componentViewer]); useEffect(() => { - const removeFeaturesChangedObserver = application.addEventObserver( - async () => { - setFeatureStatus(application.getFeatureStatus(component.identifier)); - }, - ApplicationEvent.FeaturesUpdated + const removeFeaturesChangedObserver = componentViewer.addEventObserver( + (event) => { + if (event === ComponentViewerEvent.FeatureStatusUpdated) { + setFeatureStatus(componentViewer.getFeatureStatus()); + } + } ); return () => { removeFeaturesChangedObserver(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [componentViewer]); useEffect(() => { - if (!componentUuid) { - application.componentManager.addTemporaryTemplateComponent( - templateComponent as SNComponent - ); - } - - return () => { - if (templateComponent) { - /** componentManager can be destroyed already via locking */ - application.componentManager?.removeTemporaryTemplateComponent( - templateComponent - ); + const removeActionObserver = componentViewer.addActionObserver( + (action, data) => { + switch (action) { + 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; + } } - - document.removeEventListener(VisibilityChangeKey, onVisibilityChange); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - 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(); + removeActionObserver(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [component]); + }, [componentViewer, application]); useEffect(() => { const unregisterDesktopObserver = application .getDesktopService() .registerUpdateObserver((component: SNComponent) => { if (component.uuid === component.uuid && component.active) { - reloadIframe(); + requestReload?.(componentViewer); } }); return () => { unregisterDesktopObserver(); }; - }, [application]); + }, [application, requestReload, componentViewer]); return ( <> {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 && (