* feat: (wip) authorize note access
* fix: remove multiEditorEnabled
* refactor: update SNJS + eslint
* refactor: remove privileges in favor of SNJS protections
* fix: do not close editor when editing an archived note
* chore: remove progress indicator for webpack dev server
* fix: add rel="noreferrer" to bugsnag links
* chore(deps): upgrade snjs
* chore(deps): upgrade snjs
* feat: batch manager protection + react challenge modal + eslint fix
* fix: lint errors
* fix: launch state error
* fix: challenge modal: cancel instead of dismiss when pressing escape
* feat: improve focus styles
* fix: cancel session revoking when pressing escape on confirm dialog
* fix: lint warning
* chore(deps): upgrade minor versions
* feat: make SNWebCrypto a constant
* feat: add random identifier to bugsnag reports
* fix: check onKeyUp instead of onKeyDown
* feat: implement SNJS backup file password retrieval
* chore(deps): upgrade snjs
* feat: display warning banner when using the app with no account
* fix: properly color svg button
* fix: wording
* fix: hide account warning after login + improve key storage wording
* chore(deps): upgrade stylekit
* feat: use stylekit fonts for the editor
* chore(deps): bump nokogiri from 1.10.8 to 1.11.1 (#511)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.10.8 to 1.11.1.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.10.8...v1.11.1)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com>
* chore(deps): bump ini from 1.3.5 to 1.3.8 (#504)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com>
* fix: rename master branch to main
* fix: add missing placeholders for submodules (#516)
Co-authored-by: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com>
* chore(deps): upgrade snjs, babel, typescript, reach, mobx, preact
* feat: clear protection session
* fix: use correct close icon size
* fix: hide protections paragraph when no account or passcode exist
* chore(deps): remove unused dependencies
* fix: button casing
* feat: implement SNApplication.hasProtectionSources
* chore(version): 3.6.0
* feat: enable sessions management for every build
* feat: make "Protected" flag more subtle
* fix: only match protected note title
* fix: remove inconsistencies between protected note label and date
* feat: show warning when protecting a note with no protection source
* feat: make unprotecting a note a protected action
* chore(deps): upgrade snjs
* chore(version): 3.6.0-beta01
* fix: run docker with root to fix crashing on Linux (undoes 62da387d3a) (#525)
* feat: make encrypted backups protected (#524)
Co-authored-by: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: proletarius101 <54175165+proletarius101@users.noreply.github.com>
Co-authored-by: Darius JJ Chuck <79410894+standarius@users.noreply.github.com>
Co-authored-by: Antonella Sgarlatta <antonella@standardnotes.org>
470 lines
12 KiB
TypeScript
470 lines
12 KiB
TypeScript
import { isDesktopApplication, isDev } from '@/utils';
|
|
import pull from 'lodash/pull';
|
|
import {
|
|
ApplicationEvent,
|
|
SNTag,
|
|
SNNote,
|
|
ContentType,
|
|
PayloadSource,
|
|
DeinitSource,
|
|
UuidString,
|
|
SyncOpStatus,
|
|
PrefKey,
|
|
SNApplication,
|
|
} from '@standardnotes/snjs';
|
|
import { WebApplication } from '@/ui_models/application';
|
|
import { Editor } from '@/ui_models/editor';
|
|
import { action, makeObservable, observable, runInAction } from 'mobx';
|
|
import { Bridge } from '@/services/bridge';
|
|
import { storage, StorageKey } from '@/services/localStorage';
|
|
|
|
export enum AppStateEvent {
|
|
TagChanged,
|
|
ActiveEditorChanged,
|
|
PanelResized,
|
|
EditorFocused,
|
|
BeganBackupDownload,
|
|
EndedBackupDownload,
|
|
WindowDidFocus,
|
|
WindowDidBlur,
|
|
}
|
|
|
|
export type PanelResizedData = {
|
|
panel: string;
|
|
collapsed: boolean;
|
|
};
|
|
|
|
export enum EventSource {
|
|
UserInteraction,
|
|
Script,
|
|
}
|
|
|
|
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
|
|
|
|
class ActionsMenuState {
|
|
hiddenExtensions: Record<UuidString, boolean> = {};
|
|
|
|
constructor() {
|
|
makeObservable(this, {
|
|
hiddenExtensions: observable,
|
|
toggleExtensionVisibility: action,
|
|
reset: action,
|
|
});
|
|
}
|
|
|
|
toggleExtensionVisibility(uuid: UuidString) {
|
|
this.hiddenExtensions[uuid] = !this.hiddenExtensions[uuid];
|
|
}
|
|
|
|
reset() {
|
|
this.hiddenExtensions = {};
|
|
}
|
|
}
|
|
|
|
export class SyncState {
|
|
inProgress = false;
|
|
errorMessage?: string = undefined;
|
|
humanReadablePercentage?: string = undefined;
|
|
|
|
constructor() {
|
|
makeObservable(this, {
|
|
inProgress: observable,
|
|
errorMessage: observable,
|
|
humanReadablePercentage: observable,
|
|
update: action,
|
|
});
|
|
}
|
|
|
|
update(status: SyncOpStatus): void {
|
|
this.errorMessage = status.error?.message;
|
|
this.inProgress = status.syncInProgress;
|
|
const stats = status.getStats();
|
|
const completionPercentage =
|
|
stats.uploadCompletionCount === 0
|
|
? 0
|
|
: stats.uploadCompletionCount / stats.uploadTotalCount;
|
|
|
|
if (completionPercentage === 0) {
|
|
this.humanReadablePercentage = undefined;
|
|
} else {
|
|
this.humanReadablePercentage = completionPercentage.toLocaleString(
|
|
undefined,
|
|
{ style: 'percent' }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class AccountMenuState {
|
|
show = false;
|
|
constructor() {
|
|
makeObservable(this, {
|
|
show: observable,
|
|
setShow: action,
|
|
toggleShow: action,
|
|
});
|
|
}
|
|
setShow(show: boolean) {
|
|
this.show = show;
|
|
}
|
|
toggleShow() {
|
|
this.show = !this.show;
|
|
}
|
|
}
|
|
|
|
class NoAccountWarningState {
|
|
show: boolean;
|
|
constructor(application: SNApplication, appObservers: (() => void)[]) {
|
|
this.show = application.hasAccount()
|
|
? false
|
|
: storage.get(StorageKey.ShowNoAccountWarning) ?? true;
|
|
|
|
appObservers.push(
|
|
application.addEventObserver(async () => {
|
|
runInAction(() => {
|
|
this.show = false;
|
|
});
|
|
}, ApplicationEvent.SignedIn),
|
|
application.addEventObserver(async () => {
|
|
if (application.hasAccount()) {
|
|
runInAction(() => {
|
|
this.show = false;
|
|
});
|
|
}
|
|
}, ApplicationEvent.Started)
|
|
);
|
|
|
|
makeObservable(this, {
|
|
show: observable,
|
|
hide: action,
|
|
});
|
|
}
|
|
hide() {
|
|
this.show = false;
|
|
storage.set(StorageKey.ShowNoAccountWarning, false);
|
|
}
|
|
reset() {
|
|
storage.remove(StorageKey.ShowNoAccountWarning);
|
|
}
|
|
}
|
|
|
|
export class AppState {
|
|
readonly enableUnfinishedFeatures =
|
|
isDev || location.host.includes('app-dev.standardnotes.org');
|
|
|
|
$rootScope: ng.IRootScopeService;
|
|
$timeout: ng.ITimeoutService;
|
|
application: WebApplication;
|
|
observers: ObserverCallback[] = [];
|
|
locked = true;
|
|
unsubApp: any;
|
|
rootScopeCleanup1: any;
|
|
rootScopeCleanup2: any;
|
|
onVisibilityChange: any;
|
|
selectedTag?: SNTag;
|
|
showBetaWarning: boolean;
|
|
readonly accountMenu = new AccountMenuState();
|
|
readonly actionsMenu = new ActionsMenuState();
|
|
readonly noAccountWarning: NoAccountWarningState;
|
|
readonly sync = new SyncState();
|
|
isSessionsModalVisible = false;
|
|
|
|
private appEventObserverRemovers: (() => void)[] = [];
|
|
|
|
/* @ngInject */
|
|
constructor(
|
|
$rootScope: ng.IRootScopeService,
|
|
$timeout: ng.ITimeoutService,
|
|
application: WebApplication,
|
|
private bridge: Bridge
|
|
) {
|
|
this.$timeout = $timeout;
|
|
this.$rootScope = $rootScope;
|
|
this.application = application;
|
|
this.noAccountWarning = new NoAccountWarningState(
|
|
application,
|
|
this.appEventObserverRemovers
|
|
);
|
|
this.addAppEventObserver();
|
|
this.streamNotesAndTags();
|
|
this.onVisibilityChange = () => {
|
|
const visible = document.visibilityState === 'visible';
|
|
const event = visible
|
|
? AppStateEvent.WindowDidFocus
|
|
: AppStateEvent.WindowDidBlur;
|
|
this.notifyEvent(event);
|
|
};
|
|
this.registerVisibilityObservers();
|
|
|
|
if (this.bridge.appVersion.includes('-beta')) {
|
|
this.showBetaWarning = storage.get(StorageKey.ShowBetaWarning) ?? true;
|
|
} else {
|
|
this.showBetaWarning = false;
|
|
}
|
|
|
|
makeObservable(this, {
|
|
showBetaWarning: observable,
|
|
isSessionsModalVisible: observable,
|
|
|
|
enableBetaWarning: action,
|
|
disableBetaWarning: action,
|
|
openSessionsModal: action,
|
|
closeSessionsModal: action,
|
|
});
|
|
}
|
|
|
|
deinit(source: DeinitSource): void {
|
|
if (source === DeinitSource.SignOut) {
|
|
storage.remove(StorageKey.ShowBetaWarning);
|
|
this.noAccountWarning.reset();
|
|
}
|
|
this.actionsMenu.reset();
|
|
this.unsubApp();
|
|
this.unsubApp = undefined;
|
|
this.observers.length = 0;
|
|
this.appEventObserverRemovers.forEach((remover) => remover());
|
|
this.appEventObserverRemovers.length = 0;
|
|
if (this.rootScopeCleanup1) {
|
|
this.rootScopeCleanup1();
|
|
this.rootScopeCleanup2();
|
|
this.rootScopeCleanup1 = undefined;
|
|
this.rootScopeCleanup2 = undefined;
|
|
}
|
|
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
this.onVisibilityChange = undefined;
|
|
}
|
|
|
|
openSessionsModal() {
|
|
this.isSessionsModalVisible = true;
|
|
}
|
|
|
|
closeSessionsModal() {
|
|
this.isSessionsModalVisible = false;
|
|
}
|
|
|
|
disableBetaWarning() {
|
|
this.showBetaWarning = false;
|
|
storage.set(StorageKey.ShowBetaWarning, false);
|
|
}
|
|
|
|
enableBetaWarning() {
|
|
this.showBetaWarning = true;
|
|
storage.set(StorageKey.ShowBetaWarning, true);
|
|
}
|
|
|
|
/**
|
|
* Creates a new editor if one doesn't exist. If one does, we'll replace the
|
|
* editor's note with an empty one.
|
|
*/
|
|
async createEditor(title?: string) {
|
|
const activeEditor = this.getActiveEditor();
|
|
const activeTagUuid = this.selectedTag
|
|
? this.selectedTag.isSmartTag()
|
|
? undefined
|
|
: this.selectedTag.uuid
|
|
: undefined;
|
|
|
|
if (!activeEditor) {
|
|
this.application.editorGroup.createEditor(
|
|
undefined,
|
|
title,
|
|
activeTagUuid
|
|
);
|
|
} else {
|
|
await activeEditor.reset(title, activeTagUuid);
|
|
}
|
|
}
|
|
|
|
async openEditor(noteUuid: string): Promise<void> {
|
|
if (this.getActiveEditor()?.note?.uuid === noteUuid) {
|
|
return;
|
|
}
|
|
|
|
const note = this.application.findItem(noteUuid) as SNNote;
|
|
if (!note) {
|
|
console.warn('Tried accessing a non-existant note of UUID ' + noteUuid);
|
|
return;
|
|
}
|
|
|
|
if (await this.application.authorizeNoteAccess(note)) {
|
|
const activeEditor = this.getActiveEditor();
|
|
if (!activeEditor) {
|
|
this.application.editorGroup.createEditor(noteUuid);
|
|
} else {
|
|
activeEditor.setNote(note);
|
|
}
|
|
await this.notifyEvent(AppStateEvent.ActiveEditorChanged);
|
|
}
|
|
}
|
|
|
|
getActiveEditor() {
|
|
return this.application.editorGroup.editors[0];
|
|
}
|
|
|
|
getEditors() {
|
|
return this.application.editorGroup.editors;
|
|
}
|
|
|
|
closeEditor(editor: Editor) {
|
|
this.application.editorGroup.closeEditor(editor);
|
|
}
|
|
|
|
closeActiveEditor() {
|
|
this.application.editorGroup.closeActiveEditor();
|
|
}
|
|
|
|
closeAllEditors() {
|
|
this.application.editorGroup.closeAllEditors();
|
|
}
|
|
|
|
editorForNote(note: SNNote) {
|
|
for (const editor of this.getEditors()) {
|
|
if (editor.note.uuid === note.uuid) {
|
|
return editor;
|
|
}
|
|
}
|
|
}
|
|
|
|
streamNotesAndTags() {
|
|
this.application!.streamItems(
|
|
[ContentType.Note, ContentType.Tag],
|
|
async (items, source) => {
|
|
/** Close any editors for deleted/trashed/archived notes */
|
|
if (source === PayloadSource.PreSyncSave) {
|
|
const notes = items.filter(
|
|
(candidate) => candidate.content_type === ContentType.Note
|
|
) as SNNote[];
|
|
for (const note of notes) {
|
|
const editor = this.editorForNote(note);
|
|
if (!editor) {
|
|
continue;
|
|
}
|
|
if (note.deleted) {
|
|
this.closeEditor(editor);
|
|
} else if (note.trashed && !this.selectedTag?.isTrashTag) {
|
|
this.closeEditor(editor);
|
|
} else if (
|
|
note.archived &&
|
|
!this.selectedTag?.isArchiveTag &&
|
|
!this.application.getPreference(PrefKey.NotesShowArchived, false)
|
|
) {
|
|
this.closeEditor(editor);
|
|
}
|
|
}
|
|
}
|
|
if (this.selectedTag) {
|
|
const matchingTag = items.find(
|
|
(candidate) => candidate.uuid === this.selectedTag!.uuid
|
|
);
|
|
if (matchingTag) {
|
|
this.selectedTag = matchingTag as SNTag;
|
|
}
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
addAppEventObserver() {
|
|
this.unsubApp = this.application.addEventObserver(async (eventName) => {
|
|
switch (eventName) {
|
|
case ApplicationEvent.Started:
|
|
this.locked = true;
|
|
break;
|
|
case ApplicationEvent.Launched:
|
|
this.locked = false;
|
|
break;
|
|
case ApplicationEvent.SyncStatusChanged:
|
|
this.sync.update(this.application.getSyncStatus());
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
isLocked() {
|
|
return this.locked;
|
|
}
|
|
|
|
registerVisibilityObservers() {
|
|
if (isDesktopApplication()) {
|
|
this.rootScopeCleanup1 = this.$rootScope.$on('window-lost-focus', () => {
|
|
this.notifyEvent(AppStateEvent.WindowDidBlur);
|
|
});
|
|
this.rootScopeCleanup2 = this.$rootScope.$on(
|
|
'window-gained-focus',
|
|
() => {
|
|
this.notifyEvent(AppStateEvent.WindowDidFocus);
|
|
}
|
|
);
|
|
} else {
|
|
/* Tab visibility listener, web only */
|
|
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
}
|
|
}
|
|
|
|
/** @returns A function that unregisters this observer */
|
|
addObserver(callback: ObserverCallback) {
|
|
this.observers.push(callback);
|
|
return () => {
|
|
pull(this.observers, callback);
|
|
};
|
|
}
|
|
|
|
async notifyEvent(eventName: AppStateEvent, data?: any) {
|
|
/**
|
|
* Timeout is particullary important so we can give all initial
|
|
* controllers a chance to construct before propogting any events *
|
|
*/
|
|
return new Promise<void>((resolve) => {
|
|
this.$timeout(async () => {
|
|
for (const callback of this.observers) {
|
|
await callback(eventName, data);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
setSelectedTag(tag: SNTag) {
|
|
if (this.selectedTag === tag) {
|
|
return;
|
|
}
|
|
const previousTag = this.selectedTag;
|
|
this.selectedTag = tag;
|
|
this.notifyEvent(AppStateEvent.TagChanged, {
|
|
tag: tag,
|
|
previousTag: previousTag,
|
|
});
|
|
}
|
|
|
|
/** Returns the tags that are referncing this note */
|
|
public getNoteTags(note: SNNote) {
|
|
return this.application.referencingForItem(note).filter((ref) => {
|
|
return ref.content_type === ContentType.Tag;
|
|
}) as SNTag[];
|
|
}
|
|
|
|
public getSelectedTag() {
|
|
return this.selectedTag;
|
|
}
|
|
|
|
panelDidResize(name: string, collapsed: boolean) {
|
|
const data: PanelResizedData = {
|
|
panel: name,
|
|
collapsed: collapsed,
|
|
};
|
|
this.notifyEvent(AppStateEvent.PanelResized, data);
|
|
}
|
|
|
|
editorDidFocus(eventSource: EventSource) {
|
|
this.notifyEvent(AppStateEvent.EditorFocused, { eventSource: eventSource });
|
|
}
|
|
|
|
beganBackupDownload() {
|
|
this.notifyEvent(AppStateEvent.BeganBackupDownload);
|
|
}
|
|
|
|
endedBackupDownload(success: boolean) {
|
|
this.notifyEvent(AppStateEvent.EndedBackupDownload, { success: success });
|
|
}
|
|
}
|