refactor: repo (#1070)

This commit is contained in:
Mo
2022-06-07 07:18:41 -05:00
committed by GitHub
parent 4c65784421
commit f4ef63693c
1102 changed files with 5786 additions and 3366 deletions

View File

@@ -0,0 +1,27 @@
import { CrossControllerEvent } from '../CrossControllerEvent'
import { InternalEventBus, InternalEventPublishStrategy } from '@standardnotes/snjs'
import { WebApplication } from '../../Application/Application'
import { Disposer } from '@/Types/Disposer'
export abstract class AbstractViewController {
dealloced = false
protected disposers: Disposer[] = []
constructor(public application: WebApplication, protected eventBus: InternalEventBus) {}
protected async publishEventSync(name: CrossControllerEvent): Promise<void> {
await this.eventBus.publishSync({ type: name, payload: undefined }, InternalEventPublishStrategy.SEQUENCE)
}
deinit(): void {
this.dealloced = true
;(this.application as unknown) = undefined
;(this.eventBus as unknown) = undefined
for (const disposer of this.disposers) {
disposer()
}
;(this.disposers as unknown) = undefined
}
}

View File

@@ -0,0 +1,3 @@
export function isControllerDealloced(controller: { dealloced: boolean }): boolean {
return controller.dealloced == undefined || controller.dealloced === true
}

View File

@@ -0,0 +1,181 @@
import { destroyAllObjectProperties, isDev } from '@/Utils'
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import { ApplicationEvent, ContentType, InternalEventBus, SNNote, SNTag } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { StructuredItemsCount } from './StructuredItemsCount'
export class AccountMenuController extends AbstractViewController {
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
override deinit() {
super.deinit()
;(this.notesAndTags as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
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.disposers.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),
)
this.disposers.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
}
}

View File

@@ -0,0 +1,6 @@
export type StructuredItemsCount = {
notes: number
tags: number
deleted: number
archived: number
}

View File

@@ -0,0 +1,22 @@
import { UuidString } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class ActionsMenuController {
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 = {}
}
}

View File

@@ -0,0 +1,4 @@
export enum CrossControllerEvent {
TagChanged = 'TagChanged',
ActiveEditorChanged = 'ActiveEditorChanged',
}

View File

@@ -0,0 +1,87 @@
import { WebApplication } from '@/Application/Application'
import { destroyAllObjectProperties } from '@/Utils'
import { ApplicationEvent, FeatureIdentifier, FeatureStatus, InternalEventBus } from '@standardnotes/snjs'
import { action, makeObservable, observable, runInAction, when } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class FeaturesController extends AbstractViewController {
hasFolders: boolean
hasSmartViews: boolean
hasFiles: boolean
premiumAlertFeatureName: string | undefined
override deinit() {
super.deinit()
;(this.showPremiumAlert as unknown) = undefined
;(this.closePremiumAlert as unknown) = undefined
;(this.hasFolders as unknown) = undefined
;(this.hasSmartViews as unknown) = undefined
;(this.hasFiles as unknown) = undefined
;(this.premiumAlertFeatureName as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.hasFiles = this.isEntitledToFiles()
this.premiumAlertFeatureName = undefined
makeObservable(this, {
hasFolders: observable,
hasSmartViews: observable,
hasFiles: observable,
premiumAlertFeatureName: observable,
showPremiumAlert: action,
closePremiumAlert: action,
})
this.showPremiumAlert = this.showPremiumAlert.bind(this)
this.closePremiumAlert = this.closePremiumAlert.bind(this)
this.disposers.push(
application.addEventObserver(async (event) => {
switch (event) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.hasFiles = this.isEntitledToFiles()
})
}
}),
)
}
public async showPremiumAlert(featureName: string): Promise<void> {
this.premiumAlertFeatureName = featureName
return when(() => this.premiumAlertFeatureName === undefined)
}
public closePremiumAlert() {
this.premiumAlertFeatureName = undefined
}
private isEntitledToFiles(): boolean {
const status = this.application.features.getFeatureStatus(FeatureIdentifier.Files)
return status === FeatureStatus.Entitled
}
private isEntitledToFolders(): boolean {
const status = this.application.features.getFeatureStatus(FeatureIdentifier.TagNesting)
return status === FeatureStatus.Entitled
}
private isEntitledToSmartViews(): boolean {
const status = this.application.features.getFeatureStatus(FeatureIdentifier.SmartFilters)
return status === FeatureStatus.Entitled
}
}

View File

@@ -0,0 +1,34 @@
import { FileItem } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class FilePreviewModalController {
isOpen = false
currentFile: FileItem | undefined = undefined
otherFiles: FileItem[] = []
constructor() {
makeObservable(this, {
isOpen: observable,
currentFile: observable,
otherFiles: observable,
activate: action,
dismiss: action,
setCurrentFile: action,
})
}
setCurrentFile = (currentFile: FileItem) => {
this.currentFile = currentFile
}
activate = (currentFile: FileItem, otherFiles: FileItem[]) => {
this.currentFile = currentFile
this.otherFiles = otherFiles
this.isOpen = true
}
dismiss = () => {
this.isOpen = false
}
}

View File

