import { isDesktopApplication, isDev } from '@/utils'; import pull from 'lodash/pull'; import { ApplicationEvent, SNTag, SNNote, ContentType, PayloadSource, DeinitSource, UuidString, SyncOpStatus, PrefKey, Challenge, } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { Editor } from '@/ui_models/editor'; import { action, makeObservable, observable } from 'mobx'; import { Bridge } from '@/services/bridge'; export enum AppStateEvent { TagChanged, ActiveEditorChanged, PanelResized, EditorFocused, BeganBackupDownload, EndedBackupDownload, WindowDidFocus, WindowDidBlur, } export enum EventSource { UserInteraction, Script, } type ObserverCallback = (event: AppStateEvent, data?: any) => Promise; const SHOW_BETA_WARNING_KEY = 'show_beta_warning'; class ActionsMenuState { hiddenExtensions: Record = {}; constructor() { makeObservable(this, { hiddenExtensions: observable, toggleExtensionVisibility: action, deinit: action, }); } toggleExtensionVisibility(uuid: UuidString) { this.hiddenExtensions[uuid] = !this.hiddenExtensions[uuid]; } deinit() { 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) { 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' } ); } } } 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 = false; readonly actionsMenu = new ActionsMenuState(); readonly sync = new SyncState(); isSessionsModalVisible = false; /* @ngInject */ constructor( $rootScope: ng.IRootScopeService, $timeout: ng.ITimeoutService, application: WebApplication, private bridge: Bridge ) { this.$timeout = $timeout; this.$rootScope = $rootScope; this.application = application; makeObservable(this, { showBetaWarning: observable, isSessionsModalVisible: observable, enableBetaWarning: action, disableBetaWarning: action, openSessionsModal: action, closeSessionsModal: action, }); this.addAppEventObserver(); this.streamNotesAndTags(); this.onVisibilityChange = () => { const visible = document.visibilityState === 'visible'; const event = visible ? AppStateEvent.WindowDidFocus : AppStateEvent.WindowDidBlur; this.notifyEvent(event); }; this.registerVisibilityObservers(); this.determineBetaWarningValue(); } deinit(source: DeinitSource) { if (source === DeinitSource.SignOut) { localStorage.removeItem(SHOW_BETA_WARNING_KEY); } this.actionsMenu.deinit(); this.unsubApp(); this.unsubApp = undefined; this.observers.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; localStorage.setItem(SHOW_BETA_WARNING_KEY, 'false'); } enableBetaWarning() { this.showBetaWarning = true; localStorage.setItem(SHOW_BETA_WARNING_KEY, 'true'); } clearBetaWarning() { localStorage.setItem(SHOW_BETA_WARNING_KEY, 'true'); } private determineBetaWarningValue() { if (this.bridge.appVersion.includes('-beta')) { switch (localStorage.getItem(SHOW_BETA_WARNING_KEY)) { case 'true': default: this.enableBetaWarning(); break; case 'false': this.disableBetaWarning(); break; } } } /** * 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 { 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((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) { this.notifyEvent(AppStateEvent.PanelResized, { panel: name, collapsed: collapsed, }); } editorDidFocus(eventSource: EventSource) { this.notifyEvent(AppStateEvent.EditorFocused, { eventSource: eventSource }); } beganBackupDownload() { this.notifyEvent(AppStateEvent.BeganBackupDownload); } endedBackupDownload(success: boolean) { this.notifyEvent(AppStateEvent.EndedBackupDownload, { success: success }); } }