refactor: format and lint codebase (#971)
This commit is contained in:
184
app/assets/javascripts/UIModels/AppState/AccountMenuState.ts
Normal file
184
app/assets/javascripts/UIModels/AppState/AccountMenuState.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { isDev } from '@/Utils'
|
||||
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
|
||||
import { ApplicationEvent, ContentType, SNNote, SNTag } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AccountMenuPane } from '@/Components/AccountMenu'
|
||||
|
||||
type StructuredItemsCount = {
|
||||
notes: number
|
||||
tags: number
|
||||
deleted: number
|
||||
archived: number
|
||||
}
|
||||
|
||||
export class AccountMenuState {
|
||||
show = false
|
||||
signingOut = false
|
||||
otherSessionsSignOut = false
|
||||
server: string | undefined = undefined
|
||||
enableServerOption = false
|
||||
notesAndTags: (SNNote | SNTag)[] = []
|
||||
isEncryptionEnabled = false
|
||||
encryptionStatusString = ''
|
||||
isBackupEncrypted = false
|
||||
showSignIn = false
|
||||
showRegister = false
|
||||
shouldAnimateCloseMenu = false
|
||||
currentPane = AccountMenuPane.GeneralMenu
|
||||
|
||||
constructor(private application: WebApplication, private appEventListeners: (() => void)[]) {
|
||||
makeObservable(this, {
|
||||
show: observable,
|
||||
signingOut: observable,
|
||||
otherSessionsSignOut: observable,
|
||||
server: observable,
|
||||
enableServerOption: observable,
|
||||
notesAndTags: observable,
|
||||
isEncryptionEnabled: observable,
|
||||
encryptionStatusString: observable,
|
||||
isBackupEncrypted: observable,
|
||||
showSignIn: observable,
|
||||
showRegister: observable,
|
||||
currentPane: observable,
|
||||
shouldAnimateCloseMenu: observable,
|
||||
|
||||
setShow: action,
|
||||
setShouldAnimateClose: action,
|
||||
toggleShow: action,
|
||||
setSigningOut: action,
|
||||
setIsEncryptionEnabled: action,
|
||||
setEncryptionStatusString: action,
|
||||
setIsBackupEncrypted: action,
|
||||
setOtherSessionsSignOut: action,
|
||||
setCurrentPane: action,
|
||||
setEnableServerOption: action,
|
||||
setServer: action,
|
||||
|
||||
notesAndTagsCount: computed,
|
||||
})
|
||||
|
||||
this.addAppLaunchedEventObserver()
|
||||
this.streamNotesAndTags()
|
||||
}
|
||||
|
||||
addAppLaunchedEventObserver = (): void => {
|
||||
this.appEventListeners.push(
|
||||
this.application.addEventObserver(async () => {
|
||||
runInAction(() => {
|
||||
if (isDev && window.devAccountServer) {
|
||||
this.setServer(window.devAccountServer)
|
||||
this.application.setCustomHost(window.devAccountServer).catch(console.error)
|
||||
} else {
|
||||
this.setServer(this.application.getHost())
|
||||
}
|
||||
})
|
||||
}, ApplicationEvent.Launched),
|
||||
)
|
||||
}
|
||||
|
||||
streamNotesAndTags = (): void => {
|
||||
this.appEventListeners.push(
|
||||
this.application.streamItems([ContentType.Note, ContentType.Tag], () => {
|
||||
runInAction(() => {
|
||||
this.notesAndTags = this.application.items.getItems([ContentType.Note, ContentType.Tag])
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
setShow = (show: boolean): void => {
|
||||
this.show = show
|
||||
}
|
||||
|
||||
setShouldAnimateClose = (shouldAnimateCloseMenu: boolean): void => {
|
||||
this.shouldAnimateCloseMenu = shouldAnimateCloseMenu
|
||||
}
|
||||
|
||||
closeAccountMenu = (): void => {
|
||||
this.setShouldAnimateClose(true)
|
||||
setTimeout(() => {
|
||||
this.setShow(false)
|
||||
this.setShouldAnimateClose(false)
|
||||
this.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
setSigningOut = (signingOut: boolean): void => {
|
||||
this.signingOut = signingOut
|
||||
}
|
||||
|
||||
setServer = (server: string | undefined): void => {
|
||||
this.server = server
|
||||
}
|
||||
|
||||
setEnableServerOption = (enableServerOption: boolean): void => {
|
||||
this.enableServerOption = enableServerOption
|
||||
}
|
||||
|
||||
setIsEncryptionEnabled = (isEncryptionEnabled: boolean): void => {
|
||||
this.isEncryptionEnabled = isEncryptionEnabled
|
||||
}
|
||||
|
||||
setEncryptionStatusString = (encryptionStatusString: string): void => {
|
||||
this.encryptionStatusString = encryptionStatusString
|
||||
}
|
||||
|
||||
setIsBackupEncrypted = (isBackupEncrypted: boolean): void => {
|
||||
this.isBackupEncrypted = isBackupEncrypted
|
||||
}
|
||||
|
||||
setShowSignIn = (showSignIn: boolean): void => {
|
||||
this.showSignIn = showSignIn
|
||||
}
|
||||
|
||||
setShowRegister = (showRegister: boolean): void => {
|
||||
this.showRegister = showRegister
|
||||
}
|
||||
|
||||
toggleShow = (): void => {
|
||||
if (this.show) {
|
||||
this.closeAccountMenu()
|
||||
} else {
|
||||
this.setShow(true)
|
||||
}
|
||||
}
|
||||
|
||||
setOtherSessionsSignOut = (otherSessionsSignOut: boolean): void => {
|
||||
this.otherSessionsSignOut = otherSessionsSignOut
|
||||
}
|
||||
|
||||
setCurrentPane = (pane: AccountMenuPane): void => {
|
||||
this.currentPane = pane
|
||||
}
|
||||
|
||||
get notesAndTagsCount(): number {
|
||||
return this.notesAndTags.length
|
||||
}
|
||||
|
||||
get structuredNotesAndTagsCount(): StructuredItemsCount {
|
||||
const count: StructuredItemsCount = {
|
||||
notes: 0,
|
||||
archived: 0,
|
||||
deleted: 0,
|
||||
tags: 0,
|
||||
}
|
||||
for (const item of this.notesAndTags) {
|
||||
if (item.archived) {
|
||||
count.archived++
|
||||
}
|
||||
|
||||
if (item.trashed) {
|
||||
count.deleted++
|
||||
}
|
||||
|
||||
if (item.content_type === ContentType.Note) {
|
||||
count.notes++
|
||||
}
|
||||
|
||||
if (item.content_type === ContentType.Tag) {
|
||||
count.tags++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
22
app/assets/javascripts/UIModels/AppState/ActionsMenuState.ts
Normal file
22
app/assets/javascripts/UIModels/AppState/ActionsMenuState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { UuidString } from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
|
||||
export class ActionsMenuState {
|
||||
hiddenSections: Record<UuidString, boolean> = {}
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
hiddenSections: observable,
|
||||
toggleSectionVisibility: action,
|
||||
reset: action,
|
||||
})
|
||||
}
|
||||
|
||||
toggleSectionVisibility = (uuid: UuidString): void => {
|
||||
this.hiddenSections[uuid] = !this.hiddenSections[uuid]
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.hiddenSections = {}
|
||||
}
|
||||
}
|
||||
429
app/assets/javascripts/UIModels/AppState/AppState.ts
Normal file
429
app/assets/javascripts/UIModels/AppState/AppState.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Bridge } from '@/Services/Bridge'
|
||||
import { storage, StorageKey } from '@/Services/LocalStorage'
|
||||
import { WebApplication, WebAppEvent } from '@/UIModels/Application'
|
||||
import { AccountMenuState } from '@/UIModels/AppState/AccountMenuState'
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
DeinitSource,
|
||||
NoteViewController,
|
||||
PrefKey,
|
||||
SNNote,
|
||||
SmartView,
|
||||
SNTag,
|
||||
SystemViewId,
|
||||
removeFromArray,
|
||||
PayloadSource,
|
||||
Uuid,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'
|
||||
import { ActionsMenuState } from './ActionsMenuState'
|
||||
import { FeaturesState } from './FeaturesState'
|
||||
import { FilesState } from './FilesState'
|
||||
import { NotesState } from './NotesState'
|
||||
import { NotesViewState } from './NotesViewState'
|
||||
import { NoteTagsState } from './NoteTagsState'
|
||||
import { NoAccountWarningState } from './NoAccountWarningState'
|
||||
import { PreferencesState } from './PreferencesState'
|
||||
import { PurchaseFlowState } from './PurchaseFlowState'
|
||||
import { QuickSettingsState } from './QuickSettingsState'
|
||||
import { SearchOptionsState } from './SearchOptionsState'
|
||||
import { SubscriptionState } from './SubscriptionState'
|
||||
import { SyncState } from './SyncState'
|
||||
import { TagsState } from './TagsState'
|
||||
|
||||
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>
|
||||
|
||||
export class AppState {
|
||||
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
|
||||
|
||||
application: WebApplication
|
||||
observers: ObserverCallback[] = []
|
||||
locked = true
|
||||
unsubApp: any
|
||||
webAppEventDisposer?: () => void
|
||||
onVisibilityChange: any
|
||||
showBetaWarning: boolean
|
||||
|
||||
private multiEditorSupport = false
|
||||
|
||||
readonly quickSettingsMenu = new QuickSettingsState()
|
||||
readonly accountMenu: AccountMenuState
|
||||
readonly actionsMenu = new ActionsMenuState()
|
||||
readonly preferences = new PreferencesState()
|
||||
readonly purchaseFlow: PurchaseFlowState
|
||||
readonly noAccountWarning: NoAccountWarningState
|
||||
readonly noteTags: NoteTagsState
|
||||
readonly sync = new SyncState()
|
||||
readonly searchOptions: SearchOptionsState
|
||||
readonly notes: NotesState
|
||||
readonly features: FeaturesState
|
||||
readonly tags: TagsState
|
||||
readonly notesView: NotesViewState
|
||||
readonly subscription: SubscriptionState
|
||||
readonly files: FilesState
|
||||
|
||||
isSessionsModalVisible = false
|
||||
|
||||
private appEventObserverRemovers: (() => void)[] = []
|
||||
|
||||
private readonly tagChangedDisposer: IReactionDisposer
|
||||
|
||||
constructor(application: WebApplication, private bridge: Bridge) {
|
||||
this.application = application
|
||||
this.notes = new NotesState(
|
||||
application,
|
||||
this,
|
||||
async () => {
|
||||
await this.notifyEvent(AppStateEvent.ActiveEditorChanged)
|
||||
},
|
||||
this.appEventObserverRemovers,
|
||||
)
|
||||
this.noteTags = new NoteTagsState(application, this, this.appEventObserverRemovers)
|
||||
this.features = new FeaturesState(application)
|
||||
this.tags = new TagsState(application, this.appEventObserverRemovers, this.features)
|
||||
this.noAccountWarning = new NoAccountWarningState(application, this.appEventObserverRemovers)
|
||||
this.accountMenu = new AccountMenuState(application, this.appEventObserverRemovers)
|
||||
this.searchOptions = new SearchOptionsState(application, this.appEventObserverRemovers)
|
||||
this.subscription = new SubscriptionState(application, this.appEventObserverRemovers)
|
||||
this.purchaseFlow = new PurchaseFlowState(application)
|
||||
this.notesView = new NotesViewState(application, this, this.appEventObserverRemovers)
|
||||
this.files = new FilesState(application)
|
||||
this.addAppEventObserver()
|
||||
this.streamNotesAndTags()
|
||||
this.onVisibilityChange = () => {
|
||||
const visible = document.visibilityState === 'visible'
|
||||
const event = visible ? AppStateEvent.WindowDidFocus : AppStateEvent.WindowDidBlur
|
||||
this.notifyEvent(event).catch(console.error)
|
||||
}
|
||||
this.registerVisibilityObservers()
|
||||
|
||||
if (this.bridge.appVersion.includes('-beta')) {
|
||||
this.showBetaWarning = storage.get(StorageKey.ShowBetaWarning) ?? true
|
||||
} else {
|
||||
this.showBetaWarning = false
|
||||
}
|
||||
|
||||
makeObservable(this, {
|
||||
selectedTag: computed,
|
||||
|
||||
showBetaWarning: observable,
|
||||
isSessionsModalVisible: observable,
|
||||
preferences: observable,
|
||||
|
||||
enableBetaWarning: action,
|
||||
disableBetaWarning: action,
|
||||
openSessionsModal: action,
|
||||
closeSessionsModal: action,
|
||||
})
|
||||
|
||||
this.tagChangedDisposer = this.tagChangedNotifier()
|
||||
}
|
||||
|
||||
deinit(source: DeinitSource): void {
|
||||
if (source === DeinitSource.SignOut) {
|
||||
storage.remove(StorageKey.ShowBetaWarning)
|
||||
this.noAccountWarning.reset()
|
||||
}
|
||||
;(this.application as unknown) = undefined
|
||||
this.actionsMenu.reset()
|
||||
this.unsubApp?.()
|
||||
this.unsubApp = undefined
|
||||
this.observers.length = 0
|
||||
|
||||
this.appEventObserverRemovers.forEach((remover) => remover())
|
||||
this.appEventObserverRemovers.length = 0
|
||||
|
||||
this.features.deinit()
|
||||
;(this.features as unknown) = undefined
|
||||
|
||||
this.webAppEventDisposer?.()
|
||||
this.webAppEventDisposer = undefined
|
||||
;(this.quickSettingsMenu as unknown) = undefined
|
||||
;(this.accountMenu as unknown) = undefined
|
||||
;(this.actionsMenu as unknown) = undefined
|
||||
;(this.preferences as unknown) = undefined
|
||||
;(this.purchaseFlow as unknown) = undefined
|
||||
;(this.noteTags as unknown) = undefined
|
||||
;(this.sync as unknown) = undefined
|
||||
;(this.searchOptions as unknown) = undefined
|
||||
;(this.notes as unknown) = undefined
|
||||
;(this.features as unknown) = undefined
|
||||
;(this.tags as unknown) = undefined
|
||||
;(this.notesView as unknown) = undefined
|
||||
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||
this.onVisibilityChange = undefined
|
||||
|
||||
this.tagChangedDisposer()
|
||||
;(this.tagChangedDisposer as unknown) = undefined
|
||||
}
|
||||
|
||||
openSessionsModal(): void {
|
||||
this.isSessionsModalVisible = true
|
||||
}
|
||||
|
||||
closeSessionsModal(): void {
|
||||
this.isSessionsModalVisible = false
|
||||
}
|
||||
|
||||
disableBetaWarning() {
|
||||
this.showBetaWarning = false
|
||||
storage.set(StorageKey.ShowBetaWarning, false)
|
||||
}
|
||||
|
||||
enableBetaWarning() {
|
||||
this.showBetaWarning = true
|
||||
storage.set(StorageKey.ShowBetaWarning, true)
|
||||
}
|
||||
|
||||
public get version(): string {
|
||||
return this.bridge.appVersion
|
||||
}
|
||||
|
||||
async openNewNote(title?: string) {
|
||||
if (!this.multiEditorSupport) {
|
||||
this.closeActiveNoteController()
|
||||
}
|
||||
|
||||
const selectedTag = this.selectedTag
|
||||
|
||||
const activeRegularTagUuid =
|
||||
selectedTag && selectedTag instanceof SNTag ? selectedTag.uuid : undefined
|
||||
|
||||
await this.application.noteControllerGroup.createNoteView(
|
||||
undefined,
|
||||
title,
|
||||
activeRegularTagUuid,
|
||||
)
|
||||
}
|
||||
|
||||
getActiveNoteController() {
|
||||
return this.application.noteControllerGroup.noteControllers[0]
|
||||
}
|
||||
|
||||
getNoteControllers() {
|
||||
return this.application.noteControllerGroup.noteControllers
|
||||
}
|
||||
|
||||
closeNoteController(controller: NoteViewController) {
|
||||
this.application.noteControllerGroup.closeNoteView(controller)
|
||||
}
|
||||
|
||||
closeActiveNoteController() {
|
||||
this.application.noteControllerGroup.closeActiveNoteView()
|
||||
}
|
||||
|
||||
closeAllNoteControllers() {
|
||||
this.application.noteControllerGroup.closeAllNoteViews()
|
||||
}
|
||||
|
||||
noteControllerForNote(uuid: Uuid) {
|
||||
for (const controller of this.getNoteControllers()) {
|
||||
if (controller.note.uuid === uuid) {
|
||||
return controller
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
isGlobalSpellcheckEnabled(): boolean {
|
||||
return this.application.getPreference(PrefKey.EditorSpellcheck, true)
|
||||
}
|
||||
|
||||
async toggleGlobalSpellcheck() {
|
||||
const currentValue = this.isGlobalSpellcheckEnabled()
|
||||
return this.application.setPreference(PrefKey.EditorSpellcheck, !currentValue)
|
||||
}
|
||||
|
||||
private tagChangedNotifier(): IReactionDisposer {
|
||||
return reaction(
|
||||
() => this.tags.selectedUuid,
|
||||
() => {
|
||||
const tag = this.tags.selected
|
||||
const previousTag = this.tags.previouslySelected
|
||||
|
||||
if (!tag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.application.items.isTemplateItem(tag)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.notifyEvent(AppStateEvent.TagChanged, {
|
||||
tag,
|
||||
previousTag,
|
||||
}).catch(console.error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public get selectedTag(): SNTag | SmartView | undefined {
|
||||
return this.tags.selected
|
||||
}
|
||||
|
||||
public set selectedTag(tag: SNTag | SmartView | undefined) {
|
||||
this.tags.selected = tag
|
||||
}
|
||||
|
||||
streamNotesAndTags() {
|
||||
this.application.streamItems<SNNote | SNTag>(
|
||||
[ContentType.Note, ContentType.Tag],
|
||||
async ({ changed, inserted, removed, source }) => {
|
||||
if (![PayloadSource.PreSyncSave, PayloadSource.RemoteRetrieved].includes(source)) {
|
||||
return
|
||||
}
|
||||
|
||||
const removedNotes = removed.filter((i) => i.content_type === ContentType.Note)
|
||||
|
||||
for (const removedNote of removedNotes) {
|
||||
const noteController = this.noteControllerForNote(removedNote.uuid)
|
||||
if (noteController) {
|
||||
this.closeNoteController(noteController)
|
||||
}
|
||||
}
|
||||
|
||||
const changedOrInserted = [...changed, ...inserted].filter(
|
||||
(i) => i.content_type === ContentType.Note,
|
||||
)
|
||||
|
||||
const selectedTag = this.tags.selected
|
||||
|
||||
const isBrowswingTrashedNotes =
|
||||
selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.TrashedNotes
|
||||
|
||||
const isBrowsingArchivedNotes =
|
||||
selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.ArchivedNotes
|
||||
|
||||
for (const note of changedOrInserted) {
|
||||
const noteController = this.noteControllerForNote(note.uuid)
|
||||
if (!noteController) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (note.trashed && !isBrowswingTrashedNotes && !this.searchOptions.includeTrashed) {
|
||||
this.closeNoteController(noteController)
|
||||
} else if (
|
||||
note.archived &&
|
||||
!isBrowsingArchivedNotes &&
|
||||
!this.searchOptions.includeArchived &&
|
||||
!this.application.getPreference(PrefKey.NotesShowArchived, false)
|
||||
) {
|
||||
this.closeNoteController(noteController)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
this.unsubApp = this.application.addEventObserver(async (eventName) => {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.Started:
|
||||
this.locked = true
|
||||
break
|
||||
case ApplicationEvent.Launched:
|
||||
this.locked = false
|
||||
if (window.location.search.includes('purchase=true')) {
|
||||
this.purchaseFlow.openPurchaseFlow()
|
||||
}
|
||||
break
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.sync.update(this.application.sync.getSyncStatus())
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
isLocked() {
|
||||
return this.locked
|
||||
}
|
||||
|
||||
registerVisibilityObservers() {
|
||||
if (isDesktopApplication()) {
|
||||
this.webAppEventDisposer = this.application.addWebEventObserver((event) => {
|
||||
if (event === WebAppEvent.DesktopWindowGainedFocus) {
|
||||
this.notifyEvent(AppStateEvent.WindowDidFocus).catch(console.error)
|
||||
} else if (event === WebAppEvent.DesktopWindowLostFocus) {
|
||||
this.notifyEvent(AppStateEvent.WindowDidBlur).catch(console.error)
|
||||
}
|
||||
})
|
||||
} 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 () => {
|
||||
removeFromArray(this.observers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
async notifyEvent(eventName: AppStateEvent, data?: any) {
|
||||
/**
|
||||
* Timeout is particularly important so we can give all initial
|
||||
* controllers a chance to construct before propogting any events *
|
||||
*/
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
for (const callback of this.observers) {
|
||||
await callback(eventName, data)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns the tags that are referncing this note */
|
||||
public getNoteTags(note: SNNote) {
|
||||
return this.application.items.itemsReferencingItem(note).filter((ref) => {
|
||||
return ref.content_type === ContentType.Tag
|
||||
}) as SNTag[]
|
||||
}
|
||||
|
||||
panelDidResize(name: string, collapsed: boolean) {
|
||||
const data: PanelResizedData = {
|
||||
panel: name,
|
||||
collapsed: collapsed,
|
||||
}
|
||||
this.notifyEvent(AppStateEvent.PanelResized, data).catch(console.error)
|
||||
}
|
||||
|
||||
editorDidFocus(eventSource: EventSource) {
|
||||
this.notifyEvent(AppStateEvent.EditorFocused, { eventSource: eventSource }).catch(console.error)
|
||||
}
|
||||
|
||||
beganBackupDownload() {
|
||||
this.notifyEvent(AppStateEvent.BeganBackupDownload).catch(console.error)
|
||||
}
|
||||
|
||||
endedBackupDownload(success: boolean) {
|
||||
this.notifyEvent(AppStateEvent.EndedBackupDownload, { success: success }).catch(console.error)
|
||||
}
|
||||
}
|
||||
87
app/assets/javascripts/UIModels/AppState/FeaturesState.ts
Normal file
87
app/assets/javascripts/UIModels/AppState/FeaturesState.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ApplicationEvent, FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable, runInAction, when } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders'
|
||||
export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to enable Tag folders.'
|
||||
|
||||
export const SMART_TAGS_FEATURE_NAME = 'Smart Tags'
|
||||
|
||||
/**
|
||||
* Holds state for premium/non premium features for the current user features,
|
||||
* and eventually for in-development features (feature flags).
|
||||
*/
|
||||
export class FeaturesState {
|
||||
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
|
||||
|
||||
_hasFolders = false
|
||||
_hasSmartViews = false
|
||||
_premiumAlertFeatureName: string | undefined
|
||||
|
||||
private unsub: () => void
|
||||
|
||||
constructor(private application: WebApplication) {
|
||||
this._hasFolders = this.hasNativeFolders()
|
||||
this._hasSmartViews = this.hasNativeSmartViews()
|
||||
this._premiumAlertFeatureName = undefined
|
||||
|
||||
makeObservable(this, {
|
||||
_hasFolders: observable,
|
||||
_hasSmartViews: observable,
|
||||
hasFolders: computed,
|
||||
_premiumAlertFeatureName: observable,
|
||||
showPremiumAlert: action,
|
||||
closePremiumAlert: action,
|
||||
})
|
||||
|
||||
this.showPremiumAlert = this.showPremiumAlert.bind(this)
|
||||
this.closePremiumAlert = this.closePremiumAlert.bind(this)
|
||||
|
||||
this.unsub = this.application.addEventObserver(async (eventName) => {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
case ApplicationEvent.Launched:
|
||||
runInAction(() => {
|
||||
this._hasFolders = this.hasNativeFolders()
|
||||
this._hasSmartViews = this.hasNativeSmartViews()
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
this.unsub()
|
||||
}
|
||||
|
||||
public get hasFolders(): boolean {
|
||||
return this._hasFolders
|
||||
}
|
||||
|
||||
public get hasSmartViews(): boolean {
|
||||
return this._hasSmartViews
|
||||
}
|
||||
|
||||
public async showPremiumAlert(featureName: string): Promise<void> {
|
||||
this._premiumAlertFeatureName = featureName
|
||||
return when(() => this._premiumAlertFeatureName === undefined)
|
||||
}
|
||||
|
||||
public async closePremiumAlert(): Promise<void> {
|
||||
this._premiumAlertFeatureName = undefined
|
||||
}
|
||||
|
||||
private hasNativeFolders(): boolean {
|
||||
const status = this.application.features.getFeatureStatus(FeatureIdentifier.TagNesting)
|
||||
|
||||
return status === FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private hasNativeSmartViews(): boolean {
|
||||
const status = this.application.features.getFeatureStatus(FeatureIdentifier.SmartFilters)
|
||||
|
||||
return status === FeatureStatus.Entitled
|
||||
}
|
||||
}
|
||||
169
app/assets/javascripts/UIModels/AppState/FilesState.ts
Normal file
169
app/assets/javascripts/UIModels/AppState/FilesState.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
|
||||
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
|
||||
import {
|
||||
ClassicFileReader,
|
||||
StreamingFileReader,
|
||||
StreamingFileSaver,
|
||||
ClassicFileSaver,
|
||||
parseFileName,
|
||||
} from '@standardnotes/filepicker'
|
||||
import { ClientDisplayableError, SNFile } from '@standardnotes/snjs'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
export class FilesState {
|
||||
constructor(private application: WebApplication) {}
|
||||
|
||||
public async downloadFile(file: SNFile): Promise<void> {
|
||||
let downloadingToastId = ''
|
||||
|
||||
try {
|
||||
const saver = StreamingFileSaver.available()
|
||||
? new StreamingFileSaver(file.name)
|
||||
: new ClassicFileSaver()
|
||||
|
||||
const isUsingStreamingSaver = saver instanceof StreamingFileSaver
|
||||
|
||||
if (isUsingStreamingSaver) {
|
||||
await saver.selectFileToSaveTo()
|
||||
}
|
||||
|
||||
downloadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: 'Downloading file...',
|
||||
})
|
||||
|
||||
const decryptedBytesArray: Uint8Array[] = []
|
||||
|
||||
await this.application.files.downloadFile(file, async (decryptedBytes: Uint8Array) => {
|
||||
if (isUsingStreamingSaver) {
|
||||
await saver.pushBytes(decryptedBytes)
|
||||
} else {
|
||||
decryptedBytesArray.push(decryptedBytes)
|
||||
}
|
||||
})
|
||||
|
||||
if (isUsingStreamingSaver) {
|
||||
await saver.finish()
|
||||
} else {
|
||||
const finalBytes = concatenateUint8Arrays(decryptedBytesArray)
|
||||
saver.saveFile(file.name, finalBytes)
|
||||
}
|
||||
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: 'Successfully downloaded file',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'There was an error while downloading the file',
|
||||
})
|
||||
}
|
||||
|
||||
if (downloadingToastId.length > 0) {
|
||||
dismissToast(downloadingToastId)
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadNewFile(fileOrHandle?: File | FileSystemFileHandle) {
|
||||
let toastId = ''
|
||||
|
||||
try {
|
||||
const minimumChunkSize = this.application.files.minimumChunkSize()
|
||||
|
||||
const shouldUseStreamingReader = StreamingFileReader.available()
|
||||
|
||||
const picker = shouldUseStreamingReader ? StreamingFileReader : ClassicFileReader
|
||||
const maxFileSize = picker.maximumFileSize()
|
||||
|
||||
const selectedFiles =
|
||||
fileOrHandle instanceof File
|
||||
? [fileOrHandle]
|
||||
: StreamingFileReader.available() && fileOrHandle instanceof FileSystemFileHandle
|
||||
? await StreamingFileReader.getFilesFromHandles([fileOrHandle])
|
||||
: await picker.selectFiles()
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const uploadedFiles: SNFile[] = []
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
if (!shouldUseStreamingReader && maxFileSize && file.size >= maxFileSize) {
|
||||
this.application.alertService
|
||||
.alert(
|
||||
`This file exceeds the limits supported in this browser. To upload files greater than ${
|
||||
maxFileSize / BYTES_IN_ONE_MEGABYTE
|
||||
}MB, please use the desktop application or the Chrome browser.`,
|
||||
`Cannot upload file "${file.name}"`,
|
||||
)
|
||||
.catch(console.error)
|
||||
continue
|
||||
}
|
||||
|
||||
toastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Uploading file "${file.name}"...`,
|
||||
})
|
||||
|
||||
const operation = await this.application.files.beginNewFileUpload()
|
||||
|
||||
if (operation instanceof ClientDisplayableError) {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'Unable to start upload session',
|
||||
})
|
||||
throw new Error('Unable to start upload session')
|
||||
}
|
||||
|
||||
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
|
||||
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
|
||||
}
|
||||
|
||||
const fileResult = await picker.readFile(file, minimumChunkSize, onChunk)
|
||||
|
||||
if (!fileResult.mimeType) {
|
||||
const { ext } = parseFileName(file.name)
|
||||
fileResult.mimeType = await this.application.getArchiveService().getMimeType(ext)
|
||||
}
|
||||
|
||||
const uploadedFile = await this.application.files.finishUpload(operation, fileResult)
|
||||
|
||||
if (uploadedFile instanceof ClientDisplayableError) {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'Unable to close upload session',
|
||||
})
|
||||
throw new Error('Unable to close upload session')
|
||||
}
|
||||
|
||||
uploadedFiles.push(uploadedFile)
|
||||
|
||||
dismissToast(toastId)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Uploaded file "${uploadedFile.name}"`,
|
||||
})
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
if (toastId.length > 0) {
|
||||
dismissToast(toastId)
|
||||
}
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'There was an error while uploading the file',
|
||||
})
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { storage, StorageKey } from '@/Services/LocalStorage'
|
||||
import { SNApplication, ApplicationEvent } from '@standardnotes/snjs'
|
||||
import { runInAction, makeObservable, observable, action } from 'mobx'
|
||||
|
||||
export 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 = (): void => {
|
||||
this.show = false
|
||||
storage.set(StorageKey.ShowNoAccountWarning, false)
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
storage.remove(StorageKey.ShowNoAccountWarning)
|
||||
}
|
||||
}
|
||||
232
app/assets/javascripts/UIModels/AppState/NoteTagsState.ts
Normal file
232
app/assets/javascripts/UIModels/AppState/NoteTagsState.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { ElementIds } from '@/ElementIDs'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
PrefKey,
|
||||
SNNote,
|
||||
SNTag,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
import { AppState } from './AppState'
|
||||
|
||||
export class NoteTagsState {
|
||||
autocompleteInputFocused = false
|
||||
autocompleteSearchQuery = ''
|
||||
autocompleteTagHintFocused = false
|
||||
autocompleteTagResults: SNTag[] = []
|
||||
focusedTagResultUuid: UuidString | undefined = undefined
|
||||
focusedTagUuid: UuidString | undefined = undefined
|
||||
tags: SNTag[] = []
|
||||
tagsContainerMaxWidth: number | 'auto' = 0
|
||||
addNoteToParentFolders: boolean
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
private appState: AppState,
|
||||
appEventListeners: (() => void)[],
|
||||
) {
|
||||
makeObservable(this, {
|
||||
autocompleteInputFocused: observable,
|
||||
autocompleteSearchQuery: observable,
|
||||
autocompleteTagHintFocused: observable,
|
||||
autocompleteTagResults: observable,
|
||||
focusedTagUuid: observable,
|
||||
focusedTagResultUuid: observable,
|
||||
tags: observable,
|
||||
tagsContainerMaxWidth: observable,
|
||||
|
||||
autocompleteTagHintVisible: computed,
|
||||
|
||||
setAutocompleteInputFocused: action,
|
||||
setAutocompleteSearchQuery: action,
|
||||
setAutocompleteTagHintFocused: action,
|
||||
setAutocompleteTagResults: action,
|
||||
setFocusedTagResultUuid: action,
|
||||
setFocusedTagUuid: action,
|
||||
setTags: action,
|
||||
setTagsContainerMaxWidth: action,
|
||||
})
|
||||
|
||||
this.addNoteToParentFolders = application.getPreference(PrefKey.NoteAddToParentFolders, true)
|
||||
|
||||
appEventListeners.push(
|
||||
application.streamItems(ContentType.Tag, () => {
|
||||
this.reloadTags()
|
||||
}),
|
||||
application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
|
||||
this.addNoteToParentFolders = application.getPreference(
|
||||
PrefKey.NoteAddToParentFolders,
|
||||
true,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
get activeNote(): SNNote | undefined {
|
||||
return this.appState.notes.activeNoteController?.note
|
||||
}
|
||||
|
||||
get autocompleteTagHintVisible(): boolean {
|
||||
return (
|
||||
this.autocompleteSearchQuery !== '' &&
|
||||
!this.autocompleteTagResults.some(
|
||||
(tagResult) => tagResult.title === this.autocompleteSearchQuery,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
setAutocompleteInputFocused(focused: boolean): void {
|
||||
this.autocompleteInputFocused = focused
|
||||
}
|
||||
|
||||
setAutocompleteSearchQuery(query: string): void {
|
||||
this.autocompleteSearchQuery = query
|
||||
}
|
||||
|
||||
setAutocompleteTagHintFocused(focused: boolean): void {
|
||||
this.autocompleteTagHintFocused = focused
|
||||
}
|
||||
|
||||
setAutocompleteTagResults(results: SNTag[]): void {
|
||||
this.autocompleteTagResults = results
|
||||
}
|
||||
|
||||
setFocusedTagUuid(tagUuid: UuidString | undefined): void {
|
||||
this.focusedTagUuid = tagUuid
|
||||
}
|
||||
|
||||
setFocusedTagResultUuid(tagUuid: UuidString | undefined): void {
|
||||
this.focusedTagResultUuid = tagUuid
|
||||
}
|
||||
|
||||
setTags(tags: SNTag[]): void {
|
||||
this.tags = tags
|
||||
}
|
||||
|
||||
setTagsContainerMaxWidth(width: number): void {
|
||||
this.tagsContainerMaxWidth = width
|
||||
}
|
||||
|
||||
clearAutocompleteSearch(): void {
|
||||
this.setAutocompleteSearchQuery('')
|
||||
this.setAutocompleteTagResults([])
|
||||
}
|
||||
|
||||
async createAndAddNewTag(): Promise<void> {
|
||||
const newTag = await this.application.mutator.findOrCreateTag(this.autocompleteSearchQuery)
|
||||
await this.addTagToActiveNote(newTag)
|
||||
this.clearAutocompleteSearch()
|
||||
}
|
||||
|
||||
focusNextTag(tag: SNTag): void {
|
||||
const nextTagIndex = this.getTagIndex(tag, this.tags) + 1
|
||||
if (nextTagIndex > -1 && this.tags.length > nextTagIndex) {
|
||||
const nextTag = this.tags[nextTagIndex]
|
||||
this.setFocusedTagUuid(nextTag.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
focusNextTagResult(tagResult: SNTag): void {
|
||||
const nextTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) + 1
|
||||
if (nextTagResultIndex > -1 && this.autocompleteTagResults.length > nextTagResultIndex) {
|
||||
const nextTagResult = this.autocompleteTagResults[nextTagResultIndex]
|
||||
this.setFocusedTagResultUuid(nextTagResult.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
focusPreviousTag(tag: SNTag): void {
|
||||
const previousTagIndex = this.getTagIndex(tag, this.tags) - 1
|
||||
if (previousTagIndex > -1 && this.tags.length > previousTagIndex) {
|
||||
const previousTag = this.tags[previousTagIndex]
|
||||
this.setFocusedTagUuid(previousTag.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
focusPreviousTagResult(tagResult: SNTag): void {
|
||||
const previousTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) - 1
|
||||
if (
|
||||
previousTagResultIndex > -1 &&
|
||||
this.autocompleteTagResults.length > previousTagResultIndex
|
||||
) {
|
||||
const previousTagResult = this.autocompleteTagResults[previousTagResultIndex]
|
||||
this.setFocusedTagResultUuid(previousTagResult.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
searchActiveNoteAutocompleteTags(): void {
|
||||
const newResults = this.application.items.searchTags(
|
||||
this.autocompleteSearchQuery,
|
||||
this.activeNote,
|
||||
)
|
||||
this.setAutocompleteTagResults(newResults)
|
||||
}
|
||||
|
||||
getTagIndex(tag: SNTag, tagsArr: SNTag[]): number {
|
||||
return tagsArr.findIndex((t) => t.uuid === tag.uuid)
|
||||
}
|
||||
|
||||
reloadTags(): void {
|
||||
const { activeNote } = this
|
||||
if (activeNote) {
|
||||
const tags = this.application.items.getSortedTagsForNote(activeNote)
|
||||
this.setTags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
reloadTagsContainerMaxWidth(): void {
|
||||
const editorWidth = document.getElementById(ElementIds.EditorColumn)?.clientWidth
|
||||
if (editorWidth) {
|
||||
this.setTagsContainerMaxWidth(editorWidth)
|
||||
}
|
||||
}
|
||||
|
||||
async addTagToActiveNote(tag: SNTag): Promise<void> {
|
||||
const { activeNote } = this
|
||||
|
||||
if (activeNote) {
|
||||
await this.application.items.addTagToNote(activeNote, tag, this.addNoteToParentFolders)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
this.reloadTags()
|
||||
}
|
||||
}
|
||||
|
||||
async removeTagFromActiveNote(tag: SNTag): Promise<void> {
|
||||
const { activeNote } = this
|
||||
if (activeNote) {
|
||||
await this.application.mutator.changeItem(tag, (mutator) => {
|
||||
mutator.removeItemAsRelationship(activeNote)
|
||||
})
|
||||
this.application.sync.sync().catch(console.error)
|
||||
this.reloadTags()
|
||||
}
|
||||
}
|
||||
|
||||
getSortedTagsForNote(note: SNNote): SNTag[] {
|
||||
const tags = this.application.items.getSortedTagsForNote(note)
|
||||
|
||||
const sortFunction = (tagA: SNTag, tagB: SNTag): number => {
|
||||
const a = this.getLongTitle(tagA)
|
||||
const b = this.getLongTitle(tagB)
|
||||
|
||||
if (a < b) {
|
||||
return -1
|
||||
}
|
||||
if (b > a) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
return tags.sort(sortFunction)
|
||||
}
|
||||
|
||||
getPrefixTitle(tag: SNTag): string | undefined {
|
||||
return this.application.items.getTagPrefixTitle(tag)
|
||||
}
|
||||
|
||||
getLongTitle(tag: SNTag): string {
|
||||
return this.application.items.getTagLongTitle(tag)
|
||||
}
|
||||
}
|
||||
426
app/assets/javascripts/UIModels/AppState/NotesState.ts
Normal file
426
app/assets/javascripts/UIModels/AppState/NotesState.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { confirmDialog } from '@/Services/AlertService'
|
||||
import { KeyboardModifier } from '@/Services/IOService'
|
||||
import { StringEmptyTrash, Strings, StringUtils } from '@/Strings'
|
||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import {
|
||||
UuidString,
|
||||
SNNote,
|
||||
NoteMutator,
|
||||
ContentType,
|
||||
SNTag,
|
||||
ChallengeReason,
|
||||
NoteViewController,
|
||||
} from '@standardnotes/snjs'
|
||||
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
import { AppState } from './AppState'
|
||||
|
||||
export class NotesState {
|
||||
lastSelectedNote: SNNote | undefined
|
||||
selectedNotes: Record<UuidString, SNNote> = {}
|
||||
contextMenuOpen = false
|
||||
contextMenuPosition: { top?: number; left: number; bottom?: number } = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
}
|
||||
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
|
||||
contextMenuMaxHeight: number | 'auto' = 'auto'
|
||||
showProtectedWarning = false
|
||||
showRevisionHistoryModal = false
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
private appState: AppState,
|
||||
private onActiveEditorChanged: () => Promise<void>,
|
||||
appEventListeners: (() => void)[],
|
||||
) {
|
||||
makeObservable(this, {
|
||||
selectedNotes: observable,
|
||||
contextMenuOpen: observable,
|
||||
contextMenuPosition: observable,
|
||||
showProtectedWarning: observable,
|
||||
showRevisionHistoryModal: observable,
|
||||
|
||||
selectedNotesCount: computed,
|
||||
trashedNotesCount: computed,
|
||||
|
||||
setContextMenuOpen: action,
|
||||
setContextMenuClickLocation: action,
|
||||
setContextMenuPosition: action,
|
||||
setContextMenuMaxHeight: action,
|
||||
setShowProtectedWarning: action,
|
||||
setShowRevisionHistoryModal: action,
|
||||
unselectNotes: action,
|
||||
})
|
||||
|
||||
appEventListeners.push(
|
||||
application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, removed }) => {
|
||||
runInAction(() => {
|
||||
for (const removedNote of removed) {
|
||||
delete this.selectedNotes[removedNote.uuid]
|
||||
}
|
||||
|
||||
for (const note of [...changed, ...inserted]) {
|
||||
if (this.selectedNotes[note.uuid]) {
|
||||
this.selectedNotes[note.uuid] = note
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
get activeNoteController(): NoteViewController | undefined {
|
||||
return this.application.noteControllerGroup.noteControllers[0]
|
||||
}
|
||||
|
||||
get selectedNotesCount(): number {
|
||||
return Object.keys(this.selectedNotes).length
|
||||
}
|
||||
|
||||
get trashedNotesCount(): number {
|
||||
return this.application.items.trashedItems.length
|
||||
}
|
||||
|
||||
private async selectNotesRange(selectedNote: SNNote): Promise<void> {
|
||||
const notes = this.application.items.getDisplayableNotes()
|
||||
|
||||
const lastSelectedNoteIndex = notes.findIndex(
|
||||
(note) => note.uuid == this.lastSelectedNote?.uuid,
|
||||
)
|
||||
const selectedNoteIndex = notes.findIndex((note) => note.uuid == selectedNote.uuid)
|
||||
|
||||
let notesToSelect = []
|
||||
if (selectedNoteIndex > lastSelectedNoteIndex) {
|
||||
notesToSelect = notes.slice(lastSelectedNoteIndex, selectedNoteIndex + 1)
|
||||
} else {
|
||||
notesToSelect = notes.slice(selectedNoteIndex, lastSelectedNoteIndex + 1)
|
||||
}
|
||||
|
||||
const authorizedNotes = await this.application.authorizeProtectedActionForNotes(
|
||||
notesToSelect,
|
||||
ChallengeReason.SelectProtectedNote,
|
||||
)
|
||||
|
||||
for (const note of authorizedNotes) {
|
||||
runInAction(() => {
|
||||
this.selectedNotes[note.uuid] = note
|
||||
this.lastSelectedNote = note
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> {
|
||||
const note = this.application.items.findItem(uuid) as SNNote
|
||||
if (!note) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
|
||||
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
|
||||
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
|
||||
|
||||
if (userTriggered && (hasMeta || hasCtrl)) {
|
||||
if (this.selectedNotes[uuid]) {
|
||||
delete this.selectedNotes[uuid]
|
||||
} else if (await this.application.authorizeNoteAccess(note)) {
|
||||
runInAction(() => {
|
||||
this.selectedNotes[uuid] = note
|
||||
this.lastSelectedNote = note
|
||||
})
|
||||
}
|
||||
} else if (userTriggered && hasShift) {
|
||||
await this.selectNotesRange(note)
|
||||
} else {
|
||||
const shouldSelectNote = this.selectedNotesCount > 1 || !this.selectedNotes[uuid]
|
||||
if (shouldSelectNote && (await this.application.authorizeNoteAccess(note))) {
|
||||
runInAction(() => {
|
||||
this.selectedNotes = {
|
||||
[note.uuid]: note,
|
||||
}
|
||||
this.lastSelectedNote = note
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selectedNotesCount === 1) {
|
||||
await this.openNote(Object.keys(this.selectedNotes)[0])
|
||||
}
|
||||
}
|
||||
|
||||
private async openNote(noteUuid: string): Promise<void> {
|
||||
if (this.activeNoteController?.note?.uuid === noteUuid) {
|
||||
return
|
||||
}
|
||||
|
||||
const note = this.application.items.findItem(noteUuid) as SNNote | undefined
|
||||
if (!note) {
|
||||
console.warn('Tried accessing a non-existant note of UUID ' + noteUuid)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.activeNoteController) {
|
||||
this.application.noteControllerGroup.closeActiveNoteView()
|
||||
}
|
||||
|
||||
await this.application.noteControllerGroup.createNoteView(noteUuid)
|
||||
|
||||
this.appState.noteTags.reloadTags()
|
||||
await this.onActiveEditorChanged()
|
||||
}
|
||||
|
||||
setContextMenuOpen(open: boolean): void {
|
||||
this.contextMenuOpen = open
|
||||
}
|
||||
|
||||
setContextMenuClickLocation(location: { x: number; y: number }): void {
|
||||
this.contextMenuClickLocation = location
|
||||
}
|
||||
|
||||
setContextMenuPosition(position: { top?: number; left: number; bottom?: number }): void {
|
||||
this.contextMenuPosition = position
|
||||
}
|
||||
|
||||
setContextMenuMaxHeight(maxHeight: number | 'auto'): void {
|
||||
this.contextMenuMaxHeight = maxHeight
|
||||
}
|
||||
|
||||
reloadContextMenuLayout(): void {
|
||||
const { clientHeight } = document.documentElement
|
||||
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
|
||||
const maxContextMenuHeight = parseFloat(defaultFontSize) * 30
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
// Open up-bottom is default behavior
|
||||
let openUpBottom = true
|
||||
|
||||
if (footerHeightInPx) {
|
||||
const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y
|
||||
const upSpace = this.contextMenuClickLocation.y
|
||||
|
||||
// If not enough space to open up-bottom
|
||||
if (maxContextMenuHeight > bottomSpace) {
|
||||
// If there's enough space, open bottom-up
|
||||
if (upSpace > maxContextMenuHeight) {
|
||||
openUpBottom = false
|
||||
this.setContextMenuMaxHeight('auto')
|
||||
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
|
||||
} else {
|
||||
if (upSpace > bottomSpace) {
|
||||
this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||
openUpBottom = false
|
||||
} else {
|
||||
this.setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.setContextMenuMaxHeight('auto')
|
||||
}
|
||||
}
|
||||
|
||||
if (openUpBottom) {
|
||||
this.setContextMenuPosition({
|
||||
top: this.contextMenuClickLocation.y,
|
||||
left: this.contextMenuClickLocation.x,
|
||||
})
|
||||
} else {
|
||||
this.setContextMenuPosition({
|
||||
bottom: clientHeight - this.contextMenuClickLocation.y,
|
||||
left: this.contextMenuClickLocation.x,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> {
|
||||
await this.application.mutator.changeItems(Object.values(this.selectedNotes), mutate, false)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
setHideSelectedNotePreviews(hide: boolean): void {
|
||||
this.changeSelectedNotes((mutator) => {
|
||||
mutator.hidePreview = hide
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
setLockSelectedNotes(lock: boolean): void {
|
||||
this.changeSelectedNotes((mutator) => {
|
||||
mutator.locked = lock
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
async setTrashSelectedNotes(trashed: boolean): Promise<void> {
|
||||
if (trashed) {
|
||||
const notesDeleted = await this.deleteNotes(false)
|
||||
if (notesDeleted) {
|
||||
runInAction(() => {
|
||||
this.unselectNotes()
|
||||
this.contextMenuOpen = false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await this.changeSelectedNotes((mutator) => {
|
||||
mutator.trashed = trashed
|
||||
})
|
||||
runInAction(() => {
|
||||
this.unselectNotes()
|
||||
this.contextMenuOpen = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNotesPermanently(): Promise<void> {
|
||||
await this.deleteNotes(true)
|
||||
}
|
||||
|
||||
async deleteNotes(permanently: boolean): Promise<boolean> {
|
||||
if (Object.values(this.selectedNotes).some((note) => note.locked)) {
|
||||
const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount)
|
||||
this.application.alertService.alert(text).catch(console.error)
|
||||
return false
|
||||
}
|
||||
|
||||
const title = Strings.trashNotesTitle
|
||||
let noteTitle = undefined
|
||||
if (this.selectedNotesCount === 1) {
|
||||
const selectedNote = Object.values(this.selectedNotes)[0]
|
||||
noteTitle = selectedNote.title.length ? `'${selectedNote.title}'` : 'this note'
|
||||
}
|
||||
const text = StringUtils.deleteNotes(permanently, this.selectedNotesCount, noteTitle)
|
||||
|
||||
if (
|
||||
await confirmDialog({
|
||||
title,
|
||||
text,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
if (permanently) {
|
||||
for (const note of Object.values(this.selectedNotes)) {
|
||||
await this.application.mutator.deleteItem(note)
|
||||
delete this.selectedNotes[note.uuid]
|
||||
}
|
||||
} else {
|
||||
await this.changeSelectedNotes((mutator) => {
|
||||
mutator.trashed = true
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
setPinSelectedNotes(pinned: boolean): void {
|
||||
this.changeSelectedNotes((mutator) => {
|
||||
mutator.pinned = pinned
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
async setArchiveSelectedNotes(archived: boolean): Promise<void> {
|
||||
if (Object.values(this.selectedNotes).some((note) => note.locked)) {
|
||||
this.application.alertService
|
||||
.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount))
|
||||
.catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
await this.changeSelectedNotes((mutator) => {
|
||||
mutator.archived = archived
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.selectedNotes = {}
|
||||
this.contextMenuOpen = false
|
||||
})
|
||||
}
|
||||
|
||||
async setProtectSelectedNotes(protect: boolean): Promise<void> {
|
||||
const selectedNotes = Object.values(this.selectedNotes)
|
||||
if (protect) {
|
||||
await this.application.mutator.protectNotes(selectedNotes)
|
||||
this.setShowProtectedWarning(true)
|
||||
} else {
|
||||
await this.application.mutator.unprotectNotes(selectedNotes)
|
||||
this.setShowProtectedWarning(false)
|
||||
}
|
||||
}
|
||||
|
||||
unselectNotes(): void {
|
||||
this.selectedNotes = {}
|
||||
}
|
||||
|
||||
getSpellcheckStateForNote(note: SNNote) {
|
||||
return note.spellcheck != undefined
|
||||
? note.spellcheck
|
||||
: this.appState.isGlobalSpellcheckEnabled()
|
||||
}
|
||||
|
||||
async toggleGlobalSpellcheckForNote(note: SNNote) {
|
||||
await this.application.mutator.changeItem<NoteMutator>(
|
||||
note,
|
||||
(mutator) => {
|
||||
mutator.toggleSpellcheck()
|
||||
},
|
||||
false,
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
async addTagToSelectedNotes(tag: SNTag): Promise<void> {
|
||||
const selectedNotes = Object.values(this.selectedNotes)
|
||||
const parentChainTags = this.application.items.getTagParentChain(tag)
|
||||
const tagsToAdd = [...parentChainTags, tag]
|
||||
await Promise.all(
|
||||
tagsToAdd.map(async (tag) => {
|
||||
await this.application.mutator.changeItem(tag, (mutator) => {
|
||||
for (const note of selectedNotes) {
|
||||
mutator.addItemAsRelationship(note)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
async removeTagFromSelectedNotes(tag: SNTag): Promise<void> {
|
||||
const selectedNotes = Object.values(this.selectedNotes)
|
||||
await this.application.mutator.changeItem(tag, (mutator) => {
|
||||
for (const note of selectedNotes) {
|
||||
mutator.removeItemAsRelationship(note)
|
||||
}
|
||||
})
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
isTagInSelectedNotes(tag: SNTag): boolean {
|
||||
const selectedNotes = Object.values(this.selectedNotes)
|
||||
return selectedNotes.every((note) =>
|
||||
this.appState.getNoteTags(note).find((noteTag) => noteTag.uuid === tag.uuid),
|
||||
)
|
||||
}
|
||||
|
||||
setShowProtectedWarning(show: boolean): void {
|
||||
this.showProtectedWarning = show
|
||||
}
|
||||
|
||||
async emptyTrash(): Promise<void> {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: StringEmptyTrash(this.trashedNotesCount),
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.mutator.emptyTrash().catch(console.error)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
private get io() {
|
||||
return this.application.io
|
||||
}
|
||||
|
||||
setShowRevisionHistoryModal(show: boolean): void {
|
||||
this.showRevisionHistoryModal = show
|
||||
}
|
||||
}
|
||||
545
app/assets/javascripts/UIModels/AppState/NotesViewState.ts
Normal file
545
app/assets/javascripts/UIModels/AppState/NotesViewState.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import {
|
||||
ApplicationEvent,
|
||||
CollectionSort,
|
||||
CollectionSortProperty,
|
||||
ContentType,
|
||||
findInArray,
|
||||
NotesDisplayCriteria,
|
||||
PrefKey,
|
||||
SmartView,
|
||||
SNNote,
|
||||
SNTag,
|
||||
SystemViewId,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, autorun, computed, makeObservable, observable, reaction } from 'mobx'
|
||||
import { AppState, AppStateEvent } from '.'
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
const MIN_NOTE_CELL_HEIGHT = 51.0
|
||||
const DEFAULT_LIST_NUM_NOTES = 20
|
||||
const ELEMENT_ID_SEARCH_BAR = 'search-bar'
|
||||
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable'
|
||||
|
||||
export type DisplayOptions = {
|
||||
sortBy: CollectionSortProperty
|
||||
sortReverse: boolean
|
||||
hidePinned: boolean
|
||||
showArchived: boolean
|
||||
showTrashed: boolean
|
||||
hideProtected: boolean
|
||||
hideTags: boolean
|
||||
hideNotePreview: boolean
|
||||
hideDate: boolean
|
||||
hideEditorIcon: boolean
|
||||
}
|
||||
|
||||
export class NotesViewState {
|
||||
completedFullSync = false
|
||||
noteFilterText = ''
|
||||
notes: SNNote[] = []
|
||||
notesToDisplay = 0
|
||||
pageSize = 0
|
||||
panelTitle = 'All Notes'
|
||||
panelWidth = 0
|
||||
renderedNotes: SNNote[] = []
|
||||
searchSubmitted = false
|
||||
selectedNotes: Record<UuidString, SNNote> = {}
|
||||
showDisplayOptionsMenu = false
|
||||
displayOptions = {
|
||||
sortBy: CollectionSort.CreatedAt,
|
||||
sortReverse: false,
|
||||
hidePinned: false,
|
||||
showArchived: false,
|
||||
showTrashed: false,
|
||||
hideProtected: false,
|
||||
hideTags: true,
|
||||
hideDate: false,
|
||||
hideNotePreview: false,
|
||||
hideEditorIcon: false,
|
||||
}
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
private appState: AppState,
|
||||
appObservers: (() => void)[],
|
||||
) {
|
||||
this.resetPagination()
|
||||
|
||||
appObservers.push(
|
||||
application.streamItems<SNNote>(ContentType.Note, () => {
|
||||
this.reloadNotes()
|
||||
|
||||
const activeNote = this.appState.notes.activeNoteController?.note
|
||||
|
||||
if (this.application.getAppState().notes.selectedNotesCount < 2) {
|
||||
if (activeNote) {
|
||||
const browsingTrashedNotes =
|
||||
this.appState.selectedTag instanceof SmartView &&
|
||||
this.appState.selectedTag?.uuid === SystemViewId.TrashedNotes
|
||||
|
||||
if (
|
||||
activeNote.trashed &&
|
||||
!browsingTrashedNotes &&
|
||||
!this.appState?.searchOptions.includeTrashed
|
||||
) {
|
||||
this.selectNextOrCreateNew()
|
||||
} else if (!this.selectedNotes[activeNote.uuid]) {
|
||||
this.selectNote(activeNote).catch(console.error)
|
||||
}
|
||||
} else {
|
||||
this.selectFirstNote()
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
application.streamItems<SNTag>([ContentType.Tag], async ({ changed, inserted }) => {
|
||||
const tags = [...changed, ...inserted]
|
||||
/** A tag could have changed its relationships, so we need to reload the filter */
|
||||
this.reloadNotesDisplayOptions()
|
||||
this.reloadNotes()
|
||||
|
||||
if (
|
||||
this.appState.selectedTag &&
|
||||
findInArray(tags, 'uuid', this.appState.selectedTag.uuid)
|
||||
) {
|
||||
/** Tag title could have changed */
|
||||
this.reloadPanelTitle()
|
||||
}
|
||||
}),
|
||||
application.addEventObserver(async () => {
|
||||
this.reloadPreferences()
|
||||
}, ApplicationEvent.PreferencesChanged),
|
||||
application.addEventObserver(async () => {
|
||||
this.appState.closeAllNoteControllers()
|
||||
this.selectFirstNote()
|
||||
this.setCompletedFullSync(false)
|
||||
}, ApplicationEvent.SignedIn),
|
||||
application.addEventObserver(async () => {
|
||||
this.reloadNotes()
|
||||
if (
|
||||
this.notes.length === 0 &&
|
||||
this.appState.selectedTag instanceof SmartView &&
|
||||
this.appState.selectedTag.uuid === SystemViewId.AllNotes &&
|
||||
this.noteFilterText === '' &&
|
||||
!this.appState.notes.activeNoteController
|
||||
) {
|
||||
this.createPlaceholderNote()?.catch(console.error)
|
||||
}
|
||||
this.setCompletedFullSync(true)
|
||||
}, ApplicationEvent.CompletedFullSync),
|
||||
autorun(() => {
|
||||
if (appState.notes.selectedNotes) {
|
||||
this.syncSelectedNotes()
|
||||
}
|
||||
}),
|
||||
reaction(
|
||||
() => [
|
||||
appState.searchOptions.includeProtectedContents,
|
||||
appState.searchOptions.includeArchived,
|
||||
appState.searchOptions.includeTrashed,
|
||||
],
|
||||
() => {
|
||||
this.reloadNotesDisplayOptions()
|
||||
this.reloadNotes()
|
||||
},
|
||||
),
|
||||
appState.addObserver(async (eventName) => {
|
||||
if (eventName === AppStateEvent.TagChanged) {
|
||||
this.handleTagChange()
|
||||
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange().catch(console.error)
|
||||
} else if (eventName === AppStateEvent.EditorFocused) {
|
||||
this.setShowDisplayOptionsMenu(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
makeObservable(this, {
|
||||
completedFullSync: observable,
|
||||
displayOptions: observable.struct,
|
||||
noteFilterText: observable,
|
||||
notes: observable,
|
||||
notesToDisplay: observable,
|
||||
panelTitle: observable,
|
||||
renderedNotes: observable,
|
||||
selectedNotes: observable,
|
||||
showDisplayOptionsMenu: observable,
|
||||
|
||||
reloadNotes: action,
|
||||
reloadPanelTitle: action,
|
||||
reloadPreferences: action,
|
||||
resetPagination: action,
|
||||
setCompletedFullSync: action,
|
||||
setNoteFilterText: action,
|
||||
syncSelectedNotes: action,
|
||||
setShowDisplayOptionsMenu: action,
|
||||
onFilterEnter: action,
|
||||
handleFilterTextChanged: action,
|
||||
|
||||
optionsSubtitle: computed,
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
this.resetPagination(true)
|
||||
}
|
||||
}
|
||||
|
||||
setCompletedFullSync = (completed: boolean) => {
|
||||
this.completedFullSync = completed
|
||||
}
|
||||
|
||||
setShowDisplayOptionsMenu = (enabled: boolean) => {
|
||||
this.showDisplayOptionsMenu = enabled
|
||||
}
|
||||
|
||||
get searchBarElement() {
|
||||
return document.getElementById(ELEMENT_ID_SEARCH_BAR)
|
||||
}
|
||||
|
||||
get isFiltering(): boolean {
|
||||
return !!this.noteFilterText && this.noteFilterText.length > 0
|
||||
}
|
||||
|
||||
get activeEditorNote() {
|
||||
return this.appState.notes.activeNoteController?.note
|
||||
}
|
||||
|
||||
reloadPanelTitle = () => {
|
||||
let title = this.panelTitle
|
||||
if (this.isFiltering) {
|
||||
const resultCount = this.notes.length
|
||||
title = `${resultCount} search results`
|
||||
} else if (this.appState.selectedTag) {
|
||||
title = `${this.appState.selectedTag.title}`
|
||||
}
|
||||
this.panelTitle = title
|
||||
}
|
||||
|
||||
reloadNotes = () => {
|
||||
const tag = this.appState.selectedTag
|
||||
if (!tag) {
|
||||
return
|
||||
}
|
||||
const notes = this.application.items.getDisplayableNotes()
|
||||
const renderedNotes = notes.slice(0, this.notesToDisplay)
|
||||
|
||||
this.notes = notes
|
||||
this.renderedNotes = renderedNotes
|
||||
this.reloadPanelTitle()
|
||||
}
|
||||
|
||||
reloadNotesDisplayOptions = () => {
|
||||
const tag = this.appState.selectedTag
|
||||
|
||||
const searchText = this.noteFilterText.toLowerCase()
|
||||
const isSearching = searchText.length
|
||||
let includeArchived: boolean
|
||||
let includeTrashed: boolean
|
||||
|
||||
if (isSearching) {
|
||||
includeArchived = this.appState.searchOptions.includeArchived
|
||||
includeTrashed = this.appState.searchOptions.includeTrashed
|
||||
} else {
|
||||
includeArchived = this.displayOptions.showArchived ?? false
|
||||
includeTrashed = this.displayOptions.showTrashed ?? false
|
||||
}
|
||||
|
||||
const criteria = NotesDisplayCriteria.Create({
|
||||
sortProperty: this.displayOptions.sortBy,
|
||||
sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc',
|
||||
tags: tag instanceof SNTag ? [tag] : [],
|
||||
views: tag instanceof SmartView ? [tag] : [],
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
includePinned: !this.displayOptions.hidePinned,
|
||||
includeProtected: !this.displayOptions.hideProtected,
|
||||
searchQuery: {
|
||||
query: searchText,
|
||||
includeProtectedNoteText: this.appState.searchOptions.includeProtectedContents,
|
||||
},
|
||||
})
|
||||
this.application.items.setNotesDisplayCriteria(criteria)
|
||||
}
|
||||
|
||||
reloadPreferences = () => {
|
||||
const freshDisplayOptions = {} as DisplayOptions
|
||||
const currentSortBy = this.displayOptions.sortBy
|
||||
let sortBy = this.application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
|
||||
if (sortBy === CollectionSort.UpdatedAt || (sortBy as string) === 'client_updated_at') {
|
||||
/** Use UserUpdatedAt instead */
|
||||
sortBy = CollectionSort.UpdatedAt
|
||||
}
|
||||
freshDisplayOptions.sortBy = sortBy
|
||||
freshDisplayOptions.sortReverse = this.application.getPreference(
|
||||
PrefKey.SortNotesReverse,
|
||||
false,
|
||||
)
|
||||
freshDisplayOptions.showArchived = this.application.getPreference(
|
||||
PrefKey.NotesShowArchived,
|
||||
false,
|
||||
)
|
||||
freshDisplayOptions.showTrashed = this.application.getPreference(
|
||||
PrefKey.NotesShowTrashed,
|
||||
false,
|
||||
) as boolean
|
||||
freshDisplayOptions.hidePinned = this.application.getPreference(PrefKey.NotesHidePinned, false)
|
||||
freshDisplayOptions.hideProtected = this.application.getPreference(
|
||||
PrefKey.NotesHideProtected,
|
||||
false,
|
||||
)
|
||||
freshDisplayOptions.hideNotePreview = this.application.getPreference(
|
||||
PrefKey.NotesHideNotePreview,
|
||||
false,
|
||||
)
|
||||
freshDisplayOptions.hideDate = this.application.getPreference(PrefKey.NotesHideDate, false)
|
||||
freshDisplayOptions.hideTags = this.application.getPreference(PrefKey.NotesHideTags, true)
|
||||
freshDisplayOptions.hideEditorIcon = this.application.getPreference(
|
||||
PrefKey.NotesHideEditorIcon,
|
||||
false,
|
||||
)
|
||||
const displayOptionsChanged =
|
||||
freshDisplayOptions.sortBy !== this.displayOptions.sortBy ||
|
||||
freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse ||
|
||||
freshDisplayOptions.hidePinned !== this.displayOptions.hidePinned ||
|
||||
freshDisplayOptions.showArchived !== this.displayOptions.showArchived ||
|
||||
freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed ||
|
||||
freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected ||
|
||||
freshDisplayOptions.hideEditorIcon !== this.displayOptions.hideEditorIcon ||
|
||||
freshDisplayOptions.hideTags !== this.displayOptions.hideTags
|
||||
this.displayOptions = freshDisplayOptions
|
||||
if (displayOptionsChanged) {
|
||||
this.reloadNotesDisplayOptions()
|
||||
}
|
||||
|
||||
this.reloadNotes()
|
||||
|
||||
const width = this.application.getPreference(PrefKey.NotesPanelWidth)
|
||||
if (width) {
|
||||
this.panelWidth = width
|
||||
}
|
||||
|
||||
if (freshDisplayOptions.sortBy !== currentSortBy) {
|
||||
this.selectFirstNote()
|
||||
}
|
||||
}
|
||||
|
||||
createNewNote = async () => {
|
||||
this.appState.notes.unselectNotes()
|
||||
let title = `Note ${this.notes.length + 1}`
|
||||
if (this.isFiltering) {
|
||||
title = this.noteFilterText
|
||||
}
|
||||
|
||||
await this.appState.openNewNote(title)
|
||||
|
||||
this.reloadNotes()
|
||||
this.appState.noteTags.reloadTags()
|
||||
}
|
||||
|
||||
createPlaceholderNote = () => {
|
||||
const selectedTag = this.appState.selectedTag
|
||||
if (
|
||||
selectedTag &&
|
||||
selectedTag instanceof SmartView &&
|
||||
selectedTag.uuid !== SystemViewId.AllNotes
|
||||
) {
|
||||
return
|
||||
}
|
||||
return this.createNewNote()
|
||||
}
|
||||
|
||||
get optionsSubtitle(): string {
|
||||
let base = ''
|
||||
if (this.displayOptions.sortBy === CollectionSort.CreatedAt) {
|
||||
base += ' Date Added'
|
||||
} else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) {
|
||||
base += ' Date Modified'
|
||||
} else if (this.displayOptions.sortBy === CollectionSort.Title) {
|
||||
base += ' Title'
|
||||
}
|
||||
if (this.displayOptions.showArchived) {
|
||||
base += ' | + Archived'
|
||||
}
|
||||
if (this.displayOptions.showTrashed) {
|
||||
base += ' | + Trashed'
|
||||
}
|
||||
if (this.displayOptions.hidePinned) {
|
||||
base += ' | – Pinned'
|
||||
}
|
||||
if (this.displayOptions.hideProtected) {
|
||||
base += ' | – Protected'
|
||||
}
|
||||
if (this.displayOptions.sortReverse) {
|
||||
base += ' | Reversed'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
paginate = () => {
|
||||
this.notesToDisplay += this.pageSize
|
||||
this.reloadNotes()
|
||||
if (this.searchSubmitted) {
|
||||
this.application.getDesktopService().searchText(this.noteFilterText)
|
||||
}
|
||||
}
|
||||
|
||||
resetPagination = (keepCurrentIfLarger = false) => {
|
||||
const clientHeight = document.documentElement.clientHeight
|
||||
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT)
|
||||
if (this.pageSize === 0) {
|
||||
this.pageSize = DEFAULT_LIST_NUM_NOTES
|
||||
}
|
||||
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
|
||||
return
|
||||
}
|
||||
this.notesToDisplay = this.pageSize
|
||||
}
|
||||
|
||||
getFirstNonProtectedNote = () => {
|
||||
return this.notes.find((note) => !note.protected)
|
||||
}
|
||||
|
||||
get notesListScrollContainer() {
|
||||
return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER)
|
||||
}
|
||||
|
||||
selectNote = async (
|
||||
note: SNNote,
|
||||
userTriggered?: boolean,
|
||||
scrollIntoView = true,
|
||||
): Promise<void> => {
|
||||
await this.appState.notes.selectNote(note.uuid, userTriggered)
|
||||
if (scrollIntoView) {
|
||||
const noteElement = document.getElementById(`note-${note.uuid}`)
|
||||
noteElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
selectFirstNote = () => {
|
||||
const note = this.getFirstNonProtectedNote()
|
||||
if (note) {
|
||||
this.selectNote(note, false, false).catch(console.error)
|
||||
this.resetScrollPosition()
|
||||
}
|
||||
}
|
||||
|
||||
selectNextNote = () => {
|
||||
const displayableNotes = this.notes
|
||||
const currentIndex = displayableNotes.findIndex((candidate) => {
|
||||
return candidate.uuid === this.activeEditorNote?.uuid
|
||||
})
|
||||
if (currentIndex + 1 < displayableNotes.length) {
|
||||
const nextNote = displayableNotes[currentIndex + 1]
|
||||
this.selectNote(nextNote).catch(console.error)
|
||||
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`)
|
||||
nextNoteElement?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
selectNextOrCreateNew = () => {
|
||||
const note = this.getFirstNonProtectedNote()
|
||||
if (note) {
|
||||
this.selectNote(note, false, false).catch(console.error)
|
||||
} else {
|
||||
this.appState.closeActiveNoteController()
|
||||
}
|
||||
}
|
||||
|
||||
selectPreviousNote = () => {
|
||||
const displayableNotes = this.notes
|
||||
if (this.activeEditorNote) {
|
||||
const currentIndex = displayableNotes.indexOf(this.activeEditorNote)
|
||||
if (currentIndex - 1 >= 0) {
|
||||
const previousNote = displayableNotes[currentIndex - 1]
|
||||
this.selectNote(previousNote).catch(console.error)
|
||||
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`)
|
||||
previousNoteElement?.focus()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
setNoteFilterText = (text: string) => {
|
||||
this.noteFilterText = text
|
||||
}
|
||||
|
||||
syncSelectedNotes = () => {
|
||||
this.selectedNotes = this.appState.notes.selectedNotes
|
||||
}
|
||||
|
||||
handleEditorChange = async () => {
|
||||
const activeNote = this.appState.getActiveNoteController()?.note
|
||||
if (activeNote && activeNote.conflictOf) {
|
||||
this.application.mutator
|
||||
.changeAndSaveItem(activeNote, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
if (this.isFiltering) {
|
||||
this.application.getDesktopService().searchText(this.noteFilterText)
|
||||
}
|
||||
}
|
||||
|
||||
resetScrollPosition = () => {
|
||||
if (this.notesListScrollContainer) {
|
||||
this.notesListScrollContainer.scrollTop = 0
|
||||
this.notesListScrollContainer.scrollLeft = 0
|
||||
}
|
||||
}
|
||||
|
||||
handleTagChange = () => {
|
||||
this.resetScrollPosition()
|
||||
this.setShowDisplayOptionsMenu(false)
|
||||
this.setNoteFilterText('')
|
||||
this.application.getDesktopService().searchText()
|
||||
this.resetPagination()
|
||||
|
||||
/* Capture db load state before beginning reloadNotes,
|
||||
since this status may change during reload */
|
||||
const dbLoaded = this.application.isDatabaseLoaded()
|
||||
this.reloadNotesDisplayOptions()
|
||||
this.reloadNotes()
|
||||
|
||||
const hasSomeNotes = this.notes.length > 0
|
||||
|
||||
if (hasSomeNotes) {
|
||||
this.selectFirstNote()
|
||||
} else if (dbLoaded) {
|
||||
if (this.activeEditorNote && !this.notes.includes(this.activeEditorNote)) {
|
||||
this.appState.closeActiveNoteController()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFilterEnter = () => {
|
||||
/**
|
||||
* For Desktop, performing a search right away causes
|
||||
* input to lose focus. We wait until user explicity hits
|
||||
* enter before highlighting desktop search results.
|
||||
*/
|
||||
this.searchSubmitted = true
|
||||
this.application.getDesktopService().searchText(this.noteFilterText)
|
||||
}
|
||||
|
||||
handleFilterTextChanged = () => {
|
||||
if (this.searchSubmitted) {
|
||||
this.searchSubmitted = false
|
||||
}
|
||||
this.reloadNotesDisplayOptions()
|
||||
this.reloadNotes()
|
||||
}
|
||||
|
||||
clearFilterText = () => {
|
||||
this.setNoteFilterText('')
|
||||
this.onFilterEnter()
|
||||
this.handleFilterTextChanged()
|
||||
this.resetPagination()
|
||||
}
|
||||
}
|
||||
37
app/assets/javascripts/UIModels/AppState/PreferencesState.ts
Normal file
37
app/assets/javascripts/UIModels/AppState/PreferencesState.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PreferenceId } from '@/Components/Preferences/PreferencesMenu'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
|
||||
const DEFAULT_PANE = 'account'
|
||||
|
||||
export class PreferencesState {
|
||||
private _open = false
|
||||
currentPane: PreferenceId = DEFAULT_PANE
|
||||
|
||||
constructor() {
|
||||
makeObservable<PreferencesState, '_open'>(this, {
|
||||
_open: observable,
|
||||
currentPane: observable,
|
||||
openPreferences: action,
|
||||
closePreferences: action,
|
||||
setCurrentPane: action,
|
||||
isOpen: computed,
|
||||
})
|
||||
}
|
||||
|
||||
setCurrentPane = (prefId: PreferenceId): void => {
|
||||
this.currentPane = prefId
|
||||
}
|
||||
|
||||
openPreferences = (): void => {
|
||||
this._open = true
|
||||
}
|
||||
|
||||
closePreferences = (): void => {
|
||||
this._open = false
|
||||
this.currentPane = DEFAULT_PANE
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this._open
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
export enum PurchaseFlowPane {
|
||||
SignIn,
|
||||
CreateAccount,
|
||||
}
|
||||
|
||||
export class PurchaseFlowState {
|
||||
isOpen = false
|
||||
currentPane = PurchaseFlowPane.CreateAccount
|
||||
|
||||
constructor(private application: WebApplication) {
|
||||
makeObservable(this, {
|
||||
isOpen: observable,
|
||||
currentPane: observable,
|
||||
|
||||
setCurrentPane: action,
|
||||
openPurchaseFlow: action,
|
||||
closePurchaseFlow: action,
|
||||
})
|
||||
}
|
||||
|
||||
setCurrentPane = (currentPane: PurchaseFlowPane): void => {
|
||||
this.currentPane = currentPane
|
||||
}
|
||||
|
||||
openPurchaseFlow = (): void => {
|
||||
const user = this.application.getUser()
|
||||
if (!user) {
|
||||
this.isOpen = true
|
||||
} else {
|
||||
loadPurchaseFlowUrl(this.application).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
closePurchaseFlow = (): void => {
|
||||
this.isOpen = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
|
||||
export class QuickSettingsState {
|
||||
open = false
|
||||
shouldAnimateCloseMenu = false
|
||||
focusModeEnabled = false
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
open: observable,
|
||||
shouldAnimateCloseMenu: observable,
|
||||
focusModeEnabled: observable,
|
||||
|
||||
setOpen: action,
|
||||
setShouldAnimateCloseMenu: action,
|
||||
setFocusModeEnabled: action,
|
||||
toggle: action,
|
||||
closeQuickSettingsMenu: action,
|
||||
})
|
||||
}
|
||||
|
||||
setOpen = (open: boolean): void => {
|
||||
this.open = open
|
||||
}
|
||||
|
||||
setShouldAnimateCloseMenu = (shouldAnimate: boolean): void => {
|
||||
this.shouldAnimateCloseMenu = shouldAnimate
|
||||
}
|
||||
|
||||
setFocusModeEnabled = (enabled: boolean): void => {
|
||||
this.focusModeEnabled = enabled
|
||||
}
|
||||
|
||||
toggle = (): void => {
|
||||
if (this.open) {
|
||||
this.closeQuickSettingsMenu()
|
||||
} else {
|
||||
this.setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
closeQuickSettingsMenu = (): void => {
|
||||
this.setShouldAnimateCloseMenu(true)
|
||||
setTimeout(() => {
|
||||
this.setOpen(false)
|
||||
this.setShouldAnimateCloseMenu(false)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||
import { makeObservable, observable, action, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
export class SearchOptionsState {
|
||||
includeProtectedContents = false
|
||||
includeArchived = false
|
||||
includeTrashed = false
|
||||
|
||||
constructor(private application: WebApplication, appObservers: (() => void)[]) {
|
||||
makeObservable(this, {
|
||||
includeProtectedContents: observable,
|
||||
includeTrashed: observable,
|
||||
includeArchived: observable,
|
||||
|
||||
toggleIncludeArchived: action,
|
||||
toggleIncludeTrashed: action,
|
||||
toggleIncludeProtectedContents: action,
|
||||
refreshIncludeProtectedContents: action,
|
||||
})
|
||||
|
||||
appObservers.push(
|
||||
this.application.addEventObserver(async () => {
|
||||
this.refreshIncludeProtectedContents()
|
||||
}, ApplicationEvent.UnprotectedSessionBegan),
|
||||
this.application.addEventObserver(async () => {
|
||||
this.refreshIncludeProtectedContents()
|
||||
}, ApplicationEvent.UnprotectedSessionExpired),
|
||||
)
|
||||
}
|
||||
|
||||
toggleIncludeArchived = (): void => {
|
||||
this.includeArchived = !this.includeArchived
|
||||
}
|
||||
|
||||
toggleIncludeTrashed = (): void => {
|
||||
this.includeTrashed = !this.includeTrashed
|
||||
}
|
||||
|
||||
refreshIncludeProtectedContents = (): void => {
|
||||
this.includeProtectedContents = this.application.hasUnprotectedAccessSession()
|
||||
}
|
||||
|
||||
toggleIncludeProtectedContents = async (): Promise<void> => {
|
||||
if (this.includeProtectedContents) {
|
||||
this.includeProtectedContents = false
|
||||
} else {
|
||||
await this.application.authorizeSearchingProtectedNotesText()
|
||||
runInAction(() => {
|
||||
this.refreshIncludeProtectedContents()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/assets/javascripts/UIModels/AppState/SubscriptionState.ts
Normal file
119
app/assets/javascripts/UIModels/AppState/SubscriptionState.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ClientDisplayableError,
|
||||
convertTimestampToMilliseconds,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
|
||||
type Subscription = {
|
||||
planName: string
|
||||
cancelled: boolean
|
||||
endsAt: number
|
||||
}
|
||||
|
||||
type AvailableSubscriptions = {
|
||||
[key: string]: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionState {
|
||||
userSubscription: Subscription | undefined = undefined
|
||||
availableSubscriptions: AvailableSubscriptions | undefined = undefined
|
||||
|
||||
constructor(private application: WebApplication, appObservers: (() => void)[]) {
|
||||
makeObservable(this, {
|
||||
userSubscription: observable,
|
||||
availableSubscriptions: observable,
|
||||
|
||||
userSubscriptionName: computed,
|
||||
userSubscriptionExpirationDate: computed,
|
||||
isUserSubscriptionExpired: computed,
|
||||
isUserSubscriptionCanceled: computed,
|
||||
|
||||
setUserSubscription: action,
|
||||
setAvailableSubscriptions: action,
|
||||
})
|
||||
|
||||
appObservers.push(
|
||||
application.addEventObserver(async () => {
|
||||
if (application.hasAccount()) {
|
||||
this.getSubscriptionInfo().catch(console.error)
|
||||
}
|
||||
}, ApplicationEvent.Launched),
|
||||
application.addEventObserver(async () => {
|
||||
this.getSubscriptionInfo().catch(console.error)
|
||||
}, ApplicationEvent.SignedIn),
|
||||
application.addEventObserver(async () => {
|
||||
this.getSubscriptionInfo().catch(console.error)
|
||||
}, ApplicationEvent.UserRolesChanged),
|
||||
)
|
||||
}
|
||||
|
||||
get userSubscriptionName(): string {
|
||||
if (
|
||||
this.availableSubscriptions &&
|
||||
this.userSubscription &&
|
||||
this.availableSubscriptions[this.userSubscription.planName]
|
||||
) {
|
||||
return this.availableSubscriptions[this.userSubscription.planName].name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
get userSubscriptionExpirationDate(): Date | undefined {
|
||||
if (!this.userSubscription) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Date(convertTimestampToMilliseconds(this.userSubscription.endsAt))
|
||||
}
|
||||
|
||||
get isUserSubscriptionExpired(): boolean {
|
||||
if (!this.userSubscriptionExpirationDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.userSubscriptionExpirationDate.getTime() < new Date().getTime()
|
||||
}
|
||||
|
||||
get isUserSubscriptionCanceled(): boolean {
|
||||
return Boolean(this.userSubscription?.cancelled)
|
||||
}
|
||||
|
||||
public setUserSubscription(subscription: Subscription): void {
|
||||
this.userSubscription = subscription
|
||||
}
|
||||
|
||||
public setAvailableSubscriptions(subscriptions: AvailableSubscriptions): void {
|
||||
this.availableSubscriptions = subscriptions
|
||||
}
|
||||
|
||||
private async getAvailableSubscriptions() {
|
||||
try {
|
||||
const subscriptions = await this.application.getAvailableSubscriptions()
|
||||
if (!(subscriptions instanceof ClientDisplayableError)) {
|
||||
this.setAvailableSubscriptions(subscriptions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async getSubscription() {
|
||||
try {
|
||||
const subscription = await this.application.getUserSubscription()
|
||||
if (!(subscription instanceof ClientDisplayableError)) {
|
||||
this.setUserSubscription(subscription)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async getSubscriptionInfo() {
|
||||
await this.getSubscription()
|
||||
await this.getAvailableSubscriptions()
|
||||
}
|
||||
}
|
||||
33
app/assets/javascripts/UIModels/AppState/SyncState.ts
Normal file
33
app/assets/javascripts/UIModels/AppState/SyncState.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SyncOpStatus } from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
530
app/assets/javascripts/UIModels/AppState/TagsState.ts
Normal file
530
app/assets/javascripts/UIModels/AppState/TagsState.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { confirmDialog } from '@/Services/AlertService'
|
||||
import { STRING_DELETE_TAG } from '@/Strings'
|
||||
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import {
|
||||
ComponentAction,
|
||||
ContentType,
|
||||
MessageData,
|
||||
SNApplication,
|
||||
SmartView,
|
||||
SNTag,
|
||||
TagMutator,
|
||||
UuidString,
|
||||
isSystemView,
|
||||
FindItem,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../Application'
|
||||
import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './FeaturesState'
|
||||
|
||||
type AnyTag = SNTag | SmartView
|
||||
|
||||
const rootTags = (application: SNApplication): SNTag[] => {
|
||||
const hasNoParent = (tag: SNTag) => !application.items.getTagParent(tag)
|
||||
|
||||
const allTags = application.items.getDisplayableItems<SNTag>(ContentType.Tag)
|
||||
const rootTags = allTags.filter(hasNoParent)
|
||||
|
||||
return rootTags
|
||||
}
|
||||
|
||||
const tagSiblings = (application: SNApplication, tag: SNTag): SNTag[] => {
|
||||
const withoutCurrentTag = (tags: SNTag[]) => tags.filter((other) => other.uuid !== tag.uuid)
|
||||
|
||||
const isTemplateTag = application.items.isTemplateItem(tag)
|
||||
const parentTag = !isTemplateTag && application.items.getTagParent(tag)
|
||||
|
||||
if (parentTag) {
|
||||
const siblingsAndTag = application.items.getTagChildren(parentTag)
|
||||
return withoutCurrentTag(siblingsAndTag)
|
||||
}
|
||||
|
||||
return withoutCurrentTag(rootTags(application))
|
||||
}
|
||||
|
||||
const isValidFutureSiblings = (
|
||||
application: SNApplication,
|
||||
futureSiblings: SNTag[],
|
||||
tag: SNTag,
|
||||
): boolean => {
|
||||
const siblingWithSameName = futureSiblings.find((otherTag) => otherTag.title === tag.title)
|
||||
|
||||
if (siblingWithSameName) {
|
||||
application.alertService
|
||||
?.alert(
|
||||
`A tag with the name ${tag.title} already exists at this destination. Please rename this tag before moving and try again.`,
|
||||
)
|
||||
.catch(console.error)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export class TagsState {
|
||||
tags: SNTag[] = []
|
||||
smartViews: SmartView[] = []
|
||||
allNotesCount_ = 0
|
||||
selected_: AnyTag | undefined
|
||||
previouslySelected_: AnyTag | undefined
|
||||
editing_: SNTag | SmartView | undefined
|
||||
addingSubtagTo: SNTag | undefined
|
||||
|
||||
contextMenuOpen = false
|
||||
contextMenuPosition: { top?: number; left: number; bottom?: number } = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
}
|
||||
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
|
||||
contextMenuMaxHeight: number | 'auto' = 'auto'
|
||||
|
||||
private readonly tagsCountsState: TagsCountsState
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
appEventListeners: (() => void)[],
|
||||
private features: FeaturesState,
|
||||
) {
|
||||
this.tagsCountsState = new TagsCountsState(this.application)
|
||||
|
||||
this.selected_ = undefined
|
||||
this.previouslySelected_ = undefined
|
||||
this.editing_ = undefined
|
||||
this.addingSubtagTo = undefined
|
||||
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
this.selected_ = this.smartViews[0]
|
||||
|
||||
makeObservable(this, {
|
||||
tags: observable.ref,
|
||||
smartViews: observable.ref,
|
||||
hasAtLeastOneFolder: computed,
|
||||
allNotesCount_: observable,
|
||||
allNotesCount: computed,
|
||||
setAllNotesCount: action,
|
||||
|
||||
selected_: observable.ref,
|
||||
previouslySelected_: observable.ref,
|
||||
previouslySelected: computed,
|
||||
editing_: observable.ref,
|
||||
selected: computed,
|
||||
selectedUuid: computed,
|
||||
editingTag: computed,
|
||||
|
||||
addingSubtagTo: observable,
|
||||
setAddingSubtagTo: action,
|
||||
|
||||
assignParent: action,
|
||||
|
||||
rootTags: computed,
|
||||
tagsCount: computed,
|
||||
|
||||
createNewTemplate: action,
|
||||
undoCreateNewTag: action,
|
||||
save: action,
|
||||
remove: action,
|
||||
|
||||
contextMenuOpen: observable,
|
||||
contextMenuPosition: observable,
|
||||
contextMenuMaxHeight: observable,
|
||||
contextMenuClickLocation: observable,
|
||||
setContextMenuOpen: action,
|
||||
setContextMenuClickLocation: action,
|
||||
setContextMenuPosition: action,
|
||||
setContextMenuMaxHeight: action,
|
||||
})
|
||||
|
||||
appEventListeners.push(
|
||||
this.application.streamItems(
|
||||
[ContentType.Tag, ContentType.SmartView],
|
||||
({ changed, removed }) => {
|
||||
runInAction(() => {
|
||||
this.tags = this.application.items.getDisplayableItems<SNTag>(ContentType.Tag)
|
||||
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
|
||||
const selectedTag = this.selected_
|
||||
|
||||
if (selectedTag && !isSystemView(selectedTag as SmartView)) {
|
||||
if (FindItem(removed, selectedTag.uuid)) {
|
||||
this.selected_ = this.smartViews[0]
|
||||
}
|
||||
|
||||
const updated = FindItem(changed, selectedTag.uuid)
|
||||
if (updated) {
|
||||
this.selected_ = updated as AnyTag
|
||||
}
|
||||
} else {
|
||||
this.selected_ = this.smartViews[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
appEventListeners.push(
|
||||
this.application.items.addNoteCountChangeObserver((tagUuid) => {
|
||||
if (!tagUuid) {
|
||||
this.setAllNotesCount(this.application.items.allCountableNotesCount())
|
||||
} else {
|
||||
this.tagsCountsState.update([this.application.items.findItem(tagUuid) as SNTag])
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async createSubtagAndAssignParent(parent: SNTag, title: string) {
|
||||
const hasEmptyTitle = title.length === 0
|
||||
|
||||
if (hasEmptyTitle) {
|
||||
this.setAddingSubtagTo(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const createdTag = (await this.application.mutator.createTagOrSmartView(title)) as SNTag
|
||||
|
||||
const futureSiblings = this.application.items.getTagChildren(parent)
|
||||
|
||||
if (!isValidFutureSiblings(this.application, futureSiblings, createdTag)) {
|
||||
this.setAddingSubtagTo(undefined)
|
||||
this.remove(createdTag, false).catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.assignParent(createdTag.uuid, parent.uuid).catch(console.error)
|
||||
|
||||
this.application.sync.sync().catch(console.error)
|
||||
|
||||
runInAction(() => {
|
||||
this.selected = createdTag as SNTag
|
||||
})
|
||||
|
||||
this.setAddingSubtagTo(undefined)
|
||||
}
|
||||
|
||||
setAddingSubtagTo(tag: SNTag | undefined): void {
|
||||
this.addingSubtagTo = tag
|
||||
}
|
||||
|
||||
setContextMenuOpen(open: boolean): void {
|
||||
this.contextMenuOpen = open
|
||||
}
|
||||
|
||||
setContextMenuClickLocation(location: { x: number; y: number }): void {
|
||||
this.contextMenuClickLocation = location
|
||||
}
|
||||
|
||||
setContextMenuPosition(position: { top?: number; left: number; bottom?: number }): void {
|
||||
this.contextMenuPosition = position
|
||||
}
|
||||
|
||||
setContextMenuMaxHeight(maxHeight: number | 'auto'): void {
|
||||
this.contextMenuMaxHeight = maxHeight
|
||||
}
|
||||
|
||||
reloadContextMenuLayout(): void {
|
||||
const { clientHeight } = document.documentElement
|
||||
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
|
||||
const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
// Open up-bottom is default behavior
|
||||
let openUpBottom = true
|
||||
|
||||
if (footerHeightInPx) {
|
||||
const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y
|
||||
const upSpace = this.contextMenuClickLocation.y
|
||||
|
||||
// If not enough space to open up-bottom
|
||||
if (maxContextMenuHeight > bottomSpace) {
|
||||
// If there's enough space, open bottom-up
|
||||
if (upSpace > maxContextMenuHeight) {
|
||||
openUpBottom = false
|
||||
this.setContextMenuMaxHeight('auto')
|
||||
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
|
||||
} else {
|
||||
if (upSpace > bottomSpace) {
|
||||
this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||
openUpBottom = false
|
||||
} else {
|
||||
this.setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.setContextMenuMaxHeight('auto')
|
||||
}
|
||||
}
|
||||
|
||||
if (openUpBottom) {
|
||||
this.setContextMenuPosition({
|
||||
top: this.contextMenuClickLocation.y,
|
||||
left: this.contextMenuClickLocation.x,
|
||||
})
|
||||
} else {
|
||||
this.setContextMenuPosition({
|
||||
bottom: clientHeight - this.contextMenuClickLocation.y,
|
||||
left: this.contextMenuClickLocation.x,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public get allLocalRootTags(): SNTag[] {
|
||||
if (this.editing_ instanceof SNTag && this.application.items.isTemplateItem(this.editing_)) {
|
||||
return [this.editing_, ...this.rootTags]
|
||||
}
|
||||
return this.rootTags
|
||||
}
|
||||
|
||||
public getNotesCount(tag: SNTag): number {
|
||||
return this.tagsCountsState.counts[tag.uuid] || 0
|
||||
}
|
||||
|
||||
getChildren(tag: SNTag): SNTag[] {
|
||||
if (this.application.items.isTemplateItem(tag)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const children = this.application.items.getTagChildren(tag)
|
||||
|
||||
const childrenUuids = children.map((childTag) => childTag.uuid)
|
||||
const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid))
|
||||
return childrenTags
|
||||
}
|
||||
|
||||
isValidTagParent(parent: SNTag, tag: SNTag): boolean {
|
||||
return this.application.items.isValidTagParent(parent, tag)
|
||||
}
|
||||
|
||||
public hasParent(tagUuid: UuidString): boolean {
|
||||
const item = this.application.items.findItem(tagUuid)
|
||||
return !!item && !!(item as SNTag).parentId
|
||||
}
|
||||
|
||||
public async assignParent(tagUuid: string, futureParentUuid: string | undefined): Promise<void> {
|
||||
const tag = this.application.items.findItem(tagUuid) as SNTag
|
||||
|
||||
const currentParent = this.application.items.getTagParent(tag)
|
||||
const currentParentUuid = currentParent?.uuid
|
||||
|
||||
if (currentParentUuid === futureParentUuid) {
|
||||
return
|
||||
}
|
||||
|
||||
const futureParent =
|
||||
futureParentUuid && (this.application.items.findItem(futureParentUuid) as SNTag)
|
||||
|
||||
if (!futureParent) {
|
||||
const futureSiblings = rootTags(this.application)
|
||||
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
|
||||
return
|
||||
}
|
||||
await this.application.mutator.unsetTagParent(tag)
|
||||
} else {
|
||||
const futureSiblings = this.application.items.getTagChildren(futureParent)
|
||||
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
|
||||
return
|
||||
}
|
||||
await this.application.mutator.setTagParent(futureParent, tag)
|
||||
}
|
||||
|
||||
await this.application.sync.sync()
|
||||
}
|
||||
|
||||
get rootTags(): SNTag[] {
|
||||
return this.tags.filter((tag) => !this.application.items.getTagParent(tag))
|
||||
}
|
||||
|
||||
get tagsCount(): number {
|
||||
return this.tags.length
|
||||
}
|
||||
|
||||
setAllNotesCount(allNotesCount: number) {
|
||||
this.allNotesCount_ = allNotesCount
|
||||
}
|
||||
|
||||
public get allNotesCount(): number {
|
||||
return this.allNotesCount_
|
||||
}
|
||||
|
||||
public get previouslySelected(): AnyTag | undefined {
|
||||
return this.previouslySelected_
|
||||
}
|
||||
|
||||
public get selected(): AnyTag | undefined {
|
||||
return this.selected_
|
||||
}
|
||||
|
||||
public set selected(tag: AnyTag | undefined) {
|
||||
if (tag && tag.conflictOf) {
|
||||
this.application.mutator
|
||||
.changeAndSaveItem(tag, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid
|
||||
|
||||
if (selectionHasNotChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
this.previouslySelected_ = this.selected_
|
||||
this.selected_ = tag
|
||||
}
|
||||
|
||||
public setExpanded(tag: SNTag, expanded: boolean) {
|
||||
this.application.mutator
|
||||
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.expanded = expanded
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
public get selectedUuid(): UuidString | undefined {
|
||||
return this.selected_?.uuid
|
||||
}
|
||||
|
||||
public get editingTag(): SNTag | SmartView | undefined {
|
||||
return this.editing_
|
||||
}
|
||||
|
||||
public set editingTag(editingTag: SNTag | SmartView | undefined) {
|
||||
this.editing_ = editingTag
|
||||
this.selected = editingTag
|
||||
}
|
||||
|
||||
public async createNewTemplate() {
|
||||
const isAlreadyEditingATemplate =
|
||||
this.editing_ && this.application.items.isTemplateItem(this.editing_)
|
||||
|
||||
if (isAlreadyEditingATemplate) {
|
||||
return
|
||||
}
|
||||
|
||||
const newTag = (await this.application.mutator.createTemplateItem(ContentType.Tag)) as SNTag
|
||||
|
||||
runInAction(() => {
|
||||
this.editing_ = newTag
|
||||
})
|
||||
}
|
||||
|
||||
public undoCreateNewTag() {
|
||||
this.editing_ = undefined
|
||||
const previousTag = this.previouslySelected_ || this.smartViews[0]
|
||||
this.selected = previousTag
|
||||
}
|
||||
|
||||
public async remove(tag: SNTag | SmartView, userTriggered: boolean) {
|
||||
let shouldDelete = !userTriggered
|
||||
if (userTriggered) {
|
||||
shouldDelete = await confirmDialog({
|
||||
text: STRING_DELETE_TAG,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
}
|
||||
if (shouldDelete) {
|
||||
this.application.mutator.deleteItem(tag).catch(console.error)
|
||||
this.selected = this.smartViews[0]
|
||||
}
|
||||
}
|
||||
|
||||
public async save(tag: SNTag | SmartView, newTitle: string) {
|
||||
const hasEmptyTitle = newTitle.length === 0
|
||||
const hasNotChangedTitle = newTitle === tag.title
|
||||
const isTemplateChange = this.application.items.isTemplateItem(tag)
|
||||
|
||||
const siblings = tag instanceof SNTag ? tagSiblings(this.application, tag) : []
|
||||
const hasDuplicatedTitle = siblings.some(
|
||||
(other) => other.title.toLowerCase() === newTitle.toLowerCase(),
|
||||
)
|
||||
|
||||
runInAction(() => {
|
||||
this.editing_ = undefined
|
||||
})
|
||||
|
||||
if (hasEmptyTitle || hasNotChangedTitle) {
|
||||
if (isTemplateChange) {
|
||||
this.undoCreateNewTag()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (hasDuplicatedTitle) {
|
||||
if (isTemplateChange) {
|
||||
this.undoCreateNewTag()
|
||||
}
|
||||
this.application.alertService
|
||||
?.alert('A tag with this name already exists.')
|
||||
.catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateChange) {
|
||||
const isSmartViewTitle = this.application.items.isSmartViewTitle(newTitle)
|
||||
|
||||
if (isSmartViewTitle) {
|
||||
if (!this.features.hasSmartViews) {
|
||||
await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const insertedTag = await this.application.mutator.createTagOrSmartView(newTitle)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
runInAction(() => {
|
||||
this.selected = insertedTag as SNTag
|
||||
})
|
||||
} else {
|
||||
await this.application.mutator.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.title = newTitle
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public onFoldersComponentMessage(action: ComponentAction, data: MessageData): void {
|
||||
if (action === ComponentAction.SelectItem) {
|
||||
const item = data.item
|
||||
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.content_type === ContentType.Tag || item.content_type === ContentType.SmartView) {
|
||||
const matchingTag = this.application.items.findItem(item.uuid)
|
||||
|
||||
if (matchingTag) {
|
||||
this.selected = matchingTag as AnyTag
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (action === ComponentAction.ClearSelection) {
|
||||
this.selected = this.smartViews[0]
|
||||
}
|
||||
}
|
||||
|
||||
public get hasAtLeastOneFolder(): boolean {
|
||||
return this.tags.some((tag) => !!this.application.items.getTagParent(tag))
|
||||
}
|
||||
}
|
||||
|
||||
class TagsCountsState {
|
||||
public counts: { [uuid: string]: number } = {}
|
||||
|
||||
public constructor(private application: WebApplication) {
|
||||
makeAutoObservable(this, {
|
||||
counts: observable.ref,
|
||||
update: action,
|
||||
})
|
||||
}
|
||||
|
||||
public update(tags: SNTag[]) {
|
||||
const newCounts: { [uuid: string]: number } = Object.assign({}, this.counts)
|
||||
|
||||
tags.forEach((tag) => {
|
||||
newCounts[tag.uuid] = this.application.items.countableNotesForTag(tag)
|
||||
})
|
||||
|
||||
this.counts = newCounts
|
||||
}
|
||||
}
|
||||
1
app/assets/javascripts/UIModels/AppState/index.ts
Normal file
1
app/assets/javascripts/UIModels/AppState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppState, AppStateEvent, EventSource, PanelResizedData } from './AppState'
|
||||
Reference in New Issue
Block a user