@@ -0,0 +1,430 @@
import { FilePreviewModalController } from './FilePreviewModalController'
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
import { confirmDialog } from '@/Services/AlertService'
import { Strings, StringUtils } from '@/Constants/Strings'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import {
ClassicFileReader,
StreamingFileReader,
StreamingFileSaver,
ClassicFileSaver,
parseFileName,
} from '@standardnotes/filepicker'
import { ChallengeReason, ClientDisplayableError, ContentType, FileItem, InternalEventBus } from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType, updateToast } from '@standardnotes/stylekit'
import { action, makeObservable, observable, reaction } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { NotesController } from './NotesController'
const UnprotectedFileActions = [PopoverFileItemActionType.ToggleFileProtection]
const NonMutatingFileActions = [PopoverFileItemActionType.DownloadFile, PopoverFileItemActionType.PreviewFile]
type FileContextMenuLocation = { x: number; y: number }
export class FilesController extends AbstractViewController {
allFiles: FileItem[] = []
attachedFiles: FileItem[] = []
showFileContextMenu = false
showProtectedOverlay = false
fileContextMenuLocation: FileContextMenuLocation = { x: 0, y: 0 }
override deinit(): void {
super.deinit()
;(this.notesController as unknown) = undefined
;(this.filePreviewModalController as unknown) = undefined
}
constructor(
application: WebApplication,
private notesController: NotesController,
private filePreviewModalController: FilePreviewModalController,
eventBus: InternalEventBus,
) {
super(application, eventBus)
makeObservable(this, {
allFiles: observable,
attachedFiles: observable,
showFileContextMenu: observable,
fileContextMenuLocation: observable,
showProtectedOverlay: observable,
reloadAllFiles: action,
reloadAttachedFiles: action,
setShowFileContextMenu: action,
setShowProtectedOverlay: action,
setFileContextMenuLocation: action,
})
this.disposers.push(
application.streamItems(ContentType.File, () => {
this.reloadAllFiles()
this.reloadAttachedFiles()
}),
)
this.disposers.push(
reaction(
() => notesController.selectedNotes,
() => {
this.reloadAttachedFiles()
},
),
)
}
setShowFileContextMenu = (enabled: boolean) => {
this.showFileContextMenu = enabled
}
setShowProtectedOverlay = (enabled: boolean) => {
this.showProtectedOverlay = enabled
}
setFileContextMenuLocation = (location: FileContextMenuLocation) => {
this.fileContextMenuLocation = location
}
reloadAllFiles = () => {
this.allFiles = this.application.items.getDisplayableFiles()
}
reloadAttachedFiles = () => {
const note = this.notesController.firstSelectedNote
if (note) {
this.attachedFiles = this.application.items.getFilesForNote(note)
}
}
deleteFile = async (file: FileItem) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
const deletingToastId = addToast({
type: ToastType.Loading,
message: `Deleting file "${file.name}"...`,
})
await this.application.files.deleteFile(file)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
})
dismissToast(deletingToastId)
}
}
attachFileToNote = async (file: FileItem) => {
const note = this.notesController.firstSelectedNote
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await this.application.items.associateFileWithNote(file, note)
}
detachFileFromNote = async (file: FileItem) => {
const note = this.notesController.firstSelectedNote
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await this.application.items.disassociateFileWithNote(file, note)
}
toggleFileProtection = async (file: FileItem) => {
let result: FileItem | undefined
if (file.protected) {
result = await this.application.mutator.unprotectFile(file)
} else {
result = await this.application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
}
authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await this.application.protections.authorizeProtectedActionForItems([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
renameFile = async (file: FileItem, fileName: string) => {
await this.application.items.renameFile(file, fileName)
}
handleFileAction = async (
action: PopoverFileItemAction,
): Promise<{
didHandleAction: boolean
}> => {
const file = action.payload.file
let isAuthorizedForAction = true
const requiresAuthorization = file.protected && !UnprotectedFileActions.includes(action.type)
if (requiresAuthorization) {
isAuthorizedForAction = await this.authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
}
if (!isAuthorizedForAction) {
return {
didHandleAction: false,
}
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await this.attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await this.detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await this.deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await this.downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await this.toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await this.renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile:
this.filePreviewModalController.activate(file, action.payload.otherFiles)
break
}
if (!NonMutatingFileActions.includes(action.type)) {
this.application.sync.sync().catch(console.error)
}
return {
didHandleAction: true,
}
}
public async downloadFile(file: FileItem): 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.Progress,
message: `Downloading file "${file.name}" (0%)`,
progress: 0,
})
const decryptedBytesArray: Uint8Array[] = []
const result = await this.application.files.downloadFile(file, async (decryptedBytes, progress) => {
if (isUsingStreamingSaver) {
await saver.pushBytes(decryptedBytes)
} else {
decryptedBytesArray.push(decryptedBytes)
}
if (progress) {
const progressPercent = Math.floor(progress.percentComplete)
updateToast(downloadingToastId, {
message: `Downloading file "${file.name}" (${progressPercent}%)`,
progress: progressPercent,
})
}
})
if (result instanceof ClientDisplayableError) {
throw new Error(result.text)
}
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: FileItem[] = []
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
}
const operation = await this.application.files.beginNewFileUpload(file.size)
if (operation instanceof ClientDisplayableError) {
addToast({
type: ToastType.Error,
message: 'Unable to start upload session',
})
throw new Error('Unable to start upload session')
}
const initialProgress = operation.getProgress().percentComplete
toastId = addToast({
type: ToastType.Progress,
message: `Uploading file "${file.name}" (${initialProgress}%)`,
progress: initialProgress,
})
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
const progress = Math.round(operation.getProgress().percentComplete)
updateToast(toastId, {
message: `Uploading file "${file.name}" (${progress}%)`,
progress,
})
}
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
}
deleteFilesPermanently = async (files: FileItem[]) => {
const title = Strings.trashItemsTitle
const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles
if (
await confirmDialog({
title,
text,
confirmButtonStyle: 'danger',
})
) {
await Promise.all(files.map((file) => this.application.mutator.deleteItem(file)))
}
}
setProtectionForFiles = async (protect: boolean, files: FileItem[]) => {
if (protect) {
const protectedItems = await this.application.mutator.protectItems(files)
if (protectedItems) {
this.setShowProtectedOverlay(true)
}
} else {
const unprotectedItems = await this.application.mutator.unprotectItems(files, ChallengeReason.UnprotectFile)
if (unprotectedItems) {
this.setShowProtectedOverlay(false)
}
}
}
downloadFiles = async (files: FileItem[]) => {
await Promise.all(files.map((file) => this.downloadFile(file)))
}
}

View File

