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-editor.svg b/app/assets/icons/ic-editor.svg new file mode 100644 index 000000000..209be8f42 --- /dev/null +++ b/app/assets/icons/ic-editor.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file 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/icons/ic-warning.svg b/app/assets/icons/ic-warning.svg new file mode 100644 index 000000000..0495ae3e8 --- /dev/null +++ b/app/assets/icons/ic-warning.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts deleted file mode 100644 index 49fcb298c..000000000 --- a/app/assets/javascripts/app.ts +++ /dev/null @@ -1,226 +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, - EditorMenu, - 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('editorMenu', () => new EditorMenu()) - .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..d83d12c0e --- /dev/null +++ b/app/assets/javascripts/app.tsx @@ -0,0 +1,89 @@ +'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 { Runtime, 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 ? Runtime.Dev : Runtime.Prod, + webSocketUrl + ); + + if (isDev) { + Object.defineProperties(window, { + application: { + get: () => mainApplicationGroup.primaryApplication, + }, + }); + } + + const renderApp = () => { + render( + , + document.body.appendChild(document.createElement('div')) + ); + }; + + const domReady = + document.readyState === 'complete' || document.readyState === 'interactive'; + if (domReady) { + renderApp(); + } else { + window.addEventListener('DOMContentLoaded', () => { + renderApp(); + }); + } +}; + +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..40393a52e --- /dev/null +++ b/app/assets/javascripts/components/ActionsMenu.tsx @@ -0,0 +1,329 @@ +import { WebApplication } from '@/ui_models/application'; +import { + Action, + SNActionsExtension, + UuidString, + SNNote, + ListedAccount, +} 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 ActionRow = Action & { + running?: boolean; + spinnerClass?: string; + subtitle?: string; +}; + +type MenuSection = { + uuid: UuidString; + name: string; + loading?: boolean; + error?: boolean; + hidden?: boolean; + deprecation?: string; + extension?: SNActionsExtension; + rows?: ActionRow[]; + listedAccount?: ListedAccount; +}; + +type State = { + menuSections: MenuSection[]; + selectedActionIdentifier?: string; +}; + +type Props = { + application: WebApplication; + note: SNNote; +}; + +export class ActionsMenu extends PureComponent { + constructor(props: Props) { + super(props, props.application); + + this.state = { + menuSections: [], + }; + + this.loadExtensions(); + } + + private async loadExtensions(): Promise { + const unresolvedListedSections = + await this.getNonresolvedListedMenuSections(); + const unresolvedGenericSections = + await this.getNonresolvedGenericMenuSections(); + this.setState( + { + menuSections: unresolvedListedSections.concat( + unresolvedGenericSections + ), + }, + () => { + this.state.menuSections.forEach((menuSection) => { + this.resolveMenuSection(menuSection); + }); + } + ); + } + + private async getNonresolvedGenericMenuSections(): Promise { + const genericExtensions = this.props.application.actionsManager + .getExtensions() + .sort((a, b) => { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + }); + + return genericExtensions.map((extension) => { + const menuSection: MenuSection = { + uuid: extension.uuid, + name: extension.name, + extension: extension, + loading: true, + hidden: this.appState.actionsMenu.hiddenSections[extension.uuid], + }; + return menuSection; + }); + } + + private async getNonresolvedListedMenuSections(): Promise { + const listedAccountEntries = + await this.props.application.getListedAccounts(); + return listedAccountEntries.map((entry) => { + const menuSection: MenuSection = { + uuid: entry.authorId, + name: `Listed ${entry.authorId}`, + loading: true, + listedAccount: entry, + hidden: this.appState.actionsMenu.hiddenSections[entry.authorId], + }; + return menuSection; + }); + } + + private resolveMenuSection(menuSection: MenuSection): void { + if (menuSection.listedAccount) { + this.props.application + .getListedAccountInfo(menuSection.listedAccount, this.props.note.uuid) + .then((accountInfo) => { + if (!accountInfo) { + this.promoteMenuSection({ + ...menuSection, + loading: false, + }); + return; + } + const existingMenuSection = this.state.menuSections.find( + (item) => item.uuid === menuSection.listedAccount?.authorId + ) as MenuSection; + const resolvedMenuSection: MenuSection = { + ...existingMenuSection, + loading: false, + error: false, + name: accountInfo.display_name, + rows: accountInfo?.actions, + }; + this.promoteMenuSection(resolvedMenuSection); + }); + } else if (menuSection.extension) { + this.props.application.actionsManager + .loadExtensionInContextOfItem(menuSection.extension, this.props.note) + .then((resolvedExtension) => { + if (!resolvedExtension) { + this.promoteMenuSection({ + ...menuSection, + loading: false, + }); + return; + } + + const actions = resolvedExtension.actionsWithContextForItem( + this.props.note + ); + + const resolvedMenuSection: MenuSection = { + ...menuSection, + rows: actions, + deprecation: resolvedExtension.deprecation, + loading: false, + error: false, + }; + this.promoteMenuSection(resolvedMenuSection); + }); + } + } + + private promoteMenuSection(newItem: MenuSection): void { + const menuSections = this.state.menuSections.map((menuSection) => { + if (menuSection.uuid === newItem.uuid) { + return newItem; + } else { + return menuSection; + } + }); + this.setState({ menuSections }); + } + + private promoteAction(newAction: Action, section: MenuSection): void { + const newSection: MenuSection = { + ...section, + rows: section.rows?.map((action) => { + if (action.url === newAction.url) { + return newAction; + } else { + return action; + } + }), + }; + this.promoteMenuSection(newSection); + } + + private idForAction(action: Action) { + return `${action.label}:${action.verb}:${action.desc}`; + } + + executeAction = async (action: Action, section: MenuSection) => { + const isLegacyNoteHistoryExt = action.verb === 'nested'; + if (isLegacyNoteHistoryExt) { + const showRevisionAction = action.subactions![0]; + action = showRevisionAction; + } + + this.promoteAction( + { + ...action, + running: true, + }, + section + ); + + const response = await this.props.application.actionsManager.runAction( + action, + this.props.note + ); + + this.promoteAction( + { + ...action, + running: false, + }, + section + ); + + if (!response || response.error) { + return; + } + + this.handleActionResponse(action, response); + this.resolveMenuSection(section); + }; + + handleActionResponse(action: Action, result: ActionResponse) { + switch (action.verb) { + case 'render': { + const item = result.item; + render( + , + document.body.appendChild(document.createElement('div')) + ); + } + } + } + + public toggleSectionVisibility(menuSection: MenuSection) { + this.appState.actionsMenu.toggleSectionVisibility(menuSection.uuid); + this.promoteMenuSection({ + ...menuSection, + hidden: !menuSection.hidden, + }); + } + + renderMenuSection(section: MenuSection) { + return ( +
+
{ + this.toggleSectionVisibility(section); + $event.stopPropagation(); + }} + > +
+
{section.name}
+ {section.hidden &&
} + {section.deprecation && !section.hidden && ( +
+ {section.deprecation} +
+ )} +
+ + {section.loading &&
} +
+ +
+ {section.error && !section.hidden && ( + + )} + + {!section.rows?.length && !section.hidden && ( + + )} + + {!section.hidden && + !section.loading && + !section.error && + section.rows?.map((action, index) => { + return ( + { + this.executeAction(action, section); + }} + label={action.label} + disabled={action.running} + spinnerClass={action.running ? 'info' : undefined} + subtitle={action.desc} + > + {action.access_type && ( +
+ {'Uses '} + {action.access_type} + {' access to this note.'} +
+ )} +
+ ); + })} +
+
+ ); + } + + render() { + return ( +
+
+ {this.state.menuSections.length == 0 && ( + + )} + {this.state.menuSections.map((extension) => + this.renderMenuSection(extension) + )} +
+
+ ); + } +} diff --git a/app/assets/javascripts/components/ApplicationGroupView.tsx b/app/assets/javascripts/components/ApplicationGroupView.tsx new file mode 100644 index 000000000..fd84b890b --- /dev/null +++ b/app/assets/javascripts/components/ApplicationGroupView.tsx @@ -0,0 +1,42 @@ +import { ApplicationGroup } from '@/ui_models/application_group'; +import { WebApplication } from '@/ui_models/application'; +import { Component } from 'preact'; +import { ApplicationView } from './ApplicationView'; + +type State = { + activeApplication?: WebApplication; +}; + +type Props = { + mainApplicationGroup: ApplicationGroup; +}; + +export class ApplicationGroupView extends Component { + constructor(props: Props) { + super(props); + + props.mainApplicationGroup.addApplicationChangeObserver(() => { + const activeApplication = props.mainApplicationGroup + .primaryApplication as WebApplication; + this.setState({ activeApplication }); + }); + + props.mainApplicationGroup.initialize(); + } + + render() { + return ( + <> + {this.state.activeApplication && ( +
+ +
+ )} + + ); + } +} diff --git a/app/assets/javascripts/components/ApplicationView.tsx b/app/assets/javascripts/components/ApplicationView.tsx new file mode 100644 index 000000000..92015b008 --- /dev/null +++ b/app/assets/javascripts/components/ApplicationView.tsx @@ -0,0 +1,274 @@ +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'; +import { PremiumModalProvider } from './Premium'; + +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() { + if (this.application['dealloced'] === true) { + console.error('Attempting to render dealloced application'); + return
; + } + + const renderAppContents = !this.state.needsUnlock && this.state.launched; + + return ( + +
+ {renderAppContents && ( +
+ + + + + +
+ )} + + {renderAppContents && ( + <> +
+ + + + + + )} + + {this.state.challenges.map((challenge) => { + return ( +
+ +
+ ); + })} + + {renderAppContents && ( + <> + + + + + )} +
+
+ ); + } +} diff --git a/app/assets/javascripts/components/ChallengeModal.tsx b/app/assets/javascripts/components/ChallengeModal.tsx new file mode 100644 index 000000000..0eee7d25d --- /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', + }) + ) { + this.dismiss(); + this.application.signOut(); + } + }; + + 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..25da0d34a 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'; @@ -24,7 +29,7 @@ interface IProps { application: WebApplication; appState: AppState; componentViewer: ComponentViewer; - requestReload?: (viewer: ComponentViewer) => void; + requestReload?: (viewer: ComponentViewer, force?: boolean) => void; onLoad?: (component: SNComponent) => void; manualDealloc?: boolean; } @@ -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( @@ -208,7 +206,7 @@ export const ComponentView: FunctionalComponent = observer( { - reloadValidityStatus(), requestReload?.(componentViewer); + reloadValidityStatus(), requestReload?.(componentViewer, true); }} /> )} @@ -236,6 +234,7 @@ export const ComponentView: FunctionalComponent = observer( {component.uuid && isComponentValid && (