refactor: migrate remaining angular components to react (#833)
* refactor: menuRow directive to MenuRow component * refactor: migrate footer to react * refactor: migrate actions menu to react * refactor: migrate history menu to react * fix: click outside handler use capture to trigger event before re-render occurs which would otherwise cause node.contains to return incorrect result (specifically for the account menu) * refactor: migrate revision preview modal to react * refactor: migrate permissions modal to react * refactor: migrate password wizard to react * refactor: remove unused input modal directive * refactor: remove unused delay hide component * refactor: remove unused filechange directive * refactor: remove unused elemReady directive * refactor: remove unused sn-enter directive * refactor: remove unused lowercase directive * refactor: remove unused autofocus directive * refactor(wip): note view to react * refactor: use mutation observer to deinit textarea listeners * refactor: migrate challenge modal to react * refactor: migrate note group view to react * refactor(wip): migrate remaining classes * fix: navigation parent ref * refactor: fully remove angular assets * fix: account switcher * fix: application view state * refactor: remove unused password wizard type * fix: revision preview and permissions modal * fix: remove angular comment * refactor: react panel resizers for editor * feat: simple panel resizer * fix: use simple panel resizer everywhere * fix: simplify panel resizer state * chore: rename simple panel resizer to panel resizer * refactor: simplify column layout * fix: editor mount safety check * fix: use inline onLoad callback for iframe, as setting onload after it loads will never call it * chore: fix note view test * chore(deps): upgrade snjs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
77
app/assets/javascripts/app.tsx
Normal file
77
app/assets/javascripts/app.tsx
Normal file
@@ -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(
|
||||
<ApplicationGroupView mainApplicationGroup={mainApplicationGroup} />,
|
||||
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;
|
||||
}
|
||||
@@ -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<Record<string, any>>;
|
||||
export type CtrlProps = Partial<Record<string, any>>;
|
||||
export type PureComponentState = Partial<Record<string, any>>;
|
||||
export type PureComponentProps = Partial<Record<string, any>>;
|
||||
|
||||
export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
$timeout: ng.ITimeoutService;
|
||||
/** Passed through templates */
|
||||
application!: WebApplication;
|
||||
state: S = {} as any;
|
||||
private unsubApp: any;
|
||||
private unsubState: any;
|
||||
private stateTimeout?: ng.IPromise<void>;
|
||||
/**
|
||||
* 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<P, S> {
|
||||
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<P = CtrlProps, S = CtrlState> {
|
||||
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 <div>Must override</div>;
|
||||
}
|
||||
|
||||
public get appState(): AppState {
|
||||
return this.application.getAppState();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async resetState(): Promise<void> {
|
||||
this.state = this.getInitialState();
|
||||
await this.setState(this.state);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState(): S {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
async setState(state: Partial<S>): Promise<void> {
|
||||
if (!this.$timeout) {
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((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<void> {
|
||||
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<P = CtrlProps, S = CtrlState> {
|
||||
|
||||
/** @override */
|
||||
async onAppStart() {
|
||||
await this.resetState();
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PaneSelectorProps> = observer(
|
||||
}
|
||||
);
|
||||
|
||||
const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickOutside }) => {
|
||||
const {
|
||||
currentPane,
|
||||
setCurrentPane,
|
||||
@@ -88,6 +91,11 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
closeAccountMenu,
|
||||
} = appState.accountMenu;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useCloseOnClickOutside(ref, () => {
|
||||
onClickOutside();
|
||||
});
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
|
||||
event
|
||||
) => {
|
||||
@@ -105,7 +113,7 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='sn-component'>
|
||||
<div ref={ref} id="account-menu" className="sn-component">
|
||||
<div
|
||||
className={`sn-menu-border sn-account-menu sn-dropdown ${
|
||||
shouldAnimateCloseMenu
|
||||
@@ -130,5 +138,3 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const AccountMenuDirective = toDirective<Props>(AccountMenu);
|
||||
|
||||
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal file
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal file
@@ -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<Props, State> {
|
||||
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<HTMLInputElement, Event>
|
||||
) => {
|
||||
descriptor.label = currentTarget.value;
|
||||
};
|
||||
|
||||
dismiss = () => {
|
||||
this.dismissModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.dismiss} className="sk-modal-background" />
|
||||
<div id="account-switcher" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div id="menu-panel" className="sk-menu-panel">
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Account Switcher
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-menu-panel-column">
|
||||
<a onClick={this.addNewApplication} className="sk-label info">
|
||||
Add Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.descriptors.map((descriptor) => {
|
||||
return (
|
||||
<div
|
||||
key={descriptor.identifier}
|
||||
onClick={() => this.selectDescriptor(descriptor)}
|
||||
className="sk-menu-panel-row"
|
||||
>
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<div className="left">
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-circle small success" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<input
|
||||
value={descriptor.label}
|
||||
disabled={
|
||||
descriptor !== this.state.editingDescriptor
|
||||
}
|
||||
onChange={(event) =>
|
||||
this.onDescriptorInputChange(descriptor, event)
|
||||
}
|
||||
onKeyUp={(event) =>
|
||||
event.keyCode == 13 && this.submitRename()
|
||||
}
|
||||
id={`input-${descriptor.identifier}`}
|
||||
spellcheck={false}
|
||||
className="sk-label clickable"
|
||||
/>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-sublabel">
|
||||
Current Application
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<button
|
||||
onClick={(event) =>
|
||||
this.renameDescriptor(event, descriptor)
|
||||
}
|
||||
className="sn-button success"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
390
app/assets/javascripts/components/ActionsMenu.tsx
Normal file
390
app/assets/javascripts/components/ActionsMenu.tsx
Normal file
@@ -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<UuidString, ExtensionState>;
|
||||
hiddenExtensions: Record<UuidString, boolean>;
|
||||
selectedActionId?: number;
|
||||
menuItems: MenuItem[];
|
||||
actionState: Record<number, ActionState>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
item: SNNote;
|
||||
};
|
||||
|
||||
export class ActionsMenu
|
||||
extends PureComponent<Props, ActionsMenuState>
|
||||
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<UuidString, ExtensionState> = {};
|
||||
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(
|
||||
<RevisionPreviewModal
|
||||
application={this.application}
|
||||
uuid={item.uuid}
|
||||
content={item.content}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private subRowsForAction(
|
||||
parentAction: Action,
|
||||
extension: Pick<SNActionsExtension, 'uuid'>
|
||||
): 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 (
|
||||
<div>
|
||||
<div
|
||||
key={item.uuid}
|
||||
className="sk-menu-panel-header"
|
||||
onClick={($event) => {
|
||||
this.toggleExtensionVisibility(item.uuid);
|
||||
$event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-menu-panel-header-title">{item.name}</div>
|
||||
{item.hidden && <div>…</div>}
|
||||
{item.deprecation && !item.hidden && (
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{item.deprecation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.loading && <div className="sk-spinner small loading" />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{item.error && !item.hidden && (
|
||||
<MenuRow
|
||||
faded={true}
|
||||
label="Error loading actions"
|
||||
subtitle="Please try again later."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!item.actions.length && !item.hidden && (
|
||||
<MenuRow faded={true} label="No Actions Available" />
|
||||
)}
|
||||
|
||||
{!item.hidden &&
|
||||
!item.loading &&
|
||||
!item.error &&
|
||||
item.actions.map((action, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={this.executeAction as never}
|
||||
actionArgs={[action, item.uuid]}
|
||||
label={action.label}
|
||||
disabled={this.getActionState(action).running}
|
||||
spinnerClass={
|
||||
this.getActionState(action).running ? 'info' : undefined
|
||||
}
|
||||
subRows={action.subrows}
|
||||
subtitle={action.desc}
|
||||
>
|
||||
{action.access_type && (
|
||||
<div className="sk-sublabel">
|
||||
{'Uses '}
|
||||
<strong>{action.access_type}</strong>
|
||||
{' access to this note.'}
|
||||
</div>
|
||||
)}
|
||||
</MenuRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div className="sk-menu-panel dropdown-menu">
|
||||
{this.state.extensions.length == 0 && (
|
||||
<a
|
||||
href="https://standardnotes.com/plans"
|
||||
rel="noopener"
|
||||
target="blank"
|
||||
className="no-decoration"
|
||||
>
|
||||
<MenuRow label="Download Actions" />
|
||||
</a>
|
||||
)}
|
||||
{this.state.menuItems.map((extension) =>
|
||||
this.renderMenuItem(extension)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal file
51
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal file
@@ -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<Props, State> {
|
||||
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 (
|
||||
<div id={application.identifier}>
|
||||
<ApplicationView
|
||||
key={application.identifier}
|
||||
mainApplicationGroup={this.props.mainApplicationGroup}
|
||||
application={application}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
258
app/assets/javascripts/components/ApplicationView.tsx
Normal file
258
app/assets/javascripts/components/ApplicationView.tsx
Normal file
@@ -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<Props, State> {
|
||||
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(
|
||||
<PermissionsModal
|
||||
application={this.application}
|
||||
callback={dialog.callback}
|
||||
component={dialog.component}
|
||||
permissionsString={dialog.permissionsString}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={this.platformString + ' main-ui-view sn-component'}>
|
||||
{!this.state.needsUnlock && this.state.launched && (
|
||||
<div
|
||||
id="app"
|
||||
className={this.state.appClass + ' app app-column-container'}
|
||||
>
|
||||
<Navigation application={this.application} />
|
||||
|
||||
<NotesView
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<NoteGroupView application={this.application} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!this.state.needsUnlock && this.state.launched && (
|
||||
<Footer
|
||||
application={this.application}
|
||||
applicationGroup={this.props.mainApplicationGroup}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SessionsModal
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<PreferencesViewWrapper
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
|
||||
{this.state.challenges.map((challenge) => {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<ChallengeModal
|
||||
key={challenge.id}
|
||||
application={this.application}
|
||||
challenge={challenge}
|
||||
onDismiss={this.removeChallenge}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<NotesContextMenu
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<PurchaseFlowWrapper
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal file
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal file
@@ -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<number, InputValue>;
|
||||
|
||||
type State = {
|
||||
prompts: ChallengePrompt[];
|
||||
values: Partial<Values>;
|
||||
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<Props, State> {
|
||||
submitting = false;
|
||||
protectionsSessionDurations = ProtectionSessionDurations;
|
||||
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
|
||||
private initialFocusRef = createRef<HTMLInputElement>();
|
||||
|
||||
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 ? (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<div className="sk-horizontal-group mt-3">
|
||||
<div className="sk-p sk-bold">Allow protected access for</div>
|
||||
{ProtectionSessionDurations.map((option) => (
|
||||
<a
|
||||
className={
|
||||
'sk-a info ' +
|
||||
(option.valueInSeconds ===
|
||||
this.state.values[prompt.id]!.value
|
||||
? 'boxed'
|
||||
: '')
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
this.onNumberValueChange(prompt, option.valueInSeconds);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
value={this.state.values[prompt.id]!.value as string | number}
|
||||
onChange={(event) => {
|
||||
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'}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.values[prompt.id]!.invalid && (
|
||||
<div className="sk-panel-row centered">
|
||||
<label className="sk-label danger">
|
||||
Invalid authentication. Please try again.
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.prompts) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
initialFocusRef={this.initialFocusRef}
|
||||
onDismiss={() => {
|
||||
if (this.props.challenge.cancelable) {
|
||||
this.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="challenge-modal sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">
|
||||
{this.props.challenge.modalTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-p sk-panel-row centered prompt">
|
||||
<strong>{this.props.challenge.heading}</strong>
|
||||
</div>
|
||||
{this.props.challenge.subheading && (
|
||||
<div className="sk-p sk-panel-row centered subprompt">
|
||||
{this.props.challenge.subheading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-panel-section">
|
||||
{this.renderChallengePrompts()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer extra-padding">
|
||||
<button
|
||||
className={
|
||||
'sn-button w-full ' +
|
||||
(this.state.processing ? 'neutral' : 'info')
|
||||
}
|
||||
disabled={this.state.processing}
|
||||
onClick={() => this.submit()}
|
||||
>
|
||||
{this.state.processing ? 'Generating Keys…' : 'Submit'}
|
||||
</button>
|
||||
{this.props.challenge.cancelable && (
|
||||
<>
|
||||
<div className="sk-panel-row"></div>
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered text-sm"
|
||||
onClick={() => this.cancel()}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showForgotPasscodeLink && (
|
||||
<div className="sk-panel-footer">
|
||||
{this.state.forgotPasscode ? (
|
||||
<>
|
||||
<p className="sk-panel-row sk-p">
|
||||
{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.'}
|
||||
</p>
|
||||
<a
|
||||
className="sk-panel-row sk-a danger centered"
|
||||
onClick={() => {
|
||||
this.destroyLocalData();
|
||||
}}
|
||||
>
|
||||
Delete Local Data
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered"
|
||||
onClick={() => this.onForgotPasscodeClick()}
|
||||
>
|
||||
Forgot your passcode?
|
||||
</a>
|
||||
)}
|
||||
<div className="sk-panel-row"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<IProps> = 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<IProps> = 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<IProps> = observer(
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
src={componentViewer.url || ''}
|
||||
@@ -249,10 +248,3 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
|
||||
onLoad: '=',
|
||||
componentViewer: '=',
|
||||
requestReload: '=',
|
||||
manualDealloc: '=',
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@reach/alert-dialog';
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { toDirective } from './utils';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
@@ -22,96 +21,94 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
|
||||
return <ConfirmSignoutModal {...props} />;
|
||||
});
|
||||
|
||||
const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
|
||||
export const ConfirmSignoutModal = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false);
|
||||
}
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false);
|
||||
}
|
||||
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0);
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount);
|
||||
}, [appState.accountMenu.signingOut, application.bridge]);
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount);
|
||||
}, [appState.accountMenu.signingOut, application.bridge]);
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End your session?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
{STRING_SIGN_OUT_CONFIRMATION}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups(
|
||||
(event.target as HTMLInputElement).checked
|
||||
);
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End your session?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
{STRING_SIGN_OUT_CONFIRMATION}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups(
|
||||
(event.target as HTMLInputElement).checked
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups();
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
>
|
||||
View backup files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
className="sn-button small neutral"
|
||||
ref={cancelRef}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups();
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups();
|
||||
} else {
|
||||
application.signOut();
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
View backup files
|
||||
{application.hasAccount()
|
||||
? 'Sign Out'
|
||||
: 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button
|
||||
className="sn-button small neutral"
|
||||
ref={cancelRef}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups();
|
||||
} else {
|
||||
application.signOut();
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
{application.hasAccount()
|
||||
? 'Sign Out'
|
||||
: 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
);
|
||||
});
|
||||
|
||||
export const ConfirmSignoutDirective = toDirective<Props>(
|
||||
ConfirmSignoutContainer
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
571
app/assets/javascripts/components/Footer.tsx
Normal file
571
app/assets/javascripts/components/Footer.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { WebAppEvent, WebApplication } from '@/ui_models/application';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
SNTheme,
|
||||
CollectionSort,
|
||||
ApplicationDescriptor,
|
||||
} from '@standardnotes/snjs';
|
||||
import {
|
||||
STRING_NEW_UPDATE_READY,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
} from '@/strings';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { AccountMenu, AccountMenuPane } from '@/components/AccountMenu';
|
||||
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SyncResolutionMenu } from './SyncResolutionMenu';
|
||||
import { Fragment, render } from 'preact';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
|
||||
/**
|
||||
* Disable before production release.
|
||||
* Anyone who used the beta will still have access to
|
||||
* the account switcher in production via local storage flag
|
||||
*/
|
||||
const ACCOUNT_SWITCHER_ENABLED = false;
|
||||
const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
applicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type State = {
|
||||
outOfSync: boolean;
|
||||
dataUpgradeAvailable: boolean;
|
||||
hasPasscode: boolean;
|
||||
descriptors: ApplicationDescriptor[];
|
||||
hasAccountSwitcher: boolean;
|
||||
showBetaWarning: boolean;
|
||||
showSyncResolution: boolean;
|
||||
newUpdateAvailable: boolean;
|
||||
showAccountMenu: boolean;
|
||||
showQuickSettingsMenu: boolean;
|
||||
offline: boolean;
|
||||
hasError: boolean;
|
||||
arbitraryStatusMessage?: string;
|
||||
};
|
||||
|
||||
export class Footer extends PureComponent<Props, State> {
|
||||
public user?: unknown;
|
||||
private didCheckForOffline = false;
|
||||
private observerRemovers: Array<() => void> = [];
|
||||
private completedInitialSync = false;
|
||||
private showingDownloadStatus = false;
|
||||
private webEventListenerDestroyer: () => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
offline: true,
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: props.applicationGroup.getDescriptors(),
|
||||
hasAccountSwitcher: false,
|
||||
showBetaWarning: false,
|
||||
showSyncResolution: false,
|
||||
newUpdateAvailable: false,
|
||||
showAccountMenu: false,
|
||||
showQuickSettingsMenu: false,
|
||||
};
|
||||
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver(
|
||||
(event) => {
|
||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
||||
this.onNewUpdateAvailable();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.syncResolutionClickHandler =
|
||||
this.syncResolutionClickHandler.bind(this);
|
||||
this.closeAccountMenu = this.closeAccountMenu.bind(this);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
for (const remove of this.observerRemovers) remove();
|
||||
this.observerRemovers.length = 0;
|
||||
(this.closeAccountMenu as unknown) = undefined;
|
||||
(this.syncResolutionClickHandler as unknown) = undefined;
|
||||
this.webEventListenerDestroyer();
|
||||
(this.webEventListenerDestroyer as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
});
|
||||
});
|
||||
this.loadAccountSwitcherState();
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showAccountMenu: this.appState.accountMenu.show,
|
||||
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadAccountSwitcherState() {
|
||||
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
|
||||
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
|
||||
/** Enable permanently for this user so they don't lose the feature after its disabled */
|
||||
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
|
||||
}
|
||||
const hasAccountSwitcher = stringValue
|
||||
? JSON.parse(stringValue)
|
||||
: ACCOUNT_SWITCHER_ENABLED;
|
||||
this.setState({ hasAccountSwitcher });
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadPasscodeStatus();
|
||||
this.reloadUser();
|
||||
this.reloadUpgradeStatus();
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
this.user = this.application.getUser();
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode();
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager();
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
break;
|
||||
case AppStateEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…');
|
||||
break;
|
||||
case AppStateEvent.EndedBackupDownload: {
|
||||
const successMessage = 'Successfully saved backup.';
|
||||
const errorMessage = 'Unable to save local backup.';
|
||||
statusService.setMessage(data.success ? successMessage : errorMessage);
|
||||
|
||||
const twoSeconds = 2000;
|
||||
setTimeout(() => {
|
||||
if (
|
||||
statusService.message === successMessage ||
|
||||
statusService.message === errorMessage
|
||||
) {
|
||||
statusService.setMessage('');
|
||||
}
|
||||
}, twoSeconds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.reloadPasscodeStatus();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus();
|
||||
break;
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('');
|
||||
this.completedInitialSync = true;
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true;
|
||||
if (this.state.offline && this.application.getNoteCount() === 0) {
|
||||
this.appState.accountMenu.setShow(true);
|
||||
}
|
||||
}
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus();
|
||||
break;
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus();
|
||||
break;
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser();
|
||||
break;
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.setDisplayOptions(
|
||||
ContentType.Theme,
|
||||
CollectionSort.Title,
|
||||
'asc',
|
||||
(theme: SNTheme) => {
|
||||
return !theme.errorDecrypting;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync');
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
|
||||
statusManager.setMessage(text);
|
||||
this.showingDownloadStatus = true;
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false;
|
||||
statusManager.setMessage('Download Complete.');
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('');
|
||||
}, 2000);
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0
|
||||
? 0
|
||||
: stats.uploadCompletionCount / stats.uploadTotalCount;
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
});
|
||||
|
||||
statusManager.setMessage(
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
|
||||
);
|
||||
} else {
|
||||
statusManager.setMessage('');
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const encryption = this.application.isEncryptionAvailable();
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('');
|
||||
return;
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
|
||||
const loadingStatus = encryption
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
statusManager.setMessage(loadingStatus);
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.setState({
|
||||
offline: this.application.noAccount(),
|
||||
});
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.setState({
|
||||
hasError: this.application.getSyncStatus().hasError(),
|
||||
});
|
||||
}
|
||||
|
||||
securityUpdateClickHandler = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
accountSwitcherClickHandler = () => {
|
||||
render(
|
||||
<AccountSwitcher
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
};
|
||||
|
||||
quickSettingsClickHandler = () => {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
this.appState.quickSettingsMenu.toggle();
|
||||
};
|
||||
|
||||
syncResolutionClickHandler = () => {
|
||||
this.setState({
|
||||
showSyncResolution: !this.state.showSyncResolution,
|
||||
});
|
||||
};
|
||||
|
||||
closeAccountMenu = () => {
|
||||
this.appState.accountMenu.setShow(false);
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
};
|
||||
|
||||
lockClickHandler = () => {
|
||||
this.application.lock();
|
||||
};
|
||||
|
||||
onNewUpdateAvailable = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: true,
|
||||
});
|
||||
};
|
||||
|
||||
newUpdateClickHandler = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: false,
|
||||
});
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
|
||||
};
|
||||
|
||||
betaMessageClickHandler = () => {
|
||||
alertDialog({
|
||||
title: 'You are using a beta version of the app',
|
||||
text:
|
||||
'If you wish to go back to a stable version, make sure to sign out ' +
|
||||
'of this beta app first.<br>You can silence this warning from the ' +
|
||||
'<em>Account</em> menu.',
|
||||
});
|
||||
};
|
||||
|
||||
clickOutsideAccountMenu = () => {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
};
|
||||
|
||||
clickOutsideQuickSettingsMenu = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
|
||||
<div className="left">
|
||||
<div className="sk-app-bar-item ml-0">
|
||||
<div
|
||||
onClick={this.accountMenuClickHandler}
|
||||
className={
|
||||
(this.state.showAccountMenu ? 'bg-border' : '') +
|
||||
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.state.hasError
|
||||
? 'danger'
|
||||
: (this.user ? 'info' : 'neutral') + ' w-5 h-5'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
type="account-circle"
|
||||
className="hover:color-info w-5 h-5 max-h-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showAccountMenu && (
|
||||
<AccountMenu
|
||||
onClickOutside={this.clickOutsideAccountMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-app-bar-item ml-0-important">
|
||||
<div
|
||||
onClick={this.quickSettingsClickHandler}
|
||||
className="w-8 h-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon
|
||||
type="tune"
|
||||
className={
|
||||
(this.state.showQuickSettingsMenu ? 'color-info' : '') +
|
||||
' rounded hover:color-info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showQuickSettingsMenu && (
|
||||
<QuickSettingsMenu
|
||||
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showBetaWarning && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div className="sk-app-bar-item">
|
||||
<a
|
||||
onClick={this.betaMessageClickHandler}
|
||||
className="no-decoration sk-label title"
|
||||
>
|
||||
You are using a beta version of the app
|
||||
</a>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="center">
|
||||
{this.state.arbitraryStatusMessage && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-app-bar-item-column">
|
||||
<span className="neutral sk-label">
|
||||
{this.state.arbitraryStatusMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="right">
|
||||
{this.state.dataUpgradeAvailable && (
|
||||
<div
|
||||
onClick={this.securityUpdateClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<span className="success sk-label">
|
||||
Encryption upgrade available.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{this.state.newUpdateAvailable && (
|
||||
<div
|
||||
onClick={this.newUpdateClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<span className="info sk-label">New update available.</span>
|
||||
</div>
|
||||
)}
|
||||
{(this.state.outOfSync || this.state.showSyncResolution) && (
|
||||
<div className="sk-app-bar-item">
|
||||
{this.state.outOfSync && (
|
||||
<div
|
||||
onClick={this.syncResolutionClickHandler}
|
||||
className="sk-label warning"
|
||||
>
|
||||
Potentially Out of Sync
|
||||
</div>
|
||||
)}
|
||||
{this.state.showSyncResolution && (
|
||||
<SyncResolutionMenu
|
||||
close={this.syncResolutionClickHandler}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{this.state.offline && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-label">Offline</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasAccountSwitcher && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
onClick={this.accountSwitcherClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div
|
||||
id="account-switcher-icon"
|
||||
className={
|
||||
(this.state.hasPasscode ? 'alone' : '') +
|
||||
' flex items-center'
|
||||
}
|
||||
>
|
||||
<Icon type="user-switch" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i id="footer-lock-icon" className="icon ion-locked" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
311
app/assets/javascripts/components/HistoryMenu.tsx
Normal file
311
app/assets/javascripts/components/HistoryMenu.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { NoteHistoryEntry, PayloadContent, SNNote } from '@standardnotes/snjs';
|
||||
import { RevisionListEntry } from '@standardnotes/snjs';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { MenuRow } from './MenuRow';
|
||||
import { render } from 'preact';
|
||||
import { RevisionPreviewModal } from './RevisionPreviewModal';
|
||||
|
||||
type HistoryState = {
|
||||
sessionHistory?: NoteHistoryEntry[];
|
||||
remoteHistory?: RevisionListEntry[];
|
||||
fetchingRemoteHistory: boolean;
|
||||
autoOptimize: boolean;
|
||||
diskEnabled: boolean;
|
||||
showRemoteOptions?: boolean;
|
||||
showSessionOptions?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
item: SNNote;
|
||||
};
|
||||
|
||||
export class HistoryMenu extends PureComponent<Props, HistoryState> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
|
||||
this.state = {
|
||||
fetchingRemoteHistory: false,
|
||||
autoOptimize: this.props.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory:
|
||||
this.props.application.historyManager.sessionHistoryForItem(
|
||||
this.props.item
|
||||
) as NoteHistoryEntry[],
|
||||
};
|
||||
}
|
||||
|
||||
reloadState() {
|
||||
this.setState({
|
||||
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
|
||||
autoOptimize: this.props.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory:
|
||||
this.props.application.historyManager.sessionHistoryForItem(
|
||||
this.props.item
|
||||
) as NoteHistoryEntry[],
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.fetchRemoteHistory();
|
||||
}
|
||||
|
||||
fetchRemoteHistory = async () => {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
try {
|
||||
const remoteHistory =
|
||||
await this.props.application.historyManager.remoteHistoryForItem(
|
||||
this.props.item
|
||||
);
|
||||
this.setState({ remoteHistory });
|
||||
} finally {
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
}
|
||||
};
|
||||
|
||||
private presentRevisionPreviewModal = (
|
||||
uuid: string,
|
||||
content: PayloadContent,
|
||||
title: string
|
||||
) => {
|
||||
render(
|
||||
<RevisionPreviewModal
|
||||
application={this.application}
|
||||
uuid={uuid}
|
||||
content={content}
|
||||
title={title}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
openSessionRevision = (revision: NoteHistoryEntry) => {
|
||||
this.presentRevisionPreviewModal(
|
||||
revision.payload.uuid,
|
||||
revision.payload.content,
|
||||
revision.previewTitle()
|
||||
);
|
||||
};
|
||||
|
||||
openRemoteRevision = async (revision: RevisionListEntry) => {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
|
||||
const remoteRevision =
|
||||
await this.props.application.historyManager.fetchRemoteRevision(
|
||||
this.props.item.uuid,
|
||||
revision
|
||||
);
|
||||
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
|
||||
if (!remoteRevision) {
|
||||
alertDialog({
|
||||
text: 'The remote revision could not be loaded. Please try again later.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentRevisionPreviewModal(
|
||||
remoteRevision.payload.uuid,
|
||||
remoteRevision.payload.content,
|
||||
this.previewRemoteHistoryTitle(revision)
|
||||
);
|
||||
};
|
||||
|
||||
classForSessionRevision = (revision: NoteHistoryEntry) => {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
};
|
||||
|
||||
clearItemSessionHistory = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: 'Are you sure you want to delete the local session history for this note?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.props.application.historyManager.clearHistoryForItem(
|
||||
this.props.item
|
||||
);
|
||||
this.reloadState();
|
||||
}
|
||||
};
|
||||
|
||||
clearAllSessionHistory = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: 'Are you sure you want to delete the local session history for all notes?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.props.application.historyManager.clearAllHistory();
|
||||
this.reloadState();
|
||||
}
|
||||
};
|
||||
|
||||
toggleSessionHistoryDiskSaving = async () => {
|
||||
if (!this.state.diskEnabled) {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to save history to disk? This will decrease general ' +
|
||||
'performance, especially as you type. You are advised to disable this feature ' +
|
||||
'if you experience any lagging.',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.props.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
} else {
|
||||
this.props.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
this.reloadState();
|
||||
};
|
||||
|
||||
toggleSessionHistoryAutoOptimize = () => {
|
||||
this.props.application.historyManager.toggleAutoOptimize();
|
||||
this.reloadState();
|
||||
};
|
||||
|
||||
previewRemoteHistoryTitle(revision: RevisionListEntry) {
|
||||
return new Date(revision.created_at).toLocaleString();
|
||||
}
|
||||
|
||||
toggleShowRemoteOptions = ($event: Event) => {
|
||||
$event.stopPropagation();
|
||||
this.setState({
|
||||
showRemoteOptions: !this.state.showRemoteOptions,
|
||||
});
|
||||
};
|
||||
|
||||
toggleShowSessionOptions = ($event: Event) => {
|
||||
$event.stopPropagation();
|
||||
this.setState({
|
||||
showSessionOptions: !this.state.showSessionOptions,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="history-menu" className="sn-component">
|
||||
<div className="sk-menu-panel dropdown-menu">
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Session
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{this.state.sessionHistory?.length || 'No'} revisions
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="sk-a info sk-h5"
|
||||
onClick={this.toggleShowSessionOptions}
|
||||
>
|
||||
Options
|
||||
</a>
|
||||
</div>
|
||||
{this.state.showSessionOptions && (
|
||||
<div>
|
||||
<MenuRow
|
||||
action={this.clearItemSessionHistory}
|
||||
label="Clear note local history"
|
||||
/>
|
||||
<MenuRow
|
||||
action={this.clearAllSessionHistory}
|
||||
label="Clear all local history"
|
||||
/>
|
||||
<MenuRow
|
||||
action={this.toggleSessionHistoryAutoOptimize}
|
||||
label={
|
||||
(this.state.autoOptimize ? 'Disable' : 'Enable') +
|
||||
' auto cleanup'
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">
|
||||
Automatically cleans up small revisions to conserve space.
|
||||
</div>
|
||||
</MenuRow>
|
||||
<MenuRow
|
||||
action={this.toggleSessionHistoryDiskSaving}
|
||||
label={
|
||||
(this.state.diskEnabled ? 'Disable' : 'Enable') +
|
||||
' saving history to disk'
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">
|
||||
Saving to disk is not recommended. Decreases performance and
|
||||
increases app loading time and memory footprint.
|
||||
</div>
|
||||
</MenuRow>
|
||||
</div>
|
||||
)}
|
||||
{this.state.sessionHistory?.map((revision, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={this.openSessionRevision}
|
||||
actionArgs={[revision]}
|
||||
label={revision.previewTitle()}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.classForSessionRevision(revision) +
|
||||
' sk-sublabel opaque'
|
||||
}
|
||||
>
|
||||
{revision.previewSubTitle()}
|
||||
</div>
|
||||
</MenuRow>
|
||||
);
|
||||
})}
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Remote
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{this.state.remoteHistory?.length || 'No'} revisions
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
onClick={this.toggleShowRemoteOptions}
|
||||
className="sk-a info sk-h5"
|
||||
>
|
||||
Options
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{this.state.showRemoteOptions && (
|
||||
<MenuRow
|
||||
action={this.fetchRemoteHistory}
|
||||
label="Refresh"
|
||||
disabled={this.state.fetchingRemoteHistory}
|
||||
spinnerClass={
|
||||
this.state.fetchingRemoteHistory ? 'info' : undefined
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">Fetch history from server.</div>
|
||||
</MenuRow>
|
||||
)}
|
||||
{this.state.remoteHistory?.map((revision, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={this.openRemoteRevision}
|
||||
actionArgs={[revision]}
|
||||
label={this.previewRemoteHistoryTitle(revision)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import PasswordIcon from '../../icons/ic-textbox-password.svg';
|
||||
import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
|
||||
import MoreIcon from '../../icons/ic-more.svg';
|
||||
import TuneIcon from '../../icons/ic-tune.svg';
|
||||
import UserSwitch from '../../icons/ic-user-switch.svg';
|
||||
import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg';
|
||||
import MenuCloseIcon from '../../icons/ic-menu-close.svg';
|
||||
import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
|
||||
@@ -65,11 +66,10 @@ 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 = {
|
||||
'editor': EditorIcon,
|
||||
editor: EditorIcon,
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-right': MenuArrowRight,
|
||||
notes: NotesIcon,
|
||||
@@ -93,6 +93,7 @@ const ICONS = {
|
||||
'rich-text': RichTextIcon,
|
||||
code: CodeIcon,
|
||||
markdown: MarkdownIcon,
|
||||
'user-switch': UserSwitch,
|
||||
authenticator: AuthenticatorIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
tasks: TasksIcon,
|
||||
@@ -158,8 +159,3 @@ export const Icon: FunctionalComponent<Props> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconDirective = toDirective<Props>(Icon, {
|
||||
type: '@',
|
||||
className: '@',
|
||||
});
|
||||
|
||||
120
app/assets/javascripts/components/MenuRow.tsx
Normal file
120
app/assets/javascripts/components/MenuRow.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Component } from 'preact';
|
||||
|
||||
type RowProps = {
|
||||
action?: (...args: any[]) => void;
|
||||
actionArgs?: any[];
|
||||
buttonAction?: () => void;
|
||||
buttonClass?: string;
|
||||
buttonText?: string;
|
||||
desc?: string;
|
||||
disabled?: boolean;
|
||||
circle?: string;
|
||||
circleAlign?: string;
|
||||
faded?: boolean;
|
||||
hasButton?: boolean;
|
||||
label: string;
|
||||
spinnerClass?: string;
|
||||
stylekitClass?: string;
|
||||
subRows?: RowProps[];
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
type Props = RowProps;
|
||||
|
||||
export class MenuRow extends Component<Props> {
|
||||
onClick = ($event: Event) => {
|
||||
if (this.props.disabled || !this.props.action) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
|
||||
if (this.props.actionArgs) {
|
||||
this.props.action(...this.props.actionArgs);
|
||||
} else {
|
||||
this.props.action();
|
||||
}
|
||||
};
|
||||
|
||||
clickAccessoryButton = ($event: Event) => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.props.buttonAction?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
title={this.props.desc}
|
||||
onClick={this.onClick}
|
||||
className="sk-menu-panel-row row"
|
||||
>
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="left">
|
||||
{this.props.circle &&
|
||||
(!this.props.circleAlign || this.props.circleAlign == 'left') && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.circle + ' sk-circle small'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
(this.props.faded || this.props.disabled ? 'faded' : '') +
|
||||
' sk-menu-panel-column'
|
||||
}
|
||||
>
|
||||
<div className={this.props.stylekitClass + ' sk-label'}>
|
||||
{this.props.label}
|
||||
</div>
|
||||
{this.props.subtitle && (
|
||||
<div className="sk-sublabel">{this.props.subtitle}</div>
|
||||
)}
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.subRows && this.props.subRows.length > 0 && (
|
||||
<div className="sk-menu-panel-subrows">
|
||||
{this.props.subRows.map((row) => {
|
||||
return (
|
||||
<MenuRow
|
||||
action={row.action}
|
||||
actionArgs={row.actionArgs}
|
||||
label={row.label}
|
||||
spinnerClass={row.spinnerClass}
|
||||
subtitle={row.subtitle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{this.props.circle && this.props.circleAlign == 'right' && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.circle + ' sk-circle small'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.hasButton && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<button
|
||||
className={this.props.buttonClass + ' sn-button small'}
|
||||
onClick={this.props.buttonAction!}
|
||||
>
|
||||
{this.props.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.spinnerClass && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.spinnerClass + ' sk-spinner small'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
import NotesIcon from '../../icons/il-notes.svg';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptionsPanel } from './NotesOptionsPanel';
|
||||
@@ -11,31 +10,31 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount;
|
||||
export const MultipleSelectedNotes = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<NotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">
|
||||
{count} selected notes
|
||||
</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<NotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const MultipleSelectedNotesDirective = toDirective<Props>(
|
||||
MultipleSelectedNotes
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,57 +1,63 @@
|
||||
import { ComponentView } from '@/components/ComponentView';
|
||||
import { PanelResizer } from '@/components/PanelResizer';
|
||||
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
|
||||
import { TagsSection } from '@/components/Tags/TagsSection';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { PremiumModalProvider } from './Premium';
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||
import { PremiumModalProvider } from './Premium';
|
||||
PanelResizer,
|
||||
PanelResizeType,
|
||||
} from './PanelResizer';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const NAVIGATION_SELECTOR = 'navigation';
|
||||
|
||||
const useNavigationPanelRef = (): [HTMLDivElement | null, () => void] => {
|
||||
const [panelRef, setPanelRefInternal] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const setPanelRefPublic = useCallback(() => {
|
||||
const elem = document.querySelector(
|
||||
NAVIGATION_SELECTOR
|
||||
) as HTMLDivElement | null;
|
||||
setPanelRefInternal(elem);
|
||||
}, [setPanelRefInternal]);
|
||||
|
||||
return [panelRef, setPanelRefPublic];
|
||||
};
|
||||
|
||||
export const Navigation: FunctionComponent<Props> = observer(
|
||||
({ application }) => {
|
||||
const appState = useMemo(() => application.getAppState(), [application]);
|
||||
const componentViewer = appState.foldersComponentViewer;
|
||||
const enableNativeSmartTagsFeature =
|
||||
appState.features.enableNativeSmartTagsFeature;
|
||||
const [panelRef, setPanelRef] = useNavigationPanelRef();
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>();
|
||||
const [panelWidth, setPanelWidth] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addEventObserver(async () => {
|
||||
const width = application.getPreference(PrefKey.TagsPanelWidth);
|
||||
if (width) {
|
||||
setPanelWidth(width);
|
||||
}
|
||||
}, ApplicationEvent.PreferencesChanged);
|
||||
|
||||
return () => {
|
||||
removeObserver();
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
const onCreateNewTag = useCallback(() => {
|
||||
appState.tags.createNewTemplate();
|
||||
}, [appState]);
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.TagsPanelWidth, width);
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
|
||||
},
|
||||
[appState]
|
||||
[application, appState]
|
||||
);
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
@@ -62,9 +68,9 @@ export const Navigation: FunctionComponent<Props> = observer(
|
||||
<PremiumModalProvider state={appState.features}>
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section"
|
||||
className="sn-component section app-column app-column-first"
|
||||
data-aria-label="Navigation"
|
||||
ref={setPanelRef}
|
||||
ref={setRef}
|
||||
>
|
||||
{componentViewer ? (
|
||||
<div className="component-view-container">
|
||||
@@ -102,16 +108,18 @@ export const Navigation: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{panelRef && (
|
||||
{ref && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={panelRef}
|
||||
prefKey={PrefKey.TagsPanelWidth}
|
||||
panel={ref}
|
||||
hoverable={true}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,5 +127,3 @@ export const Navigation: FunctionComponent<Props> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NavigationDirective = toDirective<Props>(Navigation);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toDirective } from './utils';
|
||||
import { Icon } from './Icon';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@@ -39,5 +38,3 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const NoAccountWarningDirective = toDirective<Props>(NoAccountWarning);
|
||||
|
||||
66
app/assets/javascripts/components/NoteGroupView.tsx
Normal file
66
app/assets/javascripts/components/NoteGroupView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NoteViewController } from '@standardnotes/snjs';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { MultipleSelectedNotes } from '@/components/MultipleSelectedNotes';
|
||||
import { NoteView } from '@/components/NoteView/NoteView';
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean;
|
||||
controllers: NoteViewController[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export class NoteGroupView extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
controllers: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(
|
||||
() => {
|
||||
this.setState({
|
||||
controllers: this.application.noteControllerGroup.noteControllers,
|
||||
});
|
||||
}
|
||||
);
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="note-group-view" className="h-full app-column app-column-third">
|
||||
{this.state.showMultipleSelectedNotes && (
|
||||
<MultipleSelectedNotes
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!this.state.showMultipleSelectedNotes && (
|
||||
<>
|
||||
{this.state.controllers.map((controller) => {
|
||||
return (
|
||||
<NoteView
|
||||
application={this.application}
|
||||
controller={controller}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { toDirective } from './utils';
|
||||
import { AutocompleteTagInput } from './AutocompleteTagInput';
|
||||
import { NoteTag } from './NoteTag';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
@@ -9,33 +8,24 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const {
|
||||
tags,
|
||||
tagsContainerMaxWidth,
|
||||
} = appState.noteTags;
|
||||
export const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const { tags, tagsContainerMaxWidth } = appState.noteTags;
|
||||
|
||||
useEffect(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
}, [appState.noteTags]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
|
||||
style={{
|
||||
maxWidth: tagsContainerMaxWidth,
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<NoteTag
|
||||
key={tag.uuid}
|
||||
appState={appState}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
<div
|
||||
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
|
||||
style={{
|
||||
maxWidth: tagsContainerMaxWidth,
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const NoteTagsContainerDirective = toDirective<Props>(NoteTagsContainer);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { NoteView } from '@Views/note_view/note_view';
|
||||
import { NoteView } from './NoteView';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
@@ -13,8 +13,7 @@ describe('editor-view', () => {
|
||||
let setShowProtectedWarningSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
|
||||
ctrl = new NoteView($timeout);
|
||||
ctrl = new NoteView({} as any);
|
||||
|
||||
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
|
||||
|
||||
@@ -162,7 +161,7 @@ describe('editor-view', () => {
|
||||
describe('the note has protection sources', () => {
|
||||
it('should reveal note contents if the authorization has been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(true));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
@@ -172,7 +171,7 @@ describe('editor-view', () => {
|
||||
|
||||
it('should not reveal note contents if the authorization has not been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(false));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
@@ -184,7 +183,7 @@ describe('editor-view', () => {
|
||||
describe('the note does not have protection sources', () => {
|
||||
it('should reveal note contents', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'hasProtectionSources')
|
||||
.spyOn(ctrl['application'], 'hasProtectionSources')
|
||||
.mockImplementation(() => false);
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils';
|
||||
import { useCloseOnBlur, useCloseOnClickOutside } from './utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptions } from './NotesOptions/NotesOptions';
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
export const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
|
||||
appState.notes;
|
||||
|
||||
@@ -19,8 +19,8 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
appState.notes.setContextMenuOpen(open)
|
||||
);
|
||||
|
||||
useCloseOnClickOutside(contextMenuRef, (open: boolean) =>
|
||||
appState.notes.setContextMenuOpen(open)
|
||||
useCloseOnClickOutside(contextMenuRef, () =>
|
||||
appState.notes.setContextMenuOpen(false)
|
||||
);
|
||||
|
||||
const reloadContextMenuLayout = useCallback(() => {
|
||||
@@ -51,5 +51,3 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
|
||||
export const NotesContextMenuDirective = toDirective<Props>(NotesContextMenu);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from './Icon';
|
||||
import { Menu } from './menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
|
||||
import { toDirective, useCloseOnClickOutside } from './utils';
|
||||
import { useCloseOnClickOutside } from './utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
@@ -118,10 +118,8 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useCloseOnClickOutside(menuRef, (open: boolean) => {
|
||||
if (!open) {
|
||||
closeDisplayOptionsMenu();
|
||||
}
|
||||
useCloseOnClickOutside(menuRef, () => {
|
||||
closeDisplayOptionsMenu();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -258,11 +256,3 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesListOptionsDirective = toDirective<Props>(
|
||||
NotesListOptionsMenu,
|
||||
{
|
||||
closeDisplayOptionsMenu: '=',
|
||||
state: '&',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/views/note_view/note_view';
|
||||
} from '@/components/NoteView/NoteView';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
@@ -97,5 +97,3 @@ export const NotesOptionsPanel = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
@@ -13,16 +9,20 @@ import { useEffect, useRef } from 'preact/hooks';
|
||||
import { NoAccountWarning } from './NoAccountWarning';
|
||||
import { NotesList } from './NotesList';
|
||||
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
|
||||
import { PanelResizer } from './PanelResizer';
|
||||
import { SearchOptions } from './SearchOptions';
|
||||
import { toDirective } from './utils';
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
PanelResizer,
|
||||
PanelResizeType,
|
||||
} from './PanelResizer';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NotesView: FunctionComponent<Props> = observer(
|
||||
export const NotesView: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
const notesViewPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -46,6 +46,7 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
onSearchInputBlur,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
} = appState.notesView;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,11 +125,12 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
_lastWidth,
|
||||
width,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed
|
||||
) => {
|
||||
application.setPreference(PrefKey.NotesPanelWidth, width);
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
|
||||
};
|
||||
@@ -140,7 +142,7 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
return (
|
||||
<div
|
||||
id="notes-column"
|
||||
className="sn-component section notes"
|
||||
className="sn-component section notes app-column app-column-second"
|
||||
aria-label="Notes"
|
||||
ref={notesViewPanelRef}
|
||||
>
|
||||
@@ -239,19 +241,19 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
{notesViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
hoverable={true}
|
||||
defaultWidth={300}
|
||||
panel={document.querySelector('notes-view') as HTMLDivElement}
|
||||
prefKey={PrefKey.NotesPanelWidth}
|
||||
panel={notesViewPanelRef.current}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesViewDirective = toDirective<Props>(NotesView);
|
||||
|
||||
@@ -1,60 +1,340 @@
|
||||
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';
|
||||
import { Component, createRef } from 'preact';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
export const PanelResizer: FunctionComponent<PanelResizerProps> = 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,
|
||||
})
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => void;
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
|
||||
export enum PanelResizeType {
|
||||
WidthOnly = 'WidthOnly',
|
||||
OffsetAndWidth = 'OffsetAndWidth',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
left: number;
|
||||
alwaysVisible?: boolean;
|
||||
collapsable?: boolean;
|
||||
defaultWidth?: number;
|
||||
hoverable?: boolean;
|
||||
minWidth?: number;
|
||||
panel: HTMLDivElement;
|
||||
side: PanelSide;
|
||||
type: PanelResizeType;
|
||||
resizeFinishCallback?: ResizeFinishCallback;
|
||||
widthEventCallback?: () => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
pressed: boolean;
|
||||
};
|
||||
|
||||
export class PanelResizer extends Component<Props, State> {
|
||||
private overlay?: HTMLDivElement;
|
||||
private resizerElementRef = createRef<HTMLDivElement>();
|
||||
private debouncedResizeHandler: () => void;
|
||||
private startLeft: number;
|
||||
private startWidth: number;
|
||||
private lastDownX: number;
|
||||
private lastLeft: number;
|
||||
private lastWidth: number;
|
||||
private widthBeforeLastDblClick: number;
|
||||
private minWidth: number;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
pressed: false,
|
||||
};
|
||||
|
||||
this.minWidth = props.minWidth || 5;
|
||||
this.startLeft = props.panel.offsetLeft;
|
||||
this.startWidth = props.panel.scrollWidth;
|
||||
this.lastDownX = 0;
|
||||
this.lastLeft = props.panel.offsetLeft;
|
||||
this.lastWidth = props.panel.scrollWidth;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
|
||||
this.setWidth(this.props.width);
|
||||
this.setLeft(this.props.left);
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
this.debouncedResizeHandler = debounce(this.handleResize, 250);
|
||||
if (this.props.side === PanelSide.Right) {
|
||||
window.addEventListener('resize', this.debouncedResizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.width != prevProps.width) {
|
||||
this.setWidth(this.props.width);
|
||||
}
|
||||
if (this.props.left !== prevProps.left) {
|
||||
this.setLeft(this.props.left);
|
||||
this.setWidth(this.props.width);
|
||||
}
|
||||
|
||||
const isCollapsed = this.isCollapsed();
|
||||
if (isCollapsed !== this.state.collapsed) {
|
||||
this.setState({ collapsed: isCollapsed });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
window.removeEventListener('resize', this.debouncedResizeHandler);
|
||||
}
|
||||
|
||||
get appFrame() {
|
||||
return document.getElementById('app')?.getBoundingClientRect() as DOMRect;
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
isAtMaxWidth = () => {
|
||||
const marginOfError = 5;
|
||||
const difference = Math.abs(
|
||||
Math.round(this.lastWidth + this.lastLeft) -
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
const panelResizerRef = useRef<HTMLDivElement>(null);
|
||||
return difference < marginOfError;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (panelResizerRef.current) {
|
||||
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.minWidth;
|
||||
}
|
||||
|
||||
finishSettingWidth = () => {
|
||||
if (!this.props.collapsable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
collapsed: this.isCollapsed(),
|
||||
});
|
||||
};
|
||||
|
||||
setWidth = (width: number, finish = false): void => {
|
||||
if (width === 0) {
|
||||
width = this.computeMaxWidth();
|
||||
}
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth;
|
||||
}
|
||||
|
||||
const parentRect = this.getParentRect();
|
||||
if (width > parentRect.width) {
|
||||
width = parentRect.width;
|
||||
}
|
||||
|
||||
const maxWidth =
|
||||
this.appFrame.width - this.props.panel.getBoundingClientRect().x;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
const isFullWidth =
|
||||
Math.round(width + this.lastLeft) === Math.round(parentRect.width);
|
||||
if (isFullWidth) {
|
||||
if (this.props.type === PanelResizeType.WidthOnly) {
|
||||
this.props.panel.style.removeProperty('width');
|
||||
} else {
|
||||
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
|
||||
}
|
||||
}, [panelResizerState]);
|
||||
} else {
|
||||
this.props.panel.style.width = width + 'px';
|
||||
}
|
||||
this.lastWidth = width;
|
||||
if (finish) {
|
||||
this.finishSettingWidth();
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLeft = (left: number) => {
|
||||
this.props.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
};
|
||||
|
||||
onDblClick = () => {
|
||||
const collapsed = this.isCollapsed();
|
||||
if (collapsed) {
|
||||
this.setWidth(
|
||||
this.widthBeforeLastDblClick || this.props.defaultWidth || 0
|
||||
);
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.minWidth);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
|
||||
this.props.resizeFinishCallback?.(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
};
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.props.widthEventCallback) {
|
||||
this.props.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.props.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.minWidth) {
|
||||
newWidth = this.minWidth;
|
||||
}
|
||||
if (newWidth > parentRect.width) {
|
||||
newWidth = parentRect.width;
|
||||
}
|
||||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth;
|
||||
}
|
||||
this.setLeft(newLeft);
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
computeMaxWidth(): number {
|
||||
const parentRect = this.getParentRect();
|
||||
let width = parentRect.width - this.props.left;
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
const startWidth = this.isAtMaxWidth()
|
||||
? this.computeMaxWidth()
|
||||
: this.props.panel.scrollWidth;
|
||||
|
||||
this.startWidth = startWidth;
|
||||
this.lastWidth = startWidth;
|
||||
|
||||
this.handleWidthEvent();
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.addInvisibleOverlay();
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.props.panel.scrollWidth;
|
||||
this.startLeft = this.props.panel.offsetLeft;
|
||||
this.setState({
|
||||
pressed: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.state.pressed) {
|
||||
return;
|
||||
}
|
||||
this.setState({ pressed: false });
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (!this.state.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.props.side === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
const overlayElement = document.createElement('div');
|
||||
overlayElement.id = 'resizer-overlay';
|
||||
this.overlay = overlayElement;
|
||||
document.body.prepend(this.overlay);
|
||||
};
|
||||
|
||||
removeInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className={`panel-resizer ${panelResizerState.side} ${
|
||||
panelResizerState.hoverable ? 'hoverable' : ''
|
||||
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${
|
||||
panelResizerState.pressed ? 'dragging' : ''
|
||||
} ${panelResizerState.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={panelResizerState.onMouseDown}
|
||||
onDblClick={panelResizerState.onDblClick}
|
||||
ref={panelResizerRef}
|
||||
className={`panel-resizer ${this.props.side} ${
|
||||
this.props.hoverable ? 'hoverable' : ''
|
||||
} ${this.props.alwaysVisible ? 'alwaysVisible' : ''} ${
|
||||
this.state.pressed ? 'dragging' : ''
|
||||
} ${this.state.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onDblClick={this.onDblClick}
|
||||
ref={this.resizerElementRef}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal file
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { createRef, JSX } from 'preact';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
type State = {
|
||||
continueTitle: string;
|
||||
formData: FormData;
|
||||
isContinuing?: boolean;
|
||||
lockContinue?: boolean;
|
||||
processing?: boolean;
|
||||
showSpinner?: boolean;
|
||||
step: Steps;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = 'Continue';
|
||||
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2,
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
newPasswordConfirmation?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export class PasswordWizard extends PureComponent<Props, State> {
|
||||
private currentPasswordInput = createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.registerWindowUnloadStopper();
|
||||
this.state = {
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: 'Change Password',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.currentPasswordInput.current?.focus();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
super.componentWillUnmount();
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
isContinuing: false,
|
||||
});
|
||||
}
|
||||
|
||||
nextStep = async () => {
|
||||
if (this.state.lockContinue || this.state.isContinuing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: true,
|
||||
showSpinner: true,
|
||||
continueTitle: 'Generating Keys...',
|
||||
});
|
||||
|
||||
const valid = await this.validateCurrentPassword();
|
||||
if (!valid) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.processPasswordChange();
|
||||
if (!success) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: false,
|
||||
showSpinner: false,
|
||||
continueTitle: 'Finish',
|
||||
step: Steps.FinishStep,
|
||||
});
|
||||
};
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword;
|
||||
const newPass = this.state.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService.alert(
|
||||
'Please enter your current password.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService.alert('Please enter a new password.');
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService.alert(
|
||||
'Your new password does not match its confirmation.'
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService.alert(
|
||||
"We don't have your email stored. Please sign out then log back in to fix this issue."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword!
|
||||
);
|
||||
if (!success) {
|
||||
this.application.alertService.alert(
|
||||
'The current password you entered is not correct. Please try again.'
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.application.downloadBackup();
|
||||
|
||||
this.setState({
|
||||
lockContinue: true,
|
||||
processing: true,
|
||||
});
|
||||
|
||||
await this.setFormDataState({
|
||||
status: 'Processing encryption keys…',
|
||||
});
|
||||
|
||||
const newPassword = this.state.formData.newPassword;
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword!,
|
||||
newPassword!
|
||||
);
|
||||
|
||||
const success = !response.error;
|
||||
this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: 'Unable to process your password. Please try again.',
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: 'Successfully changed password.',
|
||||
},
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService.alert(
|
||||
'Cannot close window until pending tasks are complete.'
|
||||
);
|
||||
} else {
|
||||
this.dismissModal();
|
||||
}
|
||||
};
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleCurrentPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
currentPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleNewPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleNewPasswordConfirmationInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPasswordConfirmation: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="password-wizard" className="sk-modal small auto-height">
|
||||
<div className="sk-modal-background" />
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">
|
||||
{this.state.title}
|
||||
</div>
|
||||
<a onClick={this.dismiss} className="sk-a info close-button">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
{this.state.step === Steps.PasswordStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-panel-column stretch">
|
||||
<form className="sk-panel-form">
|
||||
<label
|
||||
htmlFor="password-wiz-current-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
ref={this.currentPasswordInput}
|
||||
id="password-wiz-current-password"
|
||||
value={this.state.formData.currentPassword}
|
||||
onChange={this.handleCurrentPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label
|
||||
htmlFor="password-wiz-new-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-new-password"
|
||||
value={this.state.formData.newPassword}
|
||||
onChange={this.handleNewPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label
|
||||
htmlFor="password-wiz-confirm-new-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-confirm-new-password"
|
||||
value={
|
||||
this.state.formData.newPasswordConfirmation
|
||||
}
|
||||
onChange={
|
||||
this.handleNewPasswordConfirmationInputChange
|
||||
}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.step === Steps.FinishStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-label sk-bold info">
|
||||
Your password has been successfully changed.
|
||||
</div>
|
||||
<p className="sk-p">
|
||||
Please ensure you are running the latest version of
|
||||
Standard Notes on all platforms to ensure maximum
|
||||
compatibility.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.nextStep}
|
||||
disabled={this.state.lockContinue}
|
||||
className="sn-button min-w-20 info"
|
||||
>
|
||||
{this.state.continueTitle}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal file
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
import { Component } from 'preact';
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
callback: (approved: boolean) => void;
|
||||
component: SNComponent;
|
||||
permissionsString: string;
|
||||
}
|
||||
|
||||
export class PermissionsModal extends Component<Props> {
|
||||
getElement(): Element | null {
|
||||
return findDOMNode(this);
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
const elem = this.getElement();
|
||||
if (!elem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = elem.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.remove();
|
||||
unmountComponentAtNode(parent);
|
||||
};
|
||||
|
||||
accept = () => {
|
||||
this.props.callback(true);
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
deny = () => {
|
||||
this.props.callback(false);
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.deny} className="sk-modal-background" />
|
||||
<div id="permissions-modal" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Activate Component</div>
|
||||
<a onClick={this.deny} className="sk-a info close-button">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h2">
|
||||
<strong>{this.props.component.name}</strong>
|
||||
{' would like to interact with your '}
|
||||
{this.props.permissionsString}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<p className="sk-p">
|
||||
Components use an offline messaging system to communicate.
|
||||
Learn more at{' '}
|
||||
<a
|
||||
href="https://standardnotes.com/permissions"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="sk-a info"
|
||||
>
|
||||
https://standardnotes.com/permissions.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.accept}
|
||||
className="sn-button info block w-full text-base py-3"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon } from './Icon';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
@@ -34,5 +33,3 @@ export const PinNoteButton: FunctionComponent<Props> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const PinNoteButtonDirective = toDirective<Props>(PinNoteButton);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
@@ -7,7 +6,7 @@ type Props = {
|
||||
hasProtectionSources: boolean;
|
||||
};
|
||||
|
||||
function ProtectedNoteOverlay({
|
||||
export function ProtectedNoteOverlay({
|
||||
appState,
|
||||
onViewNote,
|
||||
hasProtectionSources,
|
||||
@@ -41,11 +40,3 @@ function ProtectedNoteOverlay({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProtectedNoteOverlayDirective = toDirective<Props>(
|
||||
ProtectedNoteOverlay,
|
||||
{
|
||||
onViewNote: '&',
|
||||
hasProtectionSources: '=',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch } from '../Switch';
|
||||
import { toDirective, useCloseOnBlur } from '../utils';
|
||||
import { useCloseOnBlur, useCloseOnClickOutside } from '../utils';
|
||||
import {
|
||||
quickSettingsKeyDownHandler,
|
||||
themesMenuKeyDownHandler,
|
||||
@@ -33,6 +33,7 @@ const MENU_CLASSNAME =
|
||||
type MenuProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
onClickOutside: () => void;
|
||||
};
|
||||
|
||||
const toggleFocusMode = (enabled: boolean) => {
|
||||
@@ -62,8 +63,8 @@ export const sortThemes = (a: SNTheme, b: SNTheme) => {
|
||||
}
|
||||
};
|
||||
|
||||
const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
({ application, appState }) => {
|
||||
export const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
({ application, appState, onClickOutside }) => {
|
||||
const {
|
||||
closeQuickSettingsMenu,
|
||||
shouldAnimateCloseMenu,
|
||||
@@ -84,6 +85,11 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
const quickSettingsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
useCloseOnClickOutside(mainRef, () => {
|
||||
onClickOutside();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
toggleFocusMode(focusModeEnabled);
|
||||
}, [focusModeEnabled]);
|
||||
@@ -223,7 +229,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div ref={mainRef} className="sn-component">
|
||||
<div
|
||||
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
|
||||
shouldAnimateCloseMenu
|
||||
@@ -320,6 +326,3 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const QuickSettingsMenuDirective =
|
||||
toDirective<MenuProps>(QuickSettingsMenu);
|
||||
|
||||
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal file
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
|
||||
import { PayloadContent } from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { ComponentView } from './ComponentView';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
content: PayloadContent;
|
||||
title?: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
type State = {
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
export class RevisionPreviewModal extends PureComponent<Props, State> {
|
||||
private originalNote!: SNNote;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
}
|
||||
|
||||
async componentDidMount(): Promise<void> {
|
||||
super.componentDidMount();
|
||||
|
||||
const templateNote = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.props.content
|
||||
)) as SNNote;
|
||||
|
||||
this.originalNote = this.application.findItem(this.props.uuid) as SNNote;
|
||||
|
||||
const component = this.application.componentManager.editorForNote(
|
||||
this.originalNote
|
||||
);
|
||||
if (component) {
|
||||
const componentViewer =
|
||||
this.application.componentManager.createComponentViewer(component);
|
||||
componentViewer.setReadonly(true);
|
||||
componentViewer.lockReadonly = true;
|
||||
componentViewer.overrideContextItem = templateNote;
|
||||
this.setState({ componentViewer });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
}
|
||||
super.componentWillUnmount();
|
||||
}
|
||||
|
||||
restore = (asCopy: boolean) => {
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.props.content,
|
||||
title: this.props.content.title
|
||||
? this.props.content.title + ' (copy)'
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
this.application.changeAndSaveItem(
|
||||
this.props.uuid,
|
||||
(mutator) => {
|
||||
mutator.unsafe_setCustomContent(this.props.content);
|
||||
},
|
||||
true,
|
||||
PayloadSource.RemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
if (this.originalNote.locked) {
|
||||
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
};
|
||||
|
||||
dismiss = ($event?: Event) => {
|
||||
$event?.stopPropagation();
|
||||
this.dismissModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="item-preview-modal" className="sk-modal medium">
|
||||
<div className="sk-modal-background" />
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div>
|
||||
<div className="sk-panel-header-title">Preview</div>
|
||||
{this.props.title && (
|
||||
<div className="sk-subtitle neutral mt-1">
|
||||
{this.props.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-horizontal-group">
|
||||
<a
|
||||
onClick={() => this.restore(false)}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Restore
|
||||
</a>
|
||||
<a
|
||||
onClick={() => this.restore(true)}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Restore as copy
|
||||
</a>
|
||||
<a
|
||||
onClick={this.dismiss}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!this.state.componentViewer && (
|
||||
<div className="sk-panel-content selectable">
|
||||
<div className="sk-h2">{this.props.content.title}</div>
|
||||
<p
|
||||
style="white-space: pre-wrap; font-size: 16px;"
|
||||
className="normal sk-p"
|
||||
>
|
||||
{this.props.content.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.componentViewer && (
|
||||
<>
|
||||
<div
|
||||
style="height: auto; flex-grow: 0"
|
||||
className="sk-panel-content sk-h2"
|
||||
>
|
||||
{this.props.content.title}
|
||||
</div>
|
||||
<div className="component-view">
|
||||
<ComponentView
|
||||
componentViewer={this.state.componentViewer}
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
@@ -114,5 +114,3 @@ export const SearchOptions = observer(({ appState }: Props) => {
|
||||
</Disclosure>
|
||||
);
|
||||
});
|
||||
|
||||
export const SearchOptionsDirective = toDirective<Props>(SearchOptions);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogLabel,
|
||||
} from '@reach/alert-dialog';
|
||||
import { toDirective } from './utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
@@ -26,12 +25,12 @@ type Session = RemoteSession & {
|
||||
function useSessions(
|
||||
application: SNApplication
|
||||
): [
|
||||
Session[],
|
||||
() => void,
|
||||
boolean,
|
||||
(uuid: UuidString) => Promise<void>,
|
||||
string
|
||||
] {
|
||||
Session[],
|
||||
() => void,
|
||||
boolean,
|
||||
(uuid: UuidString) => Promise<void>,
|
||||
string
|
||||
] {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastRefreshDate, setLastRefreshDate] = useState(Date.now());
|
||||
const [refreshing, setRefreshing] = useState(true);
|
||||
@@ -93,19 +92,14 @@ function useSessions(
|
||||
return [sessions, refresh, refreshing, revokeSession, errorMessage];
|
||||
}
|
||||
|
||||
const SessionsModal: FunctionComponent<{
|
||||
const SessionsModalContent: FunctionComponent<{
|
||||
appState: AppState;
|
||||
application: SNApplication;
|
||||
}> = ({ appState, application }) => {
|
||||
const close = () => appState.closeSessionsModal();
|
||||
|
||||
const [
|
||||
sessions,
|
||||
refresh,
|
||||
refreshing,
|
||||
revokeSession,
|
||||
errorMessage,
|
||||
] = useSessions(application);
|
||||
const [sessions, refresh, refreshing, revokeSession, errorMessage] =
|
||||
useSessions(application);
|
||||
|
||||
const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState('');
|
||||
const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
|
||||
@@ -240,15 +234,15 @@ const SessionsModal: FunctionComponent<{
|
||||
);
|
||||
};
|
||||
|
||||
export const Sessions: FunctionComponent<{
|
||||
export const SessionsModal: FunctionComponent<{
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
}> = observer(({ appState, application }) => {
|
||||
if (appState.isSessionsModalVisible) {
|
||||
return <SessionsModal application={application} appState={appState} />;
|
||||
return (
|
||||
<SessionsModalContent application={application} appState={appState} />
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const SessionsModalDirective = toDirective(Sessions);
|
||||
|
||||
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal file
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { Fragment } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class SyncResolutionMenu extends PureComponent<Props> {
|
||||
private status: Partial<{
|
||||
backupFinished: boolean;
|
||||
resolving: boolean;
|
||||
attemptedResolution: boolean;
|
||||
success: boolean;
|
||||
fail: boolean;
|
||||
}> = {};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
}
|
||||
|
||||
downloadBackup(encrypted: boolean) {
|
||||
this.props.application.getArchiveService().downloadBackup(encrypted);
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
skipBackup() {
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
async performSyncResolution() {
|
||||
this.status.resolving = true;
|
||||
await this.props.application.resolveOutOfSync();
|
||||
|
||||
this.status.resolving = false;
|
||||
this.status.attemptedResolution = true;
|
||||
if (this.props.application.isOutOfSync()) {
|
||||
this.status.fail = true;
|
||||
} else {
|
||||
this.status.success = true;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="sync-resolution-menu" className="sk-panel sk-panel-right">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Out of Sync</div>
|
||||
<a onClick={this.close} className="sk-a info close-button">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row sk-p">
|
||||
We've detected that the data on the server may not match the
|
||||
data in the current application session.
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 1 — Restart App:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
Quit the application and re-open it. Sometimes, this may
|
||||
resolve the issue.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 2 (recommended) — Sign Out:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
Sign out of your account, then sign back in. This will
|
||||
ensure your data is consistent with the server.
|
||||
</div>
|
||||
Be sure to download a backup of your data before doing so.
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 3 — Sync Resolution:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
We can attempt to reconcile changes by downloading all data
|
||||
from the server. No existing data will be overwritten. If
|
||||
the local contents of an item differ from what the server
|
||||
has, a conflicted copy will be created.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!this.status.backupFinished && (
|
||||
<Fragment>
|
||||
<div className="sk-p sk-panel-row">
|
||||
Please download a backup before we attempt to perform a full
|
||||
account sync resolution.
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => this.downloadBackup(true)}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Encrypted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => this.downloadBackup(false)}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Decrypted
|
||||
</button>
|
||||
<button
|
||||
onClick={this.skipBackup}
|
||||
className="sn-button small danger"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{this.status.backupFinished && (
|
||||
<div>
|
||||
{!this.status.resolving && !this.status.attemptedResolution && (
|
||||
<div className="sk-panel-row">
|
||||
<button
|
||||
onClick={this.performSyncResolution}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Perform Sync Resolution
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{this.status.resolving && (
|
||||
<div className="sk-panel-row justify-left">
|
||||
<div className="sk-horizontal-group">
|
||||
<div className="sk-spinner small info" />
|
||||
<div className="sk-label">
|
||||
Attempting sync resolution...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.status.fail && (
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-panel-row sk-label danger">
|
||||
Sync Resolution Failed
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
We attempted to reconcile local content and server
|
||||
content, but were unable to do so. At this point, we
|
||||
recommend signing out of your account and signing back
|
||||
in. You may wish to download a data backup before doing
|
||||
so.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.status.success && (
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-panel-row sk-label success">
|
||||
Sync Resolution Success
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
Your local data is now in sync with the server. You may
|
||||
close this window.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,55 +33,27 @@ export function useCloseOnBlur(
|
||||
|
||||
export function useCloseOnClickOutside(
|
||||
container: { current: HTMLDivElement | null },
|
||||
setOpen: (open: boolean) => void
|
||||
callback: () => void
|
||||
): void {
|
||||
const closeOnClickOutside = useCallback(
|
||||
(event: { target: EventTarget | null }) => {
|
||||
if (!container.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
if (!container.current) {
|
||||
return;
|
||||
}
|
||||
const isDescendant = container.current.contains(event.target as Node);
|
||||
if (!isDescendant) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
[container, setOpen]
|
||||
[container, callback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', closeOnClickOutside);
|
||||
document.addEventListener('click', closeOnClickOutside, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('click', closeOnClickOutside);
|
||||
document.removeEventListener('click', closeOnClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [closeOnClickOutside]);
|
||||
}
|
||||
|
||||
export function toDirective<Props>(
|
||||
component: FunctionComponent<Props>,
|
||||
scope: Record<string, '=' | '&' | '@'> = {}
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
return function () {
|
||||
return {
|
||||
controller: [
|
||||
'$element',
|
||||
'$scope',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
($element: JQLite, $scope: any) => {
|
||||
if ($scope.class) {
|
||||
$element.addClass($scope.class);
|
||||
}
|
||||
return {
|
||||
$onChanges() {
|
||||
render(h(component, $scope), $element[0]);
|
||||
},
|
||||
$onDestroy() {
|
||||
unmountComponentAtNode($element[0]);
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
scope: {
|
||||
application: '=',
|
||||
appState: '=',
|
||||
...scope,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function autofocus($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
shouldFocus: '='
|
||||
},
|
||||
link: function (
|
||||
$scope: ng.IScope,
|
||||
$element: JQLite
|
||||
) {
|
||||
$timeout(() => {
|
||||
if (($scope as any).shouldFocus) {
|
||||
$element[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function clickOutside($document: ng.IDocumentService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
link($scope: ng.IScope, $element: JQLite, attrs: any) {
|
||||
let didApplyClickOutside = false;
|
||||
|
||||
function onElementClick(event: JQueryEventObject) {
|
||||
didApplyClickOutside = false;
|
||||
if (attrs.isOpen) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(event: JQueryEventObject) {
|
||||
/** Ignore click if on SKAlert */
|
||||
if (event.target.closest('.sk-modal')) {
|
||||
return;
|
||||
}
|
||||
if (!didApplyClickOutside) {
|
||||
$scope.$apply(attrs.clickOutside);
|
||||
didApplyClickOutside = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
attrs.clickOutside = undefined;
|
||||
$element.unbind('click', onElementClick);
|
||||
$document.unbind('click', onDocumentClick);
|
||||
});
|
||||
|
||||
$element.bind('click', onElementClick);
|
||||
$document.bind('click', onDocumentClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
/* @ngInject */
|
||||
export function delayHide($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
show: '=',
|
||||
delay: '@'
|
||||
},
|
||||
link: function (scope: ng.IScope, elem: JQLite) {
|
||||
const scopeAny = scope as any;
|
||||
const showSpinner = () => {
|
||||
if (scopeAny.hidePromise) {
|
||||
$timeout.cancel(scopeAny.hidePromise);
|
||||
scopeAny.hidePromise = null;
|
||||
}
|
||||
showElement(true);
|
||||
};
|
||||
|
||||
const hideSpinner = () => {
|
||||
scopeAny.hidePromise = $timeout(
|
||||
showElement.bind(this as any, false),
|
||||
getDelay()
|
||||
);
|
||||
};
|
||||
|
||||
const showElement = (show: boolean) => {
|
||||
show ? elem.css({ display: '' }) : elem.css({ display: 'none' });
|
||||
};
|
||||
|
||||
const getDelay = () => {
|
||||
const delay = parseInt(scopeAny.delay);
|
||||
return angular.isNumber(delay) ? delay : 200;
|
||||
};
|
||||
|
||||
showElement(false);
|
||||
// Whenever the scope variable updates we simply
|
||||
// show if it evaluates to 'true' and hide if 'false'
|
||||
scope.$watch('show', function (newVal) {
|
||||
newVal ? showSpinner() : hideSpinner();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function elemReady($parse: ng.IParseService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function($scope: ng.IScope, elem: JQLite, attrs: any) {
|
||||
elem.ready(function() {
|
||||
$scope.$apply(function() {
|
||||
const func = $parse(attrs.elemReady);
|
||||
func($scope);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function fileChange() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
handler: '&'
|
||||
},
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('change', (event) => {
|
||||
scope.$apply(() => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
(scope as any).handler({
|
||||
files: files
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { autofocus } from './autofocus';
|
||||
export { clickOutside } from './click-outside';
|
||||
export { delayHide } from './delay-hide';
|
||||
export { elemReady } from './elemReady';
|
||||
export { fileChange } from './file-change';
|
||||
export { lowercase } from './lowercase';
|
||||
export { selectOnFocus } from './selectOnFocus';
|
||||
export { snEnter } from './snEnter';
|
||||
@@ -1,24 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function lowercase() {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
link: function (
|
||||
scope: ng.IScope,
|
||||
_: JQLite,
|
||||
attrs: any,
|
||||
ctrl: any
|
||||
) {
|
||||
const lowercase = (inputValue: string) => {
|
||||
if (inputValue === undefined) inputValue = '';
|
||||
const lowercased = inputValue.toLowerCase();
|
||||
if (lowercased !== inputValue) {
|
||||
ctrl.$setViewValue(lowercased);
|
||||
ctrl.$render();
|
||||
}
|
||||
return lowercased;
|
||||
};
|
||||
ctrl.$parsers.push(lowercase);
|
||||
lowercase((scope as any)[attrs.ngModel]);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function selectOnFocus($window: ng.IWindowService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('focus', () => {
|
||||
if (!$window.getSelection()!.toString()) {
|
||||
const input = element[0] as HTMLInputElement;
|
||||
/** Allow text to populate */
|
||||
setTimeout(() => {
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function snEnter() {
|
||||
return function (
|
||||
scope: ng.IScope,
|
||||
element: JQLite,
|
||||
attrs: any
|
||||
) {
|
||||
element.bind('keydown keypress', function (event) {
|
||||
if (event.which === 13) {
|
||||
scope.$apply(function () {
|
||||
scope.$eval(attrs.snEnter, { event: event });
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/actions-menu.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { SNItem, Action, SNActionsExtension, UuidString, CopyPayload } from '@standardnotes/snjs';
|
||||
import { ActionResponse } from '@standardnotes/snjs';
|
||||
import { ActionsExtensionMutator } from '@standardnotes/snjs';
|
||||
|
||||
type ActionsMenuScope = {
|
||||
application: WebApplication
|
||||
item: SNItem
|
||||
}
|
||||
|
||||
type ActionSubRow = {
|
||||
onClick: () => void
|
||||
label: string
|
||||
subtitle: string
|
||||
spinnerClass?: string
|
||||
}
|
||||
|
||||
type ExtensionState = {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
type ActionsMenuState = {
|
||||
extensions: SNActionsExtension[]
|
||||
extensionsState: Record<UuidString, ExtensionState>
|
||||
selectedActionId?: number
|
||||
menuItems: {
|
||||
uuid: UuidString,
|
||||
name: string,
|
||||
loading: boolean,
|
||||
error: boolean,
|
||||
hidden: boolean,
|
||||
actions: (Action & {
|
||||
subrows?: ActionSubRow[]
|
||||
})[]
|
||||
}[]
|
||||
}
|
||||
|
||||
class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements ActionsMenuScope {
|
||||
application!: WebApplication
|
||||
item!: SNItem
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
item: this.item
|
||||
});
|
||||
this.loadExtensions();
|
||||
this.autorun(() => {
|
||||
this.rebuildMenuState({
|
||||
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
const extensions = this.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<UuidString, ExtensionState> = {};
|
||||
extensions.map((extension) => {
|
||||
extensionsState[extension.uuid] = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
extensions,
|
||||
extensionsState,
|
||||
hiddenExtensions: {},
|
||||
menuItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
return {
|
||||
uuid: extension.uuid,
|
||||
name: extension.name,
|
||||
loading: state?.loading ?? false,
|
||||
error: state?.error ?? false,
|
||||
hidden: hidden ?? false,
|
||||
deprecation: extension.deprecation!,
|
||||
actions: extension.actionsWithContextForItem(this.item).map(action => {
|
||||
if (action.id === selectedActionId) {
|
||||
return {
|
||||
...action,
|
||||
subrows: this.subRowsForAction(action, extension)
|
||||
};
|
||||
} else {
|
||||
return action;
|
||||
}
|
||||
})
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async loadExtensions() {
|
||||
await Promise.all(this.state.extensions.map(async (extension: SNActionsExtension) => {
|
||||
this.setLoadingExtension(extension.uuid, true);
|
||||
const updatedExtension = await this.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.item
|
||||
);
|
||||
if (updatedExtension) {
|
||||
await this.updateExtension(updatedExtension!);
|
||||
} else {
|
||||
this.setErrorExtension(extension.uuid, true);
|
||||
}
|
||||
this.setLoadingExtension(extension.uuid, false);
|
||||
}));
|
||||
}
|
||||
|
||||
async executeAction(action: Action, extensionUuid: UuidString) {
|
||||
if (action.verb === 'nested') {
|
||||
this.rebuildMenuState({
|
||||
selectedActionId: action.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
const extension = this.application.findItem(extensionUuid) as SNActionsExtension;
|
||||
await this.updateAction(action, extension, { running: true });
|
||||
const response = await this.application.actionsManager!.runAction(
|
||||
action,
|
||||
this.item,
|
||||
async () => {
|
||||
/** @todo */
|
||||
return '';
|
||||
}
|
||||
);
|
||||
if (response.error) {
|
||||
await this.updateAction(action, extension, { error: true });
|
||||
return;
|
||||
}
|
||||
await this.updateAction(action, extension, { running: false });
|
||||
this.handleActionResponse(action, response);
|
||||
await this.reloadExtension(extension);
|
||||
}
|
||||
|
||||
handleActionResponse(action: Action, result: ActionResponse) {
|
||||
switch (action.verb) {
|
||||
case 'render': {
|
||||
const item = result.item;
|
||||
this.application.presentRevisionPreviewModal(
|
||||
item.uuid,
|
||||
item.content
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private subRowsForAction(parentAction: Action, extension: Pick<SNActionsExtension, 'uuid'>): 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: subaction.running ? 'info' : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async updateAction(
|
||||
action: Action,
|
||||
extension: SNActionsExtension,
|
||||
params: {
|
||||
running?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
) {
|
||||
const updatedExtension = await this.application.changeItem(extension.uuid, (mutator) => {
|
||||
const extensionMutator = mutator as ActionsExtensionMutator;
|
||||
extensionMutator.actions = extension!.actions.map((act) => {
|
||||
if (act && params && act.verb === action.verb && act.url === action.url) {
|
||||
return {
|
||||
...action,
|
||||
running: params?.running,
|
||||
error: params?.error,
|
||||
} as Action;
|
||||
}
|
||||
return act;
|
||||
});
|
||||
}) as SNActionsExtension;
|
||||
await this.updateExtension(updatedExtension);
|
||||
}
|
||||
|
||||
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.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionsMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = ActionsMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { WebDirective } from '../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from '%/directives/history-menu.pug';
|
||||
import { HistoryEntry, SNItem } from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@/views';
|
||||
import { RevisionListEntry, SingleRevision } from '@standardnotes/snjs';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
|
||||
type HistoryState = {
|
||||
sessionHistory?: HistoryEntry[];
|
||||
remoteHistory?: RevisionListEntry[];
|
||||
fetchingRemoteHistory: boolean;
|
||||
autoOptimize: boolean;
|
||||
diskEnabled: boolean;
|
||||
};
|
||||
|
||||
class HistoryMenuCtrl extends PureViewCtrl<unknown, HistoryState> {
|
||||
application!: WebApplication;
|
||||
item!: SNItem;
|
||||
|
||||
/** @template */
|
||||
showSessionOptions = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
fetchingRemoteHistory: false,
|
||||
autoOptimize: this.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory: this.application.historyManager.sessionHistoryForItem(
|
||||
this.item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
reloadState() {
|
||||
this.setState({
|
||||
...this.getInitialState(),
|
||||
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.fetchRemoteHistory();
|
||||
}
|
||||
|
||||
async fetchRemoteHistory() {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
try {
|
||||
const remoteHistory = await this.application.historyManager.remoteHistoryForItem(
|
||||
this.item
|
||||
);
|
||||
this.setState({ remoteHistory });
|
||||
} finally {
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
}
|
||||
}
|
||||
|
||||
async openSessionRevision(revision: HistoryEntry & { previewTitle: () => string }) {
|
||||
this.application.presentRevisionPreviewModal(
|
||||
revision.payload.uuid,
|
||||
revision.payload.content,
|
||||
revision.previewTitle()
|
||||
);
|
||||
}
|
||||
|
||||
async openRemoteRevision(revision: RevisionListEntry) {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
const remoteRevision = await this.application.historyManager.fetchRemoteRevision(
|
||||
this.item.uuid,
|
||||
revision
|
||||
);
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
if (!remoteRevision) {
|
||||
alertDialog({
|
||||
text:
|
||||
'The remote revision could not be loaded. Please try again later.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.application.presentRevisionPreviewModal(
|
||||
remoteRevision.payload.uuid,
|
||||
remoteRevision.payload.content,
|
||||
this.previewRemoteHistoryTitle(revision)
|
||||
);
|
||||
}
|
||||
|
||||
classForSessionRevision(revision: HistoryEntry) {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
async clearItemSessionHistory() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to delete the local session history for this note?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.historyManager.clearHistoryForItem(this.item);
|
||||
this.reloadState();
|
||||
}
|
||||
}
|
||||
|
||||
async clearAllSessionHistory() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to delete the local session history for all notes?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.application.historyManager.clearAllHistory();
|
||||
this.reloadState();
|
||||
}
|
||||
}
|
||||
|
||||
/** @entries */
|
||||
get sessionHistoryEntries() {
|
||||
return this.state.sessionHistory;
|
||||
}
|
||||
|
||||
async toggleSessionHistoryDiskSaving() {
|
||||
if (!this.state.diskEnabled) {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to save history to disk? This will decrease general ' +
|
||||
'performance, especially as you type. You are advised to disable this feature ' +
|
||||
'if you experience any lagging.',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
} else {
|
||||
this.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
this.reloadState();
|
||||
}
|
||||
|
||||
toggleSessionHistoryAutoOptimize() {
|
||||
this.application.historyManager.toggleAutoOptimize();
|
||||
this.reloadState();
|
||||
}
|
||||
|
||||
previewRemoteHistoryTitle(revision: RevisionListEntry) {
|
||||
return new Date(revision.created_at).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = HistoryMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export { ActionsMenu } from './actionsMenu';
|
||||
export { InputModal } from './inputModal';
|
||||
export { MenuRow } from './menuRow';
|
||||
export { PanelResizer } from './panelResizer';
|
||||
export { PasswordWizard } from './passwordWizard';
|
||||
export { PermissionsModal } from './permissionsModal';
|
||||
export { RevisionPreviewModal } from './revisionPreviewModal';
|
||||
export { HistoryMenu } from './historyMenu';
|
||||
export { SyncResolutionMenu } from './syncResolutionMenu';
|
||||
@@ -1,53 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/input-modal.pug';
|
||||
|
||||
export interface InputModalScope extends Partial<ng.IScope> {
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
callback: (value: string) => void
|
||||
}
|
||||
|
||||
class InputModalCtrl implements InputModalScope {
|
||||
|
||||
$element: JQLite
|
||||
type!: string
|
||||
title!: string
|
||||
message!: string
|
||||
callback!: (value: string) => void
|
||||
formData = { input: '' }
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.callback(this.formData.input);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class InputModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = InputModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
title: '=',
|
||||
message: '=',
|
||||
callback: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/menu-row.pug';
|
||||
|
||||
class MenuRowCtrl {
|
||||
|
||||
disabled!: boolean
|
||||
action!: () => void
|
||||
buttonAction!: () => void
|
||||
|
||||
onClick($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.action();
|
||||
}
|
||||
|
||||
clickAccessoryButton($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.buttonAction();
|
||||
}
|
||||
}
|
||||
|
||||
export class MenuRow extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.transclude = true;
|
||||
this.template = template;
|
||||
this.controller = MenuRowCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
action: '&',
|
||||
buttonAction: '&',
|
||||
buttonClass: '=',
|
||||
buttonText: '=',
|
||||
desc: '=',
|
||||
disabled: '=',
|
||||
circle: '=',
|
||||
circleAlign: '=',
|
||||
faded: '=',
|
||||
hasButton: '=',
|
||||
label: '=',
|
||||
spinnerClass: '=',
|
||||
stylekitClass: '=',
|
||||
subRows: '=',
|
||||
subtitle: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { PanelPuppet, WebDirective } from './../../types';
|
||||
import angular from 'angular';
|
||||
import template from '%/directives/panel-resizer.pug';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
enum MouseEventType {
|
||||
Move = 'mousemove',
|
||||
Down = 'mousedown',
|
||||
Up = 'mouseup',
|
||||
}
|
||||
enum CssClass {
|
||||
Hoverable = 'hoverable',
|
||||
AlwaysVisible = 'always-visible',
|
||||
Dragging = 'dragging',
|
||||
NoSelection = 'no-selection',
|
||||
Collapsed = 'collapsed',
|
||||
AnimateOpacity = 'animate-opacity',
|
||||
}
|
||||
const WINDOW_EVENT_RESIZE = 'resize';
|
||||
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
$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
|
||||
) {
|
||||
this.$compile = $compile;
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
|
||||
/** To allow for registering events */
|
||||
this.handleResize = debounce(this.handleResize.bind(this), 250);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configureDefaults();
|
||||
this.reloadDefaultValues();
|
||||
this.configureControl();
|
||||
this.addDoubleClickHandler();
|
||||
this.resizerColumn.addEventListener(MouseEventType.Down, this.onMouseDown);
|
||||
document.addEventListener(MouseEventType.Move, this.onMouseMove);
|
||||
document.addEventListener(MouseEventType.Up, this.onMouseUp);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
(this.onResizeFinish as any) = undefined;
|
||||
(this.onWidthEvent as any) = undefined;
|
||||
(this.control as any) = undefined;
|
||||
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.handleResize as any) = undefined;
|
||||
(this.onMouseMove as any) = undefined;
|
||||
(this.onMouseUp as any) = undefined;
|
||||
(this.onMouseDown as any) = undefined;
|
||||
}
|
||||
|
||||
configureControl() {
|
||||
this.control.setWidth = (value) => {
|
||||
this.setWidth(value, true);
|
||||
};
|
||||
this.control.setLeft = (value) => {
|
||||
this.setLeft(value);
|
||||
};
|
||||
this.control.flash = () => {
|
||||
this.flash();
|
||||
};
|
||||
this.control.isCollapsed = () => {
|
||||
return this.isCollapsed();
|
||||
};
|
||||
this.control.ready = true;
|
||||
this.control.onReady!();
|
||||
}
|
||||
|
||||
configureDefaults() {
|
||||
this.panel = document.getElementById(this.panelId)!;
|
||||
if (!this.panel) {
|
||||
console.error('Panel not found for', this.panelId);
|
||||
return;
|
||||
}
|
||||
this.resizerColumn = this.$element[0];
|
||||
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2;
|
||||
this.pressed = false;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.lastDownX = 0;
|
||||
this.collapsed = false;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.lastLeft = this.startLeft;
|
||||
this.appFrame = undefined;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
if (this.property === PanelSide.Right) {
|
||||
this.configureRightPanel();
|
||||
}
|
||||
if (this.alwaysVisible) {
|
||||
this.resizerColumn.classList.add(CssClass.AlwaysVisible);
|
||||
}
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(CssClass.Hoverable);
|
||||
}
|
||||
}
|
||||
|
||||
configureRightPanel() {
|
||||
window.addEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.reloadDefaultValues();
|
||||
this.handleWidthEvent();
|
||||
this.$timeout(() => {
|
||||
this.finishSettingWidth();
|
||||
});
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
const node = this.panel!.parentNode! as HTMLElement;
|
||||
return node.getBoundingClientRect();
|
||||
}
|
||||
|
||||
reloadDefaultValues() {
|
||||
this.startWidth = this.isAtMaxWidth()
|
||||
? this.getParentRect().width
|
||||
: this.panel.scrollWidth;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.appFrame = document.getElementById('app')!.getBoundingClientRect();
|
||||
}
|
||||
|
||||
addDoubleClickHandler() {
|
||||
this.resizerColumn.ondblclick = () => {
|
||||
this.$timeout(() => {
|
||||
const preClickCollapseState = this.isCollapsed();
|
||||
if (preClickCollapseState) {
|
||||
this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth);
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.currentMinWidth);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
const newCollapseState = !preClickCollapseState;
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
newCollapseState
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent) {
|
||||
this.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.panel.classList.add(CssClass.NoSelection);
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(CssClass.Dragging);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
this.pressed = false;
|
||||
this.resizerColumn.classList.remove(CssClass.Dragging);
|
||||
this.panel.classList.remove(CssClass.NoSelection);
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.onResizeFinish) {
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.property && this.property === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.onWidthEvent && this.onWidthEvent()) {
|
||||
this.onWidthEvent()();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
isAtMaxWidth() {
|
||||
return (
|
||||
Math.round(this.lastWidth + this.lastLeft) ===
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
}
|
||||
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.currentMinWidth;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
setLeft(left: number) {
|
||||
this.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
}
|
||||
|
||||
finishSettingWidth() {
|
||||
if (!this.collapsable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.collapsed = this.isCollapsed();
|
||||
if (this.collapsed) {
|
||||
this.resizerColumn.classList.add(CssClass.Collapsed);
|
||||
} else {
|
||||
this.resizerColumn.classList.remove(CssClass.Collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay() {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(
|
||||
this as any
|
||||
);
|
||||
angular.element(document.body).prepend(this.overlay);
|
||||
}
|
||||
|
||||
removeInvisibleOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
flash() {
|
||||
const FLASH_DURATION = 3000;
|
||||
this.resizerColumn.classList.add(CssClass.AnimateOpacity);
|
||||
this.$timeout(() => {
|
||||
this.resizerColumn.classList.remove(CssClass.AnimateOpacity);
|
||||
}, FLASH_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelResizer extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PanelResizerCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
alwaysVisible: '=',
|
||||
collapsable: '=',
|
||||
control: '=',
|
||||
defaultWidth: '=',
|
||||
hoverable: '=',
|
||||
index: '=',
|
||||
minWidth: '=',
|
||||
onResizeFinish: '&',
|
||||
onWidthEvent: '&',
|
||||
panelId: '=',
|
||||
property: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
PasswordWizardScope,
|
||||
PasswordWizardType,
|
||||
WebDirective,
|
||||
} from './../../types';
|
||||
import template from '%/directives/password-wizard.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = 'Continue';
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2,
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
newPasswordConfirmation?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
lockContinue: boolean;
|
||||
formData: FormData;
|
||||
continueTitle: string;
|
||||
step: Steps;
|
||||
title: string;
|
||||
showSpinner: boolean;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type: PasswordWizardType;
|
||||
changePassword: boolean;
|
||||
securityUpdate: boolean;
|
||||
};
|
||||
|
||||
class PasswordWizardCtrl
|
||||
extends PureViewCtrl<Props, State>
|
||||
implements PasswordWizardScope
|
||||
{
|
||||
$element: JQLite;
|
||||
application!: WebApplication;
|
||||
type!: PasswordWizardType;
|
||||
isContinuing = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.registerWindowUnloadStopper();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
type: this.type,
|
||||
changePassword: this.type === PasswordWizardType.ChangePassword,
|
||||
securityUpdate: this.type === PasswordWizardType.AccountUpgrade,
|
||||
});
|
||||
this.setState({
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: this.props.changePassword ? 'Change Password' : 'Account Update',
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
super.$onDestroy();
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
/** Confirms with user before closing tab */
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
});
|
||||
this.isContinuing = false;
|
||||
}
|
||||
|
||||
async nextStep() {
|
||||
if (this.state.lockContinue || this.isContinuing) {
|
||||
return;
|
||||
}
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isContinuing = true;
|
||||
await this.setState({
|
||||
showSpinner: true,
|
||||
continueTitle: 'Generating Keys...',
|
||||
});
|
||||
const valid = await this.validateCurrentPassword();
|
||||
if (!valid) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
const success = await this.processPasswordChange();
|
||||
if (!success) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
this.isContinuing = false;
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: 'Finish',
|
||||
step: Steps.FinishStep,
|
||||
});
|
||||
}
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword;
|
||||
const newPass = this.props.securityUpdate
|
||||
? currentPassword
|
||||
: this.state.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService!.alert(
|
||||
'Please enter your current password.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this.props.changePassword) {
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService!.alert('Please enter a new password.');
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService!.alert(
|
||||
'Your new password does not match its confirmation.'
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService!.alert(
|
||||
"We don't have your email stored. Please sign out then log back in to fix this issue."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword!
|
||||
);
|
||||
if (!success) {
|
||||
this.application.alertService!.alert(
|
||||
'The current password you entered is not correct. Please try again.'
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.application.downloadBackup();
|
||||
await this.setState({
|
||||
lockContinue: true,
|
||||
processing: true,
|
||||
});
|
||||
await this.setFormDataState({
|
||||
status: 'Processing encryption keys…',
|
||||
});
|
||||
const newPassword = this.props.securityUpdate
|
||||
? this.state.formData.currentPassword
|
||||
: this.state.formData.newPassword;
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword!,
|
||||
newPassword!
|
||||
);
|
||||
const success = !response.error;
|
||||
await this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
});
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: 'Unable to process your password. Please try again.',
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: this.props.changePassword
|
||||
? 'Successfully changed password.'
|
||||
: 'Successfully performed account update.',
|
||||
},
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService!.alert(
|
||||
'Cannot close window until pending tasks are complete.'
|
||||
);
|
||||
} else {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordWizard extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PasswordWizardCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/permissions-modal.pug';
|
||||
|
||||
class PermissionsModalCtrl {
|
||||
$element: JQLite;
|
||||
callback!: (success: boolean) => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
accept() {
|
||||
this.callback(true);
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
deny() {
|
||||
this.callback(false);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionsModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PermissionsModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
show: '=',
|
||||
component: '=',
|
||||
permissionsString: '=',
|
||||
callback: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
|
||||
import { PureViewCtrl } from './../../views/abstract/pure_view_ctrl';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
|
||||
import template from '%/directives/revision-preview-modal.pug';
|
||||
import { PayloadContent } from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
|
||||
|
||||
interface RevisionPreviewScope {
|
||||
uuid: string;
|
||||
content: PayloadContent;
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
type State = {
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
class RevisionPreviewModalCtrl
|
||||
extends PureViewCtrl<unknown, State>
|
||||
implements RevisionPreviewScope
|
||||
{
|
||||
$element: JQLite;
|
||||
$timeout: ng.ITimeoutService;
|
||||
uuid!: string;
|
||||
content!: PayloadContent;
|
||||
title?: string;
|
||||
application!: WebApplication;
|
||||
note!: SNNote;
|
||||
private originalNote!: SNNote;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configure();
|
||||
super.$onInit();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
}
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.application.componentManager;
|
||||
}
|
||||
|
||||
async configure() {
|
||||
this.note = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.content
|
||||
)) as SNNote;
|
||||
this.originalNote = this.application.findItem(this.uuid) as SNNote;
|
||||
const component = this.componentManager.editorForNote(this.originalNote);
|
||||
if (component) {
|
||||
const componentViewer =
|
||||
this.application.componentManager.createComponentViewer(component);
|
||||
componentViewer.setReadonly(true);
|
||||
componentViewer.lockReadonly = true;
|
||||
componentViewer.overrideContextItem = this.note;
|
||||
this.setState({ componentViewer });
|
||||
}
|
||||
}
|
||||
|
||||
restore(asCopy: boolean) {
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.content,
|
||||
title: this.content.title
|
||||
? this.content.title + ' (copy)'
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
this.application.changeAndSaveItem(
|
||||
this.uuid,
|
||||
(mutator) => {
|
||||
mutator.unsafe_setCustomContent(this.content);
|
||||
},
|
||||
true,
|
||||
PayloadSource.RemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
if (this.originalNote.locked) {
|
||||
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class RevisionPreviewModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = RevisionPreviewModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
uuid: '=',
|
||||
content: '=',
|
||||
title: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/sync-resolution-menu.pug';
|
||||
|
||||
class SyncResolutionMenuCtrl {
|
||||
|
||||
closeFunction!: () => void
|
||||
application!: WebApplication
|
||||
|
||||
$timeout: ng.ITimeoutService
|
||||
status: Partial<{
|
||||
backupFinished: boolean,
|
||||
resolving: boolean,
|
||||
attemptedResolution: boolean,
|
||||
success: boolean
|
||||
fail: boolean
|
||||
}> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
downloadBackup(encrypted: boolean) {
|
||||
this.application.getArchiveService().downloadBackup(encrypted);
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
skipBackup() {
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
async performSyncResolution() {
|
||||
this.status.resolving = true;
|
||||
await this.application.resolveOutOfSync();
|
||||
this.$timeout(() => {
|
||||
this.status.resolving = false;
|
||||
this.status.attemptedResolution = true;
|
||||
if (this.application.isOutOfSync()) {
|
||||
this.status.fail = true;
|
||||
} else {
|
||||
this.status.success = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$timeout(() => {
|
||||
this.closeFunction();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncResolutionMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = SyncResolutionMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
closeFunction: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { trusted } from './trusted';
|
||||
@@ -1,6 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function trusted($sce: ng.ISCEService) {
|
||||
return function(url: string) {
|
||||
return $sce.trustAsResourceUrl(url);
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import '@reach/dialog/styles.css';
|
||||
import '../stylesheets/index.css.scss';
|
||||
|
||||
// Vendor
|
||||
import 'angular';
|
||||
import '../../../vendor/assets/javascripts/zip/deflate';
|
||||
import '../../../vendor/assets/javascripts/zip/inflate';
|
||||
import '../../../vendor/assets/javascripts/zip/zip';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export enum RootScopeMessages {
|
||||
NewUpdateAvailable = 'new-update-available'
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
export const Title: FunctionComponent = ({ children }) => (
|
||||
<h2 className="text-base m-0 mb-1">{children}</h2>
|
||||
<>
|
||||
<h2 className="text-base m-0 mb-1">{children}</h2>
|
||||
<div className="min-h-2" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Subtitle: FunctionComponent<{ className?: string }> = ({
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { toDirective } from '../components/utils';
|
||||
import {
|
||||
PreferencesViewWrapper,
|
||||
PreferencesViewWrapperProps,
|
||||
} from './PreferencesViewWrapper';
|
||||
|
||||
export const PreferencesDirective = toDirective<PreferencesViewWrapperProps>(
|
||||
PreferencesViewWrapper
|
||||
);
|
||||
@@ -74,6 +74,7 @@ export const Extensions: FunctionComponent<{
|
||||
|
||||
const confirmExtension = async () => {
|
||||
await application.insertItem(confirmableExtension as SNComponent);
|
||||
application.sync();
|
||||
setExtensions(loadExtensions(application));
|
||||
};
|
||||
|
||||
@@ -109,7 +110,6 @@ export const Extensions: FunctionComponent<{
|
||||
{!confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<Title>Install Custom Extension</Title>
|
||||
<div className="min-h-2" />
|
||||
<DecoratedInput
|
||||
placeholder={'Enter Extension URL'}
|
||||
text={customUrl}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { ContentType, SNActionsExtension } from '@standardnotes/snjs';
|
||||
import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { BlogItem } from './listed/BlogItem';
|
||||
@@ -19,15 +19,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export const Listed = observer(({ application }: Props) => {
|
||||
const [items, setItems] = useState<SNComponent[]>([]);
|
||||
const [items, setItems] = useState<SNActionsExtension[]>([]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const reloadItems = useCallback(() => {
|
||||
const components = application
|
||||
.getItems(ContentType.ActionsExtension)
|
||||
.filter(
|
||||
(item) => (item as SNComponent).package_info?.name === 'Listed'
|
||||
) as SNComponent[];
|
||||
.filter((item) =>
|
||||
(item as SNActionsExtension).url.includes('listed')
|
||||
) as SNActionsExtension[];
|
||||
setItems(components);
|
||||
}, [application]);
|
||||
|
||||
|
||||
@@ -1,68 +1,80 @@
|
||||
import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/preferences/components';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from '@node_modules/mobx-react-lite';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { ChangeEmail } from '@/preferences/panes/account/changeEmail';
|
||||
import { PasswordWizardType } from '@/types';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { FunctionComponent, render } from 'preact';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PasswordWizard } from '@/components/PasswordWizard';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const Credentials: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false);
|
||||
export const Credentials: FunctionComponent<Props> = observer(
|
||||
({ application }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const user = application.getUser();
|
||||
const user = application.getUser();
|
||||
|
||||
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date;
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
|
||||
const passwordCreatedAtTimestamp =
|
||||
application.getUserPasswordCreationDate() as Date;
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<div className={'text-input mt-2'}>
|
||||
Email
|
||||
</div>
|
||||
<Text>
|
||||
You're signed in as <span className='font-bold'>{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className='min-w-20 mt-3'
|
||||
type='normal'
|
||||
label='Change email'
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<HorizontalSeparator classes='mt-5 mb-3' />
|
||||
<div className={'text-input mt-2'}>
|
||||
Password
|
||||
</div>
|
||||
<Text>
|
||||
Current password was set on <span className='font-bold'>{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className='min-w-20 mt-3'
|
||||
type='normal'
|
||||
label='Change password'
|
||||
onClick={() => {
|
||||
application.presentPasswordWizard(PasswordWizardType.ChangePassword);
|
||||
}}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
const presentPasswordWizard = useCallback(() => {
|
||||
render(
|
||||
<PasswordWizard application={application} />,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
}, [application]);
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<Subtitle>Email</Subtitle>
|
||||
<Text>
|
||||
You're signed in as <span className="font-bold">{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
type="normal"
|
||||
label="Change email"
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<Subtitle>Password</Subtitle>
|
||||
<Text>
|
||||
Current password was set on{' '}
|
||||
<span className="font-bold">{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
type="normal"
|
||||
label="Change password"
|
||||
onClick={presentPasswordWizard}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ const SignOutView: FunctionComponent<{
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Sign out</Title>
|
||||
<div className="min-h-2" />
|
||||
<Subtitle>Other devices</Subtitle>
|
||||
<Text>Want to sign out on all devices except this one?</Text>
|
||||
<div className="min-h-3" />
|
||||
@@ -74,7 +73,6 @@ const ClearSessionDataView: FunctionComponent<{
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Clear session data</Title>
|
||||
<div className="min-h-2" />
|
||||
<Text>This will delete all local items and preferences.</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
|
||||
@@ -1,86 +1,113 @@
|
||||
import { FunctionComponent } from "preact";
|
||||
import { SNComponent } from "@standardnotes/snjs";
|
||||
import { PreferencesSegment, SubtitleLight, Title } from "@/preferences/components";
|
||||
import { Switch } from "@/components/Switch";
|
||||
import { WebApplication } from "@/ui_models/application";
|
||||
import { useState } from "preact/hooks";
|
||||
import { Button } from "@/components/Button";
|
||||
import { RenameExtension } from "./RenameExtension";
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
import {
|
||||
PreferencesSegment,
|
||||
SubtitleLight,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Button } from '@/components/Button';
|
||||
import { RenameExtension } from './RenameExtension';
|
||||
|
||||
const UseHosted: FunctionComponent<{
|
||||
offlineOnly: boolean, toggleOfllineOnly: () => void
|
||||
offlineOnly: boolean;
|
||||
toggleOfllineOnly: () => void;
|
||||
}> = ({ offlineOnly, toggleOfllineOnly }) => (
|
||||
<div className="flex flex-row">
|
||||
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
|
||||
<SubtitleLight className="flex-grow">
|
||||
Use hosted when local is unavailable
|
||||
</SubtitleLight>
|
||||
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface ExtensionItemProps {
|
||||
application: WebApplication,
|
||||
extension: SNComponent,
|
||||
first: boolean,
|
||||
latestVersion: string | undefined,
|
||||
uninstall: (extension: SNComponent) => void,
|
||||
toggleActivate?: (extension: SNComponent) => void,
|
||||
application: WebApplication;
|
||||
extension: SNComponent;
|
||||
first: boolean;
|
||||
latestVersion: string | undefined;
|
||||
uninstall: (extension: SNComponent) => void;
|
||||
toggleActivate?: (extension: SNComponent) => void;
|
||||
}
|
||||
|
||||
export const ExtensionItem: FunctionComponent<ExtensionItemProps> =
|
||||
({ application, extension, first, uninstall}) => {
|
||||
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false);
|
||||
const [extensionName, setExtensionName] = useState(extension.name);
|
||||
export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
|
||||
application,
|
||||
extension,
|
||||
first,
|
||||
uninstall,
|
||||
}) => {
|
||||
const [offlineOnly, setOfflineOnly] = useState(
|
||||
extension.offlineOnly ?? false
|
||||
);
|
||||
const [extensionName, setExtensionName] = useState(extension.name);
|
||||
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly;
|
||||
setOfflineOnly(newOfflineOnly);
|
||||
application
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.offlineOnly = newOfflineOnly;
|
||||
})
|
||||
.then((item) => {
|
||||
const component = (item as SNComponent);
|
||||
setOfflineOnly(component.offlineOnly);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const changeExtensionName = (newName: string) => {
|
||||
setExtensionName(newName);
|
||||
application
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.name = newName;
|
||||
})
|
||||
.then((item) => {
|
||||
const component = (item as SNComponent);
|
||||
setExtensionName(component.name);
|
||||
});
|
||||
};
|
||||
|
||||
const localInstallable = extension.package_info.download_url;
|
||||
const isThirParty = application.isThirdPartyFeature(extension.identifier);
|
||||
|
||||
return (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
{first && <>
|
||||
<Title>Extensions</Title>
|
||||
<div className="w-full min-h-3" />
|
||||
</>}
|
||||
|
||||
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
|
||||
<div className="min-h-2" />
|
||||
|
||||
{isThirParty && localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
|
||||
|
||||
<>
|
||||
<div className="min-h-2" />
|
||||
<div className="flex flex-row">
|
||||
<Button className="min-w-20" type="normal" label="Uninstall" onClick={() => uninstall(extension)} />
|
||||
</div>
|
||||
</>
|
||||
</PreferencesSegment >
|
||||
);
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly;
|
||||
setOfflineOnly(newOfflineOnly);
|
||||
application
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.offlineOnly = newOfflineOnly;
|
||||
})
|
||||
.then((item) => {
|
||||
const component = item as SNComponent;
|
||||
setOfflineOnly(component.offlineOnly);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const changeExtensionName = (newName: string) => {
|
||||
setExtensionName(newName);
|
||||
application
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.name = newName;
|
||||
})
|
||||
.then((item) => {
|
||||
const component = item as SNComponent;
|
||||
setExtensionName(component.name);
|
||||
});
|
||||
};
|
||||
|
||||
const localInstallable = extension.package_info.download_url;
|
||||
const isThirParty = application.isThirdPartyFeature(extension.identifier);
|
||||
|
||||
return (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
{first && (
|
||||
<>
|
||||
<Title>Extensions</Title>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RenameExtension
|
||||
extensionName={extensionName}
|
||||
changeName={changeExtensionName}
|
||||
/>
|
||||
<div className="min-h-2" />
|
||||
|
||||
{isThirParty && localInstallable && (
|
||||
<UseHosted
|
||||
offlineOnly={offlineOnly}
|
||||
toggleOfllineOnly={toggleOffllineOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
<>
|
||||
<div className="min-h-2" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
type="normal"
|
||||
label="Uninstall"
|
||||
onClick={() => uninstall(extension)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PreferencesSegment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -148,7 +148,7 @@ export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Defaults</Title>
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<Subtitle>Default Editor</Subtitle>
|
||||
<Text>New notes will be created using this editor.</Text>
|
||||
<div className="mt-2">
|
||||
@@ -166,8 +166,9 @@ export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Spellcheck</Subtitle>
|
||||
<Text>
|
||||
The default spellcheck value for new notes. Spellcheck can be configured per note from the note context menu.
|
||||
Spellcheck may degrade overall typing performance with long notes.
|
||||
The default spellcheck value for new notes. Spellcheck can be
|
||||
configured per note from the note context menu. Spellcheck may
|
||||
degrade overall typing performance with long notes.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
|
||||
|
||||
@@ -40,7 +40,7 @@ export const Tools: FunctionalComponent<Props> = observer(
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Tools</Title>
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Monospace Font</Subtitle>
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
Action,
|
||||
ButtonType,
|
||||
SNActionsExtension,
|
||||
SNComponent,
|
||||
SNItem,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
type Props = {
|
||||
item: SNComponent;
|
||||
item: SNActionsExtension;
|
||||
showSeparator: boolean;
|
||||
disabled: boolean;
|
||||
disconnect: (item: SNItem) => Promise<unknown>;
|
||||
@@ -35,7 +34,7 @@ export const BlogItem: FunctionalComponent<Props> = ({
|
||||
const loadActions = async () => {
|
||||
setIsLoadingActions(true);
|
||||
application.actionsManager
|
||||
.loadExtensionInContextOfItem(item as SNActionsExtension, item)
|
||||
.loadExtensionInContextOfItem(item, item)
|
||||
.then((extension) => {
|
||||
setActions(extension?.actions);
|
||||
})
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { toDirective } from '@/components/utils';
|
||||
import {
|
||||
PurchaseFlowWrapper,
|
||||
PurchaseFlowWrapperProps,
|
||||
} from './PurchaseFlowWrapper';
|
||||
|
||||
export const PurchaseFlowDirective =
|
||||
toDirective<PurchaseFlowWrapperProps>(PurchaseFlowWrapper);
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isDesktopApplication } from './utils';
|
||||
|
||||
/* @ngInject */
|
||||
export function configRoutes($locationProvider: ng.ILocationProvider) {
|
||||
if (!isDesktopApplication()) {
|
||||
if (typeof window?.history?.pushState === 'function') {
|
||||
$locationProvider.html5Mode({
|
||||
enabled: true,
|
||||
requireBase: false
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$locationProvider.html5Mode(false);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable camelcase */
|
||||
import {
|
||||
SNComponent,
|
||||
ComponentMutator,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
PayloadSource,
|
||||
} from '@standardnotes/snjs';
|
||||
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebAppEvent, WebApplication } from '@/ui_models/application';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { Bridge } from './bridge';
|
||||
|
||||
@@ -21,8 +22,6 @@ export class DesktopManager
|
||||
extends ApplicationService
|
||||
implements DesktopManagerInterface
|
||||
{
|
||||
$rootScope: ng.IRootScopeService;
|
||||
$timeout: ng.ITimeoutService;
|
||||
updateObservers: {
|
||||
callback: (component: SNComponent) => void;
|
||||
}[] = [];
|
||||
@@ -31,15 +30,8 @@ export class DesktopManager
|
||||
dataLoaded = false;
|
||||
lastSearchedText?: string;
|
||||
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
application: WebApplication,
|
||||
private bridge: Bridge
|
||||
) {
|
||||
constructor(application: WebApplication, private bridge: Bridge) {
|
||||
super(application);
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
get webApplication() {
|
||||
@@ -124,11 +116,11 @@ export class DesktopManager
|
||||
}
|
||||
|
||||
desktop_windowGainedFocus(): void {
|
||||
this.$rootScope.$broadcast('window-gained-focus');
|
||||
this.webApplication.notifyWebEvent(WebAppEvent.DesktopWindowGainedFocus);
|
||||
}
|
||||
|
||||
desktop_windowLostFocus() {
|
||||
this.$rootScope.$broadcast('window-lost-focus');
|
||||
desktop_windowLostFocus(): void {
|
||||
this.webApplication.notifyWebEvent(WebAppEvent.DesktopWindowLostFocus);
|
||||
}
|
||||
|
||||
async desktop_onComponentInstallationComplete(
|
||||
@@ -155,11 +147,9 @@ export class DesktopManager
|
||||
PayloadSource.DesktopInstalled
|
||||
);
|
||||
|
||||
this.$timeout(() => {
|
||||
for (const observer of this.updateObservers) {
|
||||
observer.callback(updatedComponent as SNComponent);
|
||||
}
|
||||
});
|
||||
for (const observer of this.updateObservers) {
|
||||
observer.callback(updatedComponent as SNComponent);
|
||||
}
|
||||
}
|
||||
|
||||
async desktop_requestBackupFile() {
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
export class WebDirective implements ng.IDirective {
|
||||
controller?: string | ng.Injectable<ng.IControllerConstructor>;
|
||||
controllerAs?: string;
|
||||
bindToController?: boolean | { [boundProperty: string]: string };
|
||||
restrict?: string;
|
||||
replace?: boolean
|
||||
scope?: boolean | { [boundProperty: string]: string };
|
||||
template?: string | ((tElement: any, tAttrs: any) => string)
|
||||
transclude?: boolean
|
||||
}
|
||||
|
||||
export enum PasswordWizardType {
|
||||
ChangePassword = 1,
|
||||
AccountUpgrade = 2
|
||||
}
|
||||
|
||||
export interface PasswordWizardScope extends Partial<ng.IScope> {
|
||||
type: PasswordWizardType,
|
||||
application: any
|
||||
}
|
||||
|
||||
export interface PermissionsModalScope extends Partial<ng.IScope> {
|
||||
application: any
|
||||
component: SNComponent
|
||||
permissionsString: string
|
||||
callback: (approved: boolean) => void
|
||||
}
|
||||
|
||||
export interface AccountSwitcherScope extends Partial<ng.IScope> {
|
||||
application: any
|
||||
}
|
||||
|
||||
export type PanelPuppet = {
|
||||
onReady?: () => void
|
||||
ready?: boolean
|
||||
setWidth?: (width: number) => void
|
||||
setLeft?: (left: number) => void
|
||||
isCollapsed?: () => boolean
|
||||
flash?: () => void
|
||||
}
|
||||
onReady?: () => void;
|
||||
ready?: boolean;
|
||||
setWidth?: (width: number) => void;
|
||||
setLeft?: (left: number) => void;
|
||||
isCollapsed?: () => boolean;
|
||||
flash?: () => void;
|
||||
};
|
||||
|
||||
export type FooterStatus = {
|
||||
string: string
|
||||
}
|
||||
string: string;
|
||||
};
|
||||
|
||||
@@ -77,6 +77,7 @@ export class AccountMenuState {
|
||||
runInAction(() => {
|
||||
if (isDev && window._devAccountServer) {
|
||||
this.setServer(window._devAccountServer);
|
||||
this.application.setCustomHost(window._devAccountServer);
|
||||
} else {
|
||||
this.setServer(this.application.getHost());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Bridge } from '@/services/bridge';
|
||||
import { storage, StorageKey } from '@/services/localStorage';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebApplication, WebAppEvent } from '@/ui_models/application';
|
||||
import { AccountMenuState } from '@/ui_models/app_state/account_menu_state';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ComponentViewer,
|
||||
SNTag,
|
||||
NoteViewController,
|
||||
SNTheme,
|
||||
} from '@standardnotes/snjs';
|
||||
import pull from 'lodash/pull';
|
||||
import {
|
||||
@@ -68,14 +67,11 @@ export class AppState {
|
||||
readonly enableUnfinishedFeatures: boolean =
|
||||
window?._enable_unfinished_features;
|
||||
|
||||
$rootScope: ng.IRootScopeService;
|
||||
$timeout: ng.ITimeoutService;
|
||||
application: WebApplication;
|
||||
observers: ObserverCallback[] = [];
|
||||
locked = true;
|
||||
unsubApp: any;
|
||||
rootScopeCleanup1: any;
|
||||
rootScopeCleanup2: any;
|
||||
webAppEventDisposer?: () => void;
|
||||
onVisibilityChange: any;
|
||||
showBetaWarning: boolean;
|
||||
|
||||
@@ -105,14 +101,7 @@ export class AppState {
|
||||
private readonly foldersComponentViewerDisposer: () => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
application: WebApplication,
|
||||
private bridge: Bridge
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.$rootScope = $rootScope;
|
||||
constructor(application: WebApplication, private bridge: Bridge) {
|
||||
this.application = application;
|
||||
this.notes = new NotesState(
|
||||
application,
|
||||
@@ -203,12 +192,8 @@ export class AppState {
|
||||
this.appEventObserverRemovers.forEach((remover) => remover());
|
||||
this.features.deinit();
|
||||
this.appEventObserverRemovers.length = 0;
|
||||
if (this.rootScopeCleanup1) {
|
||||
this.rootScopeCleanup1();
|
||||
this.rootScopeCleanup2();
|
||||
this.rootScopeCleanup1 = undefined;
|
||||
this.rootScopeCleanup2 = undefined;
|
||||
}
|
||||
this.webAppEventDisposer?.();
|
||||
this.webAppEventDisposer = undefined;
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.onVisibilityChange = undefined;
|
||||
this.tagChangedDisposer();
|
||||
@@ -356,11 +341,7 @@ export class AppState {
|
||||
.componentsForArea(ComponentArea.TagsList)
|
||||
.find((component) => component.active);
|
||||
|
||||
this.application.performFunctionWithAngularDigestCycleAfterAsyncChange(
|
||||
() => {
|
||||
this.setFoldersComponent(componentViewer);
|
||||
}
|
||||
);
|
||||
this.setFoldersComponent(componentViewer);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -437,13 +418,13 @@ export class AppState {
|
||||
|
||||
registerVisibilityObservers() {
|
||||
if (isDesktopApplication()) {
|
||||
this.rootScopeCleanup1 = this.$rootScope.$on('window-lost-focus', () => {
|
||||
this.notifyEvent(AppStateEvent.WindowDidBlur);
|
||||
});
|
||||
this.rootScopeCleanup2 = this.$rootScope.$on(
|
||||
'window-gained-focus',
|
||||
() => {
|
||||
this.notifyEvent(AppStateEvent.WindowDidFocus);
|
||||
this.webAppEventDisposer = this.application.addWebEventObserver(
|
||||
(event) => {
|
||||
if (event === WebAppEvent.DesktopWindowGainedFocus) {
|
||||
this.notifyEvent(AppStateEvent.WindowDidFocus);
|
||||
} else if (event === WebAppEvent.DesktopWindowLostFocus) {
|
||||
this.notifyEvent(AppStateEvent.WindowDidBlur);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -462,11 +443,11 @@ export class AppState {
|
||||
|
||||
async notifyEvent(eventName: AppStateEvent, data?: any) {
|
||||
/**
|
||||
* Timeout is particullary important so we can give all initial
|
||||
* Timeout is particularly important so we can give all initial
|
||||
* controllers a chance to construct before propogting any events *
|
||||
*/
|
||||
return new Promise<void>((resolve) => {
|
||||
this.$timeout(async () => {
|
||||
setTimeout(async () => {
|
||||
for (const callback of this.observers) {
|
||||
await callback(eventName, data);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class NotesViewState {
|
||||
notesToDisplay = 0;
|
||||
pageSize = 0;
|
||||
panelTitle = 'All Notes';
|
||||
panelWidth = 0;
|
||||
renderedNotes: SNNote[] = [];
|
||||
searchSubmitted = false;
|
||||
selectedNotes: Record<UuidString, SNNote> = {};
|
||||
@@ -324,7 +325,14 @@ export class NotesViewState {
|
||||
if (displayOptionsChanged) {
|
||||
this.reloadNotesDisplayOptions();
|
||||
}
|
||||
|
||||
this.reloadNotes();
|
||||
|
||||
const width = this.application.getPreference(PrefKey.NotesPanelWidth);
|
||||
if (width) {
|
||||
this.panelWidth = width;
|
||||
}
|
||||
|
||||
if (freshDisplayOptions.sortBy !== currentSortBy) {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
@@ -338,12 +346,9 @@ export class NotesViewState {
|
||||
}
|
||||
|
||||
await this.appState.openNewNote(title);
|
||||
this.application.performFunctionWithAngularDigestCycleAfterAsyncChange(
|
||||
() => {
|
||||
this.reloadNotes();
|
||||
this.appState.noteTags.reloadTags();
|
||||
}
|
||||
);
|
||||
|
||||
this.reloadNotes();
|
||||
this.appState.noteTags.reloadTags();
|
||||
};
|
||||
|
||||
createPlaceholderNote = () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { WebCrypto } from '@/crypto';
|
||||
import { InputModalScope } from '@/directives/views/inputModal';
|
||||
import { AlertService } from '@/services/alertService';
|
||||
import { ArchiveManager } from '@/services/archiveManager';
|
||||
import { AutolockService } from '@/services/autolock_service';
|
||||
@@ -8,18 +7,15 @@ import { DesktopManager } from '@/services/desktopManager';
|
||||
import { IOService } from '@/services/ioService';
|
||||
import { StatusManager } from '@/services/statusManager';
|
||||
import { ThemeManager } from '@/services/themeManager';
|
||||
import { PasswordWizardScope, PasswordWizardType } from '@/types';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { WebDeviceInterface } from '@/web_device_interface';
|
||||
import {
|
||||
DeinitSource,
|
||||
PermissionDialog,
|
||||
Platform,
|
||||
SNApplication,
|
||||
NoteGroupController,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { AccountSwitcherScope, PermissionsModalScope } from './../types';
|
||||
|
||||
type WebServices = {
|
||||
appState: AppState;
|
||||
@@ -31,20 +27,23 @@ type WebServices = {
|
||||
io: IOService;
|
||||
};
|
||||
|
||||
export class WebApplication extends SNApplication {
|
||||
private scope?: angular.IScope;
|
||||
private webServices!: WebServices;
|
||||
private currentAuthenticationElement?: angular.IRootElementService;
|
||||
public noteControllerGroup: NoteGroupController;
|
||||
export enum WebAppEvent {
|
||||
NewUpdateAvailable = 'NewUpdateAvailable',
|
||||
DesktopWindowGainedFocus = 'DesktopWindowGainedFocus',
|
||||
DesktopWindowLostFocus = 'DesktopWindowLostFocus',
|
||||
}
|
||||
|
||||
export type WebEventObserver = (event: WebAppEvent) => void;
|
||||
|
||||
export class WebApplication extends SNApplication {
|
||||
private webServices!: WebServices;
|
||||
public noteControllerGroup: NoteGroupController;
|
||||
private webEventObservers: WebEventObserver[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
deviceInterface: WebDeviceInterface,
|
||||
platform: Platform,
|
||||
identifier: string,
|
||||
private $compile: angular.ICompileService,
|
||||
private $timeout: angular.ITimeoutService,
|
||||
scope: angular.IScope,
|
||||
defaultSyncServerHost: string,
|
||||
public bridge: Bridge,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
@@ -63,11 +62,8 @@ export class WebApplication extends SNApplication {
|
||||
enableUnfinishedFeatures,
|
||||
webSocketUrl
|
||||
);
|
||||
this.$compile = $compile;
|
||||
this.scope = scope;
|
||||
deviceInterface.setApplication(this);
|
||||
this.noteControllerGroup = new NoteGroupController(this);
|
||||
this.presentPermissionsDialog = this.presentPermissionsDialog.bind(this);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
@@ -79,14 +75,12 @@ export class WebApplication extends SNApplication {
|
||||
(service as any).application = undefined;
|
||||
}
|
||||
this.webServices = {} as WebServices;
|
||||
(this.$compile as unknown) = undefined;
|
||||
this.noteControllerGroup.deinit();
|
||||
(this.scope as any).application = undefined;
|
||||
this.scope!.$destroy();
|
||||
this.scope = undefined;
|
||||
(this.presentPermissionsDialog as unknown) = undefined;
|
||||
/** Allow our Angular directives to be destroyed and any pending digest cycles
|
||||
* to complete before destroying the global application instance and all its services */
|
||||
this.webEventObservers.length = 0;
|
||||
/**
|
||||
* Allow any pending renders to complete before destroying the global
|
||||
* application instance and all its services
|
||||
*/
|
||||
setTimeout(() => {
|
||||
super.deinit(source);
|
||||
if (source === DeinitSource.SignOut) {
|
||||
@@ -95,24 +89,21 @@ export class WebApplication extends SNApplication {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
onStart(): void {
|
||||
super.onStart();
|
||||
this.componentManager.presentPermissionsDialog =
|
||||
this.presentPermissionsDialog;
|
||||
}
|
||||
|
||||
setWebServices(services: WebServices): void {
|
||||
this.webServices = services;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a UI change is made in an async function, Angular might not re-render the change.
|
||||
* Use this function to force re-render the UI after an async function has made UI changes.
|
||||
*/
|
||||
public performFunctionWithAngularDigestCycleAfterAsyncChange(
|
||||
func: () => void
|
||||
) {
|
||||
this.$timeout(func);
|
||||
public addWebEventObserver(observer: WebEventObserver): () => void {
|
||||
this.webEventObservers.push(observer);
|
||||
return () => {
|
||||
removeFromArray(this.webEventObservers, observer);
|
||||
};
|
||||
}
|
||||
|
||||
public notifyWebEvent(event: WebAppEvent): void {
|
||||
for (const observer of this.webEventObservers) {
|
||||
observer(event);
|
||||
}
|
||||
}
|
||||
|
||||
public getAppState(): AppState {
|
||||
@@ -147,79 +138,12 @@ export class WebApplication extends SNApplication {
|
||||
return this.protocolUpgradeAvailable();
|
||||
}
|
||||
|
||||
presentPasswordWizard(type: PasswordWizardType) {
|
||||
const scope = this.scope!.$new(true) as PasswordWizardScope;
|
||||
scope.type = type;
|
||||
scope.application = this;
|
||||
const el = this.$compile!(
|
||||
"<password-wizard application='application' type='type'></password-wizard>"
|
||||
)(scope as any);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
downloadBackup(): void | Promise<void> {
|
||||
return this.bridge.downloadBackup();
|
||||
}
|
||||
|
||||
authenticationInProgress() {
|
||||
return this.currentAuthenticationElement != null;
|
||||
}
|
||||
|
||||
get applicationElement() {
|
||||
return angular.element(document.getElementById(this.identifier)!);
|
||||
}
|
||||
|
||||
async signOutAndDeleteLocalBackups(): Promise<void> {
|
||||
await this.bridge.deleteLocalBackups();
|
||||
return this.signOut();
|
||||
}
|
||||
|
||||
presentPasswordModal(callback: () => void) {
|
||||
const scope = this.scope!.$new(true) as InputModalScope;
|
||||
scope.type = 'password';
|
||||
scope.title = 'Decryption Assistance';
|
||||
scope.message = `Unable to decrypt this item with your current keys.
|
||||
Please enter your account password at the time of this revision.`;
|
||||
scope.callback = callback;
|
||||
const el = this.$compile!(
|
||||
`<input-modal type='type' message='message'
|
||||
title='title' callback='callback()'></input-modal>`
|
||||
)(scope as any);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
presentRevisionPreviewModal(uuid: string, content: any, title?: string) {
|
||||
const scope: any = this.scope!.$new(true);
|
||||
scope.uuid = uuid;
|
||||
scope.content = content;
|
||||
scope.title = title;
|
||||
scope.application = this;
|
||||
const el = this.$compile!(
|
||||
`<revision-preview-modal application='application' uuid='uuid' content='content' title='title'
|
||||
class='sk-modal'></revision-preview-modal>`
|
||||
)(scope);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
public openAccountSwitcher() {
|
||||
const scope = this.scope!.$new(true) as Partial<AccountSwitcherScope>;
|
||||
scope.application = this;
|
||||
const el = this.$compile!(
|
||||
"<account-switcher application='application' " +
|
||||
"class='sk-modal'></account-switcher>"
|
||||
)(scope as any);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
presentPermissionsDialog(dialog: PermissionDialog) {
|
||||
const scope = this.scope!.$new(true) as PermissionsModalScope;
|
||||
scope.permissionsString = dialog.permissionsString;
|
||||
scope.component = dialog.component;
|
||||
scope.callback = dialog.callback;
|
||||
const el = this.$compile!(
|
||||
"<permissions-modal component='component' permissions-string='permissionsString'" +
|
||||
" callback='callback' class='sk-modal'></permissions-modal>"
|
||||
)(scope as any);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,13 @@ import { StatusManager } from '@/services/statusManager';
|
||||
import { ThemeManager } from '@/services/themeManager';
|
||||
|
||||
export class ApplicationGroup extends SNApplicationGroup {
|
||||
$compile: ng.ICompileService;
|
||||
$rootScope: ng.IRootScopeService;
|
||||
$timeout: ng.ITimeoutService;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$compile: ng.ICompileService,
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
private defaultSyncServerHost: string,
|
||||
private bridge: Bridge,
|
||||
private enableUnfinishedFeatures: boolean,
|
||||
private webSocketUrl: string,
|
||||
private webSocketUrl: string
|
||||
) {
|
||||
super(new WebDeviceInterface($timeout, bridge));
|
||||
this.$compile = $compile;
|
||||
this.$timeout = $timeout;
|
||||
this.$rootScope = $rootScope;
|
||||
super(new WebDeviceInterface(bridge));
|
||||
}
|
||||
|
||||
async initialize(callback?: any): Promise<void> {
|
||||
@@ -54,33 +43,19 @@ export class ApplicationGroup extends SNApplicationGroup {
|
||||
descriptor: ApplicationDescriptor,
|
||||
deviceInterface: DeviceInterface
|
||||
) => {
|
||||
const scope = this.$rootScope.$new(true);
|
||||
const platform = getPlatform();
|
||||
const application = new WebApplication(
|
||||
deviceInterface as WebDeviceInterface,
|
||||
platform,
|
||||
descriptor.identifier,
|
||||
this.$compile,
|
||||
this.$timeout,
|
||||
scope,
|
||||
this.defaultSyncServerHost,
|
||||
this.bridge,
|
||||
this.enableUnfinishedFeatures,
|
||||
this.webSocketUrl,
|
||||
);
|
||||
const appState = new AppState(
|
||||
this.$rootScope,
|
||||
this.$timeout,
|
||||
application,
|
||||
this.bridge
|
||||
this.webSocketUrl
|
||||
);
|
||||
const appState = new AppState(application, this.bridge);
|
||||
const archiveService = new ArchiveManager(application);
|
||||
const desktopService = new DesktopManager(
|
||||
this.$rootScope,
|
||||
this.$timeout,
|
||||
application,
|
||||
this.bridge
|
||||
);
|
||||
const desktopService = new DesktopManager(application, this.bridge);
|
||||
const io = new IOService(
|
||||
platform === Platform.MacWeb || platform === Platform.MacDesktop
|
||||
);
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
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;
|
||||
overlay?: HTMLDivElement;
|
||||
|
||||
constructor({
|
||||
alwaysVisible,
|
||||
application,
|
||||
defaultWidth,
|
||||
hoverable,
|
||||
collapsable,
|
||||
minWidth,
|
||||
panel,
|
||||
prefKey,
|
||||
resizeFinishCallback,
|
||||
side,
|
||||
widthEventCallback,
|
||||
}: PanelResizerProps) {
|
||||
const currentKnownPref =
|
||||
(application.getPreference(prefKey) as number) ?? defaultWidth ?? 0;
|
||||
|
||||
this.panel = panel;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
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.prefKey = prefKey;
|
||||
this.pressed = false;
|
||||
this.side = side;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
this.widthEventCallback = widthEventCallback;
|
||||
this.resizeFinishCallback = resizeFinishCallback;
|
||||
|
||||
this.setWidth(currentKnownPref, true);
|
||||
|
||||
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,
|
||||
|
||||
addInvisibleOverlay: action,
|
||||
finishSettingWidth: action,
|
||||
handleLeftEvent: action,
|
||||
handleWidthEvent: action,
|
||||
onDblClick: action,
|
||||
onMouseDown: action,
|
||||
onMouseUp: action,
|
||||
reloadDefaultValues: action,
|
||||
removeInvisibleOverlay: action,
|
||||
setLeft: action,
|
||||
setMinWidth: action,
|
||||
setWidth: 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.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
};
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay();
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
const overlayElement = document.createElement('div');
|
||||
overlayElement.id = 'resizer-overlay';
|
||||
this.overlay = overlayElement;
|
||||
document.body.prepend(this.overlay);
|
||||
};
|
||||
|
||||
removeInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.sk-modal-background(ng-click="ctrl.dismiss()")
|
||||
#account-switcher.sk-modal-content
|
||||
.sn-component
|
||||
.sk-menu-panel#menu-panel
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-column
|
||||
.sk-menu-panel-header-title Account Switcher
|
||||
.sk-menu-panel-column
|
||||
a.sk-label.info(ng-click='ctrl.addNewApplication()') Add Account
|
||||
.sk-menu-panel-row(
|
||||
ng-repeat='descriptor in ctrl.state.descriptors track by descriptor.identifier'
|
||||
ng-click='ctrl.selectDescriptor(descriptor)'
|
||||
)
|
||||
.sk-menu-panel-column.stretch
|
||||
.left
|
||||
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
.sk-circle.small.success
|
||||
.sk-menu-panel-column.stretch
|
||||
input.sk-label.clickable(
|
||||
ng-model='descriptor.label'
|
||||
ng-disabled='descriptor != ctrl.state.editingDescriptor'
|
||||
ng-keyup='$event.keyCode == 13 && ctrl.submitRename($event)',
|
||||
ng-attr-id='input-{{descriptor.identifier}}'
|
||||
spellcheck="false"
|
||||
)
|
||||
.sk-sublabel(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
| Current Application
|
||||
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
button.sn-button.success(
|
||||
ng-click='ctrl.renameDescriptor($event, descriptor)'
|
||||
) Rename
|
||||
@@ -1,105 +0,0 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from './account-switcher.pug';
|
||||
import {
|
||||
ApplicationDescriptor,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { WebDirective } from '@/types';
|
||||
|
||||
class AccountSwitcherCtrl extends PureViewCtrl<unknown, {
|
||||
descriptors: ApplicationDescriptor[];
|
||||
editingDescriptor?: ApplicationDescriptor
|
||||
}> {
|
||||
private $element: JQLite
|
||||
application!: WebApplication
|
||||
private removeAppGroupObserver: any;
|
||||
/** @template */
|
||||
activeApplication!: WebApplication
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService,
|
||||
private mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
this.activeApplication = mainApplicationGroup.primaryApplication as WebApplication;
|
||||
this.reloadApplications();
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
}
|
||||
|
||||
reloadApplications() {
|
||||
this.setState({
|
||||
descriptors: this.mainApplicationGroup.getDescriptors()
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
addNewApplication() {
|
||||
this.dismiss();
|
||||
this.mainApplicationGroup.addNewApplication();
|
||||
}
|
||||
|
||||
/** @template */
|
||||
selectDescriptor(descriptor: ApplicationDescriptor) {
|
||||
this.dismiss();
|
||||
this.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
|
||||
}
|
||||
|
||||
inputForDescriptor(descriptor: ApplicationDescriptor) {
|
||||
return document.getElementById(`input-${descriptor.identifier}`);
|
||||
}
|
||||
|
||||
/** @template */
|
||||
renameDescriptor($event: Event, descriptor: ApplicationDescriptor) {
|
||||
$event.stopPropagation();
|
||||
this.setState({ editingDescriptor: descriptor }).then(() => {
|
||||
const input = this.inputForDescriptor(descriptor);
|
||||
input?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
submitRename() {
|
||||
this.mainApplicationGroup.renameDescriptor(
|
||||
this.state.editingDescriptor!,
|
||||
this.state.editingDescriptor!.label
|
||||
);
|
||||
this.setState({ editingDescriptor: undefined });
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.application as any) = undefined;
|
||||
super.deinit();
|
||||
this.removeAppGroupObserver();
|
||||
this.removeAppGroupObserver = undefined;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountSwitcher extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = AccountSwitcherCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
.main-ui-view.sn-component(
|
||||
ng-class='self.platformString'
|
||||
)
|
||||
#app.app(
|
||||
ng-class='self.state.appClass',
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
)
|
||||
navigation(application='self.application', appState='self.appState')
|
||||
notes-view(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
note-group-view.flex-grow(application='self.application')
|
||||
|
||||
footer-view(
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
application='self.application'
|
||||
)
|
||||
|
||||
svg(data-ionicons="5.1.2", style="display: none")
|
||||
symbol#people-circle-outline.ionicon(viewbox="0 0 512 512")
|
||||
path(d="M256 464c-114.69 0-208-93.31-208-208S141.31 48 256 48s208 93.31 208 208-93.31 208-208 208zm0-384c-97 0-176 79-176 176s79 176 176 176 176-78.95 176-176S353.05 80 256 80z")
|
||||
path(d="M323.67 292c-17.4 0-34.21-7.72-47.34-21.73a83.76 83.76 0 01-22-51.32c-1.47-20.7 4.88-39.75 17.88-53.62S303.38 144 323.67 144c20.14 0 38.37 7.62 51.33 21.46s19.47 33 18 53.51a84 84 0 01-22 51.3C357.86 284.28 341.06 292 323.67 292zm55.81-74zM163.82 295.36c-29.76 0-55.93-27.51-58.33-61.33-1.23-17.32 4.15-33.33 15.17-45.08s26.22-18 43.15-18 32.12 6.44 43.07 18.14 16.5 27.82 15.25 45c-2.44 33.77-28.6 61.27-58.31 61.27zM420.37 355.28c-1.59-4.7-5.46-9.71-13.22-14.46-23.46-14.33-52.32-21.91-83.48-21.91-30.57 0-60.23 7.9-83.53 22.25-26.25 16.17-43.89 39.75-51 68.18-1.68 6.69-4.13 19.14-1.51 26.11a192.18 192.18 0 00232.75-80.17zM163.63 401.37c7.07-28.21 22.12-51.73 45.47-70.75a8 8 0 00-2.59-13.77c-12-3.83-25.7-5.88-42.69-5.88-23.82 0-49.11 6.45-68.14 18.17-5.4 3.33-10.7 4.61-14.78 5.75a192.84 192.84 0 0077.78 86.64l1.79-.14a102.82 102.82 0 013.16-20.02z")
|
||||
|
||||
symbol#layers-sharp.ionicon(viewbox="0 0 512 512")
|
||||
path(d="M480 150L256 48 32 150l224 104 224-104zM255.71 392.95l-144.81-66.2L32 362l224 102 224-102-78.69-35.3-145.6 66.25z")
|
||||
path(d="M480 256l-75.53-33.53L256.1 290.6l-148.77-68.17L32 256l224 102 224-102z")
|
||||
sessions-modal(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
preferences(
|
||||
app-state='self.appState'
|
||||
application='self.application'
|
||||
)
|
||||
challenge-modal(
|
||||
ng-repeat="challenge in self.challenges track by challenge.id"
|
||||
class="sk-modal"
|
||||
application="self.application"
|
||||
challenge="challenge"
|
||||
on-dismiss="self.removeChallenge(challenge)"
|
||||
)
|
||||
notes-context-menu(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
purchase-flow(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -1,207 +0,0 @@
|
||||
import { RootScopeMessages } from './../../messages';
|
||||
import { WebDirective } from '@/types';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import template from './application-view.pug';
|
||||
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
Challenge,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog } from '@/services/alertService';
|
||||
|
||||
class ApplicationViewCtrl extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
started?: boolean;
|
||||
launched?: boolean;
|
||||
needsUnlock?: boolean;
|
||||
appClass: string;
|
||||
}
|
||||
> {
|
||||
public platformString: string;
|
||||
private notesCollapsed = false;
|
||||
private navigationCollapsed = false;
|
||||
|
||||
/**
|
||||
* To prevent stale state reads (setState is async),
|
||||
* challenges is a mutable array
|
||||
*/
|
||||
private challenges: Challenge[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
private $location: ng.ILocationService,
|
||||
private $rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
this.$location = $location;
|
||||
this.$rootScope = $rootScope;
|
||||
this.platformString = getPlatformString();
|
||||
this.state = this.getInitialState();
|
||||
this.onDragDrop = this.onDragDrop.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.addDragDropHandlers();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.$location as unknown) = undefined;
|
||||
(this.$rootScope as unknown) = undefined;
|
||||
(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();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.loadApplication();
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
appClass: '',
|
||||
challenges: [],
|
||||
};
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
this.application.componentManager.setDesktopManager(
|
||||
this.application.getDesktopService()
|
||||
);
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
this.$timeout(() => {
|
||||
this.challenges.push(challenge);
|
||||
});
|
||||
},
|
||||
});
|
||||
await this.application.launch();
|
||||
}
|
||||
|
||||
public async removeChallenge(challenge: Challenge) {
|
||||
this.$timeout(() => {
|
||||
removeFromArray(this.challenges, challenge);
|
||||
});
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.setState({
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
});
|
||||
this.handleDemoSignInFromParams();
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.$rootScope.$broadcast(RootScopeMessages.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;
|
||||
if (panel === PANEL_NAME_NOTES) {
|
||||
this.notesCollapsed = collapsed;
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION) {
|
||||
this.navigationCollapsed = collapsed;
|
||||
}
|
||||
let appClass = '';
|
||||
if (this.notesCollapsed) {
|
||||
appClass += 'collapsed-notes';
|
||||
}
|
||||
if (this.navigationCollapsed) {
|
||||
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 (
|
||||
this.$location.search().demo === 'true' &&
|
||||
!this.application.hasAccount()
|
||||
) {
|
||||
await this.application.setCustomHost(
|
||||
'https://syncing-server-demo.standardnotes.com'
|
||||
);
|
||||
this.application.signIn('demo@standardnotes.org', 'password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
application-view(
|
||||
ng-repeat='application in self.applications',
|
||||
ng-if='application == self.activeApplication'
|
||||
application='application'
|
||||
ng-attr-id='{{application.identifier}}'
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebDirective } from '@/types';
|
||||
import template from './application-group-view.pug';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
class ApplicationGroupViewCtrl {
|
||||
|
||||
private $timeout: ng.ITimeoutService
|
||||
private applicationGroup: ApplicationGroup
|
||||
applications!: WebApplication[]
|
||||
activeApplication!: WebApplication
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.applicationGroup = mainApplicationGroup;
|
||||
this.applicationGroup.addApplicationChangeObserver(() => {
|
||||
this.reload();
|
||||
});
|
||||
this.applicationGroup.initialize();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.$timeout(() => {
|
||||
this.activeApplication = this.applicationGroup.primaryApplication as WebApplication;
|
||||
this.applications = this.applicationGroup.getApplications() as WebApplication[];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationGroupView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationGroupViewCtrl;
|
||||
this.replace = false;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Dialog } from '@reach/dialog';
|
||||
import {
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
Challenge,
|
||||
ChallengeReason,
|
||||
ChallengePrompt,
|
||||
ChallengeValidation,
|
||||
ProtectionSessionDurations,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { WebDirective } from '@/types';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
||||
import { Ref, render } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import ng from 'angular';
|
||||
|
||||
type InputValue = {
|
||||
prompt: ChallengePrompt;
|
||||
value: string | number | boolean;
|
||||
invalid: boolean;
|
||||
};
|
||||
|
||||
type Values = Record<number, InputValue>;
|
||||
|
||||
type ChallengeModalState = {
|
||||
prompts: ChallengePrompt[];
|
||||
values: Partial<Values>;
|
||||
processing: boolean;
|
||||
forgotPasscode: boolean;
|
||||
showForgotPasscodeLink: boolean;
|
||||
processingPrompts: ChallengePrompt[];
|
||||
hasAccount: boolean;
|
||||
protectedNoteAccessDuration: number;
|
||||
};
|
||||
|
||||
class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
|
||||
application!: WebApplication;
|
||||
challenge!: Challenge;
|
||||
onDismiss!: () => void;
|
||||
submitting = false;
|
||||
|
||||
/** @template */
|
||||
protectionsSessionDurations = ProtectionSessionDurations;
|
||||
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
private $element: ng.IRootElementService,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state as ChallengeModalState;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
const values = {} as Values;
|
||||
const prompts = this.challenge.prompts;
|
||||
for (const prompt of prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
};
|
||||
}
|
||||
const showForgotPasscodeLink = [
|
||||
ChallengeReason.ApplicationUnlock,
|
||||
ChallengeReason.Migration,
|
||||
].includes(this.challenge.reason);
|
||||
this.setState({
|
||||
prompts,
|
||||
values,
|
||||
processing: false,
|
||||
forgotPasscode: false,
|
||||
showForgotPasscodeLink,
|
||||
hasAccount: this.application.hasAccount(),
|
||||
processingPrompts: [],
|
||||
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
|
||||
});
|
||||
this.application.addChallengeObserver(this.challenge, {
|
||||
onValidValue: (value) => {
|
||||
this.state.values[value.prompt.id]!.invalid = false;
|
||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||
this.reloadProcessingStatus();
|
||||
/** Trigger UI update */
|
||||
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();
|
||||
}
|
||||
/** Trigger UI update */
|
||||
this.afterStateChange();
|
||||
},
|
||||
onComplete: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
onCancel: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.application as any) = undefined;
|
||||
(this.challenge as any) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
reloadProcessingStatus() {
|
||||
return this.setState({
|
||||
processing: this.state.processingPrompts.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
async destroyLocalData() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.application.signOut();
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/** @template */
|
||||
cancel() {
|
||||
if (this.challenge.cancelable) {
|
||||
this.application!.cancelChallenge(this.challenge);
|
||||
}
|
||||
}
|
||||
|
||||
onForgotPasscodeClick() {
|
||||
this.setState({
|
||||
forgotPasscode: true,
|
||||
});
|
||||
}
|
||||
|
||||
onTextValueChange(prompt: ChallengePrompt) {
|
||||
const values = this.getState().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;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
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.getState().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)
|
||||
*/
|
||||
this.$timeout(() => {
|
||||
if (values.length > 0) {
|
||||
this.application.submitValuesForChallenge(this.challenge, values);
|
||||
} else {
|
||||
this.setState({ processing: false });
|
||||
}
|
||||
this.submitting = false;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
afterStateChange() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.onDismiss();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
render(<></>, this.$element[0]);
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.state.prompts) return;
|
||||
render(<ChallengeModalView ctrl={this} />, this.$element[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChallengeModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
// this.template = template;
|
||||
this.controller = ChallengeModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
challenge: '=',
|
||||
application: '=',
|
||||
onDismiss: '&',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ChallengeModalView({ ctrl }: { ctrl: ChallengeModalCtrl }) {
|
||||
const initialFocusRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<Dialog
|
||||
initialFocusRef={initialFocusRef}
|
||||
onDismiss={() => {
|
||||
if (ctrl.challenge.cancelable) {
|
||||
ctrl.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="challenge-modal sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">
|
||||
{ctrl.challenge.modalTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-p sk-panel-row centered prompt">
|
||||
<strong>{ctrl.challenge.heading}</strong>
|
||||
</div>
|
||||
{ctrl.challenge.subheading && (
|
||||
<div className="sk-p sk-panel-row centered subprompt">
|
||||
{ctrl.challenge.subheading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-panel-section">
|
||||
{ChallengePrompts({ ctrl, initialFocusRef })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer extra-padding">
|
||||
<button
|
||||
className={
|
||||
'sn-button w-full ' +
|
||||
(ctrl.state.processing ? 'neutral' : 'info')
|
||||
}
|
||||
disabled={ctrl.state.processing}
|
||||
onClick={() => ctrl.submit()}
|
||||
>
|
||||
{ctrl.state.processing ? 'Generating Keys…' : 'Submit'}
|
||||
</button>
|
||||
{ctrl.challenge.cancelable && (
|
||||
<>
|
||||
<div className="sk-panel-row"></div>
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered text-sm"
|
||||
onClick={() => ctrl.cancel()}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.state.showForgotPasscodeLink && (
|
||||
<div className="sk-panel-footer">
|
||||
{ctrl.state.forgotPasscode ? (
|
||||
<>
|
||||
<p className="sk-panel-row sk-p">
|
||||
{ctrl.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.'}
|
||||
</p>
|
||||
<a
|
||||
className="sk-panel-row sk-a danger centered"
|
||||
onClick={() => {
|
||||
ctrl.destroyLocalData();
|
||||
}}
|
||||
>
|
||||
Delete Local Data
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered"
|
||||
onClick={() => ctrl.onForgotPasscodeClick()}
|
||||
>
|
||||
Forgot your passcode?
|
||||
</a>
|
||||
)}
|
||||
<div className="sk-panel-row"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChallengePrompts({
|
||||
ctrl,
|
||||
initialFocusRef,
|
||||
}: {
|
||||
ctrl: ChallengeModalCtrl;
|
||||
initialFocusRef: Ref<HTMLInputElement>;
|
||||
}) {
|
||||
return ctrl.state.prompts.map((prompt, index) => (
|
||||
<>
|
||||
{/** ProtectionSessionDuration can't just be an input field */}
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<div className="sk-horizontal-group mt-3">
|
||||
<div className="sk-p sk-bold">Allow protected access for</div>
|
||||
{ProtectionSessionDurations.map((option) => (
|
||||
<a
|
||||
className={
|
||||
'sk-a info ' +
|
||||
(option.valueInSeconds === ctrl.state.values[prompt.id]!.value
|
||||
? 'boxed'
|
||||
: '')
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.onNumberValueChange(prompt, option.valueInSeconds);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
value={ctrl.state.values[prompt.id]!.value as string | number}
|
||||
onChange={(event) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
ctrl.state.values[prompt.id]!.value = value;
|
||||
ctrl.onTextValueChange(prompt);
|
||||
}}
|
||||
ref={index === 0 ? initialFocusRef : undefined}
|
||||
placeholder={prompt.title}
|
||||
type={prompt.secureTextEntry ? 'password' : 'text'}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctrl.state.values[prompt.id]!.invalid && (
|
||||
<div className="sk-panel-row centered">
|
||||
<label className="sk-label danger">
|
||||
Invalid authentication. Please try again.
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
.sn-component
|
||||
#footer-bar.sk-app-bar.no-edges.no-bottom-edge
|
||||
.left
|
||||
.sk-app-bar-item.ml-0(
|
||||
click-outside='ctrl.clickOutsideAccountMenu()',
|
||||
is-open='ctrl.showAccountMenu',
|
||||
ng-click='ctrl.accountMenuPressed()'
|
||||
)
|
||||
.w-8.h-full.flex.items-center.justify-center.cursor-pointer.rounded-full(
|
||||
ng-class="ctrl.showAccountMenu ? 'bg-border' : '' "
|
||||
)
|
||||
.w-5.h-5(
|
||||
ng-class="ctrl.hasError ? 'danger' : (ctrl.user ? 'info' : 'neutral')"
|
||||
)
|
||||
icon(
|
||||
type="account-circle"
|
||||
class-name="hover:color-info w-5 h-5 max-h-5"
|
||||
)
|
||||
account-menu(
|
||||
ng-click='$event.stopPropagation()',
|
||||
app-state='ctrl.appState'
|
||||
application='ctrl.application'
|
||||
ng-if='ctrl.showAccountMenu',
|
||||
)
|
||||
.sk-app-bar-item.ml-0-important(
|
||||
click-outside='ctrl.clickOutsideQuickSettingsMenu()',
|
||||
is-open='ctrl.showQuickSettingsMenu',
|
||||
ng-click='ctrl.quickSettingsPressed()'
|
||||
)
|
||||
.w-8.h-full.flex.items-center.justify-center.cursor-pointer
|
||||
.h-5
|
||||
icon(
|
||||
type="tune"
|
||||
class-name="rounded hover:color-info"
|
||||
ng-class="{'color-info': ctrl.showQuickSettingsMenu}"
|
||||
)
|
||||
quick-settings-menu(
|
||||
ng-click='$event.stopPropagation()',
|
||||
app-state='ctrl.appState'
|
||||
application='ctrl.application'
|
||||
ng-if='ctrl.showQuickSettingsMenu',)
|
||||
.sk-app-bar-item.border(ng-if="ctrl.state.showBetaWarning")
|
||||
.sk-app-bar-item(ng-if="ctrl.state.showBetaWarning")
|
||||
a.no-decoration.sk-label.title(
|
||||
ng-click="ctrl.displayBetaDialog()"
|
||||
) You are using a beta version of the app
|
||||
.center
|
||||
.sk-app-bar-item(ng-if='ctrl.arbitraryStatusMessage')
|
||||
.sk-app-bar-item-column
|
||||
span.neutral.sk-label {{ctrl.arbitraryStatusMessage}}
|
||||
.right
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.openSecurityUpdate()'
|
||||
ng-if='ctrl.state.dataUpgradeAvailable'
|
||||
)
|
||||
span.success.sk-label Encryption upgrade available.
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.clickedNewUpdateAnnouncement()',
|
||||
ng-if='ctrl.newUpdateAvailable == true'
|
||||
)
|
||||
span.info.sk-label New update available.
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-if='(ctrl.state.outOfSync) || ctrl.showSyncResolution'
|
||||
)
|
||||
.sk-label.warning(ng-if='ctrl.state.outOfSync') Potentially Out of Sync
|
||||
sync-resolution-menu(
|
||||
close-function='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-click='$event.stopPropagation();',
|
||||
ng-if='ctrl.showSyncResolution',
|
||||
application='ctrl.application'
|
||||
)
|
||||
.sk-app-bar-item(ng-if='ctrl.offline')
|
||||
.sk-label Offline
|
||||
.sk-app-bar-item.border(ng-if='ctrl.state.hasAccountSwitcher')
|
||||
.sk-app-bar-item(
|
||||
ng-if='ctrl.state.hasAccountSwitcher'
|
||||
ng-click='ctrl.openAccountSwitcher()',
|
||||
)
|
||||
#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(
|
||||
ng-click='ctrl.lockApp()',
|
||||
ng-if='ctrl.state.hasPasscode',
|
||||
title='Locks application and wipes unencrypted data from memory.'
|
||||
)
|
||||
.sk-label
|
||||
i#footer-lock-icon.icon.ion-locked
|
||||
@@ -1,410 +0,0 @@
|
||||
import { RootScopeMessages } from './../../messages';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebDirective } from '@/types';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
SNTheme,
|
||||
CollectionSort,
|
||||
} from '@standardnotes/snjs';
|
||||
import template from './footer-view.pug';
|
||||
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
|
||||
import {
|
||||
STRING_NEW_UPDATE_READY,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
} from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { AccountMenuPane } from '@/components/AccountMenu';
|
||||
|
||||
/**
|
||||
* Disable before production release.
|
||||
* Anyone who used the beta will still have access to
|
||||
* the account switcher in production via local storage flag
|
||||
*/
|
||||
const ACCOUNT_SWITCHER_ENABLED = false;
|
||||
const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher';
|
||||
|
||||
class FooterViewCtrl extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
outOfSync: boolean;
|
||||
hasPasscode: boolean;
|
||||
dataUpgradeAvailable: boolean;
|
||||
hasAccountSwitcher: boolean;
|
||||
showBetaWarning: boolean;
|
||||
showDataUpgrade: boolean;
|
||||
}
|
||||
> {
|
||||
private $rootScope: ng.IRootScopeService;
|
||||
private showSyncResolution = false;
|
||||
private rootScopeListener2: any;
|
||||
public arbitraryStatusMessage?: string;
|
||||
public user?: any;
|
||||
private offline = true;
|
||||
public showAccountMenu = false;
|
||||
public showQuickSettingsMenu = false;
|
||||
private didCheckForOffline = false;
|
||||
public hasError = false;
|
||||
public newUpdateAvailable = false;
|
||||
private observerRemovers: Array<() => void> = [];
|
||||
private completedInitialSync = false;
|
||||
private showingDownloadStatus = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
private mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
super($timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.addRootScopeListeners();
|
||||
this.toggleSyncResolutionMenu = this.toggleSyncResolutionMenu.bind(this);
|
||||
this.closeAccountMenu = this.closeAccountMenu.bind(this);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
for (const remove of this.observerRemovers) remove();
|
||||
this.observerRemovers.length = 0;
|
||||
this.rootScopeListener2();
|
||||
this.rootScopeListener2 = undefined;
|
||||
(this.closeAccountMenu as unknown) = undefined;
|
||||
(this.toggleSyncResolutionMenu as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.$timeout(() => {
|
||||
this.arbitraryStatusMessage = message;
|
||||
});
|
||||
});
|
||||
this.loadAccountSwitcherState();
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
this.showAccountMenu = this.appState.accountMenu.show;
|
||||
this.showQuickSettingsMenu = this.appState.quickSettingsMenu.open;
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showDataUpgrade: !showBetaWarning,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadAccountSwitcherState() {
|
||||
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
|
||||
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
|
||||
/** Enable permanently for this user so they don't lose the feature after its disabled */
|
||||
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
|
||||
}
|
||||
const hasAccountSwitcher = stringValue
|
||||
? JSON.parse(stringValue)
|
||||
: ACCOUNT_SWITCHER_ENABLED;
|
||||
this.setState({ hasAccountSwitcher });
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: this.mainApplicationGroup.getDescriptors(),
|
||||
hasAccountSwitcher: false,
|
||||
showBetaWarning: false,
|
||||
showDataUpgrade: false,
|
||||
};
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
openAccountSwitcher() {
|
||||
this.application.openAccountSwitcher();
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadPasscodeStatus();
|
||||
this.reloadUser();
|
||||
this.reloadUpgradeStatus();
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
this.user = this.application.getUser();
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode();
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
});
|
||||
}
|
||||
|
||||
addRootScopeListeners() {
|
||||
this.rootScopeListener2 = this.$rootScope.$on(
|
||||
RootScopeMessages.NewUpdateAvailable,
|
||||
() => {
|
||||
this.$timeout(() => {
|
||||
this.onNewUpdateAvailable();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager();
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
break;
|
||||
case AppStateEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…');
|
||||
break;
|
||||
case AppStateEvent.EndedBackupDownload: {
|
||||
const successMessage = 'Successfully saved backup.';
|
||||
const errorMessage = 'Unable to save local backup.';
|
||||
statusService.setMessage(data.success ? successMessage : errorMessage);
|
||||
|
||||
const twoSeconds = 2000;
|
||||
this.$timeout(() => {
|
||||
if (
|
||||
statusService.message === successMessage ||
|
||||
statusService.message === errorMessage
|
||||
) {
|
||||
statusService.setMessage('');
|
||||
}
|
||||
}, twoSeconds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.reloadPasscodeStatus();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus();
|
||||
break;
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('');
|
||||
this.completedInitialSync = true;
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true;
|
||||
if (this.offline && this.application.getNoteCount() === 0) {
|
||||
this.appState.accountMenu.setShow(true);
|
||||
}
|
||||
}
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus();
|
||||
break;
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus();
|
||||
break;
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser();
|
||||
break;
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.setDisplayOptions(
|
||||
ContentType.Theme,
|
||||
CollectionSort.Title,
|
||||
'asc',
|
||||
(theme: SNTheme) => {
|
||||
return !theme.errorDecrypting;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync');
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
|
||||
statusManager.setMessage(text);
|
||||
this.showingDownloadStatus = true;
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false;
|
||||
statusManager.setMessage('Download Complete.');
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('');
|
||||
}, 2000);
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0
|
||||
? 0
|
||||
: stats.uploadCompletionCount / stats.uploadTotalCount;
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
});
|
||||
|
||||
statusManager.setMessage(
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
|
||||
);
|
||||
} else {
|
||||
statusManager.setMessage('');
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const encryption = this.application.isEncryptionAvailable();
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('');
|
||||
return;
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
|
||||
const loadingStatus = encryption
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
statusManager.setMessage(loadingStatus);
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.offline = this.application.noAccount();
|
||||
}
|
||||
|
||||
async openSecurityUpdate() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.hasError = this.application.getSyncStatus().hasError();
|
||||
}
|
||||
|
||||
accountMenuPressed() {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
}
|
||||
|
||||
quickSettingsPressed() {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
this.appState.quickSettingsMenu.toggle();
|
||||
}
|
||||
|
||||
toggleSyncResolutionMenu() {
|
||||
this.showSyncResolution = !this.showSyncResolution;
|
||||
}
|
||||
|
||||
closeAccountMenu() {
|
||||
this.appState.accountMenu.setShow(false);
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
}
|
||||
|
||||
lockApp() {
|
||||
this.application.lock();
|
||||
}
|
||||
|
||||
onNewUpdateAvailable() {
|
||||
this.newUpdateAvailable = true;
|
||||
}
|
||||
|
||||
clickedNewUpdateAnnouncement() {
|
||||
this.newUpdateAvailable = false;
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
|
||||
}
|
||||
|
||||
displayBetaDialog() {
|
||||
alertDialog({
|
||||
title: 'You are using a beta version of the app',
|
||||
text:
|
||||
'If you wish to go back to a stable version, make sure to sign out ' +
|
||||
'of this beta app first.<br>You can silence this warning from the ' +
|
||||
'<em>Account</em> menu.',
|
||||
});
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu() {
|
||||
if (this.application && this.application.authenticationInProgress()) {
|
||||
return;
|
||||
}
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
}
|
||||
|
||||
clickOutsideQuickSettingsMenu() {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
}
|
||||
}
|
||||
|
||||
export class FooterView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = FooterViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { PureViewCtrl } from './abstract/pure_view_ctrl';
|
||||
export { ApplicationGroupView } from './application_group/application_group_view';
|
||||
export { ApplicationView } from './application/application_view';
|
||||
export { NoteGroupViewDirective } from './note_group_view/note_group_view';
|
||||
export { NoteViewDirective } from './note_view/note_view';
|
||||
export { FooterView } from './footer/footer_view';
|
||||
export { ChallengeModal } from './challenge_modal/challenge_modal';
|
||||
@@ -1,14 +0,0 @@
|
||||
.h-full
|
||||
multiple-selected-notes-panel.h-full(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
ng-if='self.state.showMultipleSelectedNotes'
|
||||
)
|
||||
.flex-grow.h-full(
|
||||
ng-if='!self.state.showMultipleSelectedNotes'
|
||||
ng-repeat='controller in self.controllers'
|
||||
)
|
||||
note-view(
|
||||
application='self.application'
|
||||
controller='controller'
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from './note-group-view.pug';
|
||||
import { PureViewCtrl } from '../abstract/pure_view_ctrl';
|
||||
import { NoteViewController } from '@standardnotes/snjs';
|
||||
|
||||
class NoteGroupView extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
showMultipleSelectedNotes: boolean;
|
||||
}
|
||||
> {
|
||||
public controllers: NoteViewController[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(
|
||||
() => {
|
||||
this.controllers = this.application.noteControllerGroup.noteControllers;
|
||||
}
|
||||
);
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoteGroupViewDirective extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = NoteGroupView;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
#editor-column.section.editor.sn-component(aria-label='Note')
|
||||
protected-note-panel.h-full.flex.justify-center.items-center(
|
||||
ng-if='self.state.showProtectedWarning'
|
||||
app-state='self.appState'
|
||||
has-protection-sources='self.application.hasProtectionSources()'
|
||||
on-view-note='self.dismissProtectedWarning()'
|
||||
)
|
||||
.flex-grow.flex.flex-col(
|
||||
ng-if='!self.appState.notes.showProtectedWarning'
|
||||
)
|
||||
.sn-component
|
||||
.sk-app-bar.no-edges(
|
||||
ng-if='self.noteLocked',
|
||||
ng-init="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
|
||||
ng-mouseleave="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
|
||||
ng-mouseover="self.lockText = 'Enable editing'; self.showLockedIcon = false"
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
ng-click='self.appState.notes.setLockSelectedNotes(!self.noteLocked)'
|
||||
)
|
||||
.sk-label.warning.flex.items-center
|
||||
icon.flex(
|
||||
type="pencil-off"
|
||||
class-name="fill-current mr-2"
|
||||
ng-if="self.showLockedIcon"
|
||||
)
|
||||
| {{self.lockText}}
|
||||
#editor-title-bar.section-title-bar.w-full(
|
||||
ng-show='self.note && !self.note.errorDecrypting'
|
||||
)
|
||||
div.flex.items-center.justify-between.h-8
|
||||
div.flex-grow(
|
||||
ng-class="{'locked' : self.noteLocked}"
|
||||
)
|
||||
.title.overflow-auto
|
||||
input#note-title-editor.input(
|
||||
ng-change='self.onTitleChange()',
|
||||
ng-disabled='self.noteLocked',
|
||||
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
||||
ng-model='self.editorValues.title',
|
||||
select-on-focus='true',
|
||||
spellcheck='false'
|
||||
)
|
||||
div.flex.items-center
|
||||
#save-status
|
||||
.message(
|
||||
ng-class="{'warning sk-bold': self.state.syncTakingTooLong, 'danger sk-bold': self.state.saveError}"
|
||||
) {{self.state.noteStatus.message}}
|
||||
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
|
||||
pin-note-button(
|
||||
class='mr-3'
|
||||
app-state='self.appState',
|
||||
ng-if='self.appState.notes.selectedNotesCount > 0'
|
||||
)
|
||||
notes-options-panel(
|
||||
application='self.application',
|
||||
app-state='self.appState',
|
||||
ng-if='self.appState.notes.selectedNotesCount > 0'
|
||||
)
|
||||
note-tags-container(
|
||||
app-state='self.appState'
|
||||
)
|
||||
.sn-component(ng-if='self.note')
|
||||
#editor-menu-bar.sk-app-bar.no-edges
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showActionsMenu', false)`,
|
||||
is-open='self.state.showActionsMenu',
|
||||
ng-class="{'selected' : self.state.showActionsMenu}",
|
||||
ng-click="self.toggleMenu('showActionsMenu')"
|
||||
)
|
||||
.sk-label Actions
|
||||
actions-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showActionsMenu',
|
||||
application='self.application'
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showHistoryMenu', false)`,
|
||||
is-open='self.state.showHistoryMenu',
|
||||
ng-class="{'selected' : self.state.showHistoryMenu}",
|
||||
ng-click="self.toggleMenu('showHistoryMenu')"
|
||||
)
|
||||
.sk-label History
|
||||
history-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showHistoryMenu',
|
||||
application='self.application'
|
||||
)
|
||||
#editor-content.editor-content(ng-if='!self.note.errorDecrypting')
|
||||
panel-resizer.left(
|
||||
control='self.leftPanelPuppet',
|
||||
hoverable='true',
|
||||
min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish',
|
||||
panel-id="'editor-content'",
|
||||
property="'left'"
|
||||
)
|
||||
component-view.component-view(
|
||||
component-viewer='self.state.editorComponentViewer',
|
||||
ng-if='self.state.editorComponentViewer',
|
||||
on-load='self.onEditorComponentLoad',
|
||||
request-reload='self.editorComponentViewerRequestsReload'
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
textarea#note-text-editor.editable.font-editor(
|
||||
dir='auto',
|
||||
ng-attr-spellcheck='{{self.state.spellcheck}}',
|
||||
ng-change='self.contentChanged()',
|
||||
ng-click='self.clickedTextArea()',
|
||||
ng-focus='self.onContentFocus()',
|
||||
ng-if='self.state.editorStateDidLoad && !self.state.editorComponentViewer && !self.state.textareaUnloading',
|
||||
ng-model='self.editorValues.text',
|
||||
ng-model-options='{ debounce: self.state.editorDebounce}',
|
||||
ng-readonly='self.noteLocked',
|
||||
ng-trim='false'
|
||||
autocomplete='off'
|
||||
)
|
||||
| {{self.onSystemEditorLoad()}}
|
||||
panel-resizer(
|
||||
control='self.rightPanelPuppet',
|
||||
hoverable='true', min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish',
|
||||
panel-id="'editor-content'",
|
||||
property="'right'"
|
||||
)
|
||||
.section(ng-show='self.note.errorDecrypting')
|
||||
.sn-component#error-decrypting-container
|
||||
.sk-panel#error-decrypting-panel
|
||||
.sk-panel-header
|
||||
.sk-panel-header-title {{self.note.waitingForKey ? 'Waiting for Key' : 'Unable to Decrypt'}}
|
||||
.sk-panel-content
|
||||
.sk-panel-section
|
||||
p.sk-p(ng-if='self.note.waitingForKey')
|
||||
| This note is awaiting its encryption key to be ready. Please wait for syncing to complete
|
||||
| for this note to be decrypted.
|
||||
p.sk-p(ng-if='!self.note.waitingForKey')
|
||||
| There was an error decrypting this item. Ensure you are running the
|
||||
| latest version of this app, then sign out and sign back in to try again.
|
||||
#editor-pane-component-stack(ng-if='!self.note.errorDecrypting' ng-show='self.note')
|
||||
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.availableStackComponents.length')
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-repeat='component in self.state.availableStackComponents track by component.uuid'
|
||||
ng-click='self.toggleStackComponent(component)',
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-circle.small(
|
||||
ng-class="{'info' : self.stackComponentExpanded(component) && component.active, 'neutral' : !self.stackComponentExpanded(component)}"
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label {{component.name}}
|
||||
.sn-component
|
||||
component-view.component-view.component-stack-item(
|
||||
ng-repeat='viewer in self.state.stackComponentViewers track by viewer.componentUuid',
|
||||
component-viewer='viewer',
|
||||
manual-dealloc='true',
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -10,9 +10,9 @@ import { Bridge } from './services/bridge';
|
||||
export class WebDeviceInterface extends DeviceInterface {
|
||||
private databases: Database[] = [];
|
||||
|
||||
constructor(timeout: any, private bridge: Bridge) {
|
||||
constructor(private bridge: Bridge) {
|
||||
super(
|
||||
timeout || setTimeout.bind(getGlobalScope()),
|
||||
setTimeout.bind(getGlobalScope()),
|
||||
setInterval.bind(getGlobalScope())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user