@@ -0,0 +1,708 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import { destroyAllObjectProperties } from '@/Utils'
import {
ApplicationEvent,
CollectionSort,
ContentType,
findInArray,
NoteViewController,
PrefKey,
SmartView,
SNNote,
SNTag,
SystemViewId,
DisplayOptions,
InternalEventBus,
InternalEventHandlerInterface,
InternalEventInterface,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { WebDisplayOptions } from './WebDisplayOptions'
import { NavigationController } from '../Navigation/NavigationController'
import { CrossControllerEvent } from '../CrossControllerEvent'
import { SearchOptionsController } from '../SearchOptionsController'
import { SelectedItemsController } from '../SelectedItemsController'
import { NotesController } from '../NotesController'
import { NoteTagsController } from '../NoteTagsController'
import { WebAppEvent } from '@/Application/WebAppEvent'
const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20
const ElementIdSearchBar = 'search-bar'
const ElementIdScrollContainer = 'notes-scrollable'
const SupportsFileSelectionState = false
export class ItemListController extends AbstractViewController implements InternalEventHandlerInterface {
completedFullSync = false
noteFilterText = ''
notes: SNNote[] = []
items: ListableContentItem[] = []
notesToDisplay = 0
pageSize = 0
panelTitle = 'All Notes'
panelWidth = 0
renderedItems: ListableContentItem[] = []
searchSubmitted = false
showDisplayOptionsMenu = false
displayOptions: DisplayOptions = {
sortBy: CollectionSort.CreatedAt,
sortDirection: 'dsc',
includePinned: true,
includeArchived: false,
includeTrashed: false,
includeProtected: true,
}
webDisplayOptions: WebDisplayOptions = {
hideTags: true,
hideDate: false,
hideNotePreview: false,
hideEditorIcon: false,
}
private reloadItemsPromise?: Promise<unknown>
override deinit() {
super.deinit()
;(this.noteFilterText as unknown) = undefined
;(this.notes as unknown) = undefined
;(this.renderedItems as unknown) = undefined
;(this.navigationController as unknown) = undefined
;(this.searchOptionsController as unknown) = undefined
;(this.selectionController as unknown) = undefined
;(this.notesController as unknown) = undefined
;(this.noteTagsController as unknown) = undefined
;(window.onresize as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(
application: WebApplication,
private navigationController: NavigationController,
private searchOptionsController: SearchOptionsController,
private selectionController: SelectedItemsController,
private notesController: NotesController,
private noteTagsController: NoteTagsController,
eventBus: InternalEventBus,
) {
super(application, eventBus)
eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
eventBus.addEventHandler(this, CrossControllerEvent.ActiveEditorChanged)
this.resetPagination()
this.disposers.push(
application.streamItems<SNNote>(ContentType.Note, () => {
void this.reloadItems()
}),
)
this.disposers.push(
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()
void this.reloadItems()
if (this.navigationController.selected && findInArray(tags, 'uuid', this.navigationController.selected.uuid)) {
/** Tag title could have changed */
this.reloadPanelTitle()
}
}),
)
this.disposers.push(
application.addEventObserver(async () => {
void this.reloadPreferences()
}, ApplicationEvent.PreferencesChanged),
)
this.disposers.push(
application.addEventObserver(async () => {
this.application.noteControllerGroup.closeAllNoteControllers()
void this.selectFirstItem()
this.setCompletedFullSync(false)
}, ApplicationEvent.SignedIn),
)
this.disposers.push(
application.addEventObserver(async () => {
void this.reloadItems().then(() => {
if (
this.notes.length === 0 &&
this.navigationController.selected instanceof SmartView &&
this.navigationController.selected.uuid === SystemViewId.AllNotes &&
this.noteFilterText === '' &&
!this.getActiveNoteController()
) {
this.createPlaceholderNote()?.catch(console.error)
}
})
this.setCompletedFullSync(true)
}, ApplicationEvent.CompletedFullSync),
)
this.disposers.push(
application.addWebEventObserver((webEvent) => {
if (webEvent === WebAppEvent.EditorFocused) {
this.setShowDisplayOptionsMenu(false)
}
}),
)
this.disposers.push(
reaction(
() => [
this.searchOptionsController.includeProtectedContents,
this.searchOptionsController.includeArchived,
this.searchOptionsController.includeTrashed,
],
() => {
this.reloadNotesDisplayOptions()
void this.reloadItems()
},
),
)
makeObservable(this, {
completedFullSync: observable,
displayOptions: observable.struct,
webDisplayOptions: observable.struct,
noteFilterText: observable,
notes: observable,
notesToDisplay: observable,
panelTitle: observable,
renderedItems: observable,
showDisplayOptionsMenu: observable,
reloadItems: action,
reloadPanelTitle: action,
reloadPreferences: action,
resetPagination: action,
setCompletedFullSync: action,
setNoteFilterText: action,
setShowDisplayOptionsMenu: action,
onFilterEnter: action,
handleFilterTextChanged: action,
optionsSubtitle: computed,
})
window.onresize = () => {
this.resetPagination(true)
}
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.TagChanged) {
this.handleTagChange()
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
this.handleEditorChange().catch(console.error)
}
}
public getActiveNoteController(): NoteViewController | undefined {
return this.application.noteControllerGroup.activeNoteViewController
}
public get activeControllerNote(): SNNote | undefined {
return this.getActiveNoteController()?.note
}
setCompletedFullSync = (completed: boolean) => {
this.completedFullSync = completed
}
setShowDisplayOptionsMenu = (enabled: boolean) => {
this.showDisplayOptionsMenu = enabled
}
get searchBarElement() {
return document.getElementById(ElementIdSearchBar)
}
get isFiltering(): boolean {
return !!this.noteFilterText && this.noteFilterText.length > 0
}
reloadPanelTitle = () => {
let title = this.panelTitle
if (this.isFiltering) {
const resultCount = this.notes.length
title = `${resultCount} search results`
} else if (this.navigationController.selected) {
title = `${this.navigationController.selected.title}`
}
this.panelTitle = title
}
reloadItems = async (): Promise<void> => {
if (this.reloadItemsPromise) {
await this.reloadItemsPromise
}
this.reloadItemsPromise = this.performReloadItems()
await this.reloadItemsPromise
}
private async performReloadItems() {
const tag = this.navigationController.selected
if (!tag) {
return
}
const notes = this.application.items.getDisplayableNotes()
const items = this.application.items.getDisplayableNotesAndFiles()
const renderedItems = items.slice(0, this.notesToDisplay)
runInAction(() => {
this.notes = notes
this.items = items
this.renderedItems = renderedItems
})
await this.recomputeSelectionAfterItemsReload()
this.reloadPanelTitle()
}
private async recomputeSelectionAfterItemsReload() {
const activeController = this.getActiveNoteController()
const activeNote = activeController?.note
const isSearching = this.noteFilterText.length > 0
const hasMultipleItemsSelected = this.selectionController.selectedItemsCount >= 2
if (hasMultipleItemsSelected) {
return
}
const selectedItem = Object.values(this.selectionController.selectedItems)[0]
const isSelectedItemFile =
this.items.includes(selectedItem) && selectedItem && selectedItem.content_type === ContentType.File
if (isSelectedItemFile && !SupportsFileSelectionState) {
return
}
if (!activeNote) {
await this.selectFirstItem()
return
}
if (activeController.isTemplateNote) {
return
}
const noteExistsInUpdatedResults = this.notes.find((note) => note.uuid === activeNote.uuid)
if (!noteExistsInUpdatedResults && !isSearching) {
this.closeNoteController(activeController)
this.selectNextItem()
return
}
const showTrashedNotes =
(this.navigationController.selected instanceof SmartView &&
this.navigationController.selected?.uuid === SystemViewId.TrashedNotes) ||
this.searchOptionsController.includeTrashed
const showArchivedNotes =
(this.navigationController.selected instanceof SmartView &&
this.navigationController.selected.uuid === SystemViewId.ArchivedNotes) ||
this.searchOptionsController.includeArchived ||
this.application.getPreference(PrefKey.NotesShowArchived, false)
if ((activeNote.trashed && !showTrashedNotes) || (activeNote.archived && !showArchivedNotes)) {
await this.selectNextItemOrCreateNewNote()
} else if (!this.selectionController.selectedItems[activeNote.uuid]) {
await this.selectionController.selectItem(activeNote.uuid).catch(console.error)
}
}
reloadNotesDisplayOptions = () => {
const tag = this.navigationController.selected
const searchText = this.noteFilterText.toLowerCase()
const isSearching = searchText.length
let includeArchived: boolean
let includeTrashed: boolean
if (isSearching) {
includeArchived = this.searchOptionsController.includeArchived
includeTrashed = this.searchOptionsController.includeTrashed
} else {
includeArchived = this.displayOptions.includeArchived ?? false
includeTrashed = this.displayOptions.includeTrashed ?? false
}
const criteria: DisplayOptions = {
sortBy: this.displayOptions.sortBy,
sortDirection: this.displayOptions.sortDirection,
tags: tag instanceof SNTag ? [tag] : [],
views: tag instanceof SmartView ? [tag] : [],
includeArchived,
includeTrashed,
includePinned: this.displayOptions.includePinned,
includeProtected: this.displayOptions.includeProtected,
searchQuery: {
query: searchText,
includeProtectedNoteText: this.searchOptionsController.includeProtectedContents,
},
}
this.application.items.setPrimaryItemDisplayOptions(criteria)
}
reloadPreferences = async () => {
const newDisplayOptions = {} as DisplayOptions
const newWebDisplayOptions = {} as WebDisplayOptions
const currentSortBy = this.displayOptions.sortBy
let sortBy = this.application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
if (sortBy === CollectionSort.UpdatedAt || (sortBy as string) === 'client_updated_at') {
sortBy = CollectionSort.UpdatedAt
}
newDisplayOptions.sortBy = sortBy
newDisplayOptions.sortDirection =
this.application.getPreference(PrefKey.SortNotesReverse, false) === false ? 'dsc' : 'asc'
newDisplayOptions.includeArchived = this.application.getPreference(PrefKey.NotesShowArchived, false)
newDisplayOptions.includeTrashed = this.application.getPreference(PrefKey.NotesShowTrashed, false) as boolean
newDisplayOptions.includePinned = !this.application.getPreference(PrefKey.NotesHidePinned, false)
newDisplayOptions.includeProtected = !this.application.getPreference(PrefKey.NotesHideProtected, false)
newWebDisplayOptions.hideNotePreview = this.application.getPreference(PrefKey.NotesHideNotePreview, false)
newWebDisplayOptions.hideDate = this.application.getPreference(PrefKey.NotesHideDate, false)
newWebDisplayOptions.hideTags = this.application.getPreference(PrefKey.NotesHideTags, true)
newWebDisplayOptions.hideEditorIcon = this.application.getPreference(PrefKey.NotesHideEditorIcon, false)
const displayOptionsChanged =
newDisplayOptions.sortBy !== this.displayOptions.sortBy ||
newDisplayOptions.sortDirection !== this.displayOptions.sortDirection ||
newDisplayOptions.includePinned !== this.displayOptions.includePinned ||
newDisplayOptions.includeArchived !== this.displayOptions.includeArchived ||
newDisplayOptions.includeTrashed !== this.displayOptions.includeTrashed ||
newDisplayOptions.includeProtected !== this.displayOptions.includeProtected ||
newWebDisplayOptions.hideNotePreview !== this.webDisplayOptions.hideNotePreview ||
newWebDisplayOptions.hideDate !== this.webDisplayOptions.hideDate ||
newWebDisplayOptions.hideEditorIcon !== this.webDisplayOptions.hideEditorIcon ||
newWebDisplayOptions.hideTags !== this.webDisplayOptions.hideTags
this.displayOptions = newDisplayOptions
this.webDisplayOptions = newWebDisplayOptions
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions()
}
await this.reloadItems()
const width = this.application.getPreference(PrefKey.NotesPanelWidth)
if (width) {
this.panelWidth = width
}
if (newDisplayOptions.sortBy !== currentSortBy) {
await this.selectFirstItem()
}
}
createNewNote = async () => {
this.notesController.unselectNotes()
if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) {
await this.navigationController.selectHomeNavigationView()
}
let title = `Note ${this.notes.length + 1}`
if (this.isFiltering) {
title = this.noteFilterText
}
await this.notesController.createNewNoteController(title)
this.noteTagsController.reloadTagsForCurrentNote()
}
createPlaceholderNote = () => {
if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) {
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.includeArchived) {
base += ' | + Archived'
}
if (this.displayOptions.includeTrashed) {
base += ' | + Trashed'
}
if (!this.displayOptions.includePinned) {
base += ' | Pinned'
}
if (!this.displayOptions.includeProtected) {
base += ' | Protected'
}
if (this.displayOptions.sortDirection === 'asc') {
base += ' | Reversed'
}
return base
}
paginate = () => {
this.notesToDisplay += this.pageSize
void this.reloadItems()
if (this.searchSubmitted) {
this.application.getDesktopService()?.searchText(this.noteFilterText)
}
}
resetPagination = (keepCurrentIfLarger = false) => {
const clientHeight = document.documentElement.clientHeight
this.pageSize = Math.ceil(clientHeight / MinNoteCellHeight)
if (this.pageSize === 0) {
this.pageSize = DefaultListNumNotes
}
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
return
}
this.notesToDisplay = this.pageSize
}
getFirstNonProtectedItem = () => {
return this.items.find((item) => !item.protected)
}
get notesListScrollContainer() {
return document.getElementById(ElementIdScrollContainer)
}
selectItemWithScrollHandling = async (
item: {
uuid: ListableContentItem['uuid']
},
{ userTriggered = false, scrollIntoView = true },
): Promise<void> => {
await this.selectionController.selectItem(item.uuid, userTriggered)
if (scrollIntoView) {
const itemElement = document.getElementById(item.uuid)
itemElement?.scrollIntoView({
behavior: 'smooth',
})
}
}
selectFirstItem = async () => {
const item = this.getFirstNonProtectedItem()
if (item) {
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
})
this.resetScrollPosition()
}
}
selectNextItem = () => {
const displayableItems = this.items
const currentIndex = displayableItems.findIndex((candidate) => {
return candidate.uuid === this.selectionController.lastSelectedItem?.uuid
})
let nextIndex = currentIndex + 1
while (nextIndex < displayableItems.length) {
const nextItem = displayableItems[nextIndex]
nextIndex++
if (nextItem.protected) {
continue
}
this.selectItemWithScrollHandling(nextItem, { userTriggered: true }).catch(console.error)
const nextNoteElement = document.getElementById(nextItem.uuid)
nextNoteElement?.focus()
return
}
}
selectNextItemOrCreateNewNote = async () => {
const item = this.getFirstNonProtectedItem()
if (item) {
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
}).catch(console.error)
} else {
await this.createNewNote()
}
}
selectPreviousItem = () => {
const displayableItems = this.items
if (!this.selectionController.lastSelectedItem) {
return
}
const currentIndex = displayableItems.indexOf(this.selectionController.lastSelectedItem)
let previousIndex = currentIndex - 1
while (previousIndex >= 0) {
const previousItem = displayableItems[previousIndex]
previousIndex--
if (previousItem.protected) {
continue
}
this.selectItemWithScrollHandling(previousItem, { userTriggered: true }).catch(console.error)
const previousNoteElement = document.getElementById(previousItem.uuid)
previousNoteElement?.focus()
return
}
}
setNoteFilterText = (text: string) => {
if (text === this.noteFilterText) {
return
}
this.noteFilterText = text
this.handleFilterTextChanged()
}
handleEditorChange = async () => {
const activeNote = this.application.noteControllerGroup.activeNoteViewController?.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
}
}
private closeNoteController(controller: NoteViewController): void {
this.application.noteControllerGroup.closeNoteController(controller)
}
handleTagChange = () => {
const activeNoteController = this.getActiveNoteController()
if (activeNoteController?.isTemplateNote) {
this.closeNoteController(activeNoteController)
}
this.resetScrollPosition()
this.setShowDisplayOptionsMenu(false)
this.setNoteFilterText('')
this.application.getDesktopService()?.searchText()
this.resetPagination()
this.reloadNotesDisplayOptions()
void this.reloadItems()
}
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)
}
public async insertCurrentIfTemplate(): Promise<void> {
const controller = this.getActiveNoteController()
if (!controller) {
return
}
if (controller.isTemplateNote) {
await controller.insertTemplatedNote()
}
}
handleFilterTextChanged = () => {
if (this.searchSubmitted) {
this.searchSubmitted = false
}
this.reloadNotesDisplayOptions()
void this.reloadItems()
}
clearFilterText = () => {
this.setNoteFilterText('')
this.onFilterEnter()
this.handleFilterTextChanged()
this.resetPagination()
}
}

