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:
Mo
2022-01-30 19:01:30 -06:00
committed by GitHub
parent 0ecbde6bac
commit 50c92619ce
117 changed files with 4715 additions and 5309 deletions

View File

@@ -4,7 +4,6 @@
"@babel/preset-env"
],
"plugins": [
"angularjs-annotate",
["@babel/plugin-transform-react-jsx", {
"pragma": "h",
"pragmaFrag": "Fragment"

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8334 17.4999V15.8333H12.5001V14.1666H15.8334V12.4999L18.3334 14.9999L15.8334 17.4999ZM10.8334 14.9999C10.8334 15.5916 10.9584 16.1583 11.1834 16.6666H1.66675V14.1666C1.66675 12.3249 4.65008 10.8333 8.33341 10.8333C9.16675 10.8333 9.96675 10.9083 10.7084 11.0499C11.4001 11.1833 12.0334 11.3666 12.5917 11.5999C11.5251 12.3583 10.8334 13.5999 10.8334 14.9999ZM3.33341 14.1666V14.9999H9.16675C9.16675 14.1333 9.35841 13.3083 9.70008 12.5666L8.33341 12.4999C5.57508 12.4999 3.33341 13.2499 3.33341 14.1666ZM8.33341 3.33325C9.21747 3.33325 10.0653 3.68444 10.6904 4.30956C11.3156 4.93468 11.6667 5.78253 11.6667 6.66659C11.6667 7.55064 11.3156 8.39849 10.6904 9.02361C10.0653 9.64873 9.21747 9.99992 8.33341 9.99992C7.44936 9.99992 6.60151 9.64873 5.97639 9.02361C5.35127 8.39849 5.00008 7.55064 5.00008 6.66659C5.00008 5.78253 5.35127 4.93468 5.97639 4.30956C6.60151 3.68444 7.44936 3.33325 8.33341 3.33325ZM8.33341 4.99992C7.89139 4.99992 7.46746 5.17551 7.1549 5.48807C6.84234 5.80063 6.66675 6.22456 6.66675 6.66659C6.66675 7.10861 6.84234 7.53254 7.1549 7.8451C7.46746 8.15766 7.89139 8.33325 8.33341 8.33325C8.77544 8.33325 9.19936 8.15766 9.51193 7.8451C9.82449 7.53254 10.0001 7.10861 10.0001 6.66659C10.0001 6.22456 9.82449 5.80063 9.51193 5.48807C9.19936 5.17551 8.77544 4.99992 8.33341 4.99992Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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;
}

View 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;
}

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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);

View 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>
);
}
}

View 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>
);
}
}

View 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>
);
}
})}
</>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View File

@@ -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: '=',
});

View File

@@ -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>
);
}
);

View 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>
);
}
}

View 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>
);
}
}

View File

@@ -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: '@',
});

View 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>
);
}
}

View File

@@ -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
);
}
);

View File

@@ -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);

View File

@@ -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);

View 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>
);
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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: '&',
}
);

View File

@@ -10,7 +10,7 @@ import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/views/note_view/note_view';
} from '@/components/NoteView/NoteView';
import {
Disclosure,
DisclosureButton,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
}
);
}

View 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>
);
}
}

View 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>
);
}
}

View File

@@ -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);

View File

@@ -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: '=',
}
);

View File

@@ -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);

View 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>
);
}
}

View File

@@ -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);

View File

@@ -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);

View 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>
);
}
}

View File

@@ -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,
},
};
};
}

View File

@@ -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();
}
});
}
};
}

View File

@@ -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);
}
};
}

View File

@@ -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();
});
}
};
}

View File

@@ -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);
});
});
}
};
}

View File

@@ -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
});
});
});
}
};
}

View File

@@ -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';

View File

@@ -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]);
}
};
}

View File

@@ -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);
}
});
}
};
}

View File

@@ -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();
}
});
};
}

View File

@@ -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: '='
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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';

View File

@@ -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: '&'
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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: '=',
};
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -1 +0,0 @@
export { trusted } from './trusted';

View File

@@ -1,6 +0,0 @@
/* @ngInject */
export function trusted($sce: ng.ISCEService) {
return function(url: string) {
return $sce.trustAsResourceUrl(url);
};
}

View File

@@ -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';

View File

@@ -1,3 +0,0 @@
export enum RootScopeMessages {
NewUpdateAvailable = 'new-update-available'
}

View File

@@ -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 }> = ({

View File

@@ -1,9 +0,0 @@
import { toDirective } from '../components/utils';
import {
PreferencesViewWrapper,
PreferencesViewWrapperProps,
} from './PreferencesViewWrapper';
export const PreferencesDirective = toDirective<PreferencesViewWrapperProps>(
PreferencesViewWrapper
);

View File

@@ -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}

View File

@@ -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]);

View File

@@ -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>
);
}
);

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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);
})

View File

@@ -1,8 +0,0 @@
import { toDirective } from '@/components/utils';
import {
PurchaseFlowWrapper,
PurchaseFlowWrapperProps,
} from './PurchaseFlowWrapper';
export const PurchaseFlowDirective =
toDirective<PurchaseFlowWrapperProps>(PurchaseFlowWrapper);

View File

@@ -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);
}
}

View File

@@ -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() {

View File

@@ -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;
};

View File

@@ -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());
}

View File

@@ -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);
}

View File

@@ -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 = () => {

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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;
}
};
}

View File

@@ -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

View File

@@ -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: '='
};
}
}

View File

@@ -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'
)

View File

@@ -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: '=',
};
}
}

View File

@@ -1,6 +0,0 @@
application-view(
ng-repeat='application in self.applications',
ng-if='application == self.activeApplication'
application='application'
ng-attr-id='{{application.identifier}}'
)

View File

@@ -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;
}
}

View File

@@ -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>
)}
</>
));
}

View File

@@ -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

View File

@@ -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: '=',
};
}
}

View File

@@ -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';

View File

@@ -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'
)

View File

@@ -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: '=',
};
}
}

View File

@@ -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'
)

View File

@@ -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())
);
}

View File

@@ -0,0 +1,20 @@
.app-column-container {
display: flex;
flex-direction: row;
}
.app-column-first {
width: 180px;
flex-grow: 0;
}
.app-column-second {
width: 350px;
flex-grow: 0;
}
.app-column-third {
flex: 1 50%;
}
.app-column {}

View File

@@ -35,8 +35,8 @@
opacity: 1;
}
navigation,
notes-view {
#navigation,
#notes-column {
will-change: opacity;
animation: fade-out 1.25s forwards;
transition: width 1.25s;
@@ -45,20 +45,20 @@
flex: none !important;
}
navigation:hover {
#navigation:hover {
flex: initial;
width: 0px !important;
}
notes-view:hover {
#notes-column:hover {
flex: initial;
width: 0px !important;
}
}
.disable-focus-mode {
navigation,
notes-view {
#navigation,
#notes-column {
transition: width 1.25s;
will-change: opacity;
animation: fade-in 1.25s forwards;

Some files were not shown because too many files have changed in this diff Show More