refactor: repo (#1070)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isControllerDealloced(controller: { dealloced: boolean }): boolean {
|
||||
return controller.dealloced == undefined || controller.dealloced === true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type StructuredItemsCount = {
|
||||
notes: number
|
||||
tags: number
|
||||
deleted: number
|
||||
archived: number
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CrossControllerEvent {
|
||||
TagChanged = 'TagChanged',
|
||||
ActiveEditorChanged = 'ActiveEditorChanged',
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
430
packages/web/src/javascripts/Controllers/FilesController.ts
Normal file
430
packages/web/src/javascripts/Controllers/FilesController.ts
Normal 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)))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type WebDisplayOptions = {
|
||||
hideTags: boolean
|
||||
hideDate: boolean
|
||||
hideNotePreview: boolean
|
||||
hideEditorIcon: boolean
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { SmartView, SNTag } from '@standardnotes/snjs'
|
||||
|
||||
export type AnyTag = SNTag | SmartView
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
38
packages/web/src/javascripts/Controllers/Navigation/Utils.ts
Normal file
38
packages/web/src/javascripts/Controllers/Navigation/Utils.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
237
packages/web/src/javascripts/Controllers/NoteTagsController.ts
Normal file
237
packages/web/src/javascripts/Controllers/NoteTagsController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
401
packages/web/src/javascripts/Controllers/NotesController.ts
Normal file
401
packages/web/src/javascripts/Controllers/NotesController.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum PurchaseFlowPane {
|
||||
SignIn,
|
||||
CreateAccount,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type AvailableSubscriptions = {
|
||||
[key: string]: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Subscription = {
|
||||
planName: string
|
||||
cancelled: boolean
|
||||
endsAt: number
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user