View File

@@ -0,0 +1,6 @@
export type WebDisplayOptions = {
hideTags: boolean
hideDate: boolean
hideNotePreview: boolean
hideEditorIcon: boolean
}

View File

@@ -0,0 +1,3 @@
import { SmartView, SNTag } from '@standardnotes/snjs'
export type AnyTag = SNTag | SmartView

View File

@@ -0,0 +1,544 @@
import { confirmDialog } from '@/Services/AlertService'
import { STRING_DELETE_TAG } from '@/Constants/Strings'
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER, SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants'
import {
ComponentAction,
ContentType,
MessageData,
SmartView,
SNTag,
TagMutator,
UuidString,
isSystemView,
FindItem,
SystemViewId,
InternalEventBus,
InternalEventPublishStrategy,
} from '@standardnotes/snjs'
import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application'
import { FeaturesController } from '../FeaturesController'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { destroyAllObjectProperties } from '@/Utils'
import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils'
import { AnyTag } from './AnyTagType'
import { CrossControllerEvent } from '../CrossControllerEvent'
export class NavigationController extends AbstractViewController {
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(application: WebApplication, private featuresController: FeaturesController, eventBus: InternalEventBus) {
super(application, eventBus)
this.tagsCountsState = new TagsCountsState(this.application)
this.selected_ = undefined
this.previouslySelected_ = undefined
this.editing_ = undefined
this.addingSubtagTo = undefined
this.smartViews = this.application.items.getSmartViews()
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,
isInFilesView: computed,
})
this.disposers.push(
this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => {
runInAction(() => {
this.tags = this.application.items.getDisplayableTags()
this.smartViews = this.application.items.getSmartViews()
const currrentSelectedTag = this.selected_
if (!currrentSelectedTag) {
this.setSelectedTagInstance(this.smartViews[0])
return
}
const updatedReference =
FindItem(changed, currrentSelectedTag.uuid) || FindItem(this.smartViews, currrentSelectedTag.uuid)
if (updatedReference) {
this.setSelectedTagInstance(updatedReference as AnyTag)
}
if (isSystemView(currrentSelectedTag as SmartView)) {
return
}
if (FindItem(removed, currrentSelectedTag.uuid)) {
this.setSelectedTagInstance(this.smartViews[0])
}
})
}),
)
this.disposers.push(
this.application.items.addNoteCountChangeObserver((tagUuid) => {
if (!tagUuid) {
this.setAllNotesCount(this.application.items.allCountableNotesCount())
} else {
const tag = this.application.items.findItem<SNTag>(tagUuid)
if (tag) {
this.tagsCountsState.update([tag])
}
}
}),
)
}
override deinit() {
super.deinit()
;(this.featuresController as unknown) = undefined
;(this.tags as unknown) = undefined
;(this.smartViews as unknown) = undefined
;(this.selected_ as unknown) = undefined
;(this.previouslySelected_ as unknown) = undefined
;(this.editing_ as unknown) = undefined
;(this.addingSubtagTo as unknown) = undefined
;(this.featuresController as unknown) = undefined
destroyAllObjectProperties(this)
}
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(() => {
void this.setSelectedTag(createdTag as SNTag)
})
this.setAddingSubtagTo(undefined)
}
public isInSmartView(): boolean {
return this.selected instanceof SmartView
}
public isInHomeView(): boolean {
return this.selected instanceof SmartView && this.selected.uuid === SystemViewId.AllNotes
}
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
let openUpBottom = true
if (footerHeightInPx) {
const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y
const upSpace = this.contextMenuClickLocation.y
const notEnoughSpaceToOpenUpBottom = maxContextMenuHeight > bottomSpace
if (notEnoughSpaceToOpenUpBottom) {
const enoughSpaceToOpenBottomUp = upSpace > maxContextMenuHeight
if (enoughSpaceToOpenBottomUp) {
openUpBottom = false
this.setContextMenuMaxHeight('auto')
} else {
const hasMoreUpSpace = upSpace > bottomSpace
if (hasMoreUpSpace) {
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 isInFilesView(): boolean {
return this.selectedUuid === SystemViewId.Files
}
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 async setSelectedTag(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.setSelectedTagInstance(tag)
if (tag && this.application.items.isTemplateItem(tag)) {
return
}
await this.eventBus.publishSync(
{
type: CrossControllerEvent.TagChanged,
payload: { tag, previousTag: this.previouslySelected_ },
},
InternalEventPublishStrategy.SEQUENCE,
)
}
public async selectHomeNavigationView(): Promise<void> {
await this.setSelectedTag(this.homeNavigationView)
}
get homeNavigationView(): SmartView {
return this.smartViews[0]
}
private setSelectedTagInstance(tag: AnyTag | undefined): void {
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
void this.setSelectedTag(editingTag)
}
public createNewTemplate() {
const isAlreadyEditingATemplate = this.editing_ && this.application.items.isTemplateItem(this.editing_)
if (isAlreadyEditingATemplate) {
return
}
const newTag = this.application.mutator.createTemplateItem(ContentType.Tag) as SNTag
runInAction(() => {
this.editing_ = newTag
})
}
public undoCreateNewTag() {
this.editing_ = undefined
const previousTag = this.previouslySelected_ || this.smartViews[0]
void this.setSelectedTag(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)
await this.setSelectedTag(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.featuresController.hasSmartViews) {
await this.featuresController.showPremiumAlert(SMART_TAGS_FEATURE_NAME)
return
}
}
const insertedTag = await this.application.mutator.createTagOrSmartView(newTitle)
this.application.sync.sync().catch(console.error)
runInAction(() => {
void this.setSelectedTag(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) {
void this.setSelectedTag(matchingTag as AnyTag)
return
}
}
} else if (action === ComponentAction.ClearSelection) {
void this.setSelectedTag(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
}
}

View File

@@ -0,0 +1,38 @@
import { SNApplication, SNTag } from '@standardnotes/snjs'
export const rootTags = (application: SNApplication): SNTag[] => {
const hasNoParent = (tag: SNTag) => !application.items.getTagParent(tag)
const allTags = application.items.getDisplayableTags()
const rootTags = allTags.filter(hasNoParent)
return rootTags
}
export 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))
}
export 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
}

