diff --git a/.babelrc b/.babelrc index 766002c6b..643b8d31e 100644 --- a/.babelrc +++ b/.babelrc @@ -4,7 +4,6 @@ "@babel/preset-env" ], "plugins": [ - "angularjs-annotate", ["@babel/plugin-transform-react-jsx", { "pragma": "h", "pragmaFrag": "Fragment" diff --git a/app/assets/icons/ic-user-switch.svg b/app/assets/icons/ic-user-switch.svg new file mode 100644 index 000000000..74d80bb4d --- /dev/null +++ b/app/assets/icons/ic-user-switch.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts deleted file mode 100644 index f5d61825a..000000000 --- a/app/assets/javascripts/app.ts +++ /dev/null @@ -1,224 +0,0 @@ -'use strict'; - -declare global { - interface Window { - // eslint-disable-next-line camelcase - _bugsnag_api_key?: string; - // eslint-disable-next-line camelcase - _purchase_url?: string; - // eslint-disable-next-line camelcase - _plans_url?: string; - // eslint-disable-next-line camelcase - _dashboard_url?: string; - // 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; - - _devAccountEmail?: string; - _devAccountPassword?: string; - _devAccountServer?: string; - } -} - -import { ComponentViewDirective } from '@/components/ComponentView'; -import { NavigationDirective } from '@/components/Navigation'; -import { PinNoteButtonDirective } from '@/components/PinNoteButton'; -import { IsWebPlatform, WebAppVersion } from '@/version'; -import { - ApplicationGroupView, - ApplicationView, - ChallengeModal, - FooterView, - NoteGroupViewDirective, - NoteViewDirective, -} from '@/views'; -import { SNLog } from '@standardnotes/snjs'; -import angular from 'angular'; -import { AccountMenuDirective } from './components/AccountMenu'; -import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; -import { IconDirective } from './components/Icon'; -import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; -import { NoAccountWarningDirective } from './components/NoAccountWarning'; -import { NotesContextMenuDirective } from './components/NotesContextMenu'; -import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; -import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; -import { NotesViewDirective } from './components/NotesView'; -import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; -import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; -import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; -import { SearchOptionsDirective } from './components/SearchOptions'; -import { SessionsModalDirective } from './components/SessionsModal'; -import { - autofocus, - clickOutside, - delayHide, - elemReady, - fileChange, - lowercase, - selectOnFocus, - snEnter, -} from './directives/functional'; -import { - ActionsMenu, - HistoryMenu, - InputModal, - MenuRow, - PanelResizer, - PasswordWizard, - PermissionsModal, - RevisionPreviewModal, - SyncResolutionMenu, -} from './directives/views'; -import { trusted } from './filters'; -import { PreferencesDirective } from './preferences'; -import { PurchaseFlowDirective } from './purchaseFlow'; -import { configRoutes } from './routes'; -import { Bridge } from './services/bridge'; -import { BrowserBridge } from './services/browserBridge'; -import { startErrorReporting } from './services/errorReporting'; -import { StartApplication } from './startApplication'; -import { ApplicationGroup } from './ui_models/application_group'; -import { isDev } from './utils'; -import { AccountSwitcher } from './views/account_switcher/account_switcher'; - -function reloadHiddenFirefoxTab(): boolean { - /** - * For Firefox pinned tab issue: - * When a new browser session is started, and SN is in a pinned tab, - * SN exhibits strange behavior until the tab is reloaded. - */ - if ( - document.hidden && - navigator.userAgent.toLowerCase().includes('firefox') - ) { - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - location.reload(); - } - }); - return true; - } else { - return false; - } -} - -const startApplication: StartApplication = async function startApplication( - defaultSyncServerHost: string, - bridge: Bridge, - enableUnfinishedFeatures: boolean, - webSocketUrl: string -) { - if (reloadHiddenFirefoxTab()) { - return; - } - - SNLog.onLog = console.log; - startErrorReporting(); - - angular.module('app', []); - - // Config - angular - .module('app') - .config(configRoutes) - .constant('bridge', bridge) - .constant('defaultSyncServerHost', defaultSyncServerHost) - .constant('appVersion', bridge.appVersion) - .constant('enableUnfinishedFeatures', enableUnfinishedFeatures) - .constant('webSocketUrl', webSocketUrl); - - // Controllers - angular - .module('app') - .directive('applicationGroupView', () => new ApplicationGroupView()) - .directive('applicationView', () => new ApplicationView()) - .directive('noteGroupView', () => new NoteGroupViewDirective()) - .directive('noteView', () => new NoteViewDirective()) - .directive('footerView', () => new FooterView()); - - // Directives - Functional - angular - .module('app') - .directive('snAutofocus', ['$timeout', autofocus]) - .directive('clickOutside', ['$document', clickOutside]) - .directive('delayHide', delayHide) - .directive('elemReady', elemReady) - .directive('fileChange', fileChange) - .directive('lowercase', lowercase) - .directive('selectOnFocus', ['$window', selectOnFocus]) - .directive('snEnter', snEnter); - - // Directives - Views - angular - .module('app') - .directive('accountSwitcher', () => new AccountSwitcher()) - .directive('actionsMenu', () => new ActionsMenu()) - .directive('challengeModal', () => new ChallengeModal()) - .directive('componentView', ComponentViewDirective) - .directive('inputModal', () => new InputModal()) - .directive('menuRow', () => new MenuRow()) - .directive('panelResizer', () => new PanelResizer()) - .directive('passwordWizard', () => new PasswordWizard()) - .directive('permissionsModal', () => new PermissionsModal()) - .directive('revisionPreviewModal', () => new RevisionPreviewModal()) - .directive('historyMenu', () => new HistoryMenu()) - .directive('syncResolutionMenu', () => new SyncResolutionMenu()) - .directive('sessionsModal', SessionsModalDirective) - .directive('accountMenu', AccountMenuDirective) - .directive('quickSettingsMenu', QuickSettingsMenuDirective) - .directive('noAccountWarning', NoAccountWarningDirective) - .directive('protectedNotePanel', ProtectedNoteOverlayDirective) - .directive('searchOptions', SearchOptionsDirective) - .directive('confirmSignout', ConfirmSignoutDirective) - .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective) - .directive('notesContextMenu', NotesContextMenuDirective) - .directive('notesOptionsPanel', NotesOptionsPanelDirective) - .directive('notesListOptionsMenu', NotesListOptionsDirective) - .directive('icon', IconDirective) - .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('navigation', NavigationDirective) - .directive('preferences', PreferencesDirective) - .directive('purchaseFlow', PurchaseFlowDirective) - .directive('notesView', NotesViewDirective) - .directive('pinNoteButton', PinNoteButtonDirective); - - // Filters - angular.module('app').filter('trusted', ['$sce', trusted]); - - // Services - angular.module('app').service('mainApplicationGroup', ApplicationGroup); - - // Debug - if (isDev) { - Object.defineProperties(window, { - application: { - get: () => - ( - angular - .element(document) - .injector() - .get('mainApplicationGroup') as any - ).primaryApplication, - }, - }); - } - - angular.element(document).ready(() => { - angular.bootstrap(document, ['app']); - }); -}; - -if (IsWebPlatform) { - startApplication( - window._default_sync_server, - new BrowserBridge(WebAppVersion), - window._enable_unfinished_features, - window._websocket_url - ); -} else { - window.startApplication = startApplication; -} diff --git a/app/assets/javascripts/app.tsx b/app/assets/javascripts/app.tsx new file mode 100644 index 000000000..2abec7be4 --- /dev/null +++ b/app/assets/javascripts/app.tsx @@ -0,0 +1,77 @@ +'use strict'; + +declare global { + interface Window { + // eslint-disable-next-line camelcase + _bugsnag_api_key?: string; + // eslint-disable-next-line camelcase + _purchase_url?: string; + // eslint-disable-next-line camelcase + _plans_url?: string; + // eslint-disable-next-line camelcase + _dashboard_url?: string; + // 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; + + _devAccountEmail?: string; + _devAccountPassword?: string; + _devAccountServer?: string; + } +} + +import { IsWebPlatform, WebAppVersion } from '@/version'; +import { SNLog } from '@standardnotes/snjs'; +import { render } from 'preact'; +import { ApplicationGroupView } from './components/ApplicationGroupView'; +import { Bridge } from './services/bridge'; +import { BrowserBridge } from './services/browserBridge'; +import { startErrorReporting } from './services/errorReporting'; +import { StartApplication } from './startApplication'; +import { ApplicationGroup } from './ui_models/application_group'; +import { isDev } from './utils'; + +const startApplication: StartApplication = async function startApplication( + defaultSyncServerHost: string, + bridge: Bridge, + enableUnfinishedFeatures: boolean, + webSocketUrl: string +) { + SNLog.onLog = console.log; + startErrorReporting(); + + const mainApplicationGroup = new ApplicationGroup( + defaultSyncServerHost, + bridge, + enableUnfinishedFeatures, + webSocketUrl + ); + + if (isDev) { + Object.defineProperties(window, { + application: { + get: () => mainApplicationGroup.primaryApplication, + }, + }); + } + + render( + , + document.body.appendChild(document.createElement('div')) + ); +}; + +if (IsWebPlatform) { + startApplication( + window._default_sync_server, + new BrowserBridge(WebAppVersion), + window._enable_unfinished_features, + window._websocket_url + ); +} else { + window.startApplication = startApplication; +} diff --git a/app/assets/javascripts/views/abstract/pure_view_ctrl.ts b/app/assets/javascripts/components/Abstract/PureComponent.tsx similarity index 52% rename from app/assets/javascripts/views/abstract/pure_view_ctrl.ts rename to app/assets/javascripts/components/Abstract/PureComponent.tsx index 8e2691359..9a56d834d 100644 --- a/app/assets/javascripts/views/abstract/pure_view_ctrl.ts +++ b/app/assets/javascripts/components/Abstract/PureComponent.tsx @@ -2,38 +2,27 @@ import { ApplicationEvent } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { AppState } from '@/ui_models/app_state'; import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'; +import { Component } from 'preact'; +import { findDOMNode, unmountComponentAtNode } from 'preact/compat'; -export type CtrlState = Partial>; -export type CtrlProps = Partial>; +export type PureComponentState = Partial>; +export type PureComponentProps = Partial>; -export class PureViewCtrl

{ - $timeout: ng.ITimeoutService; - /** Passed through templates */ - application!: WebApplication; - state: S = {} as any; - private unsubApp: any; - private unsubState: any; - private stateTimeout?: ng.IPromise; - /** - * Subclasses can optionally add an ng-if=ctrl.templateReady to make sure that - * no Angular handlebars/syntax render in the UI before display data is ready. - */ - protected templateReady = false; +export abstract class PureComponent< + P = PureComponentProps, + S = PureComponentState +> extends Component { + private unsubApp!: () => void; + private unsubState!: () => void; private reactionDisposers: IReactionDisposer[] = []; - /* @ngInject */ - constructor($timeout: ng.ITimeoutService, public props: P = {} as any) { - this.$timeout = $timeout; + constructor(props: P, protected application: WebApplication) { + super(props); } - $onInit(): void { - this.state = { - ...this.getInitialState(), - ...this.state, - }; + componentDidMount() { this.addAppEventObserver(); this.addAppStateObserver(); - this.templateReady = true; } deinit(): void { @@ -43,63 +32,38 @@ export class PureViewCtrl

{ disposer(); } this.reactionDisposers.length = 0; - this.unsubApp = undefined; - this.unsubState = undefined; - if (this.stateTimeout) { - this.$timeout.cancel(this.stateTimeout); - } + (this.unsubApp as unknown) = undefined; + (this.unsubState as unknown) = undefined; } - $onDestroy(): void { + protected dismissModal(): void { + const elem = this.getElement(); + if (!elem) { + return; + } + + const parent = elem.parentElement; + if (!parent) { + return; + } + parent.remove(); + unmountComponentAtNode(parent); + } + + componentWillUnmount(): void { this.deinit(); } + render() { + return

Must override
; + } + public get appState(): AppState { return this.application.getAppState(); } - /** @private */ - async resetState(): Promise { - this.state = this.getInitialState(); - await this.setState(this.state); - } - - /** @override */ - getInitialState(): S { - return {} as any; - } - - async setState(state: Partial): Promise { - if (!this.$timeout) { - return; - } - return new Promise((resolve) => { - this.stateTimeout = this.$timeout(() => { - /** - * State changes must be *inside* the timeout block for them to be affected in the UI - * Otherwise UI controllers will need to use $timeout everywhere - */ - this.state = Object.freeze(Object.assign({}, this.state, state)); - resolve(); - this.afterStateChange(); - }); - }); - } - - /** @override */ - // eslint-disable-next-line @typescript-eslint/no-empty-function - afterStateChange(): void {} - - /** @returns a promise that resolves after the UI has been updated. */ - flushUI(): angular.IPromise { - return this.$timeout(); - } - - initProps(props: CtrlProps): void { - if (Object.keys(this.props).length > 0) { - throw 'Already init-ed props.'; - } - this.props = Object.freeze(Object.assign({}, this.props, props)); + protected getElement(): Element | null { + return findDOMNode(this); } autorun(view: (r: IReactionPublic) => void): void { @@ -151,7 +115,7 @@ export class PureViewCtrl

{ /** @override */ async onAppStart() { - await this.resetState(); + /** Optional override */ } onLocalDataLoaded() { diff --git a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx index eb16d545c..1a75878df 100644 --- a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx @@ -1,9 +1,8 @@ import { WebApplication } from '@/ui_models/application'; import { AppState } from '@/ui_models/app_state'; -import { isDev } from '@/utils'; import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { Checkbox } from '../Checkbox'; import { Icon } from '../Icon'; import { InputWithIcon } from '../InputWithIcon'; diff --git a/app/assets/javascripts/components/AccountMenu/index.tsx b/app/assets/javascripts/components/AccountMenu/index.tsx index 12f6a12b0..36bc5cae0 100644 --- a/app/assets/javascripts/components/AccountMenu/index.tsx +++ b/app/assets/javascripts/components/AccountMenu/index.tsx @@ -1,8 +1,8 @@ import { observer } from 'mobx-react-lite'; -import { toDirective } from '@/components/utils'; +import { useCloseOnClickOutside } from '@/components/utils'; import { AppState } from '@/ui_models/app_state'; import { WebApplication } from '@/ui_models/application'; -import { useState } from 'preact/hooks'; +import { useRef, useState } from 'preact/hooks'; import { GeneralAccountMenu } from './GeneralAccountMenu'; import { FunctionComponent } from 'preact'; import { SignInPane } from './SignIn'; @@ -21,9 +21,12 @@ export enum AccountMenuPane { type Props = { appState: AppState; application: WebApplication; + onClickOutside: () => void; }; -type PaneSelectorProps = Props & { +type PaneSelectorProps = { + appState: AppState; + application: WebApplication; menuPane: AccountMenuPane; setMenuPane: (pane: AccountMenuPane) => void; closeMenu: () => void; @@ -79,8 +82,8 @@ const MenuPaneSelector: FunctionComponent = observer( } ); -const AccountMenu: FunctionComponent = observer( - ({ application, appState }) => { +export const AccountMenu: FunctionComponent = observer( + ({ application, appState, onClickOutside }) => { const { currentPane, setCurrentPane, @@ -88,6 +91,11 @@ const AccountMenu: FunctionComponent = observer( closeAccountMenu, } = appState.accountMenu; + const ref = useRef(null); + useCloseOnClickOutside(ref, () => { + onClickOutside(); + }); + const handleKeyDown: JSXInternal.KeyboardEventHandler = ( event ) => { @@ -105,7 +113,7 @@ const AccountMenu: FunctionComponent = observer( }; return ( -

+
= observer( ); } ); - -export const AccountMenuDirective = toDirective(AccountMenu); diff --git a/app/assets/javascripts/components/AccountSwitcher.tsx b/app/assets/javascripts/components/AccountSwitcher.tsx new file mode 100644 index 000000000..a94e65bd8 --- /dev/null +++ b/app/assets/javascripts/components/AccountSwitcher.tsx @@ -0,0 +1,169 @@ +import { ApplicationGroup } from '@/ui_models/application_group'; +import { WebApplication } from '@/ui_models/application'; +import { ApplicationDescriptor } from '@standardnotes/snjs'; +import { PureComponent } from '@/components/Abstract/PureComponent'; +import { JSX } from 'preact'; + +type Props = { + application: WebApplication; + mainApplicationGroup: ApplicationGroup; +}; + +type State = { + descriptors: ApplicationDescriptor[]; + editingDescriptor?: ApplicationDescriptor; +}; + +export class AccountSwitcher extends PureComponent { + private removeAppGroupObserver: any; + activeApplication!: WebApplication; + + constructor(props: Props) { + super(props, props.application); + this.removeAppGroupObserver = + props.mainApplicationGroup.addApplicationChangeObserver(() => { + this.activeApplication = props.mainApplicationGroup + .primaryApplication as WebApplication; + this.reloadApplications(); + }); + } + + reloadApplications() { + this.setState({ + descriptors: this.props.mainApplicationGroup.getDescriptors(), + }); + } + + addNewApplication = () => { + this.dismiss(); + this.props.mainApplicationGroup.addNewApplication(); + }; + + selectDescriptor = (descriptor: ApplicationDescriptor) => { + this.dismiss(); + this.props.mainApplicationGroup.loadApplicationForDescriptor(descriptor); + }; + + inputForDescriptor(descriptor: ApplicationDescriptor) { + return document.getElementById(`input-${descriptor.identifier}`); + } + + renameDescriptor = (event: Event, descriptor: ApplicationDescriptor) => { + event.stopPropagation(); + + this.setState({ editingDescriptor: descriptor }); + + setTimeout(() => { + this.inputForDescriptor(descriptor)?.focus(); + }); + }; + + submitRename = () => { + this.props.mainApplicationGroup.renameDescriptor( + this.state.editingDescriptor!, + this.state.editingDescriptor!.label + ); + this.setState({ editingDescriptor: undefined }); + }; + + deinit() { + super.deinit(); + this.removeAppGroupObserver(); + this.removeAppGroupObserver = undefined; + } + + onDescriptorInputChange = ( + descriptor: ApplicationDescriptor, + { currentTarget }: JSX.TargetedEvent + ) => { + descriptor.label = currentTarget.value; + }; + + dismiss = () => { + this.dismissModal(); + }; + + render() { + return ( +
+
+
+
+ +
+
+ ); + } +} diff --git a/app/assets/javascripts/components/ActionsMenu.tsx b/app/assets/javascripts/components/ActionsMenu.tsx new file mode 100644 index 000000000..906b9afe9 --- /dev/null +++ b/app/assets/javascripts/components/ActionsMenu.tsx @@ -0,0 +1,390 @@ +import { WebApplication } from '@/ui_models/application'; +import { + SNItem, + Action, + SNActionsExtension, + UuidString, + CopyPayload, + SNNote, +} from '@standardnotes/snjs'; +import { ActionResponse } from '@standardnotes/snjs'; +import { render } from 'preact'; +import { PureComponent } from './Abstract/PureComponent'; +import { MenuRow } from './MenuRow'; +import { RevisionPreviewModal } from './RevisionPreviewModal'; +type ActionsMenuScope = { + application: WebApplication; + item: SNItem; +}; + +type ActionSubRow = { + onClick: () => void; + label: string; + subtitle: string; + spinnerClass?: string; +}; + +type ExtensionState = { + loading: boolean; + error: boolean; +}; + +type MenuItem = { + uuid: UuidString; + name: string; + loading: boolean; + error: boolean; + hidden: boolean; + deprecation?: string; + actions: (Action & { + subrows?: ActionSubRow[]; + })[]; +}; + +type ActionState = { + error: boolean; + running: boolean; +}; + +type ActionsMenuState = { + extensions: SNActionsExtension[]; + extensionsState: Record; + hiddenExtensions: Record; + selectedActionId?: number; + menuItems: MenuItem[]; + actionState: Record; +}; + +type Props = { + application: WebApplication; + item: SNNote; +}; + +export class ActionsMenu + extends PureComponent + implements ActionsMenuScope +{ + application!: WebApplication; + item!: SNItem; + + constructor(props: Props) { + super(props, props.application); + + const extensions = props.application.actionsManager + .getExtensions() + .sort((a, b) => { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + }) + .map((extension) => { + return new SNActionsExtension( + CopyPayload(extension.payload, { + content: { + ...extension.payload.safeContent, + actions: [], + }, + }) + ); + }); + const extensionsState: Record = {}; + extensions.map((extension) => { + extensionsState[extension.uuid] = { + loading: true, + error: false, + }; + }); + + this.state = { + extensions, + extensionsState, + hiddenExtensions: {}, + menuItems: [], + actionState: {}, + }; + } + + componentDidMount() { + this.loadExtensions(); + this.autorun(() => { + this.rebuildMenuState({ + hiddenExtensions: this.appState.actionsMenu.hiddenExtensions, + }); + }); + } + + rebuildMenuState({ + extensions = this.state.extensions, + extensionsState = this.state.extensionsState, + selectedActionId = this.state.selectedActionId, + hiddenExtensions = this.appState.actionsMenu.hiddenExtensions, + } = {}) { + return this.setState({ + extensions, + extensionsState, + selectedActionId, + menuItems: extensions.map((extension) => { + const state = extensionsState[extension.uuid]; + const hidden = hiddenExtensions[extension.uuid]; + const item: MenuItem = { + uuid: extension.uuid, + name: extension.name, + loading: state?.loading ?? false, + error: state?.error ?? false, + hidden: hidden ?? false, + deprecation: extension.deprecation!, + actions: extension + .actionsWithContextForItem(this.props.item) + .map((action) => { + if (action.id === selectedActionId) { + return { + ...action, + subrows: this.subRowsForAction(action, extension), + }; + } else { + return action; + } + }), + }; + return item; + }), + }); + } + + async loadExtensions() { + await Promise.all( + this.state.extensions.map(async (extension: SNActionsExtension) => { + this.setLoadingExtension(extension.uuid, true); + const updatedExtension = + await this.props.application.actionsManager.loadExtensionInContextOfItem( + extension, + this.props.item + ); + if (updatedExtension) { + await this.updateExtension(updatedExtension!); + } else { + this.setErrorExtension(extension.uuid, true); + } + this.setLoadingExtension(extension.uuid, false); + }) + ); + } + + executeAction = async (action: Action, extensionUuid: UuidString) => { + if (action.verb === 'nested') { + this.rebuildMenuState({ + selectedActionId: action.id, + }); + return; + } + + const extension = this.props.application.findItem( + extensionUuid + ) as SNActionsExtension; + + this.updateActionState(action, { running: true, error: false }); + + const response = await this.props.application.actionsManager.runAction( + action, + this.props.item, + async () => { + /** @todo */ + return ''; + } + ); + if (response.error) { + this.updateActionState(action, { error: true, running: false }); + return; + } + + this.updateActionState(action, { running: false, error: false }); + this.handleActionResponse(action, response); + await this.reloadExtension(extension); + }; + + handleActionResponse(action: Action, result: ActionResponse) { + switch (action.verb) { + case 'render': { + const item = result.item; + render( + , + document.body.appendChild(document.createElement('div')) + ); + } + } + } + + private subRowsForAction( + parentAction: Action, + extension: Pick + ): ActionSubRow[] | undefined { + if (!parentAction.subactions) { + return undefined; + } + return parentAction.subactions.map((subaction) => { + return { + id: subaction.id, + onClick: () => { + this.executeAction(subaction, extension.uuid); + }, + label: subaction.label, + subtitle: subaction.desc, + spinnerClass: this.getActionState(subaction).running + ? 'info' + : undefined, + }; + }); + } + + private updateActionState(action: Action, actionState: ActionState): void { + const state = this.state.actionState; + state[action.id] = actionState; + this.setState({ actionState: state }); + } + + private getActionState(action: Action): ActionState { + return this.state.actionState[action.id] || {}; + } + + private async updateExtension(extension: SNActionsExtension) { + const extensions = this.state.extensions.map((ext: SNActionsExtension) => { + if (extension.uuid === ext.uuid) { + return extension; + } + return ext; + }); + await this.rebuildMenuState({ + extensions, + }); + } + + private async reloadExtension(extension: SNActionsExtension) { + const extensionInContext = + await this.props.application.actionsManager.loadExtensionInContextOfItem( + extension, + this.props.item + ); + const extensions = this.state.extensions.map((ext: SNActionsExtension) => { + if (extension.uuid === ext.uuid) { + return extensionInContext!; + } + return ext; + }); + this.rebuildMenuState({ + extensions, + }); + } + + public toggleExtensionVisibility(extensionUuid: UuidString) { + this.appState.actionsMenu.toggleExtensionVisibility(extensionUuid); + } + + private setLoadingExtension(extensionUuid: UuidString, value = false) { + const { extensionsState } = this.state; + extensionsState[extensionUuid].loading = value; + this.rebuildMenuState({ + extensionsState, + }); + } + + private setErrorExtension(extensionUuid: UuidString, value = false) { + const { extensionsState } = this.state; + extensionsState[extensionUuid].error = value; + this.rebuildMenuState({ + extensionsState, + }); + } + + renderMenuItem(item: MenuItem) { + return ( +
+
{ + this.toggleExtensionVisibility(item.uuid); + $event.stopPropagation(); + }} + > +
+
{item.name}
+ {item.hidden &&
} + {item.deprecation && !item.hidden && ( +
+ {item.deprecation} +
+ )} +
+ + {item.loading &&
} +
+ +
+ {item.error && !item.hidden && ( + + )} + + {!item.actions.length && !item.hidden && ( + + )} + + {!item.hidden && + !item.loading && + !item.error && + item.actions.map((action, index) => { + return ( + + {action.access_type && ( +
+ {'Uses '} + {action.access_type} + {' access to this note.'} +
+ )} +
+ ); + })} +
+
+ ); + } + + render() { + return ( +
+
+ {this.state.extensions.length == 0 && ( + + + + )} + {this.state.menuItems.map((extension) => + this.renderMenuItem(extension) + )} +
+
+ ); + } +} diff --git a/app/assets/javascripts/components/ApplicationGroupView.tsx b/app/assets/javascripts/components/ApplicationGroupView.tsx new file mode 100644 index 000000000..adafaa496 --- /dev/null +++ b/app/assets/javascripts/components/ApplicationGroupView.tsx @@ -0,0 +1,51 @@ +import { ApplicationGroup } from '@/ui_models/application_group'; +import { WebApplication } from '@/ui_models/application'; +import { Component } from 'preact'; +import { ApplicationView } from './ApplicationView'; + +type State = { + applications: WebApplication[]; + activeApplication?: WebApplication; +}; + +type Props = { + mainApplicationGroup: ApplicationGroup; +}; + +export class ApplicationGroupView extends Component { + constructor(props: Props) { + super(props); + this.state = { + applications: [], + }; + props.mainApplicationGroup.addApplicationChangeObserver(() => { + this.setState({ + activeApplication: props.mainApplicationGroup + .primaryApplication as WebApplication, + applications: + props.mainApplicationGroup.getApplications() as WebApplication[], + }); + }); + props.mainApplicationGroup.initialize(); + } + + render() { + return ( + <> + {this.state.applications.map((application) => { + if (application === this.state.activeApplication) { + return ( +
+ +
+ ); + } + })} + + ); + } +} diff --git a/app/assets/javascripts/components/ApplicationView.tsx b/app/assets/javascripts/components/ApplicationView.tsx new file mode 100644 index 000000000..74fa60157 --- /dev/null +++ b/app/assets/javascripts/components/ApplicationView.tsx @@ -0,0 +1,258 @@ +import { ApplicationGroup } from '@/ui_models/application_group'; +import { getPlatformString } from '@/utils'; +import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state'; +import { + ApplicationEvent, + Challenge, + PermissionDialog, + removeFromArray, +} from '@standardnotes/snjs'; +import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants'; +import { STRING_DEFAULT_FILE_ERROR } from '@/strings'; +import { alertDialog } from '@/services/alertService'; +import { WebAppEvent, WebApplication } from '@/ui_models/application'; +import { PureComponent } from '@/components/Abstract/PureComponent'; +import { Navigation } from '@/components/Navigation'; +import { NotesView } from '@/components/NotesView'; +import { NoteGroupView } from '@/components/NoteGroupView'; +import { Footer } from '@/components/Footer'; +import { SessionsModal } from '@/components/SessionsModal'; +import { PreferencesViewWrapper } from '@/preferences/PreferencesViewWrapper'; +import { ChallengeModal } from '@/components/ChallengeModal'; +import { NotesContextMenu } from '@/components/NotesContextMenu'; +import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper'; +import { render } from 'preact'; +import { PermissionsModal } from './PermissionsModal'; + +type Props = { + application: WebApplication; + mainApplicationGroup: ApplicationGroup; +}; + +type State = { + started?: boolean; + launched?: boolean; + needsUnlock?: boolean; + appClass: string; + challenges: Challenge[]; +}; + +export class ApplicationView extends PureComponent { + public readonly platformString = getPlatformString(); + + constructor(props: Props) { + super(props, props.application); + this.state = { + appClass: '', + challenges: [], + }; + this.onDragDrop = this.onDragDrop.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.addDragDropHandlers(); + } + + deinit() { + (this.application as unknown) = undefined; + window.removeEventListener('dragover', this.onDragOver, true); + window.removeEventListener('drop', this.onDragDrop, true); + (this.onDragDrop as unknown) = undefined; + (this.onDragOver as unknown) = undefined; + super.deinit(); + } + + componentDidMount(): void { + super.componentDidMount(); + this.loadApplication(); + } + + async loadApplication() { + this.application.componentManager.setDesktopManager( + this.application.getDesktopService() + ); + await this.application.prepareForLaunch({ + receiveChallenge: async (challenge) => { + const challenges = this.state.challenges.slice(); + challenges.push(challenge); + this.setState({ challenges: challenges }); + }, + }); + await this.application.launch(); + } + + public removeChallenge = async (challenge: Challenge) => { + const challenges = this.state.challenges.slice(); + removeFromArray(challenges, challenge); + this.setState({ challenges: challenges }); + }; + + async onAppStart() { + super.onAppStart(); + this.setState({ + started: true, + needsUnlock: this.application.hasPasscode(), + }); + + this.application.componentManager.presentPermissionsDialog = + this.presentPermissionsDialog; + } + + async onAppLaunch() { + super.onAppLaunch(); + this.setState({ + launched: true, + needsUnlock: false, + }); + this.handleDemoSignInFromParams(); + } + + onUpdateAvailable() { + this.application.notifyWebEvent(WebAppEvent.NewUpdateAvailable); + } + + /** @override */ + async onAppEvent(eventName: ApplicationEvent) { + super.onAppEvent(eventName); + switch (eventName) { + case ApplicationEvent.LocalDatabaseReadError: + alertDialog({ + text: 'Unable to load local database. Please restart the app and try again.', + }); + break; + case ApplicationEvent.LocalDatabaseWriteError: + alertDialog({ + text: 'Unable to write to local database. Please restart the app and try again.', + }); + break; + } + } + + /** @override */ + async onAppStateEvent(eventName: AppStateEvent, data?: unknown) { + if (eventName === AppStateEvent.PanelResized) { + const { panel, collapsed } = data as PanelResizedData; + let appClass = ''; + if (panel === PANEL_NAME_NOTES && collapsed) { + appClass += 'collapsed-notes'; + } + if (panel === PANEL_NAME_NAVIGATION && collapsed) { + appClass += ' collapsed-navigation'; + } + this.setState({ appClass }); + } else if (eventName === AppStateEvent.WindowDidFocus) { + if (!(await this.application.isLocked())) { + this.application.sync(); + } + } + } + + addDragDropHandlers() { + /** + * Disable dragging and dropping of files (but allow text) into main SN interface. + * both 'dragover' and 'drop' are required to prevent dropping of files. + * This will not prevent extensions from receiving drop events. + */ + window.addEventListener('dragover', this.onDragOver, true); + window.addEventListener('drop', this.onDragDrop, true); + } + + onDragOver(event: DragEvent) { + if (event.dataTransfer?.files.length) { + event.preventDefault(); + } + } + + onDragDrop(event: DragEvent) { + if (event.dataTransfer?.files.length) { + event.preventDefault(); + void alertDialog({ + text: STRING_DEFAULT_FILE_ERROR, + }); + } + } + + async handleDemoSignInFromParams() { + if ( + window.location.href.includes('demo') && + !this.application.hasAccount() + ) { + await this.application.setCustomHost( + 'https://syncing-server-demo.standardnotes.com' + ); + this.application.signIn('demo@standardnotes.org', 'password'); + } + } + + presentPermissionsDialog = (dialog: PermissionDialog) => { + render( + , + document.body.appendChild(document.createElement('div')) + ); + }; + + render() { + return ( +
+ {!this.state.needsUnlock && this.state.launched && ( +
+ + + + + +
+ )} + + {!this.state.needsUnlock && this.state.launched && ( +
+ )} + + + + + + {this.state.challenges.map((challenge) => { + return ( +
+ +
+ ); + })} + + + + +
+ ); + } +} diff --git a/app/assets/javascripts/components/ChallengeModal.tsx b/app/assets/javascripts/components/ChallengeModal.tsx new file mode 100644 index 000000000..9a92c53e9 --- /dev/null +++ b/app/assets/javascripts/components/ChallengeModal.tsx @@ -0,0 +1,371 @@ +import { WebApplication } from '@/ui_models/application'; +import { Dialog } from '@reach/dialog'; +import { + ChallengeValue, + removeFromArray, + Challenge, + ChallengeReason, + ChallengePrompt, + ChallengeValidation, + ProtectionSessionDurations, +} from '@standardnotes/snjs'; +import { confirmDialog } from '@/services/alertService'; +import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings'; +import { createRef } from 'preact'; +import { PureComponent } from '@/components/Abstract/PureComponent'; + +type InputValue = { + prompt: ChallengePrompt; + value: string | number | boolean; + invalid: boolean; +}; + +type Values = Record; + +type State = { + prompts: ChallengePrompt[]; + values: Partial; + processing: boolean; + forgotPasscode: boolean; + showForgotPasscodeLink: boolean; + processingPrompts: ChallengePrompt[]; + hasAccount: boolean; + protectedNoteAccessDuration: number; +}; + +type Props = { + challenge: Challenge; + application: WebApplication; + onDismiss: (challenge: Challenge) => void; +}; + +export class ChallengeModal extends PureComponent { + submitting = false; + protectionsSessionDurations = ProtectionSessionDurations; + protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration; + private initialFocusRef = createRef(); + + constructor(props: Props) { + super(props, props.application); + + const values = {} as Values; + const prompts = this.props.challenge.prompts; + for (const prompt of prompts) { + values[prompt.id] = { + prompt, + value: prompt.initialValue ?? '', + invalid: false, + }; + } + const showForgotPasscodeLink = [ + ChallengeReason.ApplicationUnlock, + ChallengeReason.Migration, + ].includes(this.props.challenge.reason); + this.state = { + prompts, + values, + processing: false, + forgotPasscode: false, + showForgotPasscodeLink, + hasAccount: this.application.hasAccount(), + processingPrompts: [], + protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds, + }; + } + + componentDidMount(): void { + super.componentDidMount(); + + this.application.addChallengeObserver(this.props.challenge, { + onValidValue: (value) => { + this.state.values[value.prompt.id]!.invalid = false; + removeFromArray(this.state.processingPrompts, value.prompt); + this.reloadProcessingStatus(); + this.afterStateChange(); + }, + onInvalidValue: (value) => { + this.state.values[value.prompt.id]!.invalid = true; + /** If custom validation, treat all values together and not individually */ + if (!value.prompt.validates) { + this.setState({ processingPrompts: [], processing: false }); + } else { + removeFromArray(this.state.processingPrompts, value.prompt); + this.reloadProcessingStatus(); + } + this.afterStateChange(); + }, + onComplete: () => { + this.dismiss(); + }, + onCancel: () => { + this.dismiss(); + }, + }); + } + + deinit() { + (this.application as unknown) = undefined; + (this.props.challenge as unknown) = undefined; + super.deinit(); + } + + reloadProcessingStatus() { + return this.setState({ + processing: this.state.processingPrompts.length > 0, + }); + } + + destroyLocalData = async () => { + if ( + await confirmDialog({ + text: STRING_SIGN_OUT_CONFIRMATION, + confirmButtonStyle: 'danger', + }) + ) { + await this.application.signOut(); + this.dismiss(); + } + }; + + cancel = () => { + if (this.props.challenge.cancelable) { + this.application!.cancelChallenge(this.props.challenge); + } + }; + + onForgotPasscodeClick = () => { + this.setState({ + forgotPasscode: true, + }); + }; + + onTextValueChange = (prompt: ChallengePrompt) => { + const values = this.state.values; + values[prompt.id]!.invalid = false; + this.setState({ values }); + }; + + onNumberValueChange(prompt: ChallengePrompt, value: number) { + const values = this.state.values; + values[prompt.id]!.invalid = false; + values[prompt.id]!.value = value; + this.setState({ values }); + } + + validate() { + let failed = 0; + for (const prompt of this.state.prompts) { + const value = this.state.values[prompt.id]!; + if (typeof value.value === 'string' && value.value.length === 0) { + this.state.values[prompt.id]!.invalid = true; + failed++; + } + } + return failed === 0; + } + + submit = async () => { + if (!this.validate()) { + return; + } + if (this.submitting || this.state.processing) { + return; + } + this.submitting = true; + await this.setState({ processing: true }); + const values: ChallengeValue[] = []; + for (const inputValue of Object.values(this.state.values)) { + const rawValue = inputValue!.value; + const value = new ChallengeValue(inputValue!.prompt, rawValue); + values.push(value); + } + const processingPrompts = values.map((v) => v.prompt); + await this.setState({ + processingPrompts: processingPrompts, + processing: processingPrompts.length > 0, + }); + /** + * Unfortunately neccessary to wait 50ms so that the above setState call completely + * updates the UI to change processing state, before we enter into UI blocking operation + * (crypto key generation) + */ + setTimeout(() => { + if (values.length > 0) { + this.application.submitValuesForChallenge(this.props.challenge, values); + } else { + this.setState({ processing: false }); + } + this.submitting = false; + }, 50); + }; + + afterStateChange() { + this.render(); + } + + dismiss = () => { + this.props.onDismiss(this.props.challenge); + }; + + private renderChallengePrompts() { + return this.state.prompts.map((prompt, index) => ( + <> + {/** ProtectionSessionDuration can't just be an input field */} + {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( +
+
+
Allow protected access for
+ {ProtectionSessionDurations.map((option) => ( + { + event.preventDefault(); + this.onNumberValueChange(prompt, option.valueInSeconds); + }} + > + {option.label} + + ))} +
+
+ ) : ( +
+
{ + event.preventDefault(); + this.submit(); + }} + > + { + const value = (event.target as HTMLInputElement).value; + this.state.values[prompt.id]!.value = value; + this.onTextValueChange(prompt); + }} + ref={index === 0 ? this.initialFocusRef : undefined} + placeholder={prompt.title} + type={prompt.secureTextEntry ? 'password' : 'text'} + /> +
+
+ )} + + {this.state.values[prompt.id]!.invalid && ( +
+ +
+ )} + + )); + } + + render() { + if (!this.state.prompts) { + return <>; + } + return ( + { + if (this.props.challenge.cancelable) { + this.cancel(); + } + }} + > +
+
+
+
+
+ {this.props.challenge.modalTitle} +
+
+
+
+
+ {this.props.challenge.heading} +
+ {this.props.challenge.subheading && ( +
+ {this.props.challenge.subheading} +
+ )} +
+ +
+ {this.renderChallengePrompts()} +
+
+
+ + {this.props.challenge.cancelable && ( + <> +
+ this.cancel()} + > + Cancel + + + )} +
+ {this.state.showForgotPasscodeLink && ( +
+ {this.state.forgotPasscode ? ( + <> +

+ {this.state.hasAccount + ? 'If you forgot your application passcode, your ' + + 'only option is to clear your local data from this ' + + 'device and sign back in to your account.' + : 'If you forgot your application passcode, your ' + + 'only option is to delete your data.'} +

+ { + this.destroyLocalData(); + }} + > + Delete Local Data + + + ) : ( + this.onForgotPasscodeClick()} + > + Forgot your passcode? + + )} +
+
+ )} +
+
+
+
+ ); + } +} diff --git a/app/assets/javascripts/components/ComponentView/index.tsx b/app/assets/javascripts/components/ComponentView/index.tsx index 436397b70..9f4931465 100644 --- a/app/assets/javascripts/components/ComponentView/index.tsx +++ b/app/assets/javascripts/components/ComponentView/index.tsx @@ -9,8 +9,13 @@ import { } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { FunctionalComponent } from 'preact'; -import { toDirective } from '@/components/utils'; -import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; import { observer } from 'mobx-react-lite'; import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted'; import { UrlMissing } from '@/components/ComponentView/UrlMissing'; @@ -66,20 +71,6 @@ export const ComponentView: FunctionalComponent = observer( 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 reloadValidityStatus = useCallback(() => { setFeatureStatus(componentViewer.getFeatureStatus()); if (!componentViewer.lockReadonly) { @@ -128,28 +119,35 @@ export const ComponentView: FunctionalComponent = observer( } else { document.addEventListener(VisibilityChangeKey, onVisibilityChange); } - }, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]); + }, [didAttemptReload, onVisibilityChange, componentViewer, requestReload]); - useEffect(() => { - if (!iframeRef.current) { - return; - } + useMemo(() => { + const loadTimeout = setTimeout(() => { + handleIframeTakingTooLongToLoad(); + }, MaxLoadThreshold); - const iframe = iframeRef.current as HTMLIFrameElement; - iframe.onload = () => { - const contentWindow = iframe.contentWindow as Window; + excessiveLoadingTimeout.current = loadTimeout; + + return () => { excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current); - - componentViewer.setWindow(contentWindow); - - setTimeout(() => { - setIsLoading(false); - setHasIssueLoading(false); - onLoad?.(component); - }, MSToWaitAfterIframeLoadToAvoidFlicker); }; - }, [onLoad, component, componentViewer]); + }, [handleIframeTakingTooLongToLoad]); + + const onIframeLoad = useCallback(() => { + const iframe = iframeRef.current as HTMLIFrameElement; + const contentWindow = iframe.contentWindow as Window; + excessiveLoadingTimeout.current && + clearTimeout(excessiveLoadingTimeout.current); + + componentViewer.setWindow(contentWindow); + + setTimeout(() => { + setIsLoading(false); + setHasIssueLoading(false); + onLoad?.(component); + }, MSToWaitAfterIframeLoadToAvoidFlicker); + }, [componentViewer, onLoad, component, excessiveLoadingTimeout]); useEffect(() => { const removeFeaturesChangedObserver = componentViewer.addEventObserver( @@ -236,6 +234,7 @@ export const ComponentView: FunctionalComponent = observer( {component.uuid && isComponentValid && (