refactor: migrate remaining angular components to react (#833)
* refactor: menuRow directive to MenuRow component * refactor: migrate footer to react * refactor: migrate actions menu to react * refactor: migrate history menu to react * fix: click outside handler use capture to trigger event before re-render occurs which would otherwise cause node.contains to return incorrect result (specifically for the account menu) * refactor: migrate revision preview modal to react * refactor: migrate permissions modal to react * refactor: migrate password wizard to react * refactor: remove unused input modal directive * refactor: remove unused delay hide component * refactor: remove unused filechange directive * refactor: remove unused elemReady directive * refactor: remove unused sn-enter directive * refactor: remove unused lowercase directive * refactor: remove unused autofocus directive * refactor(wip): note view to react * refactor: use mutation observer to deinit textarea listeners * refactor: migrate challenge modal to react * refactor: migrate note group view to react * refactor(wip): migrate remaining classes * fix: navigation parent ref * refactor: fully remove angular assets * fix: account switcher * fix: application view state * refactor: remove unused password wizard type * fix: revision preview and permissions modal * fix: remove angular comment * refactor: react panel resizers for editor * feat: simple panel resizer * fix: use simple panel resizer everywhere * fix: simplify panel resizer state * chore: rename simple panel resizer to panel resizer * refactor: simplify column layout * fix: editor mount safety check * fix: use inline onLoad callback for iframe, as setting onload after it loads will never call it * chore: fix note view test * chore(deps): upgrade snjs
This commit is contained in:
@@ -1,176 +0,0 @@
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
|
||||
|
||||
export type CtrlState = Partial<Record<string, any>>;
|
||||
export type CtrlProps = 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;
|
||||
private reactionDisposers: IReactionDisposer[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
$onInit(): void {
|
||||
this.state = {
|
||||
...this.getInitialState(),
|
||||
...this.state,
|
||||
};
|
||||
this.addAppEventObserver();
|
||||
this.addAppStateObserver();
|
||||
this.templateReady = true;
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.unsubApp?.();
|
||||
this.unsubState?.();
|
||||
for (const disposer of this.reactionDisposers) {
|
||||
disposer();
|
||||
}
|
||||
this.reactionDisposers.length = 0;
|
||||
this.unsubApp = undefined;
|
||||
this.unsubState = undefined;
|
||||
if (this.stateTimeout) {
|
||||
this.$timeout.cancel(this.stateTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
$onDestroy(): void {
|
||||
this.deinit();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
autorun(view: (r: IReactionPublic) => void): void {
|
||||
this.reactionDisposers.push(autorun(view));
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.unsubState = this.application!.getAppState().addObserver(
|
||||
async (eventName, data) => {
|
||||
this.onAppStateEvent(eventName, data);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onAppStateEvent(eventName: any, data: any) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
if (this.application!.isStarted()) {
|
||||
this.onAppStart();
|
||||
}
|
||||
if (this.application!.isLaunched()) {
|
||||
this.onAppLaunch();
|
||||
}
|
||||
this.unsubApp = this.application!.addEventObserver(
|
||||
async (eventName, data: any) => {
|
||||
this.onAppEvent(eventName, data);
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart();
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch();
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync();
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync();
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange();
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onAppEvent(eventName: ApplicationEvent, data?: any) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppStart() {
|
||||
await this.resetState();
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppIncrementalSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppFullSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.sk-modal-background(ng-click="ctrl.dismiss()")
|
||||
#account-switcher.sk-modal-content
|
||||
.sn-component
|
||||
.sk-menu-panel#menu-panel
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-column
|
||||
.sk-menu-panel-header-title Account Switcher
|
||||
.sk-menu-panel-column
|
||||
a.sk-label.info(ng-click='ctrl.addNewApplication()') Add Account
|
||||
.sk-menu-panel-row(
|
||||
ng-repeat='descriptor in ctrl.state.descriptors track by descriptor.identifier'
|
||||
ng-click='ctrl.selectDescriptor(descriptor)'
|
||||
)
|
||||
.sk-menu-panel-column.stretch
|
||||
.left
|
||||
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
.sk-circle.small.success
|
||||
.sk-menu-panel-column.stretch
|
||||
input.sk-label.clickable(
|
||||
ng-model='descriptor.label'
|
||||
ng-disabled='descriptor != ctrl.state.editingDescriptor'
|
||||
ng-keyup='$event.keyCode == 13 && ctrl.submitRename($event)',
|
||||
ng-attr-id='input-{{descriptor.identifier}}'
|
||||
spellcheck="false"
|
||||
)
|
||||
.sk-sublabel(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
| Current Application
|
||||
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
|
||||
button.sn-button.success(
|
||||
ng-click='ctrl.renameDescriptor($event, descriptor)'
|
||||
) Rename
|
||||
@@ -1,105 +0,0 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from './account-switcher.pug';
|
||||
import {
|
||||
ApplicationDescriptor,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { WebDirective } from '@/types';
|
||||
|
||||
class AccountSwitcherCtrl extends PureViewCtrl<unknown, {
|
||||
descriptors: ApplicationDescriptor[];
|
||||
editingDescriptor?: ApplicationDescriptor
|
||||
}> {
|
||||
private $element: JQLite
|
||||
application!: WebApplication
|
||||
private removeAppGroupObserver: any;
|
||||
/** @template */
|
||||
activeApplication!: WebApplication
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService,
|
||||
private mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
this.activeApplication = mainApplicationGroup.primaryApplication as WebApplication;
|
||||
this.reloadApplications();
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
}
|
||||
|
||||
reloadApplications() {
|
||||
this.setState({
|
||||
descriptors: this.mainApplicationGroup.getDescriptors()
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
addNewApplication() {
|
||||
this.dismiss();
|
||||
this.mainApplicationGroup.addNewApplication();
|
||||
}
|
||||
|
||||
/** @template */
|
||||
selectDescriptor(descriptor: ApplicationDescriptor) {
|
||||
this.dismiss();
|
||||
this.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
|
||||
}
|
||||
|
||||
inputForDescriptor(descriptor: ApplicationDescriptor) {
|
||||
return document.getElementById(`input-${descriptor.identifier}`);
|
||||
}
|
||||
|
||||
/** @template */
|
||||
renameDescriptor($event: Event, descriptor: ApplicationDescriptor) {
|
||||
$event.stopPropagation();
|
||||
this.setState({ editingDescriptor: descriptor }).then(() => {
|
||||
const input = this.inputForDescriptor(descriptor);
|
||||
input?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
submitRename() {
|
||||
this.mainApplicationGroup.renameDescriptor(
|
||||
this.state.editingDescriptor!,
|
||||
this.state.editingDescriptor!.label
|
||||
);
|
||||
this.setState({ editingDescriptor: undefined });
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.application as any) = undefined;
|
||||
super.deinit();
|
||||
this.removeAppGroupObserver();
|
||||
this.removeAppGroupObserver = undefined;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountSwitcher extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = AccountSwitcherCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
.main-ui-view.sn-component(
|
||||
ng-class='self.platformString'
|
||||
)
|
||||
#app.app(
|
||||
ng-class='self.state.appClass',
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
)
|
||||
navigation(application='self.application', appState='self.appState')
|
||||
notes-view(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
note-group-view.flex-grow(application='self.application')
|
||||
|
||||
footer-view(
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
application='self.application'
|
||||
)
|
||||
|
||||
svg(data-ionicons="5.1.2", style="display: none")
|
||||
symbol#people-circle-outline.ionicon(viewbox="0 0 512 512")
|
||||
path(d="M256 464c-114.69 0-208-93.31-208-208S141.31 48 256 48s208 93.31 208 208-93.31 208-208 208zm0-384c-97 0-176 79-176 176s79 176 176 176 176-78.95 176-176S353.05 80 256 80z")
|
||||
path(d="M323.67 292c-17.4 0-34.21-7.72-47.34-21.73a83.76 83.76 0 01-22-51.32c-1.47-20.7 4.88-39.75 17.88-53.62S303.38 144 323.67 144c20.14 0 38.37 7.62 51.33 21.46s19.47 33 18 53.51a84 84 0 01-22 51.3C357.86 284.28 341.06 292 323.67 292zm55.81-74zM163.82 295.36c-29.76 0-55.93-27.51-58.33-61.33-1.23-17.32 4.15-33.33 15.17-45.08s26.22-18 43.15-18 32.12 6.44 43.07 18.14 16.5 27.82 15.25 45c-2.44 33.77-28.6 61.27-58.31 61.27zM420.37 355.28c-1.59-4.7-5.46-9.71-13.22-14.46-23.46-14.33-52.32-21.91-83.48-21.91-30.57 0-60.23 7.9-83.53 22.25-26.25 16.17-43.89 39.75-51 68.18-1.68 6.69-4.13 19.14-1.51 26.11a192.18 192.18 0 00232.75-80.17zM163.63 401.37c7.07-28.21 22.12-51.73 45.47-70.75a8 8 0 00-2.59-13.77c-12-3.83-25.7-5.88-42.69-5.88-23.82 0-49.11 6.45-68.14 18.17-5.4 3.33-10.7 4.61-14.78 5.75a192.84 192.84 0 0077.78 86.64l1.79-.14a102.82 102.82 0 013.16-20.02z")
|
||||
|
||||
symbol#layers-sharp.ionicon(viewbox="0 0 512 512")
|
||||
path(d="M480 150L256 48 32 150l224 104 224-104zM255.71 392.95l-144.81-66.2L32 362l224 102 224-102-78.69-35.3-145.6 66.25z")
|
||||
path(d="M480 256l-75.53-33.53L256.1 290.6l-148.77-68.17L32 256l224 102 224-102z")
|
||||
sessions-modal(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
preferences(
|
||||
app-state='self.appState'
|
||||
application='self.application'
|
||||
)
|
||||
challenge-modal(
|
||||
ng-repeat="challenge in self.challenges track by challenge.id"
|
||||
class="sk-modal"
|
||||
application="self.application"
|
||||
challenge="challenge"
|
||||
on-dismiss="self.removeChallenge(challenge)"
|
||||
)
|
||||
notes-context-menu(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
purchase-flow(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -1,207 +0,0 @@
|
||||
import { RootScopeMessages } from './../../messages';
|
||||
import { WebDirective } from '@/types';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import template from './application-view.pug';
|
||||
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
Challenge,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog } from '@/services/alertService';
|
||||
|
||||
class ApplicationViewCtrl extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
started?: boolean;
|
||||
launched?: boolean;
|
||||
needsUnlock?: boolean;
|
||||
appClass: string;
|
||||
}
|
||||
> {
|
||||
public platformString: string;
|
||||
private notesCollapsed = false;
|
||||
private navigationCollapsed = false;
|
||||
|
||||
/**
|
||||
* To prevent stale state reads (setState is async),
|
||||
* challenges is a mutable array
|
||||
*/
|
||||
private challenges: Challenge[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
private $location: ng.ILocationService,
|
||||
private $rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
this.$location = $location;
|
||||
this.$rootScope = $rootScope;
|
||||
this.platformString = getPlatformString();
|
||||
this.state = this.getInitialState();
|
||||
this.onDragDrop = this.onDragDrop.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.addDragDropHandlers();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.$location as unknown) = undefined;
|
||||
(this.$rootScope as unknown) = undefined;
|
||||
(this.application as unknown) = undefined;
|
||||
window.removeEventListener('dragover', this.onDragOver, true);
|
||||
window.removeEventListener('drop', this.onDragDrop, true);
|
||||
(this.onDragDrop as unknown) = undefined;
|
||||
(this.onDragOver as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.loadApplication();
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
appClass: '',
|
||||
challenges: [],
|
||||
};
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
this.application.componentManager.setDesktopManager(
|
||||
this.application.getDesktopService()
|
||||
);
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
this.$timeout(() => {
|
||||
this.challenges.push(challenge);
|
||||
});
|
||||
},
|
||||
});
|
||||
await this.application.launch();
|
||||
}
|
||||
|
||||
public async removeChallenge(challenge: Challenge) {
|
||||
this.$timeout(() => {
|
||||
removeFromArray(this.challenges, challenge);
|
||||
});
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.setState({
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
});
|
||||
this.handleDemoSignInFromParams();
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.$rootScope.$broadcast(RootScopeMessages.NewUpdateAvailable);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.LocalDatabaseReadError:
|
||||
alertDialog({
|
||||
text: 'Unable to load local database. Please restart the app and try again.',
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.LocalDatabaseWriteError:
|
||||
alertDialog({
|
||||
text: 'Unable to write to local database. Please restart the app and try again.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
|
||||
if (eventName === AppStateEvent.PanelResized) {
|
||||
const { panel, collapsed } = data as PanelResizedData;
|
||||
if (panel === PANEL_NAME_NOTES) {
|
||||
this.notesCollapsed = collapsed;
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION) {
|
||||
this.navigationCollapsed = collapsed;
|
||||
}
|
||||
let appClass = '';
|
||||
if (this.notesCollapsed) {
|
||||
appClass += 'collapsed-notes';
|
||||
}
|
||||
if (this.navigationCollapsed) {
|
||||
appClass += ' collapsed-navigation';
|
||||
}
|
||||
this.setState({ appClass });
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
if (!(await this.application.isLocked())) {
|
||||
this.application.sync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDragDropHandlers() {
|
||||
/**
|
||||
* Disable dragging and dropping of files (but allow text) into main SN interface.
|
||||
* both 'dragover' and 'drop' are required to prevent dropping of files.
|
||||
* This will not prevent extensions from receiving drop events.
|
||||
*/
|
||||
window.addEventListener('dragover', this.onDragOver, true);
|
||||
window.addEventListener('drop', this.onDragDrop, true);
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer?.files.length) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onDragDrop(event: DragEvent) {
|
||||
if (event.dataTransfer?.files.length) {
|
||||
event.preventDefault();
|
||||
void alertDialog({
|
||||
text: STRING_DEFAULT_FILE_ERROR,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleDemoSignInFromParams() {
|
||||
if (
|
||||
this.$location.search().demo === 'true' &&
|
||||
!this.application.hasAccount()
|
||||
) {
|
||||
await this.application.setCustomHost(
|
||||
'https://syncing-server-demo.standardnotes.com'
|
||||
);
|
||||
this.application.signIn('demo@standardnotes.org', 'password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
application-view(
|
||||
ng-repeat='application in self.applications',
|
||||
ng-if='application == self.activeApplication'
|
||||
application='application'
|
||||
ng-attr-id='{{application.identifier}}'
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebDirective } from '@/types';
|
||||
import template from './application-group-view.pug';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
class ApplicationGroupViewCtrl {
|
||||
|
||||
private $timeout: ng.ITimeoutService
|
||||
private applicationGroup: ApplicationGroup
|
||||
applications!: WebApplication[]
|
||||
activeApplication!: WebApplication
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.applicationGroup = mainApplicationGroup;
|
||||
this.applicationGroup.addApplicationChangeObserver(() => {
|
||||
this.reload();
|
||||
});
|
||||
this.applicationGroup.initialize();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.$timeout(() => {
|
||||
this.activeApplication = this.applicationGroup.primaryApplication as WebApplication;
|
||||
this.applications = this.applicationGroup.getApplications() as WebApplication[];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationGroupView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationGroupViewCtrl;
|
||||
this.replace = false;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Dialog } from '@reach/dialog';
|
||||
import {
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
Challenge,
|
||||
ChallengeReason,
|
||||
ChallengePrompt,
|
||||
ChallengeValidation,
|
||||
ProtectionSessionDurations,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { WebDirective } from '@/types';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
||||
import { Ref, render } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import ng from 'angular';
|
||||
|
||||
type InputValue = {
|
||||
prompt: ChallengePrompt;
|
||||
value: string | number | boolean;
|
||||
invalid: boolean;
|
||||
};
|
||||
|
||||
type Values = Record<number, InputValue>;
|
||||
|
||||
type ChallengeModalState = {
|
||||
prompts: ChallengePrompt[];
|
||||
values: Partial<Values>;
|
||||
processing: boolean;
|
||||
forgotPasscode: boolean;
|
||||
showForgotPasscodeLink: boolean;
|
||||
processingPrompts: ChallengePrompt[];
|
||||
hasAccount: boolean;
|
||||
protectedNoteAccessDuration: number;
|
||||
};
|
||||
|
||||
class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
|
||||
application!: WebApplication;
|
||||
challenge!: Challenge;
|
||||
onDismiss!: () => void;
|
||||
submitting = false;
|
||||
|
||||
/** @template */
|
||||
protectionsSessionDurations = ProtectionSessionDurations;
|
||||
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
private $element: ng.IRootElementService,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state as ChallengeModalState;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
const values = {} as Values;
|
||||
const prompts = this.challenge.prompts;
|
||||
for (const prompt of prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
};
|
||||
}
|
||||
const showForgotPasscodeLink = [
|
||||
ChallengeReason.ApplicationUnlock,
|
||||
ChallengeReason.Migration,
|
||||
].includes(this.challenge.reason);
|
||||
this.setState({
|
||||
prompts,
|
||||
values,
|
||||
processing: false,
|
||||
forgotPasscode: false,
|
||||
showForgotPasscodeLink,
|
||||
hasAccount: this.application.hasAccount(),
|
||||
processingPrompts: [],
|
||||
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
|
||||
});
|
||||
this.application.addChallengeObserver(this.challenge, {
|
||||
onValidValue: (value) => {
|
||||
this.state.values[value.prompt.id]!.invalid = false;
|
||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||
this.reloadProcessingStatus();
|
||||
/** Trigger UI update */
|
||||
this.afterStateChange();
|
||||
},
|
||||
onInvalidValue: (value) => {
|
||||
this.state.values[value.prompt.id]!.invalid = true;
|
||||
/** If custom validation, treat all values together and not individually */
|
||||
if (!value.prompt.validates) {
|
||||
this.setState({ processingPrompts: [], processing: false });
|
||||
} else {
|
||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||
this.reloadProcessingStatus();
|
||||
}
|
||||
/** Trigger UI update */
|
||||
this.afterStateChange();
|
||||
},
|
||||
onComplete: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
onCancel: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.application as any) = undefined;
|
||||
(this.challenge as any) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
reloadProcessingStatus() {
|
||||
return this.setState({
|
||||
processing: this.state.processingPrompts.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
async destroyLocalData() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.application.signOut();
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/** @template */
|
||||
cancel() {
|
||||
if (this.challenge.cancelable) {
|
||||
this.application!.cancelChallenge(this.challenge);
|
||||
}
|
||||
}
|
||||
|
||||
onForgotPasscodeClick() {
|
||||
this.setState({
|
||||
forgotPasscode: true,
|
||||
});
|
||||
}
|
||||
|
||||
onTextValueChange(prompt: ChallengePrompt) {
|
||||
const values = this.getState().values;
|
||||
values[prompt.id]!.invalid = false;
|
||||
this.setState({ values });
|
||||
}
|
||||
|
||||
onNumberValueChange(prompt: ChallengePrompt, value: number) {
|
||||
const values = this.state.values;
|
||||
values[prompt.id]!.invalid = false;
|
||||
values[prompt.id]!.value = value;
|
||||
this.setState({ values });
|
||||
}
|
||||
|
||||
validate() {
|
||||
let failed = 0;
|
||||
for (const prompt of this.state.prompts) {
|
||||
const value = this.state.values[prompt.id]!;
|
||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||
this.state.values[prompt.id]!.invalid = true;
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return failed === 0;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.validate()) {
|
||||
return;
|
||||
}
|
||||
if (this.submitting || this.state.processing) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
await this.setState({ processing: true });
|
||||
const values: ChallengeValue[] = [];
|
||||
for (const inputValue of Object.values(this.getState().values)) {
|
||||
const rawValue = inputValue!.value;
|
||||
const value = new ChallengeValue(inputValue!.prompt, rawValue);
|
||||
values.push(value);
|
||||
}
|
||||
const processingPrompts = values.map((v) => v.prompt);
|
||||
await this.setState({
|
||||
processingPrompts: processingPrompts,
|
||||
processing: processingPrompts.length > 0,
|
||||
});
|
||||
/**
|
||||
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||
* (crypto key generation)
|
||||
*/
|
||||
this.$timeout(() => {
|
||||
if (values.length > 0) {
|
||||
this.application.submitValuesForChallenge(this.challenge, values);
|
||||
} else {
|
||||
this.setState({ processing: false });
|
||||
}
|
||||
this.submitting = false;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
afterStateChange() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.onDismiss();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
render(<></>, this.$element[0]);
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.state.prompts) return;
|
||||
render(<ChallengeModalView ctrl={this} />, this.$element[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChallengeModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
// this.template = template;
|
||||
this.controller = ChallengeModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
challenge: '=',
|
||||
application: '=',
|
||||
onDismiss: '&',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ChallengeModalView({ ctrl }: { ctrl: ChallengeModalCtrl }) {
|
||||
const initialFocusRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<Dialog
|
||||
initialFocusRef={initialFocusRef}
|
||||
onDismiss={() => {
|
||||
if (ctrl.challenge.cancelable) {
|
||||
ctrl.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="challenge-modal sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">
|
||||
{ctrl.challenge.modalTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-p sk-panel-row centered prompt">
|
||||
<strong>{ctrl.challenge.heading}</strong>
|
||||
</div>
|
||||
{ctrl.challenge.subheading && (
|
||||
<div className="sk-p sk-panel-row centered subprompt">
|
||||
{ctrl.challenge.subheading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-panel-section">
|
||||
{ChallengePrompts({ ctrl, initialFocusRef })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer extra-padding">
|
||||
<button
|
||||
className={
|
||||
'sn-button w-full ' +
|
||||
(ctrl.state.processing ? 'neutral' : 'info')
|
||||
}
|
||||
disabled={ctrl.state.processing}
|
||||
onClick={() => ctrl.submit()}
|
||||
>
|
||||
{ctrl.state.processing ? 'Generating Keys…' : 'Submit'}
|
||||
</button>
|
||||
{ctrl.challenge.cancelable && (
|
||||
<>
|
||||
<div className="sk-panel-row"></div>
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered text-sm"
|
||||
onClick={() => ctrl.cancel()}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.state.showForgotPasscodeLink && (
|
||||
<div className="sk-panel-footer">
|
||||
{ctrl.state.forgotPasscode ? (
|
||||
<>
|
||||
<p className="sk-panel-row sk-p">
|
||||
{ctrl.state.hasAccount
|
||||
? 'If you forgot your application passcode, your ' +
|
||||
'only option is to clear your local data from this ' +
|
||||
'device and sign back in to your account.'
|
||||
: 'If you forgot your application passcode, your ' +
|
||||
'only option is to delete your data.'}
|
||||
</p>
|
||||
<a
|
||||
className="sk-panel-row sk-a danger centered"
|
||||
onClick={() => {
|
||||
ctrl.destroyLocalData();
|
||||
}}
|
||||
>
|
||||
Delete Local Data
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered"
|
||||
onClick={() => ctrl.onForgotPasscodeClick()}
|
||||
>
|
||||
Forgot your passcode?
|
||||
</a>
|
||||
)}
|
||||
<div className="sk-panel-row"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChallengePrompts({
|
||||
ctrl,
|
||||
initialFocusRef,
|
||||
}: {
|
||||
ctrl: ChallengeModalCtrl;
|
||||
initialFocusRef: Ref<HTMLInputElement>;
|
||||
}) {
|
||||
return ctrl.state.prompts.map((prompt, index) => (
|
||||
<>
|
||||
{/** ProtectionSessionDuration can't just be an input field */}
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<div className="sk-horizontal-group mt-3">
|
||||
<div className="sk-p sk-bold">Allow protected access for</div>
|
||||
{ProtectionSessionDurations.map((option) => (
|
||||
<a
|
||||
className={
|
||||
'sk-a info ' +
|
||||
(option.valueInSeconds === ctrl.state.values[prompt.id]!.value
|
||||
? 'boxed'
|
||||
: '')
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.onNumberValueChange(prompt, option.valueInSeconds);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
value={ctrl.state.values[prompt.id]!.value as string | number}
|
||||
onChange={(event) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
ctrl.state.values[prompt.id]!.value = value;
|
||||
ctrl.onTextValueChange(prompt);
|
||||
}}
|
||||
ref={index === 0 ? initialFocusRef : undefined}
|
||||
placeholder={prompt.title}
|
||||
type={prompt.secureTextEntry ? 'password' : 'text'}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctrl.state.values[prompt.id]!.invalid && (
|
||||
<div className="sk-panel-row centered">
|
||||
<label className="sk-label danger">
|
||||
Invalid authentication. Please try again.
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
.sn-component
|
||||
#footer-bar.sk-app-bar.no-edges.no-bottom-edge
|
||||
.left
|
||||
.sk-app-bar-item.ml-0(
|
||||
click-outside='ctrl.clickOutsideAccountMenu()',
|
||||
is-open='ctrl.showAccountMenu',
|
||||
ng-click='ctrl.accountMenuPressed()'
|
||||
)
|
||||
.w-8.h-full.flex.items-center.justify-center.cursor-pointer.rounded-full(
|
||||
ng-class="ctrl.showAccountMenu ? 'bg-border' : '' "
|
||||
)
|
||||
.w-5.h-5(
|
||||
ng-class="ctrl.hasError ? 'danger' : (ctrl.user ? 'info' : 'neutral')"
|
||||
)
|
||||
icon(
|
||||
type="account-circle"
|
||||
class-name="hover:color-info w-5 h-5 max-h-5"
|
||||
)
|
||||
account-menu(
|
||||
ng-click='$event.stopPropagation()',
|
||||
app-state='ctrl.appState'
|
||||
application='ctrl.application'
|
||||
ng-if='ctrl.showAccountMenu',
|
||||
)
|
||||
.sk-app-bar-item.ml-0-important(
|
||||
click-outside='ctrl.clickOutsideQuickSettingsMenu()',
|
||||
is-open='ctrl.showQuickSettingsMenu',
|
||||
ng-click='ctrl.quickSettingsPressed()'
|
||||
)
|
||||
.w-8.h-full.flex.items-center.justify-center.cursor-pointer
|
||||
.h-5
|
||||
icon(
|
||||
type="tune"
|
||||
class-name="rounded hover:color-info"
|
||||
ng-class="{'color-info': ctrl.showQuickSettingsMenu}"
|
||||
)
|
||||
quick-settings-menu(
|
||||
ng-click='$event.stopPropagation()',
|
||||
app-state='ctrl.appState'
|
||||
application='ctrl.application'
|
||||
ng-if='ctrl.showQuickSettingsMenu',)
|
||||
.sk-app-bar-item.border(ng-if="ctrl.state.showBetaWarning")
|
||||
.sk-app-bar-item(ng-if="ctrl.state.showBetaWarning")
|
||||
a.no-decoration.sk-label.title(
|
||||
ng-click="ctrl.displayBetaDialog()"
|
||||
) You are using a beta version of the app
|
||||
.center
|
||||
.sk-app-bar-item(ng-if='ctrl.arbitraryStatusMessage')
|
||||
.sk-app-bar-item-column
|
||||
span.neutral.sk-label {{ctrl.arbitraryStatusMessage}}
|
||||
.right
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.openSecurityUpdate()'
|
||||
ng-if='ctrl.state.dataUpgradeAvailable'
|
||||
)
|
||||
span.success.sk-label Encryption upgrade available.
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.clickedNewUpdateAnnouncement()',
|
||||
ng-if='ctrl.newUpdateAvailable == true'
|
||||
)
|
||||
span.info.sk-label New update available.
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-if='(ctrl.state.outOfSync) || ctrl.showSyncResolution'
|
||||
)
|
||||
.sk-label.warning(ng-if='ctrl.state.outOfSync') Potentially Out of Sync
|
||||
sync-resolution-menu(
|
||||
close-function='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-click='$event.stopPropagation();',
|
||||
ng-if='ctrl.showSyncResolution',
|
||||
application='ctrl.application'
|
||||
)
|
||||
.sk-app-bar-item(ng-if='ctrl.offline')
|
||||
.sk-label Offline
|
||||
.sk-app-bar-item.border(ng-if='ctrl.state.hasAccountSwitcher')
|
||||
.sk-app-bar-item(
|
||||
ng-if='ctrl.state.hasAccountSwitcher'
|
||||
ng-click='ctrl.openAccountSwitcher()',
|
||||
)
|
||||
#account-switcher-icon.flex.items-center(ng-class='{"alone": !ctrl.state.hasPasscode}')
|
||||
svg.info.ionicon.w-5.h-5
|
||||
use(href="#layers-sharp")
|
||||
.sk-app-bar-item.border(ng-if='ctrl.state.hasPasscode')
|
||||
#lock-item.sk-app-bar-item(
|
||||
ng-click='ctrl.lockApp()',
|
||||
ng-if='ctrl.state.hasPasscode',
|
||||
title='Locks application and wipes unencrypted data from memory.'
|
||||
)
|
||||
.sk-label
|
||||
i#footer-lock-icon.icon.ion-locked
|
||||
@@ -1,410 +0,0 @@
|
||||
import { RootScopeMessages } from './../../messages';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebDirective } from '@/types';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
SNTheme,
|
||||
CollectionSort,
|
||||
} from '@standardnotes/snjs';
|
||||
import template from './footer-view.pug';
|
||||
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
|
||||
import {
|
||||
STRING_NEW_UPDATE_READY,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
} from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { AccountMenuPane } from '@/components/AccountMenu';
|
||||
|
||||
/**
|
||||
* Disable before production release.
|
||||
* Anyone who used the beta will still have access to
|
||||
* the account switcher in production via local storage flag
|
||||
*/
|
||||
const ACCOUNT_SWITCHER_ENABLED = false;
|
||||
const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher';
|
||||
|
||||
class FooterViewCtrl extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
outOfSync: boolean;
|
||||
hasPasscode: boolean;
|
||||
dataUpgradeAvailable: boolean;
|
||||
hasAccountSwitcher: boolean;
|
||||
showBetaWarning: boolean;
|
||||
showDataUpgrade: boolean;
|
||||
}
|
||||
> {
|
||||
private $rootScope: ng.IRootScopeService;
|
||||
private showSyncResolution = false;
|
||||
private rootScopeListener2: any;
|
||||
public arbitraryStatusMessage?: string;
|
||||
public user?: any;
|
||||
private offline = true;
|
||||
public showAccountMenu = false;
|
||||
public showQuickSettingsMenu = false;
|
||||
private didCheckForOffline = false;
|
||||
public hasError = false;
|
||||
public newUpdateAvailable = false;
|
||||
private observerRemovers: Array<() => void> = [];
|
||||
private completedInitialSync = false;
|
||||
private showingDownloadStatus = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
private mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
super($timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.addRootScopeListeners();
|
||||
this.toggleSyncResolutionMenu = this.toggleSyncResolutionMenu.bind(this);
|
||||
this.closeAccountMenu = this.closeAccountMenu.bind(this);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
for (const remove of this.observerRemovers) remove();
|
||||
this.observerRemovers.length = 0;
|
||||
this.rootScopeListener2();
|
||||
this.rootScopeListener2 = undefined;
|
||||
(this.closeAccountMenu as unknown) = undefined;
|
||||
(this.toggleSyncResolutionMenu as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.$timeout(() => {
|
||||
this.arbitraryStatusMessage = message;
|
||||
});
|
||||
});
|
||||
this.loadAccountSwitcherState();
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
this.showAccountMenu = this.appState.accountMenu.show;
|
||||
this.showQuickSettingsMenu = this.appState.quickSettingsMenu.open;
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showDataUpgrade: !showBetaWarning,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadAccountSwitcherState() {
|
||||
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
|
||||
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
|
||||
/** Enable permanently for this user so they don't lose the feature after its disabled */
|
||||
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
|
||||
}
|
||||
const hasAccountSwitcher = stringValue
|
||||
? JSON.parse(stringValue)
|
||||
: ACCOUNT_SWITCHER_ENABLED;
|
||||
this.setState({ hasAccountSwitcher });
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: this.mainApplicationGroup.getDescriptors(),
|
||||
hasAccountSwitcher: false,
|
||||
showBetaWarning: false,
|
||||
showDataUpgrade: false,
|
||||
};
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @template */
|
||||
openAccountSwitcher() {
|
||||
this.application.openAccountSwitcher();
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadPasscodeStatus();
|
||||
this.reloadUser();
|
||||
this.reloadUpgradeStatus();
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
this.user = this.application.getUser();
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode();
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
});
|
||||
}
|
||||
|
||||
addRootScopeListeners() {
|
||||
this.rootScopeListener2 = this.$rootScope.$on(
|
||||
RootScopeMessages.NewUpdateAvailable,
|
||||
() => {
|
||||
this.$timeout(() => {
|
||||
this.onNewUpdateAvailable();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager();
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
break;
|
||||
case AppStateEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…');
|
||||
break;
|
||||
case AppStateEvent.EndedBackupDownload: {
|
||||
const successMessage = 'Successfully saved backup.';
|
||||
const errorMessage = 'Unable to save local backup.';
|
||||
statusService.setMessage(data.success ? successMessage : errorMessage);
|
||||
|
||||
const twoSeconds = 2000;
|
||||
this.$timeout(() => {
|
||||
if (
|
||||
statusService.message === successMessage ||
|
||||
statusService.message === errorMessage
|
||||
) {
|
||||
statusService.setMessage('');
|
||||
}
|
||||
}, twoSeconds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.reloadPasscodeStatus();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus();
|
||||
break;
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('');
|
||||
this.completedInitialSync = true;
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true;
|
||||
if (this.offline && this.application.getNoteCount() === 0) {
|
||||
this.appState.accountMenu.setShow(true);
|
||||
}
|
||||
}
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus();
|
||||
break;
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus();
|
||||
break;
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser();
|
||||
break;
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.setDisplayOptions(
|
||||
ContentType.Theme,
|
||||
CollectionSort.Title,
|
||||
'asc',
|
||||
(theme: SNTheme) => {
|
||||
return !theme.errorDecrypting;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync');
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
|
||||
statusManager.setMessage(text);
|
||||
this.showingDownloadStatus = true;
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false;
|
||||
statusManager.setMessage('Download Complete.');
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('');
|
||||
}, 2000);
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0
|
||||
? 0
|
||||
: stats.uploadCompletionCount / stats.uploadTotalCount;
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
});
|
||||
|
||||
statusManager.setMessage(
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
|
||||
);
|
||||
} else {
|
||||
statusManager.setMessage('');
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const encryption = this.application.isEncryptionAvailable();
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('');
|
||||
return;
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
|
||||
const loadingStatus = encryption
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
statusManager.setMessage(loadingStatus);
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.offline = this.application.noAccount();
|
||||
}
|
||||
|
||||
async openSecurityUpdate() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.hasError = this.application.getSyncStatus().hasError();
|
||||
}
|
||||
|
||||
accountMenuPressed() {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
}
|
||||
|
||||
quickSettingsPressed() {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
this.appState.quickSettingsMenu.toggle();
|
||||
}
|
||||
|
||||
toggleSyncResolutionMenu() {
|
||||
this.showSyncResolution = !this.showSyncResolution;
|
||||
}
|
||||
|
||||
closeAccountMenu() {
|
||||
this.appState.accountMenu.setShow(false);
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
}
|
||||
|
||||
lockApp() {
|
||||
this.application.lock();
|
||||
}
|
||||
|
||||
onNewUpdateAvailable() {
|
||||
this.newUpdateAvailable = true;
|
||||
}
|
||||
|
||||
clickedNewUpdateAnnouncement() {
|
||||
this.newUpdateAvailable = false;
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
|
||||
}
|
||||
|
||||
displayBetaDialog() {
|
||||
alertDialog({
|
||||
title: 'You are using a beta version of the app',
|
||||
text:
|
||||
'If you wish to go back to a stable version, make sure to sign out ' +
|
||||
'of this beta app first.<br>You can silence this warning from the ' +
|
||||
'<em>Account</em> menu.',
|
||||
});
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu() {
|
||||
if (this.application && this.application.authenticationInProgress()) {
|
||||
return;
|
||||
}
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
}
|
||||
|
||||
clickOutsideQuickSettingsMenu() {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
}
|
||||
}
|
||||
|
||||
export class FooterView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = FooterViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { PureViewCtrl } from './abstract/pure_view_ctrl';
|
||||
export { ApplicationGroupView } from './application_group/application_group_view';
|
||||
export { ApplicationView } from './application/application_view';
|
||||
export { NoteGroupViewDirective } from './note_group_view/note_group_view';
|
||||
export { NoteViewDirective } from './note_view/note_view';
|
||||
export { FooterView } from './footer/footer_view';
|
||||
export { ChallengeModal } from './challenge_modal/challenge_modal';
|
||||
@@ -1,14 +0,0 @@
|
||||
.h-full
|
||||
multiple-selected-notes-panel.h-full(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
ng-if='self.state.showMultipleSelectedNotes'
|
||||
)
|
||||
.flex-grow.h-full(
|
||||
ng-if='!self.state.showMultipleSelectedNotes'
|
||||
ng-repeat='controller in self.controllers'
|
||||
)
|
||||
note-view(
|
||||
application='self.application'
|
||||
controller='controller'
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from './note-group-view.pug';
|
||||
import { PureViewCtrl } from '../abstract/pure_view_ctrl';
|
||||
import { NoteViewController } from '@standardnotes/snjs';
|
||||
|
||||
class NoteGroupView extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
showMultipleSelectedNotes: boolean;
|
||||
}
|
||||
> {
|
||||
public controllers: NoteViewController[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(
|
||||
() => {
|
||||
this.controllers = this.application.noteControllerGroup.noteControllers;
|
||||
}
|
||||
);
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoteGroupViewDirective extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = NoteGroupView;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
#editor-column.section.editor.sn-component(aria-label='Note')
|
||||
protected-note-panel.h-full.flex.justify-center.items-center(
|
||||
ng-if='self.state.showProtectedWarning'
|
||||
app-state='self.appState'
|
||||
has-protection-sources='self.application.hasProtectionSources()'
|
||||
on-view-note='self.dismissProtectedWarning()'
|
||||
)
|
||||
.flex-grow.flex.flex-col(
|
||||
ng-if='!self.appState.notes.showProtectedWarning'
|
||||
)
|
||||
.sn-component
|
||||
.sk-app-bar.no-edges(
|
||||
ng-if='self.noteLocked',
|
||||
ng-init="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
|
||||
ng-mouseleave="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
|
||||
ng-mouseover="self.lockText = 'Enable editing'; self.showLockedIcon = false"
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
ng-click='self.appState.notes.setLockSelectedNotes(!self.noteLocked)'
|
||||
)
|
||||
.sk-label.warning.flex.items-center
|
||||
icon.flex(
|
||||
type="pencil-off"
|
||||
class-name="fill-current mr-2"
|
||||
ng-if="self.showLockedIcon"
|
||||
)
|
||||
| {{self.lockText}}
|
||||
#editor-title-bar.section-title-bar.w-full(
|
||||
ng-show='self.note && !self.note.errorDecrypting'
|
||||
)
|
||||
div.flex.items-center.justify-between.h-8
|
||||
div.flex-grow(
|
||||
ng-class="{'locked' : self.noteLocked}"
|
||||
)
|
||||
.title.overflow-auto
|
||||
input#note-title-editor.input(
|
||||
ng-change='self.onTitleChange()',
|
||||
ng-disabled='self.noteLocked',
|
||||
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
||||
ng-model='self.editorValues.title',
|
||||
select-on-focus='true',
|
||||
spellcheck='false'
|
||||
)
|
||||
div.flex.items-center
|
||||
#save-status
|
||||
.message(
|
||||
ng-class="{'warning sk-bold': self.state.syncTakingTooLong, 'danger sk-bold': self.state.saveError}"
|
||||
) {{self.state.noteStatus.message}}
|
||||
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
|
||||
pin-note-button(
|
||||
class='mr-3'
|
||||
app-state='self.appState',
|
||||
ng-if='self.appState.notes.selectedNotesCount > 0'
|
||||
)
|
||||
notes-options-panel(
|
||||
application='self.application',
|
||||
app-state='self.appState',
|
||||
ng-if='self.appState.notes.selectedNotesCount > 0'
|
||||
)
|
||||
note-tags-container(
|
||||
app-state='self.appState'
|
||||
)
|
||||
.sn-component(ng-if='self.note')
|
||||
#editor-menu-bar.sk-app-bar.no-edges
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showActionsMenu', false)`,
|
||||
is-open='self.state.showActionsMenu',
|
||||
ng-class="{'selected' : self.state.showActionsMenu}",
|
||||
ng-click="self.toggleMenu('showActionsMenu')"
|
||||
)
|
||||
.sk-label Actions
|
||||
actions-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showActionsMenu',
|
||||
application='self.application'
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showHistoryMenu', false)`,
|
||||
is-open='self.state.showHistoryMenu',
|
||||
ng-class="{'selected' : self.state.showHistoryMenu}",
|
||||
ng-click="self.toggleMenu('showHistoryMenu')"
|
||||
)
|
||||
.sk-label History
|
||||
history-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showHistoryMenu',
|
||||
application='self.application'
|
||||
)
|
||||
#editor-content.editor-content(ng-if='!self.note.errorDecrypting')
|
||||
panel-resizer.left(
|
||||
control='self.leftPanelPuppet',
|
||||
hoverable='true',
|
||||
min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish',
|
||||
panel-id="'editor-content'",
|
||||
property="'left'"
|
||||
)
|
||||
component-view.component-view(
|
||||
component-viewer='self.state.editorComponentViewer',
|
||||
ng-if='self.state.editorComponentViewer',
|
||||
on-load='self.onEditorComponentLoad',
|
||||
request-reload='self.editorComponentViewerRequestsReload'
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
textarea#note-text-editor.editable.font-editor(
|
||||
dir='auto',
|
||||
ng-attr-spellcheck='{{self.state.spellcheck}}',
|
||||
ng-change='self.contentChanged()',
|
||||
ng-click='self.clickedTextArea()',
|
||||
ng-focus='self.onContentFocus()',
|
||||
ng-if='self.state.editorStateDidLoad && !self.state.editorComponentViewer && !self.state.textareaUnloading',
|
||||
ng-model='self.editorValues.text',
|
||||
ng-model-options='{ debounce: self.state.editorDebounce}',
|
||||
ng-readonly='self.noteLocked',
|
||||
ng-trim='false'
|
||||
autocomplete='off'
|
||||
)
|
||||
| {{self.onSystemEditorLoad()}}
|
||||
panel-resizer(
|
||||
control='self.rightPanelPuppet',
|
||||
hoverable='true', min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish',
|
||||
panel-id="'editor-content'",
|
||||
property="'right'"
|
||||
)
|
||||
.section(ng-show='self.note.errorDecrypting')
|
||||
.sn-component#error-decrypting-container
|
||||
.sk-panel#error-decrypting-panel
|
||||
.sk-panel-header
|
||||
.sk-panel-header-title {{self.note.waitingForKey ? 'Waiting for Key' : 'Unable to Decrypt'}}
|
||||
.sk-panel-content
|
||||
.sk-panel-section
|
||||
p.sk-p(ng-if='self.note.waitingForKey')
|
||||
| This note is awaiting its encryption key to be ready. Please wait for syncing to complete
|
||||
| for this note to be decrypted.
|
||||
p.sk-p(ng-if='!self.note.waitingForKey')
|
||||
| There was an error decrypting this item. Ensure you are running the
|
||||
| latest version of this app, then sign out and sign back in to try again.
|
||||
#editor-pane-component-stack(ng-if='!self.note.errorDecrypting' ng-show='self.note')
|
||||
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.availableStackComponents.length')
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-repeat='component in self.state.availableStackComponents track by component.uuid'
|
||||
ng-click='self.toggleStackComponent(component)',
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-circle.small(
|
||||
ng-class="{'info' : self.stackComponentExpanded(component) && component.active, 'neutral' : !self.stackComponentExpanded(component)}"
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label {{component.name}}
|
||||
.sn-component
|
||||
component-view.component-view.component-stack-item(
|
||||
ng-repeat='viewer in self.state.stackComponentViewers track by viewer.componentUuid',
|
||||
component-viewer='viewer',
|
||||
manual-dealloc='true',
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { NoteView } from '@Views/note_view/note_view';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
} from '@standardnotes/snjs/';
|
||||
|
||||
describe('editor-view', () => {
|
||||
let ctrl: NoteView;
|
||||
let setShowProtectedWarningSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
|
||||
ctrl = new NoteView($timeout);
|
||||
|
||||
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
|
||||
|
||||
Object.defineProperties(ctrl, {
|
||||
application: {
|
||||
value: {
|
||||
getAppState: () => {
|
||||
return {
|
||||
notes: {
|
||||
setShowProtectedWarning: jest.fn(),
|
||||
},
|
||||
};
|
||||
},
|
||||
hasProtectionSources: () => true,
|
||||
authorizeNoteAccess: jest.fn(),
|
||||
},
|
||||
},
|
||||
removeComponentsObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
removeTrashKeyObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
unregisterComponent: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
editor: {
|
||||
value: {
|
||||
clearNoteChangeListener: jest.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctrl.deinit();
|
||||
});
|
||||
|
||||
describe('note is protected', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
|
||||
jest
|
||||
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
|
||||
5
|
||||
);
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
|
||||
const secondsElapsedSinceLastEdit =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
3;
|
||||
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
const secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastEdit;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000);
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
|
||||
const secondsElapsedSinceLastModification = 3;
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(
|
||||
Date.now() - secondsElapsedSinceLastModification * 1000
|
||||
),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
let secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastModification;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
|
||||
// A new modification has just happened
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000);
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note is unprotected', () => {
|
||||
it('should not call any hiding logic', async () => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: false,
|
||||
},
|
||||
});
|
||||
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
|
||||
ctrl,
|
||||
'hideProtectedNoteIfInactive'
|
||||
);
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissProtectedWarning', () => {
|
||||
describe('the note has protection sources', () => {
|
||||
it('should reveal note contents if the authorization has been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(true));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not reveal note contents if the authorization has not been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(false));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the note does not have protection sources', () => {
|
||||
it('should reveal note contents', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'hasProtectionSources')
|
||||
.mockImplementation(() => false);
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,920 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PanelPuppet, WebDirective } from '@/types';
|
||||
import angular from 'angular';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
isPayloadSourceRetrieved,
|
||||
isPayloadSourceInternalChange,
|
||||
ContentType,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
ComponentArea,
|
||||
PrefKey,
|
||||
ComponentMutator,
|
||||
PayloadSource,
|
||||
ComponentViewer,
|
||||
ComponentManagerEvent,
|
||||
TransactionalMutation,
|
||||
ItemMutator,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
NoteViewController,
|
||||
} from '@standardnotes/snjs';
|
||||
import { debounce, isDesktopApplication } from '@/utils';
|
||||
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
|
||||
import template from './note-view.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { EventSource } from '@/ui_models/app_state';
|
||||
import {
|
||||
STRING_DELETE_PLACEHOLDER_ATTEMPT,
|
||||
STRING_DELETE_LOCKED_ATTEMPT,
|
||||
StringDeleteNote,
|
||||
} from '@/strings';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
|
||||
const MINIMUM_STATUS_DURATION = 400;
|
||||
const EDITOR_DEBOUNCE = 100;
|
||||
|
||||
const ElementIds = {
|
||||
NoteTextEditor: 'note-text-editor',
|
||||
NoteTitleEditor: 'note-title-editor',
|
||||
EditorContent: 'editor-content',
|
||||
};
|
||||
|
||||
type NoteStatus = {
|
||||
message?: string;
|
||||
desc?: string;
|
||||
};
|
||||
|
||||
type EditorState = {
|
||||
availableStackComponents: SNComponent[];
|
||||
stackComponentViewers: ComponentViewer[];
|
||||
editorComponentViewer?: ComponentViewer;
|
||||
editorStateDidLoad: boolean;
|
||||
saveError?: any;
|
||||
noteStatus?: NoteStatus;
|
||||
marginResizersEnabled?: boolean;
|
||||
monospaceFont?: boolean;
|
||||
isDesktop?: boolean;
|
||||
syncTakingTooLong: boolean;
|
||||
showActionsMenu: boolean;
|
||||
showHistoryMenu: boolean;
|
||||
spellcheck: boolean;
|
||||
/** Setting to true then false will allow the main content textarea to be destroyed
|
||||
* then re-initialized. Used when reloading spellcheck status. */
|
||||
textareaUnloading: boolean;
|
||||
showProtectedWarning: boolean;
|
||||
};
|
||||
|
||||
type EditorValues = {
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function copyEditorValues(values: EditorValues) {
|
||||
return Object.assign({}, values);
|
||||
}
|
||||
|
||||
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
|
||||
return array.sort((a, b) =>
|
||||
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
|
||||
);
|
||||
}
|
||||
|
||||
export const transactionForAssociateComponentWithCurrentNote = (
|
||||
component: SNComponent,
|
||||
note: SNNote
|
||||
) => {
|
||||
const transaction: TransactionalMutation = {
|
||||
itemUuid: component.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeDisassociatedItemId(note.uuid);
|
||||
mutator.associateWithItem(note.uuid);
|
||||
},
|
||||
};
|
||||
return transaction;
|
||||
};
|
||||
|
||||
export const transactionForDisassociateComponentWithCurrentNote = (
|
||||
component: SNComponent,
|
||||
note: SNNote
|
||||
) => {
|
||||
const transaction: TransactionalMutation = {
|
||||
itemUuid: component.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeAssociatedItemId(note.uuid);
|
||||
mutator.disassociateWithItem(note.uuid);
|
||||
},
|
||||
};
|
||||
return transaction;
|
||||
};
|
||||
|
||||
export const reloadFont = (monospaceFont?: boolean) => {
|
||||
const root = document.querySelector(':root') as HTMLElement;
|
||||
const propertyName = '--sn-stylekit-editor-font-family';
|
||||
if (monospaceFont) {
|
||||
root.style.setProperty(propertyName, 'var(--sn-stylekit-monospace-font)');
|
||||
} else {
|
||||
root.style.setProperty(propertyName, 'var(--sn-stylekit-sans-serif-font)');
|
||||
}
|
||||
};
|
||||
|
||||
export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
/** Passed through template */
|
||||
readonly application!: WebApplication;
|
||||
readonly controller!: NoteViewController;
|
||||
|
||||
private leftPanelPuppet?: PanelPuppet;
|
||||
private rightPanelPuppet?: PanelPuppet;
|
||||
private statusTimeout?: ng.IPromise<void>;
|
||||
private lastEditorFocusEventSource?: EventSource;
|
||||
public editorValues: EditorValues = { title: '', text: '' };
|
||||
onEditorComponentLoad?: () => void;
|
||||
|
||||
private scrollPosition = 0;
|
||||
private removeTrashKeyObserver?: () => void;
|
||||
private removeTabObserver?: () => void;
|
||||
private removeComponentStreamObserver?: () => void;
|
||||
private removeComponentManagerObserver?: () => void;
|
||||
private removeInnerNoteObserver?: () => void;
|
||||
|
||||
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.leftPanelPuppet = {
|
||||
onReady: () => this.reloadPreferences(),
|
||||
};
|
||||
this.rightPanelPuppet = {
|
||||
onReady: () => this.reloadPreferences(),
|
||||
};
|
||||
|
||||
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
|
||||
this.setScrollPosition = this.setScrollPosition.bind(this);
|
||||
this.resetScrollPosition = this.resetScrollPosition.bind(this);
|
||||
this.editorComponentViewerRequestsReload =
|
||||
this.editorComponentViewerRequestsReload.bind(this);
|
||||
this.onEditorComponentLoad = () => {
|
||||
this.application.getDesktopService().redoSearch();
|
||||
};
|
||||
this.debounceReloadEditorComponent = debounce(
|
||||
this.debounceReloadEditorComponent.bind(this),
|
||||
25
|
||||
);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.removeComponentStreamObserver?.();
|
||||
(this.removeComponentStreamObserver as unknown) = undefined;
|
||||
this.removeInnerNoteObserver?.();
|
||||
(this.removeInnerNoteObserver as unknown) = undefined;
|
||||
this.removeComponentManagerObserver?.();
|
||||
(this.removeComponentManagerObserver as unknown) = undefined;
|
||||
this.removeTrashKeyObserver?.();
|
||||
this.removeTrashKeyObserver = undefined;
|
||||
this.clearNoteProtectionInactivityTimer();
|
||||
this.removeTabObserver?.();
|
||||
this.removeTabObserver = undefined;
|
||||
this.leftPanelPuppet = undefined;
|
||||
this.rightPanelPuppet = undefined;
|
||||
this.onEditorComponentLoad = undefined;
|
||||
this.statusTimeout = undefined;
|
||||
(this.onPanelResizeFinish as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state as EditorState;
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.controller.note;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.registerKeyboardShortcuts();
|
||||
this.removeInnerNoteObserver =
|
||||
this.controller.addNoteInnerValueChangeObserver((note, source) => {
|
||||
this.onNoteInnerChange(note, source);
|
||||
});
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showProtectedWarning: this.appState.notes.showProtectedWarning,
|
||||
});
|
||||
});
|
||||
this.reloadEditorComponent();
|
||||
this.reloadStackComponents();
|
||||
|
||||
const showProtectedWarning =
|
||||
this.note.protected && !this.application.hasProtectionSources();
|
||||
this.setShowProtectedOverlay(showProtectedWarning);
|
||||
|
||||
this.reloadPreferences();
|
||||
|
||||
if (this.controller.isTemplateNote) {
|
||||
this.$timeout(() => {
|
||||
this.focusTitle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onNoteInnerChange(note: SNNote, source: PayloadSource): void {
|
||||
if (note.uuid !== this.note.uuid) {
|
||||
throw Error('Editor received changes for non-current note');
|
||||
}
|
||||
if (isPayloadSourceRetrieved(source)) {
|
||||
this.editorValues.title = note.title;
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
if (!this.editorValues.title) {
|
||||
this.editorValues.title = note.title;
|
||||
}
|
||||
if (!this.editorValues.text) {
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
|
||||
this.reloadSpellcheck();
|
||||
|
||||
const isTemplateNoteInsertedToBeInteractableWithEditor =
|
||||
source === PayloadSource.Constructor && note.dirty;
|
||||
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
if (note.lastSyncEnd) {
|
||||
if (
|
||||
note.dirty ||
|
||||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
|
||||
) {
|
||||
this.showSavingStatus();
|
||||
} else if (
|
||||
this.state.noteStatus &&
|
||||
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
|
||||
) {
|
||||
this.showAllChangesSavedStatus();
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$onDestroy(): void {
|
||||
if (this.state.editorComponentViewer) {
|
||||
this.application.componentManager?.destroyComponentViewer(
|
||||
this.state.editorComponentViewer
|
||||
);
|
||||
}
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
return {
|
||||
availableStackComponents: [],
|
||||
stackComponentViewers: [],
|
||||
editorStateDidLoad: false,
|
||||
editorDebounce: EDITOR_DEBOUNCE,
|
||||
isDesktop: isDesktopApplication(),
|
||||
spellcheck: true,
|
||||
syncTakingTooLong: false,
|
||||
showActionsMenu: false,
|
||||
showHistoryMenu: false,
|
||||
noteStatus: undefined,
|
||||
textareaUnloading: false,
|
||||
showProtectedWarning: false,
|
||||
} as EditorState;
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
await super.onAppLaunch();
|
||||
this.streamItems();
|
||||
this.registerComponentManagerEventObserver();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.PreferencesChanged:
|
||||
this.reloadPreferences();
|
||||
break;
|
||||
case ApplicationEvent.HighLatencySync:
|
||||
this.setState({ syncTakingTooLong: true });
|
||||
break;
|
||||
case ApplicationEvent.CompletedFullSync: {
|
||||
this.setState({ syncTakingTooLong: false });
|
||||
const isInErrorState = this.state.saveError;
|
||||
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
||||
if (!this.note.dirty && isInErrorState) {
|
||||
this.showAllChangesSavedStatus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ApplicationEvent.FailedSync:
|
||||
/**
|
||||
* Only show error status in editor if the note is dirty.
|
||||
* Otherwise, it means the originating sync came from somewhere else
|
||||
* and we don't want to display an error here.
|
||||
*/
|
||||
if (this.note.dirty) {
|
||||
this.showErrorStatus();
|
||||
}
|
||||
break;
|
||||
case ApplicationEvent.LocalDatabaseWriteError:
|
||||
this.showErrorStatus({
|
||||
message: 'Offline Saving Issue',
|
||||
desc: 'Changes not saved',
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.UnprotectedSessionBegan: {
|
||||
this.setShowProtectedOverlay(false);
|
||||
break;
|
||||
}
|
||||
case ApplicationEvent.UnprotectedSessionExpired: {
|
||||
if (this.note.protected) {
|
||||
this.hideProtectedNoteIfInactive();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSecondsElapsedSinceLastEdit(): number {
|
||||
return (Date.now() - this.note.userModifiedDate.getTime()) / 1000;
|
||||
}
|
||||
|
||||
hideProtectedNoteIfInactive(): void {
|
||||
const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit();
|
||||
if (
|
||||
secondsElapsedSinceLastEdit >=
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
|
||||
) {
|
||||
this.setShowProtectedOverlay(true);
|
||||
} else {
|
||||
const secondsUntilTheNextCheck =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastEdit;
|
||||
this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck);
|
||||
}
|
||||
}
|
||||
|
||||
startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void {
|
||||
this.clearNoteProtectionInactivityTimer();
|
||||
this.protectionTimeoutId = setTimeout(() => {
|
||||
this.hideProtectedNoteIfInactive();
|
||||
}, timerDurationInSeconds * 1000);
|
||||
}
|
||||
|
||||
clearNoteProtectionInactivityTimer(): void {
|
||||
if (this.protectionTimeoutId) {
|
||||
clearTimeout(this.protectionTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async dismissProtectedWarning() {
|
||||
let showNoteContents = true;
|
||||
if (this.application.hasProtectionSources()) {
|
||||
showNoteContents = await this.application.authorizeNoteAccess(this.note);
|
||||
}
|
||||
if (!showNoteContents) {
|
||||
return;
|
||||
}
|
||||
this.setShowProtectedOverlay(false);
|
||||
this.focusTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Because note.locked accesses note.content.appData,
|
||||
* we do not want to expose the template to direct access to note.locked,
|
||||
* otherwise an exception will occur when trying to access note.locked if the note
|
||||
* is deleted. There is potential for race conditions to occur with setState, where a
|
||||
* previous setState call may have queued a digest cycle, and the digest cycle triggers
|
||||
* on a deleted note.
|
||||
*/
|
||||
get noteLocked() {
|
||||
if (!this.note || this.note.deleted) {
|
||||
return false;
|
||||
}
|
||||
return this.note.locked;
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.removeComponentStreamObserver = this.application.streamItems(
|
||||
ContentType.Component,
|
||||
async (_items, source) => {
|
||||
if (
|
||||
isPayloadSourceInternalChange(source) ||
|
||||
source === PayloadSource.InitialObserverRegistrationPush
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.note) return;
|
||||
await this.reloadStackComponents();
|
||||
this.debounceReloadEditorComponent();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private createComponentViewer(component: SNComponent) {
|
||||
const viewer = this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
this.note.uuid
|
||||
);
|
||||
return viewer;
|
||||
}
|
||||
|
||||
public async editorComponentViewerRequestsReload(
|
||||
viewer: ComponentViewer
|
||||
): Promise<void> {
|
||||
const component = viewer.component;
|
||||
this.application.componentManager.destroyComponentViewer(viewer);
|
||||
await this.setState({
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
await this.setState({
|
||||
editorComponentViewer: this.createComponentViewer(component),
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calling reloadEditorComponent successively without waiting for state to settle
|
||||
* can result in componentViewers being dealloced twice
|
||||
*/
|
||||
debounceReloadEditorComponent() {
|
||||
this.reloadEditorComponent();
|
||||
}
|
||||
|
||||
private async reloadEditorComponent() {
|
||||
const newEditor = this.application.componentManager.editorForNote(
|
||||
this.note
|
||||
);
|
||||
/** Editors cannot interact with template notes so the note must be inserted */
|
||||
if (newEditor && this.controller.isTemplateNote) {
|
||||
await this.controller.insertTemplatedNote();
|
||||
this.associateComponentWithCurrentNote(newEditor);
|
||||
}
|
||||
const currentComponentViewer = this.state.editorComponentViewer;
|
||||
|
||||
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
|
||||
if (currentComponentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
currentComponentViewer
|
||||
);
|
||||
}
|
||||
await this.setState({
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
if (newEditor) {
|
||||
await this.setState({
|
||||
editorComponentViewer: this.createComponentViewer(newEditor),
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
reloadFont(this.state.monospaceFont);
|
||||
} else {
|
||||
await this.setState({
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setMenuState(menu: string, state: boolean) {
|
||||
this.setState({
|
||||
[menu]: state,
|
||||
});
|
||||
this.closeAllMenus(menu);
|
||||
}
|
||||
|
||||
toggleMenu(menu: keyof EditorState) {
|
||||
this.setMenuState(menu, !this.state[menu]);
|
||||
this.application.getAppState().notes.setContextMenuOpen(false);
|
||||
}
|
||||
|
||||
closeAllMenus(exclude?: string) {
|
||||
const allMenus = ['showActionsMenu', 'showHistoryMenu'];
|
||||
const menuState: any = {};
|
||||
for (const candidate of allMenus) {
|
||||
if (candidate !== exclude) {
|
||||
menuState[candidate] = false;
|
||||
}
|
||||
}
|
||||
this.setState(menuState);
|
||||
}
|
||||
|
||||
hasAvailableExtensions() {
|
||||
return (
|
||||
this.application.actionsManager.extensionsInContextOfItem(this.note)
|
||||
.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
showSavingStatus() {
|
||||
this.setStatus({ message: 'Saving…' }, false);
|
||||
}
|
||||
|
||||
showAllChangesSavedStatus() {
|
||||
this.setState({
|
||||
saveError: false,
|
||||
syncTakingTooLong: false,
|
||||
});
|
||||
this.setStatus({
|
||||
message:
|
||||
'All changes saved' + (this.application.noAccount() ? ' offline' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
showErrorStatus(error?: NoteStatus) {
|
||||
if (!error) {
|
||||
error = {
|
||||
message: 'Sync Unreachable',
|
||||
desc: 'Changes saved offline',
|
||||
};
|
||||
}
|
||||
this.setState({
|
||||
saveError: true,
|
||||
syncTakingTooLong: false,
|
||||
});
|
||||
this.setStatus(error);
|
||||
}
|
||||
|
||||
setStatus(status: NoteStatus, wait = true) {
|
||||
if (this.statusTimeout) {
|
||||
this.$timeout.cancel(this.statusTimeout);
|
||||
}
|
||||
if (wait) {
|
||||
this.statusTimeout = this.$timeout(() => {
|
||||
this.setState({
|
||||
noteStatus: status,
|
||||
});
|
||||
}, MINIMUM_STATUS_DURATION);
|
||||
} else {
|
||||
this.setState({
|
||||
noteStatus: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cancelPendingSetStatus() {
|
||||
if (this.statusTimeout) {
|
||||
this.$timeout.cancel(this.statusTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
contentChanged() {
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
isUserModified: true,
|
||||
});
|
||||
}
|
||||
|
||||
onTitleEnter($event: Event) {
|
||||
($event.target as HTMLInputElement).blur();
|
||||
this.onTitleChange();
|
||||
this.focusEditor();
|
||||
}
|
||||
|
||||
onTitleChange() {
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
isUserModified: true,
|
||||
dontUpdatePreviews: true,
|
||||
});
|
||||
}
|
||||
|
||||
focusEditor() {
|
||||
const element = document.getElementById(ElementIds.NoteTextEditor);
|
||||
if (element) {
|
||||
this.lastEditorFocusEventSource = EventSource.Script;
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusTitle() {
|
||||
document.getElementById(ElementIds.NoteTitleEditor)?.focus();
|
||||
}
|
||||
|
||||
clickedTextArea() {
|
||||
this.closeAllMenus();
|
||||
}
|
||||
|
||||
onContentFocus() {
|
||||
this.application
|
||||
.getAppState()
|
||||
.editorDidFocus(this.lastEditorFocusEventSource!);
|
||||
this.lastEditorFocusEventSource = undefined;
|
||||
}
|
||||
|
||||
setShowProtectedOverlay(show: boolean) {
|
||||
this.appState.notes.setShowProtectedWarning(show);
|
||||
}
|
||||
|
||||
async deleteNote(permanently: boolean) {
|
||||
if (this.controller.isTemplateNote) {
|
||||
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
if (this.note.locked) {
|
||||
this.application.alertService.alert(STRING_DELETE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
const title = this.note.safeTitle().length
|
||||
? `'${this.note.title}'`
|
||||
: 'this note';
|
||||
const text = StringDeleteNote(title, permanently);
|
||||
if (
|
||||
await confirmDialog({
|
||||
text,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
if (permanently) {
|
||||
this.performNoteDeletion(this.note);
|
||||
} else {
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
bypassDebouncer: true,
|
||||
dontUpdatePreviews: true,
|
||||
customMutate: (mutator) => {
|
||||
mutator.trashed = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
performNoteDeletion(note: SNNote) {
|
||||
this.application.deleteItem(note);
|
||||
}
|
||||
|
||||
async onPanelResizeFinish(width: number, left: number, isMaxWidth: boolean) {
|
||||
if (isMaxWidth) {
|
||||
await this.application.setPreference(PrefKey.EditorWidth, null);
|
||||
} else {
|
||||
if (width !== undefined && width !== null) {
|
||||
await this.application.setPreference(PrefKey.EditorWidth, width);
|
||||
this.leftPanelPuppet!.setWidth!(width);
|
||||
}
|
||||
}
|
||||
if (left !== undefined && left !== null) {
|
||||
await this.application.setPreference(PrefKey.EditorLeft, left);
|
||||
this.rightPanelPuppet!.setLeft!(left);
|
||||
}
|
||||
this.application.sync();
|
||||
}
|
||||
|
||||
async reloadSpellcheck() {
|
||||
const spellcheck = this.appState.notes.getSpellcheckStateForNote(this.note);
|
||||
|
||||
if (spellcheck !== this.state.spellcheck) {
|
||||
await this.setState({ textareaUnloading: true });
|
||||
await this.setState({ textareaUnloading: false });
|
||||
reloadFont(this.state.monospaceFont);
|
||||
|
||||
await this.setState({
|
||||
spellcheck,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reloadPreferences() {
|
||||
const monospaceFont = this.application.getPreference(
|
||||
PrefKey.EditorMonospaceEnabled,
|
||||
true
|
||||
);
|
||||
|
||||
const marginResizersEnabled = this.application.getPreference(
|
||||
PrefKey.EditorResizersEnabled,
|
||||
true
|
||||
);
|
||||
|
||||
await this.reloadSpellcheck();
|
||||
|
||||
await this.setState({
|
||||
monospaceFont,
|
||||
marginResizersEnabled,
|
||||
});
|
||||
|
||||
if (!document.getElementById(ElementIds.EditorContent)) {
|
||||
/** Elements have not yet loaded due to ng-if around wrapper */
|
||||
return;
|
||||
}
|
||||
|
||||
reloadFont(this.state.monospaceFont);
|
||||
|
||||
if (
|
||||
this.state.marginResizersEnabled &&
|
||||
this.leftPanelPuppet?.ready &&
|
||||
this.rightPanelPuppet?.ready
|
||||
) {
|
||||
const width = this.application.getPreference(PrefKey.EditorWidth, null);
|
||||
if (width != null) {
|
||||
this.leftPanelPuppet!.setWidth!(width);
|
||||
this.rightPanelPuppet!.setWidth!(width);
|
||||
}
|
||||
const left = this.application.getPreference(PrefKey.EditorLeft, null);
|
||||
if (left != null) {
|
||||
this.leftPanelPuppet!.setLeft!(left);
|
||||
this.rightPanelPuppet!.setLeft!(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @components */
|
||||
|
||||
registerComponentManagerEventObserver() {
|
||||
this.removeComponentManagerObserver =
|
||||
this.application.componentManager.addEventObserver((eventName, data) => {
|
||||
if (eventName === ComponentManagerEvent.ViewerDidFocus) {
|
||||
const viewer = data?.componentViewer;
|
||||
if (viewer?.component.isEditor) {
|
||||
this.closeAllMenus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async reloadStackComponents() {
|
||||
const stackComponents = sortAlphabetically(
|
||||
this.application.componentManager
|
||||
.componentsForArea(ComponentArea.EditorStack)
|
||||
.filter((component) => component.active)
|
||||
);
|
||||
const enabledComponents = stackComponents.filter((component) => {
|
||||
return component.isExplicitlyEnabledForItem(this.note.uuid);
|
||||
});
|
||||
|
||||
const needsNewViewer = enabledComponents.filter((component) => {
|
||||
const hasExistingViewer = this.state.stackComponentViewers.find(
|
||||
(viewer) => viewer.componentUuid === component.uuid
|
||||
);
|
||||
return !hasExistingViewer;
|
||||
});
|
||||
|
||||
const needsDestroyViewer = this.state.stackComponentViewers.filter(
|
||||
(viewer) => {
|
||||
const viewerComponentExistsInEnabledComponents = enabledComponents.find(
|
||||
(component) => {
|
||||
return component.uuid === viewer.componentUuid;
|
||||
}
|
||||
);
|
||||
return !viewerComponentExistsInEnabledComponents;
|
||||
}
|
||||
);
|
||||
|
||||
const newViewers: ComponentViewer[] = [];
|
||||
for (const component of needsNewViewer) {
|
||||
newViewers.push(
|
||||
this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
this.note.uuid
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const viewer of needsDestroyViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(viewer);
|
||||
}
|
||||
await this.setState({
|
||||
availableStackComponents: stackComponents,
|
||||
stackComponentViewers: newViewers,
|
||||
});
|
||||
}
|
||||
|
||||
stackComponentExpanded(component: SNComponent): boolean {
|
||||
return !!this.state.stackComponentViewers.find(
|
||||
(viewer) => viewer.componentUuid === component.uuid
|
||||
);
|
||||
}
|
||||
|
||||
async toggleStackComponent(component: SNComponent) {
|
||||
if (!component.isExplicitlyEnabledForItem(this.note.uuid)) {
|
||||
await this.associateComponentWithCurrentNote(component);
|
||||
} else {
|
||||
await this.disassociateComponentWithCurrentNote(component);
|
||||
}
|
||||
this.application.sync();
|
||||
}
|
||||
|
||||
async disassociateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
transactionForDisassociateComponentWithCurrentNote(component, this.note)
|
||||
);
|
||||
}
|
||||
|
||||
async associateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
transactionForAssociateComponentWithCurrentNote(component, this.note)
|
||||
);
|
||||
}
|
||||
|
||||
registerKeyboardShortcuts() {
|
||||
this.removeTrashKeyObserver = this.application.io.addKeyObserver({
|
||||
key: KeyboardKey.Backspace,
|
||||
notTags: ['INPUT', 'TEXTAREA'],
|
||||
modifiers: [KeyboardModifier.Meta],
|
||||
onKeyDown: () => {
|
||||
this.deleteNote(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setScrollPosition() {
|
||||
const editor = document.getElementById(
|
||||
ElementIds.NoteTextEditor
|
||||
) as HTMLInputElement;
|
||||
this.scrollPosition = editor.scrollTop;
|
||||
}
|
||||
|
||||
resetScrollPosition() {
|
||||
const editor = document.getElementById(
|
||||
ElementIds.NoteTextEditor
|
||||
) as HTMLInputElement;
|
||||
editor.scrollTop = this.scrollPosition;
|
||||
}
|
||||
|
||||
onSystemEditorLoad() {
|
||||
if (this.removeTabObserver) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Insert 4 spaces when a tab key is pressed,
|
||||
* only used when inside of the text editor.
|
||||
* If the shift key is pressed first, this event is
|
||||
* not fired.
|
||||
*/
|
||||
const editor = document.getElementById(
|
||||
ElementIds.NoteTextEditor
|
||||
)! as HTMLInputElement;
|
||||
this.removeTabObserver = this.application.io.addKeyObserver({
|
||||
element: editor,
|
||||
key: KeyboardKey.Tab,
|
||||
onKeyDown: (event) => {
|
||||
if (document.hidden || this.note.locked || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
/** Using document.execCommand gives us undo support */
|
||||
const insertSuccessful = document.execCommand(
|
||||
'insertText',
|
||||
false,
|
||||
'\t'
|
||||
);
|
||||
if (!insertSuccessful) {
|
||||
/** document.execCommand works great on Chrome/Safari but not Firefox */
|
||||
const start = editor.selectionStart!;
|
||||
const end = editor.selectionEnd!;
|
||||
const spaces = ' ';
|
||||
/** Insert 4 spaces */
|
||||
editor.value =
|
||||
editor.value.substring(0, start) +
|
||||
spaces +
|
||||
editor.value.substring(end);
|
||||
/** Place cursor 4 spaces away from where the tab key was pressed */
|
||||
editor.selectionStart = editor.selectionEnd = start + 4;
|
||||
}
|
||||
this.editorValues.text = editor.value;
|
||||
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
bypassDebouncer: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.addEventListener('scroll', this.setScrollPosition);
|
||||
editor.addEventListener('input', this.resetScrollPosition);
|
||||
|
||||
/**
|
||||
* Handles when the editor is destroyed,
|
||||
* (and not when our controller is destroyed.)
|
||||
*/
|
||||
angular.element(editor).one('$destroy', () => {
|
||||
this.removeTabObserver?.();
|
||||
this.removeTabObserver = undefined;
|
||||
editor.removeEventListener('scroll', this.setScrollPosition);
|
||||
editor.removeEventListener('scroll', this.resetScrollPosition);
|
||||
this.scrollPosition = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoteViewDirective extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.scope = {
|
||||
controller: '=',
|
||||
application: '=',
|
||||
};
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = NoteView;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user