View File

@@ -0,0 +1,47 @@
import { storage, StorageKey } from '@/Services/LocalStorage'
import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs'
import { runInAction, makeObservable, observable, action } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class NoAccountWarningController extends AbstractViewController {
show: boolean
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
this.show = application.hasAccount() ? false : storage.get(StorageKey.ShowNoAccountWarning) ?? true
this.disposers.push(
application.addEventObserver(async () => {
runInAction(() => {
this.show = false
})
}, ApplicationEvent.SignedIn),
)
this.disposers.push(
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)
}
}

View File

@@ -0,0 +1,237 @@
import { ElementIds } from '@/Constants/ElementIDs'
import { destroyAllObjectProperties } from '@/Utils'
import {
ApplicationEvent,
ContentType,
InternalEventBus,
PrefKey,
SNNote,
SNTag,
UuidString,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { ItemListController } from './ItemList/ItemListController'
export class NoteTagsController extends AbstractViewController {
autocompleteInputFocused = false
autocompleteSearchQuery = ''
autocompleteTagHintFocused = false
autocompleteTagResults: SNTag[] = []
focusedTagResultUuid: UuidString | undefined = undefined
focusedTagUuid: UuidString | undefined = undefined
tags: SNTag[] = []
tagsContainerMaxWidth: number | 'auto' = 0
addNoteToParentFolders: boolean
private itemListController!: ItemListController
override deinit() {
super.deinit()
;(this.tags as unknown) = undefined
;(this.autocompleteTagResults as unknown) = undefined
;(this.itemListController as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
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)
}
public setServicestPostConstruction(itemListController: ItemListController) {
this.itemListController = itemListController
this.disposers.push(
this.application.streamItems(ContentType.Tag, () => {
this.reloadTagsForCurrentNote()
}),
this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.addNoteToParentFolders = this.application.getPreference(PrefKey.NoteAddToParentFolders, true)
}),
)
}
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.itemListController.activeControllerNote,
)
this.setAutocompleteTagResults(newResults)
}
getTagIndex(tag: SNTag, tagsArr: SNTag[]): number {
return tagsArr.findIndex((t) => t.uuid === tag.uuid)
}
reloadTagsForCurrentNote(): void {
const activeNote = this.itemListController.activeControllerNote
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.itemListController.activeControllerNote
if (activeNote) {
await this.application.items.addTagToNote(activeNote, tag, this.addNoteToParentFolders)
this.application.sync.sync().catch(console.error)
this.reloadTagsForCurrentNote()
}
}
async removeTagFromActiveNote(tag: SNTag): Promise<void> {
const activeNote = this.itemListController.activeControllerNote
if (activeNote) {
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.removeItemAsRelationship(activeNote)
})
this.application.sync.sync().catch(console.error)
this.reloadTagsForCurrentNote()
}
}
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)
}
}

View File

@@ -0,0 +1,401 @@
import { destroyAllObjectProperties } from '@/Utils'
import { confirmDialog } from '@/Services/AlertService'
import { StringEmptyTrash, Strings, StringUtils } from '@/Constants/Strings'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { SNNote, NoteMutator, ContentType, SNTag, TagMutator, InternalEventBus } from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { SelectedItemsController } from './SelectedItemsController'
import { ItemListController } from './ItemList/ItemListController'
import { NoteTagsController } from './NoteTagsController'
import { NavigationController } from './Navigation/NavigationController'
import { CrossControllerEvent } from './CrossControllerEvent'
export class NotesController extends AbstractViewController {
lastSelectedNote: SNNote | 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'
showProtectedWarning = false
showRevisionHistoryModal = false
private itemListController!: ItemListController
override deinit() {
super.deinit()
;(this.lastSelectedNote as unknown) = undefined
;(this.selectionController as unknown) = undefined
;(this.noteTagsController as unknown) = undefined
;(this.navigationController as unknown) = undefined
;(this.itemListController as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(
application: WebApplication,
private selectionController: SelectedItemsController,
private noteTagsController: NoteTagsController,
private navigationController: NavigationController,
eventBus: InternalEventBus,
) {
super(application, eventBus)
makeObservable(this, {
contextMenuOpen: observable,
contextMenuPosition: observable,
showProtectedWarning: observable,
showRevisionHistoryModal: observable,
selectedNotes: computed,
firstSelectedNote: computed,
selectedNotesCount: computed,
trashedNotesCount: computed,
setContextMenuOpen: action,
setContextMenuClickLocation: action,
setContextMenuPosition: action,
setContextMenuMaxHeight: action,
setShowProtectedWarning: action,
setShowRevisionHistoryModal: action,
unselectNotes: action,
})
}
public setServicestPostConstruction(itemListController: ItemListController) {
this.itemListController = itemListController
this.disposers.push(
this.application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedNote of removed) {
this.selectionController.deselectItem(removedNote)
}
for (const note of [...changed, ...inserted]) {
if (this.selectionController.isItemSelected(note)) {
this.selectionController.updateReferenceOfSelectedItem(note)
}
}
})
}),
this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = this.application.noteControllerGroup.noteControllers
const activeNoteUuids = controllers.map((c) => c.note.uuid)
const selectedUuids = this.getSelectedNotesList().map((n) => n.uuid)
for (const selectedId of selectedUuids) {
if (!activeNoteUuids.includes(selectedId)) {
this.selectionController.deselectItem({ uuid: selectedId })
}
}
}),
)
}
public get selectedNotes(): SNNote[] {
return this.selectionController.getSelectedItems<SNNote>(ContentType.Note)
}
get firstSelectedNote(): SNNote | undefined {
return Object.values(this.selectedNotes)[0]
}
get selectedNotesCount(): number {
if (this.dealloced) {
return 0
}
return Object.keys(this.selectedNotes).length
}
get trashedNotesCount(): number {
return this.application.items.trashedItems.length
}
async openNote(noteUuid: string): Promise<void> {
if (this.itemListController.activeControllerNote?.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
}
await this.application.noteControllerGroup.createNoteController(noteUuid)
this.noteTagsController.reloadTagsForCurrentNote()
await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged)
}
async createNewNoteController(title?: string) {
const selectedTag = this.navigationController.selected
const activeRegularTagUuid = selectedTag && selectedTag instanceof SNTag ? selectedTag.uuid : undefined
await this.application.noteControllerGroup.createNoteController(undefined, title, activeRegularTagUuid)
}
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(this.getSelectedNotesList(), 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 (this.getSelectedNotesList().some((note) => note.locked)) {
const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount)
this.application.alertService.alert(text).catch(console.error)
return false
}
const title = Strings.trashItemsTitle
let noteTitle = undefined
if (this.selectedNotesCount === 1) {
const selectedNote = this.getSelectedNotesList()[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 this.getSelectedNotesList()) {
await this.application.mutator.deleteItem(note)
this.selectionController.deselectItem(note)
}
} 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 (this.getSelectedNotesList().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.selectionController.setSelectedItems({})
this.contextMenuOpen = false
})
}
async setProtectSelectedNotes(protect: boolean): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
if (protect) {
await this.application.mutator.protectNotes(selectedNotes)
this.setShowProtectedWarning(true)
} else {
await this.application.mutator.unprotectNotes(selectedNotes)
this.setShowProtectedWarning(false)
}
}
unselectNotes(): void {
this.selectionController.setSelectedItems({})
}
getSpellcheckStateForNote(note: SNNote) {
return note.spellcheck != undefined ? note.spellcheck : this.application.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 = this.getSelectedNotesList()
const parentChainTags = this.application.items.getTagParentChain(tag)
const tagsToAdd = [...parentChainTags, tag]
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.mutator.changeItem<TagMutator>(tag, (mutator) => {
for (const note of selectedNotes) {
mutator.addNote(note)
}
})
}),
)
this.application.sync.sync().catch(console.error)
}
async removeTagFromSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
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 = this.getSelectedNotesList()
return selectedNotes.every((note) =>
this.application.getItemTags(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 getSelectedNotesList(): SNNote[] {
return Object.values(this.selectedNotes)
}
setShowRevisionHistoryModal(show: boolean): void {
this.showRevisionHistoryModal = show
}
}

View File

@@ -0,0 +1,37 @@
import { PreferenceId } from '@/Components/Preferences/PreferencesMenu'
import { action, computed, makeObservable, observable } from 'mobx'
const DEFAULT_PANE = 'account'
export class PreferencesController {
private _open = false
currentPane: PreferenceId = DEFAULT_PANE
constructor() {
makeObservable<PreferencesController, '_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
}
}

View File

@@ -0,0 +1,41 @@
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
import { InternalEventBus } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
import { WebApplication } from '../../Application/Application'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { PurchaseFlowPane } from './PurchaseFlowPane'
export class PurchaseFlowController extends AbstractViewController {
isOpen = false
currentPane = PurchaseFlowPane.CreateAccount
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
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
}
}

View File

@@ -0,0 +1,4 @@
export enum PurchaseFlowPane {
SignIn,
CreateAccount,
}

View File

@@ -0,0 +1,49 @@
import { action, makeObservable, observable } from 'mobx'
export class QuickSettingsController {
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)
}
}

View File

@@ -0,0 +1,57 @@
import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs'
import { makeObservable, observable, action, runInAction } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class SearchOptionsController extends AbstractViewController {
includeProtectedContents = false
includeArchived = false
includeTrashed = false
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
makeObservable(this, {
includeProtectedContents: observable,
includeTrashed: observable,
includeArchived: observable,
toggleIncludeArchived: action,
toggleIncludeTrashed: action,
toggleIncludeProtectedContents: action,
refreshIncludeProtectedContents: action,
})
this.disposers.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()
})
}
}
}

View File

@@ -0,0 +1,210 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import {
ChallengeReason,
ContentType,
KeyboardModifier,
FileItem,
SNNote,
UuidString,
InternalEventBus,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { ItemListController } from './ItemList/ItemListController'
import { NotesController } from './NotesController'
type SelectedItems = Record<UuidString, ListableContentItem>
export class SelectedItemsController extends AbstractViewController {
lastSelectedItem: ListableContentItem | undefined
selectedItems: SelectedItems = {}
private itemListController!: ItemListController
private notesController!: NotesController
override deinit(): void {
super.deinit()
;(this.itemListController as unknown) = undefined
;(this.notesController as unknown) = undefined
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
makeObservable(this, {
selectedItems: observable,
selectedItemsCount: computed,
selectedFiles: computed,
selectedFilesCount: computed,
selectItem: action,
setSelectedItems: action,
})
}
public setServicestPostConstruction(itemListController: ItemListController, notesController: NotesController) {
this.itemListController = itemListController
this.notesController = notesController
this.disposers.push(
this.application.streamItems<SNNote | FileItem>(
[ContentType.Note, ContentType.File],
({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedNote of removed) {
delete this.selectedItems[removedNote.uuid]
}
for (const item of [...changed, ...inserted]) {
if (this.selectedItems[item.uuid]) {
this.selectedItems[item.uuid] = item
}
}
})
},
),
)
}
private get io() {
return this.application.io
}
get selectedItemsCount(): number {
return Object.keys(this.selectedItems).length
}
get selectedFiles(): FileItem[] {
return this.getSelectedItems<FileItem>(ContentType.File)
}
get selectedFilesCount(): number {
return this.selectedFiles.length
}
getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => {
return Object.values(this.selectedItems).filter((item) => {
return !contentType ? true : item.content_type === contentType
}) as T[]
}
setSelectedItems = (selectedItems: SelectedItems) => {
this.selectedItems = selectedItems
}
public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => {
delete this.selectedItems[item.uuid]
if (item.uuid === this.lastSelectedItem?.uuid) {
this.lastSelectedItem = undefined
}
}
public isItemSelected = (item: ListableContentItem): boolean => {
return this.selectedItems[item.uuid] != undefined
}
public updateReferenceOfSelectedItem = (item: ListableContentItem): void => {
this.selectedItems[item.uuid] = item
}
private selectItemsRange = async (selectedItem: ListableContentItem): Promise<void> => {
const items = this.itemListController.renderedItems
const lastSelectedItemIndex = items.findIndex((item) => item.uuid == this.lastSelectedItem?.uuid)
const selectedItemIndex = items.findIndex((item) => item.uuid == selectedItem.uuid)
let itemsToSelect = []
if (selectedItemIndex > lastSelectedItemIndex) {
itemsToSelect = items.slice(lastSelectedItemIndex, selectedItemIndex + 1)
} else {
itemsToSelect = items.slice(selectedItemIndex, lastSelectedItemIndex + 1)
}
const authorizedItems = await this.application.protections.authorizeProtectedActionForItems(
itemsToSelect,
ChallengeReason.SelectProtectedNote,
)
for (const item of authorizedItems) {
runInAction(() => {
this.selectedItems[item.uuid] = item
this.lastSelectedItem = item
})
}
}
cancelMultipleSelection = () => {
this.io.cancelAllKeyboardModifiers()
const firstSelectedItem = this.getSelectedItems()[0]
if (firstSelectedItem) {
this.replaceSelection(firstSelectedItem)
} else {
this.deselectAll()
}
}
private replaceSelection = (item: ListableContentItem): void => {
this.setSelectedItems({
[item.uuid]: item,
})
this.lastSelectedItem = item
}
private deselectAll = (): void => {
this.setSelectedItems({})
this.lastSelectedItem = undefined
}
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
): Promise<{
didSelect: boolean
}> => {
const item = this.application.items.findItem<ListableContentItem>(uuid)
if (!item) {
return {
didSelect: false,
}
}
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1
const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item)
if (userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedItems[uuid] && hasMoreThanOneSelected) {
delete this.selectedItems[uuid]
} else if (isAuthorizedForAccess) {
this.selectedItems[uuid] = item
this.lastSelectedItem = item
}
} else if (userTriggered && hasShift) {
await this.selectItemsRange(item)
} else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedItems[uuid]
if (shouldSelectNote && isAuthorizedForAccess) {
this.replaceSelection(item)
}
}
if (this.selectedItemsCount === 1) {
const item = Object.values(this.selectedItems)[0]
if (item.content_type === ContentType.Note) {
await this.notesController.openNote(item.uuid)
}
}
return {
didSelect: this.selectedItems[uuid] != undefined,
}
}
}

View File

@@ -0,0 +1,5 @@
export type AvailableSubscriptions = {
[key: string]: {
name: string
}
}

View File

@@ -0,0 +1,128 @@
import { destroyAllObjectProperties } from '@/Utils'
import {
ApplicationEvent,
ClientDisplayableError,
convertTimestampToMilliseconds,
InternalEventBus,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { WebApplication } from '../../Application/Application'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { AvailableSubscriptions } from './AvailableSubscriptionsType'
import { Subscription } from './SubscriptionType'
export class SubscriptionController extends AbstractViewController {
userSubscription: Subscription | undefined = undefined
availableSubscriptions: AvailableSubscriptions | undefined = undefined
override deinit() {
super.deinit()
;(this.userSubscription as unknown) = undefined
;(this.availableSubscriptions as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
super(application, eventBus)
makeObservable(this, {
userSubscription: observable,
availableSubscriptions: observable,
userSubscriptionName: computed,
userSubscriptionExpirationDate: computed,
isUserSubscriptionExpired: computed,
isUserSubscriptionCanceled: computed,
setUserSubscription: action,
setAvailableSubscriptions: action,
})
this.disposers.push(
application.addEventObserver(async () => {
if (application.hasAccount()) {
this.getSubscriptionInfo().catch(console.error)
}
}, ApplicationEvent.Launched),
)
this.disposers.push(
application.addEventObserver(async () => {
this.getSubscriptionInfo().catch(console.error)
}, ApplicationEvent.SignedIn),
)
this.disposers.push(
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()
}
}

View File

@@ -0,0 +1,5 @@
export type Subscription = {
planName: string
cancelled: boolean
endsAt: number
}

View File

@@ -0,0 +1,33 @@
import { SyncOpStatus } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class SyncStatusController {
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',
})
}
}
}