refactor(web): dependency management (#2386)

This commit is contained in:
Mo
2023-08-05 12:48:39 -05:00
committed by GitHub
parent b07da5b663
commit d8d4052a52
274 changed files with 4065 additions and 3873 deletions

View File

@@ -1,6 +1,5 @@
import { CrossControllerEvent } from '../CrossControllerEvent'
import { InternalEventBusInterface, InternalEventPublishStrategy, removeFromArray } from '@standardnotes/snjs'
import { WebApplication } from '../../Application/WebApplication'
import { Disposer } from '@/Types/Disposer'
type ControllerEventObserver<Event = void, EventData = void> = (event: Event, data: EventData) => void
@@ -10,10 +9,7 @@ export abstract class AbstractViewController<Event = void, EventData = void> {
protected disposers: Disposer[] = []
private eventObservers: ControllerEventObserver<Event, EventData>[] = []
constructor(
public application: WebApplication,
protected eventBus: InternalEventBusInterface,
) {}
constructor(protected eventBus: InternalEventBusInterface) {}
protected async publishCrossControllerEventSync(name: CrossControllerEvent, data?: unknown): Promise<void> {
await this.eventBus.publishSync({ type: name, payload: data }, InternalEventPublishStrategy.SEQUENCE)
@@ -21,7 +17,6 @@ export abstract class AbstractViewController<Event = void, EventData = void> {
deinit(): void {
this.dealloced = true
;(this.application as unknown) = undefined
;(this.eventBus as unknown) = undefined
for (const disposer of this.disposers) {

View File

@@ -1,44 +1,72 @@
import { WebApplication } from '@/Application/WebApplication'
import { ShouldPersistNoteStateKey } from '@/Components/Preferences/Panes/General/Persistence'
import { ApplicationEvent, ContentType, InternalEventBusInterface } from '@standardnotes/snjs'
import { PersistedStateValue, StorageKey } from '@standardnotes/ui-services'
import {
ApplicationEvent,
ContentType,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
StorageServiceInterface,
SyncServiceInterface,
} from '@standardnotes/snjs'
import { PersistedStateValue, PersistenceKey, StorageKey } from '@standardnotes/ui-services'
import { CrossControllerEvent } from '../CrossControllerEvent'
import { NavigationController } from '../Navigation/NavigationController'
import { ItemListController } from '../ItemList/ItemListController'
export class PersistenceService {
private unsubAppEventObserver: () => void
export class PersistenceService implements InternalEventHandlerInterface {
private didHydrateOnce = false
constructor(
private application: WebApplication,
private itemListController: ItemListController,
private navigationController: NavigationController,
private storage: StorageServiceInterface,
private items: ItemManagerInterface,
private sync: SyncServiceInterface,
private eventBus: InternalEventBusInterface,
) {
this.unsubAppEventObserver = this.application.addEventObserver(async (eventName) => {
if (!this.application) {
return
}
void this.onAppEvent(eventName)
})
eventBus.addEventHandler(this, ApplicationEvent.LocalDataLoaded)
eventBus.addEventHandler(this, ApplicationEvent.LocalDataIncrementalLoad)
eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues)
eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence)
}
async onAppEvent(eventName: ApplicationEvent) {
if (eventName === ApplicationEvent.LocalDataLoaded && !this.didHydrateOnce) {
this.hydratePersistedValues()
this.didHydrateOnce = true
} else if (eventName === ApplicationEvent.LocalDataIncrementalLoad) {
const canHydrate = this.application.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.Tag]).length > 0
if (!canHydrate) {
return
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.LocalDataLoaded: {
if (!this.didHydrateOnce) {
this.hydratePersistedValues()
this.didHydrateOnce = true
}
break
}
this.hydratePersistedValues()
this.didHydrateOnce = true
case ApplicationEvent.LocalDataIncrementalLoad: {
const canHydrate = this.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.Tag]).length > 0
if (!canHydrate) {
return
}
this.hydratePersistedValues()
this.didHydrateOnce = true
break
}
case CrossControllerEvent.HydrateFromPersistedValues: {
this.hydrateFromPersistedValues(event.payload as PersistedStateValue | undefined)
break
}
case CrossControllerEvent.RequestValuePersistence: {
this.persistCurrentState()
break
}
}
}
get persistenceEnabled() {
return this.application.getValue(ShouldPersistNoteStateKey) ?? true
return this.storage.getValue(ShouldPersistNoteStateKey) ?? true
}
hydratePersistedValues = () => {
@@ -48,8 +76,37 @@ export class PersistenceService {
})
}
persistCurrentState(): void {
const values: PersistedStateValue = {
[PersistenceKey.ItemListController]: this.itemListController.getPersistableValue(),
[PersistenceKey.NavigationController]: this.navigationController.getPersistableValue(),
}
this.persistValues(values)
const selectedItemsState = values['selected-items-controller']
const navigationSelectionState = values['navigation-controller']
const launchPriorityUuids: string[] = []
if (selectedItemsState.selectedUuids.length) {
launchPriorityUuids.push(...selectedItemsState.selectedUuids)
}
if (navigationSelectionState.selectedTagUuid) {
launchPriorityUuids.push(navigationSelectionState.selectedTagUuid)
}
this.sync.setLaunchPriorityUuids(launchPriorityUuids)
}
hydrateFromPersistedValues(values: PersistedStateValue | undefined): void {
const navigationState = values?.[PersistenceKey.NavigationController]
this.navigationController.hydrateFromPersistedValue(navigationState)
const selectedItemsState = values?.[PersistenceKey.ItemListController]
this.itemListController.hydrateFromPersistedValue(selectedItemsState)
}
persistValues(values: PersistedStateValue): void {
if (!this.application.isDatabaseLoaded()) {
if (!this.sync.isDatabaseLoaded()) {
return
}
@@ -57,22 +114,18 @@ export class PersistenceService {
return
}
this.application.setValue(StorageKey.MasterStatePersistenceKey, values)
this.storage.setValue(StorageKey.MasterStatePersistenceKey, values)
}
clearPersistedValues(): void {
if (!this.application.isDatabaseLoaded()) {
if (!this.sync.isDatabaseLoaded()) {
return
}
void this.application.removeValue(StorageKey.MasterStatePersistenceKey)
void this.storage.removeValue(StorageKey.MasterStatePersistenceKey)
}
getPersistedValues(): PersistedStateValue {
return this.application.getValue(StorageKey.MasterStatePersistenceKey) as PersistedStateValue
}
deinit() {
this.unsubAppEventObserver()
return this.storage.getValue(StorageKey.MasterStatePersistenceKey) as PersistedStateValue
}
}

View File

@@ -1,11 +1,20 @@
import { destroyAllObjectProperties, isDev } from '@/Utils'
import { destroyAllObjectProperties } from '@/Utils'
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import { ApplicationEvent, ContentType, InternalEventBusInterface, SNNote, SNTag } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/WebApplication'
import {
ApplicationEvent,
ContentType,
GetHost,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
SNNote,
SNTag,
} from '@standardnotes/snjs'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { AbstractViewController } from '../Abstract/AbstractViewController'
export class AccountMenuController extends AbstractViewController {
export class AccountMenuController extends AbstractViewController implements InternalEventHandlerInterface {
show = false
signingOut = false
otherSessionsSignOut = false
@@ -28,8 +37,12 @@ export class AccountMenuController extends AbstractViewController {
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private items: ItemManagerInterface,
private _getHost: GetHost,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
makeObservable(this, {
show: observable,
@@ -63,27 +76,26 @@ export class AccountMenuController extends AbstractViewController {
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),
)
eventBus.addEventHandler(this, ApplicationEvent.Launched)
this.disposers.push(
this.application.streamItems([ContentType.TYPES.Note, ContentType.TYPES.Tag], () => {
this.items.streamItems([ContentType.TYPES.Note, ContentType.TYPES.Tag], () => {
runInAction(() => {
this.notesAndTags = this.application.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.Tag])
this.notesAndTags = this.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.Tag])
})
}),
)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.Launched: {
runInAction(() => {
this.setServer(this._getHost.execute().getValue())
})
break
}
}
}
setShow = (show: boolean): void => {
this.show = show

View File

@@ -1,7 +1,8 @@
export enum CrossControllerEvent {
TagChanged = 'TagChanged',
ActiveEditorChanged = 'ActiveEditorChanged',
HydrateFromPersistedValues = 'HydrateFromPersistedValues',
RequestValuePersistence = 'RequestValuePersistence',
DisplayPremiumModal = 'DisplayPremiumModal',
TagChanged = 'CrossControllerEvent:TagChanged',
ActiveEditorChanged = 'CrossControllerEvent:ActiveEditorChanged',
HydrateFromPersistedValues = 'CrossControllerEvent:HydrateFromPersistedValues',
RequestValuePersistence = 'CrossControllerEvent:RequestValuePersistence',
DisplayPremiumModal = 'CrossControllerEvent:DisplayPremiumModal',
UnselectAllNotes = 'CrossControllerEvent:UnselectAllNotes',
}

View File

@@ -1,5 +1,5 @@
import { FeaturesClientInterface, InternalEventHandlerInterface } from '@standardnotes/services'
import { FeatureName } from './FeatureName'
import { WebApplication } from '@/Application/WebApplication'
import { PremiumFeatureModalType } from '@/Components/PremiumFeaturesModal/PremiumFeatureModalType'
import { destroyAllObjectProperties } from '@/Utils'
import {
@@ -13,7 +13,7 @@ import { action, makeObservable, observable, runInAction, when } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { CrossControllerEvent } from './CrossControllerEvent'
export class FeaturesController extends AbstractViewController {
export class FeaturesController extends AbstractViewController implements InternalEventHandlerInterface {
hasFolders: boolean
hasSmartViews: boolean
entitledToFiles: boolean
@@ -33,16 +33,17 @@ export class FeaturesController extends AbstractViewController {
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private features: FeaturesClientInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.entitledToFiles = this.isEntitledToFiles()
this.premiumAlertFeatureName = undefined
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
makeObservable(this, {
hasFolders: observable,
hasSmartViews: observable,
@@ -54,33 +55,38 @@ export class FeaturesController extends AbstractViewController {
showPurchaseSuccessAlert: action,
})
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
eventBus.addEventHandler(this, ApplicationEvent.DidPurchaseSubscription)
eventBus.addEventHandler(this, ApplicationEvent.FeaturesAvailabilityChanged)
eventBus.addEventHandler(this, ApplicationEvent.Launched)
eventBus.addEventHandler(this, ApplicationEvent.LocalDataLoaded)
eventBus.addEventHandler(this, ApplicationEvent.UserRolesChanged)
this.showPremiumAlert = this.showPremiumAlert.bind(this)
this.closePremiumAlert = this.closePremiumAlert.bind(this)
this.disposers.push(
application.addEventObserver(async (event) => {
switch (event) {
case ApplicationEvent.DidPurchaseSubscription:
this.showPurchaseSuccessAlert()
break
case ApplicationEvent.FeaturesAvailabilityChanged:
case ApplicationEvent.Launched:
case ApplicationEvent.LocalDataLoaded:
case ApplicationEvent.UserRolesChanged:
runInAction(() => {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.entitledToFiles = this.isEntitledToFiles()
})
}
}),
)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.DisplayPremiumModal) {
const payload = event.payload as { featureName: string }
void this.showPremiumAlert(payload.featureName)
switch (event.type) {
case ApplicationEvent.DidPurchaseSubscription:
this.showPurchaseSuccessAlert()
break
case ApplicationEvent.FeaturesAvailabilityChanged:
case ApplicationEvent.Launched:
case ApplicationEvent.LocalDataLoaded:
case ApplicationEvent.UserRolesChanged:
runInAction(() => {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.entitledToFiles = this.isEntitledToFiles()
})
break
case CrossControllerEvent.DisplayPremiumModal:
{
const payload = event.payload as { featureName: string }
void this.showPremiumAlert(payload.featureName)
}
break
}
}
@@ -100,7 +106,7 @@ export class FeaturesController extends AbstractViewController {
}
private isEntitledToFiles(): boolean {
const status = this.application.features.getFeatureStatus(
const status = this.features.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.Files).getValue(),
)
@@ -108,7 +114,7 @@ export class FeaturesController extends AbstractViewController {
}
private isEntitledToFolders(): boolean {
const status = this.application.features.getFeatureStatus(
const status = this.features.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.TagNesting).getValue(),
)
@@ -116,7 +122,7 @@ export class FeaturesController extends AbstractViewController {
}
private isEntitledToSmartViews(): boolean {
const status = this.application.features.getFeatureStatus(
const status = this.features.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.SmartFilters).getValue(),
)

View File

@@ -1,5 +1,4 @@
import { WebApplication } from '@/Application/WebApplication'
import { ContentType, FileItem } from '@standardnotes/snjs'
import { ContentType, FileItem, ItemManagerInterface } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class FilePreviewModalController {
@@ -9,7 +8,7 @@ export class FilePreviewModalController {
eventObservers: (() => void)[] = []
constructor(application: WebApplication) {
constructor(items: ItemManagerInterface) {
makeObservable(this, {
isOpen: observable,
currentFile: observable,
@@ -21,7 +20,7 @@ export class FilePreviewModalController {
})
this.eventObservers.push(
application.streamItems(ContentType.TYPES.File, ({ changed, removed }) => {
items.streamItems(ContentType.TYPES.File, ({ changed, removed }) => {
if (!this.currentFile) {
return
}

View File

@@ -1,12 +1,18 @@
import {
FileDownloadProgress,
fileProgressToHumanReadableString,
FilesClientInterface,
OnChunkCallbackNoProgress,
} from '@standardnotes/files'
import { FilePreviewModalController } from './FilePreviewModalController'
import { FileItemAction, FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
import { confirmDialog } from '@standardnotes/ui-services'
import {
ArchiveManager,
confirmDialog,
IsNativeMobileWeb,
VaultDisplayServiceInterface,
} from '@standardnotes/ui-services'
import { Strings, StringUtils } from '@/Constants/Strings'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import {
@@ -17,17 +23,22 @@ import {
parseFileName,
} from '@standardnotes/filepicker'
import {
AlertService,
ChallengeReason,
ClientDisplayableError,
ContentType,
FileItem,
InternalEventBusInterface,
isFile,
ItemManagerInterface,
MobileDeviceInterface,
MutatorClientInterface,
Platform,
ProtectionsClientInterface,
SyncServiceInterface,
} from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType, updateToast } from '@standardnotes/toast'
import { action, makeObservable, observable, reaction } from 'mobx'
import { WebApplication } from '../Application/WebApplication'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { NotesController } from './NotesController/NotesController'
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
@@ -65,12 +76,22 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
constructor(
application: WebApplication,
private notesController: NotesController,
private filePreviewModalController: FilePreviewModalController,
private archiveService: ArchiveManager,
private vaultDisplayService: VaultDisplayServiceInterface,
private items: ItemManagerInterface,
private files: FilesClientInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private protections: ProtectionsClientInterface,
private alerts: AlertService,
private platform: Platform,
private mobileDevice: MobileDeviceInterface | undefined,
private _isNativeMobileWeb: IsNativeMobileWeb,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
super(eventBus)
makeObservable(this, {
allFiles: observable,
@@ -88,7 +109,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
})
this.disposers.push(
application.streamItems(ContentType.TYPES.File, () => {
items.streamItems(ContentType.TYPES.File, () => {
this.reloadAllFiles()
this.reloadAttachedFiles()
}),
@@ -117,13 +138,13 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
reloadAllFiles = () => {
this.allFiles = this.application.items.getDisplayableFiles()
this.allFiles = this.items.getDisplayableFiles()
}
reloadAttachedFiles = () => {
const note = this.notesController.firstSelectedNote
if (note) {
this.attachedFiles = this.application.items.itemsReferencingItem(note).filter(isFile)
this.attachedFiles = this.items.itemsReferencingItem(note).filter(isFile)
}
}
@@ -137,7 +158,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
type: ToastType.Loading,
message: `Deleting file "${file.name}"...`,
})
await this.application.files.deleteFile(file)
await this.files.deleteFile(file)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
@@ -156,8 +177,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
return
}
await this.application.mutator.associateFileWithNote(file, note)
void this.application.sync.sync()
await this.mutator.associateFileWithNote(file, note)
void this.sync.sync()
}
detachFileFromNote = async (file: FileItem) => {
@@ -169,31 +190,31 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
})
return
}
await this.application.mutator.disassociateFileWithNote(file, note)
void this.application.sync.sync()
await this.mutator.disassociateFileWithNote(file, note)
void this.sync.sync()
}
toggleFileProtection = async (file: FileItem) => {
let result: FileItem | undefined
if (file.protected) {
result = await this.application.protections.unprotectFile(file)
result = await this.protections.unprotectFile(file)
} else {
result = await this.application.protections.protectFile(file)
result = await this.protections.protectFile(file)
}
void this.application.sync.sync()
void this.sync.sync()
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 authorizedFiles = await this.protections.authorizeProtectedActionForItems([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
renameFile = async (file: FileItem, fileName: string) => {
await this.application.mutator.renameFile(file, fileName)
void this.application.sync.sync()
await this.mutator.renameFile(file, fileName)
void this.sync.sync()
}
handleFileAction = async (
@@ -243,7 +264,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
if (!NonMutatingFileActions.includes(action.type)) {
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
}
return {
@@ -273,7 +294,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
let lastProgress: FileDownloadProgress | undefined
const result = await this.application.files.downloadFile(file, async (decryptedBytes, progress) => {
const result = await this.files.downloadFile(file, async (decryptedBytes, progress) => {
if (isUsingStreamingSaver) {
await saver.pushBytes(decryptedBytes)
} else {
@@ -301,7 +322,16 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const blob = new Blob([finalBytes], {
type: file.mimeType,
})
await downloadOrShareBlobBasedOnPlatform(this.application, blob, file.name, false)
// await downloadOrShareBlobBasedOnPlatform(this, blob, file.name, false)
await downloadOrShareBlobBasedOnPlatform({
archiveService: this.archiveService,
platform: this.platform,
mobileDevice: this.mobileDevice,
blob,
filename: file.name,
isNativeMobileWeb: this._isNativeMobileWeb.execute().getValue(),
showToastOnAndroid: false,
})
}
addToast({
@@ -326,7 +356,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
alertIfFileExceedsSizeLimit = (file: File): boolean => {
if (!this.shouldUseStreamingReader && this.maxFileSize && file.size >= this.maxFileSize) {
this.application.alerts
this.alerts
.alert(
`This file exceeds the limits supported in this browser. To upload files greater than ${
this.maxFileSize / BYTES_IN_ONE_MEGABYTE
@@ -360,7 +390,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
let toastId: string | undefined
try {
const minimumChunkSize = this.application.files.minimumChunkSize()
const minimumChunkSize = this.files.minimumChunkSize()
const fileToUpload =
fileOrHandle instanceof File
@@ -377,9 +407,9 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
return
}
const operation = await this.application.files.beginNewFileUpload(
const operation = await this.files.beginNewFileUpload(
fileToUpload.size,
this.application.vaultDisplayService.exclusivelyShownVault,
this.vaultDisplayService.exclusivelyShownVault,
)
if (operation instanceof ClientDisplayableError) {
@@ -401,7 +431,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => {
await this.application.files.pushBytesForUpload(operation, data, index, isLast)
await this.files.pushBytesForUpload(operation, data, index, isLast)
const percentComplete = Math.round(operation.getProgress().percentComplete)
if (toastId) {
@@ -416,10 +446,10 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
if (!fileResult.mimeType) {
const { ext } = parseFileName(fileToUpload.name)
fileResult.mimeType = await this.application.getArchiveService().getMimeType(ext)
fileResult.mimeType = await this.archiveService.getMimeType(ext)
}
const uploadedFile = await this.application.files.finishUpload(operation, fileResult)
const uploadedFile = await this.files.finishUpload(operation, fileResult)
if (uploadedFile instanceof ClientDisplayableError) {
addToast({
@@ -485,28 +515,28 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
confirmButtonStyle: 'danger',
})
) {
await Promise.all(files.map((file) => this.application.files.deleteFile(file)))
void this.application.sync.sync()
await Promise.all(files.map((file) => this.files.deleteFile(file)))
void this.sync.sync()
}
}
setProtectionForFiles = async (protect: boolean, files: FileItem[]) => {
if (protect) {
const protectedItems = await this.application.protections.protectItems(files)
const protectedItems = await this.protections.protectItems(files)
if (protectedItems) {
this.setShowProtectedOverlay(true)
}
} else {
const unprotectedItems = await this.application.protections.unprotectItems(files, ChallengeReason.UnprotectFile)
const unprotectedItems = await this.protections.unprotectItems(files, ChallengeReason.UnprotectFile)
if (unprotectedItems) {
this.setShowProtectedOverlay(false)
}
}
void this.application.sync.sync()
void this.sync.sync()
}
downloadFiles = async (files: FileItem[]) => {
if (this.application.platform === Platform.MacDesktop) {
if (this.platform === Platform.MacDesktop) {
for (const file of files) {
await this.handleFileAction({
type: FileItemActionType.DownloadFile,

View File

@@ -1,6 +1,11 @@
import { WebApplication } from '@/Application/WebApplication'
import { DecryptedTransferPayload, SNTag, TagContent } from '@standardnotes/models'
import { ContentType, pluralize, UuidGenerator } from '@standardnotes/snjs'
import {
ContentType,
ItemManagerInterface,
MutatorClientInterface,
pluralize,
UuidGenerator,
} from '@standardnotes/snjs'
import { Importer, NoteImportType } from '@standardnotes/ui-services'
import { action, makeObservable, observable } from 'mobx'
import { NavigationController } from './Navigation/NavigationController'
@@ -23,13 +28,14 @@ export type ImportModalFile = (
export class ImportModalController {
isVisible = false
importer: Importer
files: ImportModalFile[] = []
importTag: SNTag | undefined = undefined
constructor(
private application: WebApplication,
private importer: Importer,
private navigationController: NavigationController,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
) {
makeObservable(this, {
isVisible: observable,
@@ -43,8 +49,6 @@ export class ImportModalController {
importTag: observable,
setImportTag: action,
})
this.importer = new Importer(application)
}
setIsVisible = (isVisible: boolean) => {
@@ -152,7 +156,7 @@ export class ImportModalController {
}
}
const currentDate = new Date()
const importTagItem = this.application.items.createTemplateItem<TagContent, SNTag>(ContentType.TYPES.Tag, {
const importTagItem = this.items.createTemplateItem<TagContent, SNTag>(ContentType.TYPES.Tag, {
title: `Imported on ${currentDate.toLocaleString()}`,
expanded: false,
iconString: '',
@@ -163,7 +167,7 @@ export class ImportModalController {
uuid: payload.uuid,
})),
})
const importTag = await this.application.mutator.insertItem(importTagItem)
const importTag = await this.mutator.insertItem(importTagItem)
if (importTag) {
this.setImportTag(importTag as SNTag)
}

View File

@@ -1,39 +1,51 @@
import { ContentType, SNTag } from '@standardnotes/snjs'
import { InternalEventBus } from '@standardnotes/services'
import { ContentType, Result, SNTag } from '@standardnotes/snjs'
import { InternalEventBus, ItemManagerInterface } from '@standardnotes/services'
import { WebApplication } from '@/Application/WebApplication'
import { NavigationController } from '../Navigation/NavigationController'
import { NotesController } from '../NotesController/NotesController'
import { SearchOptionsController } from '../SearchOptionsController'
import { SelectedItemsController } from '../SelectedItemsController'
import { ItemListController } from './ItemListController'
import { ItemsReloadSource } from './ItemsReloadSource'
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
import { runInAction } from 'mobx'
describe('item list controller', () => {
let application: WebApplication
let controller: ItemListController
let navigationController: NavigationController
let selectionController: SelectedItemsController
beforeEach(() => {
application = {} as jest.Mocked<WebApplication>
application.streamItems = jest.fn()
application = {
navigationController: {} as jest.Mocked<NavigationController>,
searchOptionsController: {} as jest.Mocked<SearchOptionsController>,
notesController: {} as jest.Mocked<NotesController>,
isNativeMobileWebUseCase: {
execute: jest.fn().mockReturnValue(Result.ok(false)),
} as unknown as jest.Mocked<IsNativeMobileWeb>,
items: {
streamItems: jest.fn(),
} as unknown as jest.Mocked<ItemManagerInterface>,
} as unknown as jest.Mocked<WebApplication>
application.addEventObserver = jest.fn()
application.addWebEventObserver = jest.fn()
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
navigationController = {} as jest.Mocked<NavigationController>
selectionController = {} as jest.Mocked<SelectedItemsController>
const searchOptionsController = {} as jest.Mocked<SearchOptionsController>
const notesController = {} as jest.Mocked<NotesController>
const eventBus = new InternalEventBus()
controller = new ItemListController(
application,
navigationController,
searchOptionsController,
selectionController,
notesController,
application.keyboardService,
application.paneController,
application.navigationController,
application.searchOptionsController,
application.items,
application.preferences,
application.itemControllerGroup,
application.vaultDisplayService,
application.desktopManager,
application.protections,
application.options,
application.isNativeMobileWebUseCase,
application.changeAndSaveItem,
eventBus,
)
})
@@ -42,14 +54,13 @@ describe('item list controller', () => {
beforeEach(() => {
controller.getFirstNonProtectedItem = jest.fn()
Object.defineProperty(selectionController, 'selectedUuids', {
get: () => new Set(),
configurable: true,
runInAction(() => {
controller.selectedUuids = new Set()
})
})
it('should return false is platform is native mobile web', () => {
application.isNativeMobileWeb = jest.fn().mockReturnValue(true)
it('should return false if platform is native mobile web', () => {
application.isNativeMobileWebUseCase.execute = jest.fn().mockReturnValue(Result.ok(true))
expect(controller.shouldSelectFirstItem(ItemsReloadSource.TagChange)).toBe(false)
})
@@ -68,7 +79,7 @@ describe('item list controller', () => {
content_type: ContentType.TYPES.Tag,
} as jest.Mocked<SNTag>
Object.defineProperty(navigationController, 'selected', {
Object.defineProperty(application.navigationController, 'selected', {
get: () => tag,
})
@@ -80,7 +91,7 @@ describe('item list controller', () => {
content_type: ContentType.TYPES.Tag,
} as jest.Mocked<SNTag>
Object.defineProperty(navigationController, 'selected', {
Object.defineProperty(application.navigationController, 'selected', {
get: () => tag,
})
@@ -92,11 +103,11 @@ describe('item list controller', () => {
content_type: ContentType.TYPES.Tag,
} as jest.Mocked<SNTag>
Object.defineProperty(selectionController, 'selectedUuids', {
get: () => new Set(['123']),
runInAction(() => {
controller.selectedUuids = new Set(['123'])
})
Object.defineProperty(navigationController, 'selected', {
Object.defineProperty(application.navigationController, 'selected', {
get: () => tag,
})

View File

@@ -23,15 +23,23 @@ import {
NotesAndFilesDisplayControllerOptions,
InternalEventBusInterface,
PrefDefaults,
ItemManagerInterface,
PreferenceServiceInterface,
ChangeAndSaveItem,
DesktopManagerInterface,
UuidString,
ProtectionsClientInterface,
FullyResolvedApplicationOptions,
Uuids,
isNote,
ChallengeReason,
KeyboardModifier,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
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/NotesController'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { AbstractViewController } from '../Abstract/AbstractViewController'
@@ -40,14 +48,28 @@ import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewCon
import { FileViewController } from '@/Components/NoteView/Controller/FileViewController'
import { TemplateNoteViewAutofocusBehavior } from '@/Components/NoteView/Controller/TemplateNoteViewControllerOptions'
import { ItemsReloadSource } from './ItemsReloadSource'
import { VaultDisplayServiceEvent } from '@standardnotes/ui-services'
import {
IsNativeMobileWeb,
KeyboardService,
SelectionControllerPersistableValue,
VaultDisplayServiceEvent,
VaultDisplayServiceInterface,
} from '@standardnotes/ui-services'
import { getDayjsFormattedString } from '@/Utils/GetDayjsFormattedString'
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
import { Persistable } from '../Abstract/Persistable'
import { PaneController } from '../PaneController/PaneController'
import { requestCloseAllOpenModalsAndPopovers } from '@/Utils/CloseOpenModalsAndPopovers'
import { PaneLayout } from '../PaneController/PaneLayout'
const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20
const ElementIdScrollContainer = 'notes-scrollable'
export class ItemListController extends AbstractViewController implements InternalEventHandlerInterface {
export class ItemListController
extends AbstractViewController
implements InternalEventHandlerInterface, Persistable<SelectionControllerPersistableValue>
{
completedFullSync = false
noteFilterText = ''
notes: SNNote[] = []
@@ -75,6 +97,10 @@ export class ItemListController extends AbstractViewController implements Intern
isTableViewEnabled = false
private reloadItemsPromise?: Promise<unknown>
lastSelectedItem: ListableContentItem | undefined
selectedUuids: Set<UuidString> = observable(new Set<UuidString>())
selectedItems: Record<UuidString, ListableContentItem> = {}
override deinit() {
super.deinit()
;(this.noteFilterText as unknown) = undefined
@@ -82,113 +108,28 @@ export class ItemListController extends AbstractViewController implements Intern
;(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
;(window.onresize as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(
application: WebApplication,
private keyboardService: KeyboardService,
private paneController: PaneController,
private navigationController: NavigationController,
private searchOptionsController: SearchOptionsController,
private selectionController: SelectedItemsController,
private notesController: NotesController,
private itemManager: ItemManagerInterface,
private preferences: PreferenceServiceInterface,
private itemControllerGroup: ItemGroupController,
private vaultDisplayService: VaultDisplayServiceInterface,
private desktopManager: DesktopManagerInterface | undefined,
private protections: ProtectionsClientInterface,
private options: FullyResolvedApplicationOptions,
private _isNativeMobileWeb: IsNativeMobileWeb,
private _changeAndSaveItem: ChangeAndSaveItem,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
eventBus.addEventHandler(this, CrossControllerEvent.ActiveEditorChanged)
eventBus.addEventHandler(this, VaultDisplayServiceEvent.VaultDisplayOptionsChanged)
this.resetPagination()
this.disposers.push(
application.streamItems<SNNote>([ContentType.TYPES.Note, ContentType.TYPES.File], () => {
void this.reloadItems(ItemsReloadSource.ItemStream)
}),
)
this.disposers.push(
application.streamItems<SNTag>(
[ContentType.TYPES.Tag, ContentType.TYPES.SmartView],
async ({ changed, inserted }) => {
const tags = [...changed, ...inserted]
const { didReloadItems } = await this.reloadDisplayPreferences({ userTriggered: false })
if (!didReloadItems) {
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions()
void this.reloadItems(ItemsReloadSource.ItemStream)
}
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.reloadDisplayPreferences({ userTriggered: false })
}, ApplicationEvent.PreferencesChanged),
)
this.disposers.push(
application.addEventObserver(async () => {
this.application.itemControllerGroup.closeAllItemControllers()
void this.selectFirstItem()
this.setCompletedFullSync(false)
}, ApplicationEvent.SignedIn),
)
this.disposers.push(
application.addEventObserver(async () => {
if (!this.completedFullSync) {
void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => {
if (
this.notes.length === 0 &&
this.navigationController.selected instanceof SmartView &&
this.navigationController.selected.uuid === SystemViewId.AllNotes &&
this.noteFilterText === '' &&
!this.getActiveItemController()
) {
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(ItemsReloadSource.DisplayOptionsChange)
},
),
)
super(eventBus)
makeObservable(this, {
completedFullSync: observable,
@@ -214,21 +155,186 @@ export class ItemListController extends AbstractViewController implements Intern
optionsSubtitle: computed,
activeControllerItem: computed,
selectedUuids: observable,
selectedItems: observable,
selectedItemsCount: computed,
selectedFiles: computed,
selectedFilesCount: computed,
firstSelectedItem: computed,
selectItem: action,
setSelectedUuids: action,
setSelectedItems: action,
hydrateFromPersistedValue: action,
})
eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
eventBus.addEventHandler(this, CrossControllerEvent.ActiveEditorChanged)
eventBus.addEventHandler(this, VaultDisplayServiceEvent.VaultDisplayOptionsChanged)
this.resetPagination()
this.disposers.push(
itemManager.streamItems<SNNote>([ContentType.TYPES.Note, ContentType.TYPES.File], () => {
void this.reloadItems(ItemsReloadSource.ItemStream)
}),
)
this.disposers.push(
itemManager.streamItems<SNTag>(
[ContentType.TYPES.Tag, ContentType.TYPES.SmartView],
async ({ changed, inserted }) => {
const tags = [...changed, ...inserted]
const { didReloadItems } = await this.reloadDisplayPreferences({ userTriggered: false })
if (!didReloadItems) {
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions()
void this.reloadItems(ItemsReloadSource.ItemStream)
}
if (
this.navigationController.selected &&
findInArray(tags, 'uuid', this.navigationController.selected.uuid)
) {
/** Tag title could have changed */
this.reloadPanelTitle()
}
},
),
)
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
eventBus.addEventHandler(this, WebAppEvent.EditorFocused)
this.disposers.push(
reaction(
() => [
this.searchOptionsController.includeProtectedContents,
this.searchOptionsController.includeArchived,
this.searchOptionsController.includeTrashed,
],
() => {
this.reloadNotesDisplayOptions()
void this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
},
),
)
this.disposers.push(
reaction(
() => this.selectedUuids,
() => {
eventBus.publish({
type: CrossControllerEvent.RequestValuePersistence,
payload: undefined,
})
},
),
)
this.disposers.push(
this.itemManager.streamItems<SNNote | FileItem>(
[ContentType.TYPES.Note, ContentType.TYPES.File],
({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedItem of removed) {
this.removeSelectedItem(removedItem.uuid)
}
for (const item of [...changed, ...inserted]) {
if (this.selectedItems[item.uuid]) {
this.selectedItems[item.uuid] = item
}
}
})
},
),
)
window.onresize = () => {
this.resetPagination(true)
}
}
getPersistableValue = (): SelectionControllerPersistableValue => {
return {
selectedUuids: Array.from(this.selectedUuids),
}
}
hydrateFromPersistedValue = (state: SelectionControllerPersistableValue | undefined): void => {
if (!state) {
return
}
if (!this.selectedUuids.size && state.selectedUuids.length > 0) {
if (!this.options.allowNoteSelectionStatePersistence) {
const items = this.itemManager.findItems(state.selectedUuids).filter((item) => !isNote(item))
void this.selectUuids(Uuids(items))
} else {
void this.selectUuids(state.selectedUuids)
}
}
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.TagChanged) {
const payload = event.payload as { userTriggered: boolean }
await this.handleTagChange(payload.userTriggered)
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
this.handleEditorChange().catch(console.error)
} else if (event.type === VaultDisplayServiceEvent.VaultDisplayOptionsChanged) {
void this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
switch (event.type) {
case CrossControllerEvent.TagChanged: {
const payload = event.payload as { userTriggered: boolean }
await this.handleTagChange(payload.userTriggered)
break
}
case CrossControllerEvent.ActiveEditorChanged: {
await this.handleEditorChange()
break
}
case VaultDisplayServiceEvent.VaultDisplayOptionsChanged: {
void this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
break
}
case ApplicationEvent.PreferencesChanged: {
void this.reloadDisplayPreferences({ userTriggered: false })
break
}
case WebAppEvent.EditorFocused: {
this.setShowDisplayOptionsMenu(false)
break
}
case ApplicationEvent.SignedIn: {
this.itemControllerGroup.closeAllItemControllers()
void this.selectFirstItem()
this.setCompletedFullSync(false)
break
}
case ApplicationEvent.CompletedFullSync: {
if (!this.completedFullSync) {
void this.reloadItems(ItemsReloadSource.SyncEvent).then(() => {
if (
this.notes.length === 0 &&
this.navigationController.selected instanceof SmartView &&
this.navigationController.selected.uuid === SystemViewId.AllNotes &&
this.noteFilterText === '' &&
!this.getActiveItemController()
) {
this.createPlaceholderNote()?.catch(console.error)
}
})
this.setCompletedFullSync(true)
break
}
}
}
}
@@ -237,7 +343,7 @@ export class ItemListController extends AbstractViewController implements Intern
}
public getActiveItemController(): NoteViewController | FileViewController | undefined {
return this.application.itemControllerGroup.activeItemViewController
return this.itemControllerGroup.activeItemViewController
}
public get activeControllerItem() {
@@ -249,13 +355,13 @@ export class ItemListController extends AbstractViewController implements Intern
return
}
const note = this.application.items.findItem<SNNote>(uuid)
const note = this.itemManager.findItem<SNNote>(uuid)
if (!note) {
console.warn('Tried accessing a non-existant note of UUID ' + uuid)
return
}
await this.application.itemControllerGroup.createItemController({ note })
await this.itemControllerGroup.createItemController({ note })
await this.publishCrossControllerEventSync(CrossControllerEvent.ActiveEditorChanged)
}
@@ -265,13 +371,13 @@ export class ItemListController extends AbstractViewController implements Intern
return
}
const file = this.application.items.findItem<FileItem>(fileUuid)
const file = this.itemManager.findItem<FileItem>(fileUuid)
if (!file) {
console.warn('Tried accessing a non-existant file of UUID ' + fileUuid)
return
}
await this.application.itemControllerGroup.createItemController({ file })
await this.itemControllerGroup.createItemController({ file })
}
setCompletedFullSync = (completed: boolean) => {
@@ -315,9 +421,9 @@ export class ItemListController extends AbstractViewController implements Intern
return
}
const notes = this.application.items.getDisplayableNotes()
const notes = this.itemManager.getDisplayableNotes()
const items = this.application.items.getDisplayableNotesAndFiles()
const items = this.itemManager.getDisplayableNotesAndFiles()
const renderedItems = items.slice(0, this.notesToDisplay)
@@ -333,7 +439,7 @@ export class ItemListController extends AbstractViewController implements Intern
}
private shouldLeaveSelectionUnchanged = (activeController: NoteViewController | FileViewController | undefined) => {
const hasMultipleItemsSelected = this.selectionController.selectedItemsCount >= 2
const hasMultipleItemsSelected = this.selectedItemsCount >= 2
return (
hasMultipleItemsSelected || (activeController instanceof NoteViewController && activeController.isTemplateNote)
@@ -420,11 +526,11 @@ export class ItemListController extends AbstractViewController implements Intern
}
private shouldSelectActiveItem = (activeItem: SNNote | FileItem) => {
return !this.selectionController.isItemSelected(activeItem)
return !this.isItemSelected(activeItem)
}
shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => {
if (this.application.isNativeMobileWeb()) {
if (this._isNativeMobileWeb.execute().getValue()) {
return false
}
@@ -440,7 +546,7 @@ export class ItemListController extends AbstractViewController implements Intern
}
const userChangedTag = itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange
const hasNoSelectedItem = !this.selectionController.selectedUuids.size
const hasNoSelectedItem = !this.selectedUuids.size
return userChangedTag || hasNoSelectedItem
}
@@ -458,7 +564,7 @@ export class ItemListController extends AbstractViewController implements Intern
if (activeController && activeItem && this.shouldCloseActiveItem(activeItem, itemsReloadSource)) {
this.closeItemController(activeController)
this.selectionController.deselectItem(activeItem)
this.deselectItem(activeItem)
if (this.shouldSelectFirstItem(itemsReloadSource)) {
if (this.isTableViewEnabled && !isMobileScreen()) {
@@ -466,11 +572,11 @@ export class ItemListController extends AbstractViewController implements Intern
}
log(LoggingDomain.Selection, 'Selecting next item after closing active one')
this.selectionController.selectNextItem({ userTriggered: false })
this.selectNextItem({ userTriggered: false })
}
} else if (activeItem && this.shouldSelectActiveItem(activeItem)) {
log(LoggingDomain.Selection, 'Selecting active item')
await this.selectionController.selectItem(activeItem.uuid).catch(console.error)
await this.selectItem(activeItem.uuid).catch(console.error)
} else if (this.shouldSelectFirstItem(itemsReloadSource)) {
await this.selectFirstItem()
} else if (this.shouldSelectNextItemOrCreateNewNote(activeItem)) {
@@ -511,7 +617,7 @@ export class ItemListController extends AbstractViewController implements Intern
},
}
this.application.items.setPrimaryItemDisplayOptions(criteria)
this.itemManager.setPrimaryItemDisplayOptions(criteria)
}
reloadDisplayPreferences = async ({
@@ -525,7 +631,7 @@ export class ItemListController extends AbstractViewController implements Intern
const selectedTag = this.navigationController.selected
const isSystemTag = selectedTag && isSmartView(selectedTag) && isSystemView(selectedTag)
const selectedTagPreferences = isSystemTag
? this.application.getPreference(PrefKey.SystemViewPreferences)?.[selectedTag.uuid as SystemViewId]
? this.preferences.getValue(PrefKey.SystemViewPreferences)?.[selectedTag.uuid as SystemViewId]
: selectedTag?.preferences
this.isTableViewEnabled = Boolean(selectedTagPreferences?.useTableView)
@@ -533,7 +639,7 @@ export class ItemListController extends AbstractViewController implements Intern
const currentSortBy = this.displayOptions.sortBy
let sortBy =
selectedTagPreferences?.sortBy ||
this.application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy])
this.preferences.getValue(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy])
if (sortBy === CollectionSort.UpdatedAt || (sortBy as string) === 'client_updated_at') {
sortBy = CollectionSort.UpdatedAt
}
@@ -543,49 +649,49 @@ export class ItemListController extends AbstractViewController implements Intern
newDisplayOptions.sortDirection =
useBoolean(
selectedTagPreferences?.sortReverse,
this.application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
this.preferences.getValue(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
) === false
? 'dsc'
: 'asc'
newDisplayOptions.includeArchived = useBoolean(
selectedTagPreferences?.showArchived,
this.application.getPreference(PrefKey.NotesShowArchived, PrefDefaults[PrefKey.NotesShowArchived]),
this.preferences.getValue(PrefKey.NotesShowArchived, PrefDefaults[PrefKey.NotesShowArchived]),
)
newDisplayOptions.includeTrashed = useBoolean(
selectedTagPreferences?.showTrashed,
this.application.getPreference(PrefKey.NotesShowTrashed, PrefDefaults[PrefKey.NotesShowTrashed]),
this.preferences.getValue(PrefKey.NotesShowTrashed, PrefDefaults[PrefKey.NotesShowTrashed]),
)
newDisplayOptions.includePinned = !useBoolean(
selectedTagPreferences?.hidePinned,
this.application.getPreference(PrefKey.NotesHidePinned, PrefDefaults[PrefKey.NotesHidePinned]),
this.preferences.getValue(PrefKey.NotesHidePinned, PrefDefaults[PrefKey.NotesHidePinned]),
)
newDisplayOptions.includeProtected = !useBoolean(
selectedTagPreferences?.hideProtected,
this.application.getPreference(PrefKey.NotesHideProtected, PrefDefaults[PrefKey.NotesHideProtected]),
this.preferences.getValue(PrefKey.NotesHideProtected, PrefDefaults[PrefKey.NotesHideProtected]),
)
newWebDisplayOptions.hideNotePreview = useBoolean(
selectedTagPreferences?.hideNotePreview,
this.application.getPreference(PrefKey.NotesHideNotePreview, PrefDefaults[PrefKey.NotesHideNotePreview]),
this.preferences.getValue(PrefKey.NotesHideNotePreview, PrefDefaults[PrefKey.NotesHideNotePreview]),
)
newWebDisplayOptions.hideDate = useBoolean(
selectedTagPreferences?.hideDate,
this.application.getPreference(PrefKey.NotesHideDate, PrefDefaults[PrefKey.NotesHideDate]),
this.preferences.getValue(PrefKey.NotesHideDate, PrefDefaults[PrefKey.NotesHideDate]),
)
newWebDisplayOptions.hideTags = useBoolean(
selectedTagPreferences?.hideTags,
this.application.getPreference(PrefKey.NotesHideTags, PrefDefaults[PrefKey.NotesHideTags]),
this.preferences.getValue(PrefKey.NotesHideTags, PrefDefaults[PrefKey.NotesHideTags]),
)
newWebDisplayOptions.hideEditorIcon = useBoolean(
selectedTagPreferences?.hideEditorIcon,
this.application.getPreference(PrefKey.NotesHideEditorIcon, PrefDefaults[PrefKey.NotesHideEditorIcon]),
this.preferences.getValue(PrefKey.NotesHideEditorIcon, PrefDefaults[PrefKey.NotesHideEditorIcon]),
)
const displayOptionsChanged =
@@ -633,13 +739,13 @@ export class ItemListController extends AbstractViewController implements Intern
const activeRegularTagUuid = selectedTag instanceof SNTag ? selectedTag.uuid : undefined
return this.application.itemControllerGroup.createItemController({
return this.itemControllerGroup.createItemController({
templateOptions: {
title,
tag: activeRegularTagUuid,
createdAt,
autofocusBehavior,
vault: this.application.vaultDisplayService.exclusivelyShownVault,
vault: this.vaultDisplayService.exclusivelyShownVault,
},
})
}
@@ -652,12 +758,12 @@ export class ItemListController extends AbstractViewController implements Intern
const selectedTag = this.navigationController.selected
const isSystemTag = selectedTag && isSmartView(selectedTag) && isSystemView(selectedTag)
const selectedTagPreferences = isSystemTag
? this.application.getPreference(PrefKey.SystemViewPreferences)?.[selectedTag.uuid as SystemViewId]
? this.preferences.getValue(PrefKey.SystemViewPreferences)?.[selectedTag.uuid as SystemViewId]
: selectedTag?.preferences
const titleFormat =
selectedTagPreferences?.newNoteTitleFormat ||
this.application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat])
this.preferences.getValue(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat])
if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) {
return `Note ${this.notes.length + 1}`
@@ -666,7 +772,7 @@ export class ItemListController extends AbstractViewController implements Intern
if (titleFormat === NewNoteTitleFormat.CustomFormat) {
const customFormat =
this.navigationController.selected?.preferences?.customNoteTitleFormat ||
this.application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat])
this.preferences.getValue(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat])
try {
return getDayjsFormattedString(createdAt, customFormat)
@@ -684,7 +790,7 @@ export class ItemListController extends AbstractViewController implements Intern
}
createNewNote = async (title?: string, createdAt?: Date, autofocusBehavior?: TemplateNoteViewAutofocusBehavior) => {
this.notesController.unselectNotes()
void this.publishCrossControllerEventSync(CrossControllerEvent.UnselectAllNotes)
if (this.navigationController.isInSmartView() && !this.navigationController.isInHomeView()) {
await this.navigationController.selectHomeNavigationView()
@@ -694,7 +800,7 @@ export class ItemListController extends AbstractViewController implements Intern
const controller = await this.createNewNoteController(useTitle, createdAt, autofocusBehavior)
this.selectionController.scrollToItem(controller.item)
this.scrollToItem(controller.item)
}
createPlaceholderNote = () => {
@@ -725,7 +831,7 @@ export class ItemListController extends AbstractViewController implements Intern
void this.reloadItems(ItemsReloadSource.Pagination)
if (this.searchSubmitted) {
this.application.getDesktopService()?.searchText(this.noteFilterText)
this.desktopManager?.searchText(this.noteFilterText)
}
}
@@ -759,7 +865,7 @@ export class ItemListController extends AbstractViewController implements Intern
if (item) {
log(LoggingDomain.Selection, 'Selecting first item', item.uuid)
await this.selectionController.selectItemWithScrollHandling(item, {
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
})
@@ -773,12 +879,10 @@ export class ItemListController extends AbstractViewController implements Intern
if (item) {
log(LoggingDomain.Selection, 'selectNextItemOrCreateNewNote')
await this.selectionController
.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
})
.catch(console.error)
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
}).catch(console.error)
} else {
await this.createNewNote()
}
@@ -794,18 +898,16 @@ export class ItemListController extends AbstractViewController implements Intern
}
handleEditorChange = async () => {
const activeNote = this.application.itemControllerGroup.activeItemViewController?.item
const activeNote = this.itemControllerGroup.activeItemViewController?.item
if (activeNote && activeNote.conflictOf) {
this.application
.changeAndSaveItem(activeNote, (mutator) => {
mutator.conflictOf = undefined
})
.catch(console.error)
void this._changeAndSaveItem.execute(activeNote, (mutator) => {
mutator.conflictOf = undefined
})
}
if (this.isFiltering) {
this.application.getDesktopService()?.searchText(this.noteFilterText)
this.desktopManager?.searchText(this.noteFilterText)
}
}
@@ -818,7 +920,7 @@ export class ItemListController extends AbstractViewController implements Intern
private closeItemController(controller: NoteViewController | FileViewController): void {
log(LoggingDomain.Selection, 'Closing item controller', controller.runtimeId)
this.application.itemControllerGroup.closeItemController(controller)
this.itemControllerGroup.closeItemController(controller)
}
handleTagChange = async (userTriggered: boolean) => {
@@ -833,7 +935,7 @@ export class ItemListController extends AbstractViewController implements Intern
this.setNoteFilterText('')
this.application.getDesktopService()?.searchText()
this.desktopManager?.searchText()
this.resetPagination()
@@ -853,7 +955,7 @@ export class ItemListController extends AbstractViewController implements Intern
*/
this.searchSubmitted = true
this.application.getDesktopService()?.searchText(this.noteFilterText)
this.desktopManager?.searchText(this.noteFilterText)
}
get isCurrentNoteTemplate(): boolean {
@@ -894,4 +996,292 @@ export class ItemListController extends AbstractViewController implements Intern
this.handleFilterTextChanged()
this.resetPagination()
}
get selectedItemsCount(): number {
return Object.keys(this.selectedItems).length
}
get selectedFiles(): FileItem[] {
return this.getFilteredSelectedItems<FileItem>(ContentType.TYPES.File)
}
get selectedFilesCount(): number {
return this.selectedFiles.length
}
get firstSelectedItem() {
return Object.values(this.selectedItems)[0]
}
getSelectedItems = () => {
const uuids = Array.from(this.selectedUuids)
return uuids.map((uuid) => this.itemManager.findSureItem<SNNote | FileItem>(uuid)).filter((item) => !!item)
}
getFilteredSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: string): T[] => {
return Object.values(this.selectedItems).filter((item) => {
return !contentType ? true : item.content_type === contentType
}) as T[]
}
setSelectedItems = () => {
this.selectedItems = Object.fromEntries(this.getSelectedItems().map((item) => [item.uuid, item]))
}
setSelectedUuids = (selectedUuids: Set<UuidString>) => {
log(LoggingDomain.Selection, 'Setting selected uuids', selectedUuids)
this.selectedUuids = new Set(selectedUuids)
this.setSelectedItems()
}
private removeSelectedItem = (uuid: UuidString) => {
this.selectedUuids.delete(uuid)
this.setSelectedUuids(this.selectedUuids)
delete this.selectedItems[uuid]
}
public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => {
log(LoggingDomain.Selection, 'Deselecting item', item.uuid)
this.removeSelectedItem(item.uuid)
if (item.uuid === this.lastSelectedItem?.uuid) {
this.lastSelectedItem = undefined
}
}
public isItemSelected = (item: ListableContentItem): boolean => {
return this.selectedUuids.has(item.uuid)
}
private selectItemsRange = async ({
selectedItem,
startingIndex,
endingIndex,
}: {
selectedItem?: ListableContentItem
startingIndex?: number
endingIndex?: number
}): Promise<void> => {
const items = this.renderedItems
const lastSelectedItemIndex = startingIndex ?? items.findIndex((item) => item.uuid == this.lastSelectedItem?.uuid)
const selectedItemIndex = endingIndex ?? 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.protections.authorizeProtectedActionForItems(
itemsToSelect,
ChallengeReason.SelectProtectedNote,
)
for (const item of authorizedItems) {
runInAction(() => {
this.setSelectedUuids(this.selectedUuids.add(item.uuid))
this.lastSelectedItem = item
})
}
}
cancelMultipleSelection = () => {
this.keyboardService.cancelAllKeyboardModifiers()
const firstSelectedItem = this.firstSelectedItem
if (firstSelectedItem) {
this.replaceSelection(firstSelectedItem)
} else {
this.deselectAll()
}
}
private replaceSelection = (item: ListableContentItem): void => {
this.deselectAll()
runInAction(() => this.setSelectedUuids(this.selectedUuids.add(item.uuid)))
this.lastSelectedItem = item
}
selectAll = () => {
void this.selectItemsRange({
startingIndex: 0,
endingIndex: this.listLength - 1,
})
}
deselectAll = (): void => {
this.selectedUuids.clear()
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = undefined
}
openSingleSelectedItem = async ({ userTriggered } = { userTriggered: true }) => {
if (this.selectedItemsCount === 1) {
const item = this.firstSelectedItem
if (item.content_type === ContentType.TYPES.Note) {
await this.openNote(item.uuid)
} else if (item.content_type === ContentType.TYPES.File) {
await this.openFile(item.uuid)
}
if (!this.paneController.isInMobileView || userTriggered) {
void this.paneController.setPaneLayout(PaneLayout.Editing)
}
if (this.paneController.isInMobileView && userTriggered) {
requestCloseAllOpenModalsAndPopovers()
}
}
}
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
): Promise<{
didSelect: boolean
}> => {
const item = this.itemManager.findItem<ListableContentItem>(uuid)
if (!item) {
return {
didSelect: false,
}
}
log(LoggingDomain.Selection, 'Select item', item.uuid)
const supportsMultipleSelection = this.options.allowMultipleSelection
const hasMeta = this.keyboardService.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.keyboardService.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.keyboardService.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1
const isAuthorizedForAccess = await this.protections.authorizeItemAccess(item)
if (supportsMultipleSelection && userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) {
this.removeSelectedItem(uuid)
} else if (isAuthorizedForAccess) {
this.selectedUuids.add(uuid)
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = item
}
} else if (supportsMultipleSelection && userTriggered && hasShift) {
await this.selectItemsRange({ selectedItem: item })
} else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid)
if (shouldSelectNote && isAuthorizedForAccess) {
this.replaceSelection(item)
}
}
await this.openSingleSelectedItem({ userTriggered: userTriggered ?? false })
return {
didSelect: this.selectedUuids.has(uuid),
}
}
selectItemWithScrollHandling = async (
item: {
uuid: ListableContentItem['uuid']
},
{ userTriggered = false, scrollIntoView = true, animated = true },
): Promise<void> => {
const { didSelect } = await this.selectItem(item.uuid, userTriggered)
const avoidMobileScrollingDueToIncompatibilityWithPaneAnimations = isMobileScreen()
if (didSelect && scrollIntoView && !avoidMobileScrollingDueToIncompatibilityWithPaneAnimations) {
this.scrollToItem(item, animated)
}
}
scrollToItem = (item: { uuid: ListableContentItem['uuid'] }, animated = true): void => {
const itemElement = document.getElementById(item.uuid)
itemElement?.scrollIntoView({
behavior: animated ? 'smooth' : 'auto',
})
}
selectUuids = async (uuids: UuidString[], userTriggered = false) => {
const itemsForUuids = this.itemManager.findItems(uuids).filter((item) => !isFile(item))
if (itemsForUuids.length < 1) {
return
}
if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) {
return
}
this.setSelectedUuids(new Set(Uuids(itemsForUuids)))
if (itemsForUuids.length === 1) {
void this.openSingleSelectedItem({ userTriggered })
}
}
selectNextItem = ({ userTriggered } = { userTriggered: true }) => {
const displayableItems = this.items
const currentIndex = displayableItems.findIndex((candidate) => {
return candidate.uuid === this.lastSelectedItem?.uuid
})
let nextIndex = currentIndex + 1
while (nextIndex < displayableItems.length) {
const nextItem = displayableItems[nextIndex]
nextIndex++
if (nextItem.protected) {
continue
}
this.selectItemWithScrollHandling(nextItem, { userTriggered }).catch(console.error)
const nextNoteElement = document.getElementById(nextItem.uuid)
nextNoteElement?.focus()
return
}
}
selectPreviousItem = () => {
const displayableItems = this.items
if (!this.lastSelectedItem) {
return
}
const currentIndex = displayableItems.indexOf(this.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
}
}
}

View File

@@ -14,12 +14,12 @@ import {
ItemInterface,
InternalFeatureService,
InternalFeature,
PreferenceServiceInterface,
} from '@standardnotes/snjs'
import { FilesController } from './FilesController'
import { ItemListController } from './ItemList/ItemListController'
import { LinkingController } from './LinkingController'
import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController'
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
@@ -46,44 +46,52 @@ const createFile = (name: string, options?: Partial<FileItem>) => {
}
describe('LinkingController', () => {
let linkingController: LinkingController
let application: WebApplication
let navigationController: NavigationController
let selectionController: SelectedItemsController
let eventBus: InternalEventBus
let itemListController: ItemListController
let filesController: FilesController
let subscriptionController: SubscriptionController
beforeEach(() => {
application = {
vaults: {} as jest.Mocked<WebApplication['vaults']>,
alerts: {} as jest.Mocked<WebApplication['alerts']>,
sync: {} as jest.Mocked<WebApplication['sync']>,
mutator: {} as jest.Mocked<WebApplication['mutator']>,
preferences: {
getValue: jest.fn().mockReturnValue(true),
} as unknown as jest.Mocked<PreferenceServiceInterface>,
itemControllerGroup: {} as jest.Mocked<WebApplication['itemControllerGroup']>,
navigationController: {} as jest.Mocked<NavigationController>,
itemListController: {} as jest.Mocked<ItemListController>,
filesController: {} as jest.Mocked<FilesController>,
subscriptionController: {} as jest.Mocked<SubscriptionController>,
} as unknown as jest.Mocked<WebApplication>
application.getPreference = jest.fn()
application.addSingleEventObserver = jest.fn()
application.streamItems = jest.fn()
application.sync.sync = jest.fn()
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemManagerInterface> })
navigationController = {} as jest.Mocked<NavigationController>
selectionController = {} as jest.Mocked<SelectedItemsController>
eventBus = {} as jest.Mocked<InternalEventBus>
eventBus.addEventHandler = jest.fn()
itemListController = {} as jest.Mocked<ItemListController>
filesController = {} as jest.Mocked<FilesController>
subscriptionController = {} as jest.Mocked<SubscriptionController>
linkingController = new LinkingController(application, navigationController, selectionController, eventBus)
linkingController.setServicesPostConstruction(itemListController, filesController, subscriptionController)
Object.defineProperty(application, 'linkingController', {
get: () =>
new LinkingController(
application.itemListController,
application.filesController,
application.subscriptionController,
application.navigationController,
application.itemControllerGroup,
application.vaultDisplayService,
application.preferences,
application.items,
application.mutator,
application.sync,
application.vaults,
eventBus,
),
configurable: true,
})
})
describe('isValidSearchResult', () => {
@@ -257,7 +265,7 @@ describe('LinkingController', () => {
return undefined
})
await linkingController.linkItems(note, file)
await application.linkingController.linkItems(note, file)
expect(moveToVaultSpy).toHaveBeenCalled()
})

View File

@@ -17,6 +17,13 @@ import {
InternalEventBusInterface,
isTag,
PrefDefaults,
PreferenceServiceInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
MutatorClientInterface,
SyncServiceInterface,
VaultServiceInterface,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
@@ -24,25 +31,30 @@ import { CrossControllerEvent } from './CrossControllerEvent'
import { FilesController } from './FilesController'
import { ItemListController } from './ItemList/ItemListController'
import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController'
import { WebApplication } from '@/Application/WebApplication'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
import { VaultDisplayServiceInterface } from '@standardnotes/ui-services'
export class LinkingController extends AbstractViewController {
export class LinkingController extends AbstractViewController implements InternalEventHandlerInterface {
shouldLinkToParentFolders: boolean
isLinkingPanelOpen = false
private itemListController!: ItemListController
private filesController!: FilesController
private subscriptionController!: SubscriptionController
constructor(
application: WebApplication,
private itemListController: ItemListController,
private filesController: FilesController,
private subscriptionController: SubscriptionController,
private navigationController: NavigationController,
private selectionController: SelectedItemsController,
private itemControllerGroup: ItemGroupController,
private vaultDisplayService: VaultDisplayServiceInterface,
private preferences: PreferenceServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private vaults: VaultServiceInterface,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
super(eventBus)
makeObservable(this, {
isLinkingPanelOpen: observable,
@@ -52,29 +64,26 @@ export class LinkingController extends AbstractViewController {
setIsLinkingPanelOpen: action,
})
this.shouldLinkToParentFolders = application.getPreference(
this.shouldLinkToParentFolders = preferences.getValue(
PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders],
)
this.disposers.push(
this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.shouldLinkToParentFolders = this.application.getPreference(
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
}
handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.PreferencesChanged: {
this.shouldLinkToParentFolders = this.preferences.getValue(
PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders],
)
}),
)
}
break
}
}
public setServicesPostConstruction(
itemListController: ItemListController,
filesController: FilesController,
subscriptionController: SubscriptionController,
) {
this.itemListController = itemListController
this.filesController = filesController
this.subscriptionController = subscriptionController
return Promise.resolve()
}
get isEntitledToNoteLinking() {
@@ -86,20 +95,20 @@ export class LinkingController extends AbstractViewController {
}
get activeItem() {
return this.application.itemControllerGroup.activeItemViewController?.item
return this.itemControllerGroup.activeItemViewController?.item
}
getFilesLinksForItem = (item: LinkableItem | undefined) => {
if (!item || this.application.items.isTemplateItem(item)) {
if (!item || this.items.isTemplateItem(item)) {
return {
filesLinkedToItem: [],
filesLinkingToItem: [],
}
}
const referencesOfItem = naturalSort(this.application.items.referencesForItem(item).filter(isFile), 'title')
const referencesOfItem = naturalSort(this.items.referencesForItem(item).filter(isFile), 'title')
const referencingItem = naturalSort(this.application.items.itemsReferencingItem(item).filter(isFile), 'title')
const referencingItem = naturalSort(this.items.itemsReferencingItem(item).filter(isFile), 'title')
if (item.content_type === ContentType.TYPES.File) {
return {
@@ -119,15 +128,15 @@ export class LinkingController extends AbstractViewController {
return
}
return this.application.items.getSortedTagsForItem(item).map((tag) => createLinkFromItem(tag, 'linked'))
return this.items.getSortedTagsForItem(item).map((tag) => createLinkFromItem(tag, 'linked'))
}
getLinkedNotesForItem = (item: LinkableItem | undefined) => {
if (!item || this.application.items.isTemplateItem(item)) {
if (!item || this.items.isTemplateItem(item)) {
return []
}
return naturalSort(this.application.items.referencesForItem(item).filter(isNote), 'title').map((item) =>
return naturalSort(this.items.referencesForItem(item).filter(isNote), 'title').map((item) =>
createLinkFromItem(item, 'linked'),
)
}
@@ -137,7 +146,7 @@ export class LinkingController extends AbstractViewController {
return []
}
return naturalSort(this.application.items.itemsReferencingItem(item).filter(isNote), 'title').map((item) =>
return naturalSort(this.items.itemsReferencingItem(item).filter(isNote), 'title').map((item) =>
createLinkFromItem(item, 'linked-by'),
)
}
@@ -150,7 +159,7 @@ export class LinkingController extends AbstractViewController {
return AppPaneId.Items
} else if (item instanceof SNNote) {
await this.navigationController.selectHomeNavigationView()
const { didSelect } = await this.selectionController.selectItem(item.uuid, true)
const { didSelect } = await this.itemListController.selectItem(item.uuid, true)
if (didSelect) {
return AppPaneId.Editor
}
@@ -169,16 +178,16 @@ export class LinkingController extends AbstractViewController {
unlinkItems = async (item: LinkableItem, itemToUnlink: LinkableItem) => {
try {
await this.application.mutator.unlinkItems(item, itemToUnlink)
await this.mutator.unlinkItems(item, itemToUnlink)
} catch (error) {
console.error(error)
}
void this.application.sync.sync()
void this.sync.sync()
}
unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => {
const selectedItem = this.selectionController.firstSelectedItem
const selectedItem = this.itemListController.firstSelectedItem
if (!selectedItem) {
return
@@ -196,25 +205,25 @@ export class LinkingController extends AbstractViewController {
linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
const linkNoteAndFile = async (note: SNNote, file: FileItem) => {
const updatedFile = await this.application.mutator.associateFileWithNote(file, note)
const updatedFile = await this.mutator.associateFileWithNote(file, note)
if (featureTrunkVaultsEnabled()) {
if (updatedFile) {
const noteVault = this.application.vaults.getItemVault(note)
const fileVault = this.application.vaults.getItemVault(updatedFile)
const noteVault = this.vaults.getItemVault(note)
const fileVault = this.vaults.getItemVault(updatedFile)
if (noteVault && !fileVault) {
await this.application.vaults.moveItemToVault(noteVault, file)
await this.vaults.moveItemToVault(noteVault, file)
}
}
}
}
const linkFileAndFile = async (file1: FileItem, file2: FileItem) => {
await this.application.mutator.linkFileToFile(file1, file2)
await this.mutator.linkFileToFile(file1, file2)
}
const linkNoteToNote = async (note1: SNNote, note2: SNNote) => {
await this.application.mutator.linkNoteToNote(note1, note2)
await this.mutator.linkNoteToNote(note1, note2)
}
const linkTagToNote = async (tag: SNTag, note: SNNote) => {
@@ -260,7 +269,7 @@ export class LinkingController extends AbstractViewController {
throw new Error('First item must be a note or file')
}
void this.application.sync.sync()
void this.sync.sync()
}
linkItemToSelectedItem = async (itemToLink: LinkableItem): Promise<boolean> => {
@@ -286,9 +295,9 @@ export class LinkingController extends AbstractViewController {
createAndAddNewTag = async (title: string): Promise<SNTag> => {
await this.ensureActiveItemIsInserted()
const vault = this.application.vaultDisplayService.exclusivelyShownVault
const vault = this.vaultDisplayService.exclusivelyShownVault
const newTag = await this.application.mutator.findOrCreateTag(title, vault)
const newTag = await this.mutator.findOrCreateTag(title, vault)
const activeItem = this.activeItem
if (activeItem) {
@@ -300,11 +309,11 @@ export class LinkingController extends AbstractViewController {
addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {
if (item instanceof SNNote) {
await this.application.mutator.addTagToNote(item, tag, this.shouldLinkToParentFolders)
await this.mutator.addTagToNote(item, tag, this.shouldLinkToParentFolders)
} else if (item instanceof FileItem) {
await this.application.mutator.addTagToFile(item, tag, this.shouldLinkToParentFolders)
await this.mutator.addTagToFile(item, tag, this.shouldLinkToParentFolders)
}
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
}
}

View File

@@ -1,13 +1,26 @@
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { ApplicationEvent, InternalEventBusInterface, StorageKey } from '@standardnotes/services'
import {
ApplicationEvent,
DesktopDeviceInterface,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
PreferenceServiceInterface,
ProtectionEvent,
ProtectionsClientInterface,
StorageKey,
StorageServiceInterface,
} from '@standardnotes/services'
import { isDev } from '@/Utils'
import { FileItem, PrefKey, sleep, SNTag } from '@standardnotes/snjs'
import { FilesController } from '../FilesController'
import { action, makeObservable, observable } from 'mobx'
import { AbstractViewController } from '@/Controllers/Abstract/AbstractViewController'
import { WebApplication } from '@/Application/WebApplication'
import { dateToStringStyle1 } from '@/Utils/DateUtils'
import { PhotoRecorder } from './PhotoRecorder'
import { LinkingController } from '../LinkingController'
import { IsMobileDevice } from '@standardnotes/ui-services'
const EVERY_HOUR = 1000 * 60 * 60
const EVERY_TEN_SECONDS = 1000 * 10
@@ -15,33 +28,22 @@ const DEBUG_MODE = isDev && false
const DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS = 2000
export class MomentsService extends AbstractViewController {
export class MomentsService extends AbstractViewController implements InternalEventHandlerInterface {
isEnabled = false
private intervalReference: ReturnType<typeof setInterval> | undefined
constructor(
application: WebApplication,
private filesController: FilesController,
private linkingController: LinkingController,
private storage: StorageServiceInterface,
private preferences: PreferenceServiceInterface,
private items: ItemManagerInterface,
private protections: ProtectionsClientInterface,
private desktopDevice: DesktopDeviceInterface | undefined,
private _isMobileDevice: IsMobileDevice,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
this.disposers.push(
application.addEventObserver(async () => {
this.isEnabled = (this.application.getValue(StorageKey.MomentsEnabled) as boolean) ?? false
if (this.isEnabled) {
void this.beginTakingPhotos()
}
}, ApplicationEvent.LocalDataLoaded),
application.addEventObserver(async () => {
this.pauseMoments()
}, ApplicationEvent.BiometricsSoftLockEngaged),
application.addEventObserver(async () => {
this.resumeMoments()
}, ApplicationEvent.BiometricsSoftLockDisengaged),
)
super(eventBus)
makeObservable(this, {
isEnabled: observable,
@@ -49,16 +51,41 @@ export class MomentsService extends AbstractViewController {
enableMoments: action,
disableMoments: action,
})
eventBus.addEventHandler(this, ApplicationEvent.LocalDataLoaded)
eventBus.addEventHandler(this, ProtectionEvent.BiometricsSoftLockEngaged)
eventBus.addEventHandler(this, ProtectionEvent.BiometricsSoftLockDisengaged)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.LocalDataLoaded: {
this.isEnabled = (this.storage.getValue(StorageKey.MomentsEnabled) as boolean) ?? false
if (this.isEnabled) {
void this.beginTakingPhotos()
}
break
}
case ProtectionEvent.BiometricsSoftLockEngaged: {
this.pauseMoments()
break
}
case ProtectionEvent.BiometricsSoftLockDisengaged: {
this.resumeMoments()
break
}
}
}
override deinit() {
super.deinit()
;(this.application as unknown) = undefined
;(this.filesController as unknown) = undefined
}
public enableMoments = (): void => {
this.application.setValue(StorageKey.MomentsEnabled, true)
this.storage.setValue(StorageKey.MomentsEnabled, true)
this.isEnabled = true
@@ -66,7 +93,7 @@ export class MomentsService extends AbstractViewController {
}
public disableMoments = (): void => {
this.application.setValue(StorageKey.MomentsEnabled, false)
this.storage.setValue(StorageKey.MomentsEnabled, false)
this.isEnabled = false
@@ -101,15 +128,15 @@ export class MomentsService extends AbstractViewController {
}
private getDefaultTag(): SNTag | undefined {
const defaultTagId = this.application.getPreference(PrefKey.MomentsDefaultTagUuid)
const defaultTagId = this.preferences.getValue(PrefKey.MomentsDefaultTagUuid)
if (defaultTagId) {
return this.application.items.findItem(defaultTagId)
return this.items.findItem(defaultTagId)
}
}
public takePhoto = async (): Promise<FileItem | undefined> => {
const isAppLocked = await this.application.isLocked()
const isAppLocked = await this.protections.isLocked()
if (isAppLocked) {
return
@@ -127,8 +154,8 @@ export class MomentsService extends AbstractViewController {
})
}
if (this.application.desktopDevice) {
const granted = await this.application.desktopDevice.askForMediaAccess('camera')
if (this.desktopDevice) {
const granted = await this.desktopDevice.askForMediaAccess('camera')
if (!granted) {
if (toastId) {
dismissToast(toastId)
@@ -147,7 +174,7 @@ export class MomentsService extends AbstractViewController {
const camera = new PhotoRecorder()
await camera.initialize()
if (this.application.isMobileDevice) {
if (this._isMobileDevice.execute().getValue()) {
await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS)
}
@@ -168,12 +195,12 @@ export class MomentsService extends AbstractViewController {
if (uploadedFile) {
if (isAppInForeground) {
void this.application.linkingController.linkItemToSelectedItem(uploadedFile)
void this.linkingController.linkItemToSelectedItem(uploadedFile)
}
const defaultTag = this.getDefaultTag()
if (defaultTag) {
void this.application.linkingController.linkItems(uploadedFile, defaultTag)
void this.linkingController.linkItems(uploadedFile, defaultTag)
}
}

View File

@@ -1,7 +1,9 @@
import {
confirmDialog,
CREATE_NEW_TAG_COMMAND,
KeyboardService,
NavigationControllerPersistableValue,
VaultDisplayService,
VaultDisplayServiceEvent,
} from '@standardnotes/ui-services'
import { STRING_DELETE_TAG } from '@/Constants/Strings'
@@ -22,9 +24,14 @@ import {
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
SyncServiceInterface,
MutatorClientInterface,
AlertService,
PreferenceServiceInterface,
ChangeAndSaveItem,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
import { FeaturesController } from '../FeaturesController'
import { destroyAllObjectProperties } from '@/Utils'
import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils'
@@ -35,6 +42,7 @@ import { Persistable } from '../Abstract/Persistable'
import { TagListSectionType } from '@/Components/Tags/TagListSection'
import { PaneLayout } from '../PaneController/PaneLayout'
import { TagsCountsState } from './TagsCountsState'
import { PaneController } from '../PaneController/PaneController'
export class NavigationController
extends AbstractViewController
@@ -63,16 +71,24 @@ export class NavigationController
private readonly tagsCountsState: TagsCountsState
constructor(
application: WebApplication,
private featuresController: FeaturesController,
private vaultDisplayService: VaultDisplayService,
private keyboardService: KeyboardService,
private paneController: PaneController,
private sync: SyncServiceInterface,
private mutator: MutatorClientInterface,
private items: ItemManagerInterface,
private preferences: PreferenceServiceInterface,
private alerts: AlertService,
private _changeAndSaveItem: ChangeAndSaveItem,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
super(eventBus)
eventBus.addEventHandler(this, VaultDisplayServiceEvent.VaultDisplayOptionsChanged)
this.tagsCountsState = new TagsCountsState(this.application)
this.smartViews = this.application.items.getSmartViews()
this.tagsCountsState = new TagsCountsState(items)
this.smartViews = items.getSmartViews()
makeObservable(this, {
tags: observable,
@@ -122,7 +138,7 @@ export class NavigationController
})
this.disposers.push(
this.application.streamItems([ContentType.TYPES.Tag, ContentType.TYPES.SmartView], ({ changed, removed }) => {
this.items.streamItems([ContentType.TYPES.Tag, ContentType.TYPES.SmartView], ({ changed, removed }) => {
this.reloadTags()
runInAction(() => {
@@ -150,12 +166,12 @@ export class NavigationController
)
this.disposers.push(
this.application.items.addNoteCountChangeObserver((tagUuid) => {
this.items.addNoteCountChangeObserver((tagUuid) => {
if (!tagUuid) {
this.setAllNotesCount(this.application.items.allCountableNotesCount())
this.setAllFilesCount(this.application.items.allCountableFilesCount())
this.setAllNotesCount(this.items.allCountableNotesCount())
this.setAllFilesCount(this.items.allCountableFilesCount())
} else {
const tag = this.application.items.findItem<SNTag>(tagUuid)
const tag = this.items.findItem<SNTag>(tagUuid)
if (tag) {
this.tagsCountsState.update([tag])
}
@@ -176,7 +192,7 @@ export class NavigationController
)
this.disposers.push(
this.application.keyboardService.addCommandHandler({
this.keyboardService.addCommandHandler({
command: CREATE_NEW_TAG_COMMAND,
onKeyDown: () => {
this.createNewTemplate()
@@ -187,9 +203,9 @@ export class NavigationController
private reloadTags(): void {
runInAction(() => {
this.tags = this.application.items.getDisplayableTags()
this.tags = this.items.getDisplayableTags()
this.starredTags = this.tags.filter((tag) => tag.starred)
this.smartViews = this.application.items.getSmartViews()
this.smartViews = this.items.getSmartViews()
})
}
@@ -258,14 +274,14 @@ export class NavigationController
return
}
const createdTag = await this.application.mutator.createTagOrSmartView<SNTag>(
const createdTag = await this.mutator.createTagOrSmartView<SNTag>(
title,
this.application.vaultDisplayService.exclusivelyShownVault,
this.vaultDisplayService.exclusivelyShownVault,
)
const futureSiblings = this.application.items.getTagChildren(parent)
const futureSiblings = this.items.getTagChildren(parent)
if (!isValidFutureSiblings(this.application, futureSiblings, createdTag)) {
if (!isValidFutureSiblings(this.alerts, futureSiblings, createdTag)) {
this.setAddingSubtagTo(undefined)
this.remove(createdTag, false).catch(console.error)
return
@@ -273,7 +289,7 @@ export class NavigationController
this.assignParent(createdTag.uuid, parent.uuid).catch(console.error)
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
runInAction(() => {
void this.setSelectedTag(createdTag as SNTag, 'all')
@@ -301,7 +317,7 @@ export class NavigationController
tagUsesTableView(tag: AnyTag): boolean {
const isSystemView = tag instanceof SmartView && Object.values(SystemViewId).includes(tag.uuid as SystemViewId)
const useTableView = isSystemView
? this.application.getPreference(PrefKey.SystemViewPreferences)?.[tag.uuid as SystemViewId]
? this.preferences.getValue(PrefKey.SystemViewPreferences)?.[tag.uuid as SystemViewId]
: tag?.preferences
return Boolean(useTableView)
}
@@ -390,7 +406,7 @@ export class NavigationController
}
public get allLocalRootTags(): SNTag[] {
if (this.editing_ instanceof SNTag && this.application.items.isTemplateItem(this.editing_)) {
if (this.editing_ instanceof SNTag && this.items.isTemplateItem(this.editing_)) {
return [this.editing_, ...this.rootTags]
}
return this.rootTags
@@ -401,11 +417,11 @@ export class NavigationController
}
getChildren(tag: SNTag): SNTag[] {
if (this.application.items.isTemplateItem(tag)) {
if (this.items.isTemplateItem(tag)) {
return []
}
const children = this.application.items.getTagChildren(tag)
const children = this.items.getTagChildren(tag)
const childrenUuids = children.map((childTag) => childTag.uuid)
const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid))
@@ -413,45 +429,45 @@ export class NavigationController
}
isValidTagParent(parent: SNTag, tag: SNTag): boolean {
return this.application.items.isValidTagParent(parent, tag)
return this.items.isValidTagParent(parent, tag)
}
public hasParent(tagUuid: UuidString): boolean {
const item = this.application.items.findItem(tagUuid)
const item = this.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 tag = this.items.findItem(tagUuid) as SNTag
const currentParent = this.application.items.getTagParent(tag)
const currentParent = this.items.getTagParent(tag)
const currentParentUuid = currentParent?.uuid
if (currentParentUuid === futureParentUuid) {
return
}
const futureParent = futureParentUuid && (this.application.items.findItem(futureParentUuid) as SNTag)
const futureParent = futureParentUuid && (this.items.findItem(futureParentUuid) as SNTag)
if (!futureParent) {
const futureSiblings = rootTags(this.application)
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
const futureSiblings = rootTags(this.items)
if (!isValidFutureSiblings(this.alerts, futureSiblings, tag)) {
return
}
await this.application.mutator.unsetTagParent(tag)
await this.mutator.unsetTagParent(tag)
} else {
const futureSiblings = this.application.items.getTagChildren(futureParent)
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
const futureSiblings = this.items.getTagChildren(futureParent)
if (!isValidFutureSiblings(this.alerts, futureSiblings, tag)) {
return
}
await this.application.mutator.setTagParent(futureParent, tag)
await this.mutator.setTagParent(futureParent, tag)
}
await this.application.sync.sync()
await this.sync.sync()
}
get rootTags(): SNTag[] {
return this.tags.filter((tag) => !this.application.items.getTagParent(tag))
return this.tags.filter((tag) => !this.items.getTagParent(tag))
}
get tagsCount(): number {
@@ -483,7 +499,7 @@ export class NavigationController
}
public async setPanelWidthForTag(tag: SNTag, width: number): Promise<void> {
await this.application.changeAndSaveItem<TagMutator>(tag, (mutator) => {
await this._changeAndSaveItem.execute<TagMutator>(tag, (mutator) => {
mutator.preferences = {
...mutator.preferences,
panelWidth: width,
@@ -497,17 +513,17 @@ export class NavigationController
{ userTriggered } = { userTriggered: false },
) {
if (tag && tag.conflictOf) {
this.application
.changeAndSaveItem(tag, (mutator) => {
this._changeAndSaveItem
.execute(tag, (mutator) => {
mutator.conflictOf = undefined
})
.catch(console.error)
}
if (tag && (this.isTagFilesView(tag) || this.tagUsesTableView(tag))) {
this.application.paneController.setPaneLayout(PaneLayout.TableView)
this.paneController.setPaneLayout(PaneLayout.TableView)
} else if (userTriggered) {
this.application.paneController.setPaneLayout(PaneLayout.ItemSelection)
this.paneController.setPaneLayout(PaneLayout.ItemSelection)
}
this.previouslySelected_ = this.selected_
@@ -516,7 +532,7 @@ export class NavigationController
this.setSelectedTagInstance(tag)
this.selectedLocation = location
if (tag && this.application.items.isTemplateItem(tag)) {
if (tag && this.items.isTemplateItem(tag)) {
return
}
@@ -558,24 +574,24 @@ export class NavigationController
return
}
this.application
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
this._changeAndSaveItem
.execute<TagMutator>(tag, (mutator) => {
mutator.expanded = expanded
})
.catch(console.error)
}
public async setFavorite(tag: SNTag, favorite: boolean) {
return this.application
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
return this._changeAndSaveItem
.execute<TagMutator>(tag, (mutator) => {
mutator.starred = favorite
})
.catch(console.error)
}
public setIcon(tag: SNTag, icon: VectorIconNameOrEmoji) {
this.application
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
this._changeAndSaveItem
.execute<TagMutator>(tag, (mutator) => {
mutator.iconString = icon as string
})
.catch(console.error)
@@ -593,13 +609,13 @@ export class NavigationController
}
public createNewTemplate() {
const isAlreadyEditingATemplate = this.editing_ && this.application.items.isTemplateItem(this.editing_)
const isAlreadyEditingATemplate = this.editing_ && this.items.isTemplateItem(this.editing_)
if (isAlreadyEditingATemplate) {
return
}
const newTag = this.application.items.createTemplateItem(ContentType.TYPES.Tag) as SNTag
const newTag = this.items.createTemplateItem(ContentType.TYPES.Tag) as SNTag
runInAction(() => {
this.selectedLocation = 'all'
@@ -622,9 +638,9 @@ export class NavigationController
})
}
if (shouldDelete) {
this.application.mutator
this.mutator
.deleteItem(tag)
.then(() => this.application.sync.sync())
.then(() => this.sync.sync())
.catch(console.error)
await this.setSelectedTag(this.smartViews[0], 'views')
}
@@ -633,9 +649,9 @@ export class NavigationController
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 isTemplateChange = this.items.isTemplateItem(tag)
const siblings = tag instanceof SNTag ? tagSiblings(this.application, tag) : []
const siblings = tag instanceof SNTag ? tagSiblings(this.items, tag) : []
const hasDuplicatedTitle = siblings.some((other) => other.title.toLowerCase() === newTitle.toLowerCase())
runInAction(() => {
@@ -653,12 +669,12 @@ export class NavigationController
if (isTemplateChange) {
this.undoCreateNewTag()
}
this.application.alerts?.alert('A tag with this name already exists.').catch(console.error)
this.alerts.alert('A tag with this name already exists.').catch(console.error)
return
}
if (isTemplateChange) {
const isSmartViewTitle = this.application.items.isSmartViewTitle(newTitle)
const isSmartViewTitle = this.items.isSmartViewTitle(newTitle)
if (isSmartViewTitle) {
if (!this.featuresController.hasSmartViews) {
@@ -667,16 +683,16 @@ export class NavigationController
}
}
const insertedTag = await this.application.mutator.createTagOrSmartView<SNTag>(
const insertedTag = await this.mutator.createTagOrSmartView<SNTag>(
newTitle,
this.application.vaultDisplayService.exclusivelyShownVault,
this.vaultDisplayService.exclusivelyShownVault,
)
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
runInAction(() => {
void this.setSelectedTag(insertedTag, this.selectedLocation || 'views')
})
} else {
await this.application.changeAndSaveItem<TagMutator>(tag, (mutator) => {
await this._changeAndSaveItem.execute<TagMutator>(tag, (mutator) => {
mutator.title = newTitle
})
}

View File

@@ -1,11 +1,10 @@
import { SNTag } from '@standardnotes/snjs'
import { ItemManagerInterface, SNTag } from '@standardnotes/snjs'
import { action, makeAutoObservable, observable } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
export class TagsCountsState {
public counts: { [uuid: string]: number } = {}
public constructor(private application: WebApplication) {
public constructor(private items: ItemManagerInterface) {
makeAutoObservable(this, {
counts: observable.ref,
update: action,
@@ -16,7 +15,7 @@ export class TagsCountsState {
const newCounts: { [uuid: string]: number } = Object.assign({}, this.counts)
tags.forEach((tag) => {
newCounts[tag.uuid] = this.application.items.countableNotesForTag(tag)
newCounts[tag.uuid] = this.items.countableNotesForTag(tag)
})
this.counts = newCounts

View File

@@ -1,33 +1,33 @@
import { SNApplication, SNTag } from '@standardnotes/snjs'
import { AlertService, ItemManagerInterface, SNTag } from '@standardnotes/snjs'
export const rootTags = (application: SNApplication): SNTag[] => {
const hasNoParent = (tag: SNTag) => !application.items.getTagParent(tag)
export const rootTags = (items: ItemManagerInterface): SNTag[] => {
const hasNoParent = (tag: SNTag) => !items.getTagParent(tag)
const allTags = application.items.getDisplayableTags()
const allTags = items.getDisplayableTags()
const rootTags = allTags.filter(hasNoParent)
return rootTags
}
export const tagSiblings = (application: SNApplication, tag: SNTag): SNTag[] => {
export const tagSiblings = (items: ItemManagerInterface, 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)
const isTemplateTag = items.isTemplateItem(tag)
const parentTag = !isTemplateTag && items.getTagParent(tag)
if (parentTag) {
const siblingsAndTag = application.items.getTagChildren(parentTag)
const siblingsAndTag = items.getTagChildren(parentTag)
return withoutCurrentTag(siblingsAndTag)
}
return withoutCurrentTag(rootTags(application))
return withoutCurrentTag(rootTags(items))
}
export const isValidFutureSiblings = (application: SNApplication, futureSiblings: SNTag[], tag: SNTag): boolean => {
export const isValidFutureSiblings = (alerts: AlertService, futureSiblings: SNTag[], tag: SNTag): boolean => {
const siblingWithSameName = futureSiblings.find((otherTag) => otherTag.title === tag.title)
if (siblingWithSameName) {
application.alerts
alerts
?.alert(
`A tag with the name ${tag.title} already exists at this destination. Please rename this tag before moving and try again.`,
)

View File

@@ -1,34 +1,27 @@
import { storage, StorageKey } from '@standardnotes/ui-services'
import { ApplicationEvent, InternalEventBusInterface } from '@standardnotes/snjs'
import {
ApplicationEvent,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
SessionsClientInterface,
} from '@standardnotes/snjs'
import { runInAction, makeObservable, observable, action } from 'mobx'
import { WebApplication } from '../Application/WebApplication'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class NoAccountWarningController extends AbstractViewController {
export class NoAccountWarningController extends AbstractViewController implements InternalEventHandlerInterface {
show: boolean
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private sessions: SessionsClientInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
this.show = application.hasAccount() ? false : storage.get(StorageKey.ShowNoAccountWarning) ?? true
this.show = sessions.isSignedIn() ? 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),
)
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
eventBus.addEventHandler(this, ApplicationEvent.Started)
makeObservable(this, {
show: observable,
@@ -36,6 +29,23 @@ export class NoAccountWarningController extends AbstractViewController {
})
}
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.SignedIn:
runInAction(() => {
this.show = false
})
break
case ApplicationEvent.Started:
if (this.sessions.isSignedIn()) {
runInAction(() => {
this.show = false
})
}
break
}
}
hide = (): void => {
this.show = false
storage.set(StorageKey.ShowNoAccountWarning, false)

View File

@@ -1,6 +1,5 @@
import { WebApplication } from '@/Application/WebApplication'
import { InternalEventBusInterface, SNNote } from '@standardnotes/snjs'
import { OPEN_NOTE_HISTORY_COMMAND } from '@standardnotes/ui-services'
import { KeyboardService, OPEN_NOTE_HISTORY_COMMAND } from '@standardnotes/ui-services'
import { action, makeObservable, observable } from 'mobx'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { NotesControllerInterface } from '../NotesController/NotesControllerInterface'
@@ -14,11 +13,11 @@ export class HistoryModalController extends AbstractViewController {
}
constructor(
application: WebApplication,
eventBus: InternalEventBusInterface,
notesController: NotesControllerInterface,
keyboardService: KeyboardService,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
super(eventBus)
makeObservable(this, {
note: observable,
@@ -26,7 +25,7 @@ export class HistoryModalController extends AbstractViewController {
})
this.disposers.push(
application.keyboardService.addCommandHandler({
keyboardService.addCommandHandler({
command: OPEN_NOTE_HISTORY_COMMAND,
onKeyDown: () => {
this.openModal(notesController.firstSelectedNote)

View File

@@ -1,42 +1,38 @@
import { WebApplication } from '@/Application/WebApplication'
import { RevisionType } from '@/Components/RevisionHistoryModal/RevisionType'
import {
LegacyHistoryEntry,
ListGroup,
RemoteRevisionListGroup,
sortRevisionListIntoGroups,
} from '@/Components/RevisionHistoryModal/utils'
import { sortRevisionListIntoGroups } from '@/Components/RevisionHistoryModal/utils'
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/Constants/Strings'
import { confirmDialog } from '@standardnotes/ui-services'
import {
Action,
ActionVerb,
ActionsService,
AlertService,
ButtonType,
ChangeAndSaveItem,
DeleteRevision,
FeaturesClientInterface,
GetRevision,
HistoryEntry,
HistoryServiceInterface,
ItemManagerInterface,
ListRevisions,
MutatorClientInterface,
NoteHistoryEntry,
PayloadEmitSource,
RevisionMetadata,
SNNote,
SyncServiceInterface,
} from '@standardnotes/snjs'
import { makeObservable, observable, action } from 'mobx'
import { SelectedItemsController } from '../SelectedItemsController'
type RemoteHistory = RemoteRevisionListGroup[]
type SessionHistory = ListGroup<NoteHistoryEntry>[]
type LegacyHistory = Action[]
type SelectedRevision = HistoryEntry | LegacyHistoryEntry | undefined
type SelectedEntry = RevisionMetadata | NoteHistoryEntry | Action | undefined
export enum RevisionContentState {
Idle,
Loading,
Loaded,
NotEntitled,
}
import {
RemoteHistory,
SessionHistory,
LegacyHistory,
SelectedRevision,
SelectedEntry,
RevisionContentState,
} from './Types'
import { ItemListController } from '../ItemList/ItemListController'
export class NoteHistoryController {
remoteHistory: RemoteHistory = []
@@ -52,12 +48,20 @@ export class NoteHistoryController {
currentTab = RevisionType.Remote
constructor(
private application: WebApplication,
private note: SNNote,
private selectionController: SelectedItemsController,
private itemListController: ItemListController,
private features: FeaturesClientInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private actions: ActionsService,
private history: HistoryServiceInterface,
private alerts: AlertService,
private _getRevision: GetRevision,
private _listRevisions: ListRevisions,
private _deleteRevision: DeleteRevision,
private _changeAndSaveItem: ChangeAndSaveItem,
) {
void this.fetchAllHistory()
makeObservable(this, {
selectedRevision: observable,
setSelectedRevision: action,
@@ -84,6 +88,8 @@ export class NoteHistoryController {
contentState: observable,
setContentState: action,
})
void this.fetchAllHistory()
}
setSelectedRevision = (revision: SelectedRevision) => {
@@ -119,7 +125,7 @@ export class NoteHistoryController {
return
}
if (!this.application.features.hasMinimumRole(entry.required_role)) {
if (!this.features.hasMinimumRole(entry.required_role)) {
this.setContentState(RevisionContentState.NotEntitled)
this.setSelectedRevision(undefined)
return
@@ -130,7 +136,7 @@ export class NoteHistoryController {
try {
this.setSelectedEntry(entry)
const remoteRevisionOrError = await this.application.getRevision.execute({
const remoteRevisionOrError = await this._getRevision.execute({
itemUuid: this.note.uuid,
revisionUuid: entry.uuid,
})
@@ -162,7 +168,7 @@ export class NoteHistoryController {
this.setSelectedEntry(entry)
const response = await this.application.actions.runAction(entry.subactions[0], this.note)
const response = await this.actions.runAction(entry.subactions[0], this.note)
if (!response) {
throw new Error('Could not fetch revision')
@@ -241,7 +247,7 @@ export class NoteHistoryController {
if (this.note) {
this.setIsFetchingRemoteHistory(true)
try {
const revisionsListOrError = await this.application.listRevisions.execute({ itemUuid: this.note.uuid })
const revisionsListOrError = await this._listRevisions.execute({ itemUuid: this.note.uuid })
if (revisionsListOrError.isFailed()) {
throw new Error(revisionsListOrError.getError())
}
@@ -261,14 +267,14 @@ export class NoteHistoryController {
}
fetchLegacyHistory = async () => {
const actionExtensions = this.application.actions.getExtensions()
const actionExtensions = this.actions.getExtensions()
actionExtensions.forEach(async (ext) => {
if (!this.note) {
return
}
const actionExtension = await this.application.actions.loadExtensionInContextOfItem(ext, this.note)
const actionExtension = await this.actions.loadExtensionInContextOfItem(ext, this.note)
if (!actionExtension) {
return
@@ -296,9 +302,7 @@ export class NoteHistoryController {
}
this.setSessionHistory(
sortRevisionListIntoGroups<NoteHistoryEntry>(
this.application.history.sessionHistoryForItem(this.note) as NoteHistoryEntry[],
),
sortRevisionListIntoGroups<NoteHistoryEntry>(this.history.sessionHistoryForItem(this.note) as NoteHistoryEntry[]),
)
await this.fetchRemoteHistory()
await this.fetchLegacyHistory()
@@ -313,10 +317,10 @@ export class NoteHistoryController {
}
restoreRevision = async (revision: NonNullable<SelectedRevision>) => {
const originalNote = this.application.items.findItem<SNNote>(revision.payload.uuid)
const originalNote = this.items.findItem<SNNote>(revision.payload.uuid)
if (originalNote?.locked) {
this.application.alerts.alert(STRING_RESTORE_LOCKED_ATTEMPT).catch(console.error)
this.alerts.alert(STRING_RESTORE_LOCKED_ATTEMPT).catch(console.error)
return
}
@@ -330,7 +334,7 @@ export class NoteHistoryController {
}
if (didConfirm) {
void this.application.changeAndSaveItem(
void this._changeAndSaveItem.execute(
originalNote,
(mutator) => {
mutator.setCustomContent(revision.payload.content)
@@ -342,20 +346,20 @@ export class NoteHistoryController {
}
restoreRevisionAsCopy = async (revision: NonNullable<SelectedRevision>) => {
const originalNote = this.application.items.findSureItem<SNNote>(revision.payload.uuid)
const originalNote = this.items.findSureItem<SNNote>(revision.payload.uuid)
const duplicatedItem = await this.application.mutator.duplicateItem(originalNote, false, {
const duplicatedItem = await this.mutator.duplicateItem(originalNote, false, {
...revision.payload.content,
title: revision.payload.content.title ? revision.payload.content.title + ' (copy)' : undefined,
})
void this.application.sync.sync()
void this.sync.sync()
this.selectionController.selectItem(duplicatedItem.uuid).catch(console.error)
this.itemListController.selectItem(duplicatedItem.uuid).catch(console.error)
}
deleteRemoteRevision = async (revisionEntry: RevisionMetadata) => {
const shouldDelete = await this.application.alerts.confirm(
const shouldDelete = await this.alerts.confirm(
'Are you sure you want to delete this revision?',
'Delete revision?',
'Delete revision',
@@ -367,7 +371,7 @@ export class NoteHistoryController {
return
}
const deleteRevisionOrError = await this.application.deleteRevision.execute({
const deleteRevisionOrError = await this._deleteRevision.execute({
itemUuid: this.note.uuid,
revisionUuid: revisionEntry.uuid,
})

View File

@@ -0,0 +1,20 @@
import { LegacyHistoryEntry, ListGroup, RemoteRevisionListGroup } from '@/Components/RevisionHistoryModal/utils'
import { Action, HistoryEntry, NoteHistoryEntry, RevisionMetadata } from '@standardnotes/snjs'
export type RemoteHistory = RemoteRevisionListGroup[]
export type SessionHistory = ListGroup<NoteHistoryEntry>[]
export type LegacyHistory = Action[]
export type SelectedRevision = HistoryEntry | LegacyHistoryEntry | undefined
export type SelectedEntry = RevisionMetadata | NoteHistoryEntry | Action | undefined
export enum RevisionContentState {
Idle,
Loading,
Loaded,
NotEntitled,
}

View File

@@ -1,8 +1,15 @@
import { WebApplication } from '@/Application/WebApplication'
import { MutationType, NoteMutator, SNNote } from '@standardnotes/models'
import { InfoStrings } from '@standardnotes/snjs'
import {
AlertService,
InfoStrings,
ItemManagerInterface,
MutatorClientInterface,
SessionsClientInterface,
SyncServiceInterface,
} from '@standardnotes/snjs'
import { Deferred } from '@standardnotes/utils'
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
const NotePreviewCharLimit = 160
@@ -24,8 +31,13 @@ export class NoteSyncController {
private saveTimeout?: ReturnType<typeof setTimeout>
constructor(
private application: WebApplication,
private item: SNNote,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sessions: SessionsClientInterface,
private sync: SyncServiceInterface,
private alerts: AlertService,
private _isNativeMobileWeb: IsNativeMobileWeb,
) {}
setItem(item: SNNote) {
@@ -41,7 +53,6 @@ export class NoteSyncController {
}
this.savingLocallyPromise = null
this.saveTimeout = undefined
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
}
@@ -52,11 +63,11 @@ export class NoteSyncController {
clearTimeout(this.saveTimeout)
}
const noDebounce = params.bypassDebouncer || this.application.noAccount()
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
const syncDebouceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange
: this.application.isNativeMobileWeb()
: this._isNativeMobileWeb.execute().getValue()
? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop
@@ -76,12 +87,12 @@ export class NoteSyncController {
}
private async undebouncedSave(params: NoteSaveFunctionParams): Promise<void> {
if (!this.application.items.findItem(this.item.uuid)) {
void this.application.alerts.alert(InfoStrings.InvalidNote)
if (!this.items.findItem(this.item.uuid)) {
void this.alerts.alert(InfoStrings.InvalidNote)
return
}
await this.application.mutator.changeItem(
await this.mutator.changeItem(
this.item,
(mutator) => {
const noteMutator = mutator as NoteMutator
@@ -112,7 +123,7 @@ export class NoteSyncController {
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
)
void this.application.sync.sync().then(() => {
void this.sync.sync().then(() => {
params.onRemoteSyncComplete?.()
})

View File

@@ -1,5 +1,12 @@
import { destroyAllObjectProperties } from '@/Utils'
import { confirmDialog, PIN_NOTE_COMMAND, STAR_NOTE_COMMAND } from '@standardnotes/ui-services'
import {
confirmDialog,
GetItemTags,
IsGlobalSpellcheckEnabled,
KeyboardService,
PIN_NOTE_COMMAND,
STAR_NOTE_COMMAND,
} from '@standardnotes/ui-services'
import { StringEmptyTrash, Strings, StringUtils } from '@/Constants/Strings'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import {
@@ -13,16 +20,27 @@ import {
InternalEventBusInterface,
MutationType,
PrefDefaults,
PreferenceServiceInterface,
InternalEventHandlerInterface,
InternalEventInterface,
ItemManagerInterface,
MutatorClientInterface,
SyncServiceInterface,
AlertService,
ProtectionsClientInterface,
} from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { SelectedItemsController } from '../SelectedItemsController'
import { ItemListController } from '../ItemList/ItemListController'
import { NavigationController } from '../Navigation/NavigationController'
import { NotesControllerInterface } from './NotesControllerInterface'
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
import { CrossControllerEvent } from '../CrossControllerEvent'
import { ItemListController } from '../ItemList/ItemListController'
export class NotesController extends AbstractViewController implements NotesControllerInterface {
export class NotesController
extends AbstractViewController
implements NotesControllerInterface, InternalEventHandlerInterface
{
shouldLinkToParentFolders: boolean
lastSelectedNote: SNNote | undefined
contextMenuOpen = false
@@ -33,25 +51,23 @@ export class NotesController extends AbstractViewController implements NotesCont
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
contextMenuMaxHeight: number | 'auto' = 'auto'
showProtectedWarning = false
private itemListController!: ItemListController
override deinit() {
super.deinit()
;(this.lastSelectedNote as unknown) = undefined
;(this.selectionController as unknown) = undefined
;(this.navigationController as unknown) = undefined
;(this.itemListController as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(
application: WebApplication,
private selectionController: SelectedItemsController,
private itemListController: ItemListController,
private navigationController: NavigationController,
private itemControllerGroup: ItemGroupController,
private keyboardService: KeyboardService,
private preferences: PreferenceServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private protections: ProtectionsClientInterface,
private alerts: AlertService,
private _isGlobalSpellcheckEnabled: IsGlobalSpellcheckEnabled,
private _getItemTags: GetItemTags,
eventBus: InternalEventBusInterface,
) {
super(application, eventBus)
super(eventBus)
makeObservable(this, {
contextMenuOpen: observable,
@@ -71,39 +87,32 @@ export class NotesController extends AbstractViewController implements NotesCont
unselectNotes: action,
})
this.shouldLinkToParentFolders = application.getPreference(
this.shouldLinkToParentFolders = preferences.getValue(
PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders],
)
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
eventBus.addEventHandler(this, CrossControllerEvent.UnselectAllNotes)
this.disposers.push(
this.application.keyboardService.addCommandHandler({
this.keyboardService.addCommandHandler({
command: PIN_NOTE_COMMAND,
onKeyDown: () => {
this.togglePinSelectedNotes()
},
}),
this.application.keyboardService.addCommandHandler({
this.keyboardService.addCommandHandler({
command: STAR_NOTE_COMMAND,
onKeyDown: () => {
this.toggleStarSelectedNotes()
},
}),
this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.shouldLinkToParentFolders = this.application.getPreference(
PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders],
)
}),
)
}
public setServicesPostConstruction(itemListController: ItemListController) {
this.itemListController = itemListController
this.disposers.push(
this.application.itemControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = this.application.itemControllerGroup.itemControllers
this.itemControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = this.itemControllerGroup.itemControllers
const activeNoteUuids = controllers.map((controller) => controller.item.uuid)
@@ -111,15 +120,35 @@ export class NotesController extends AbstractViewController implements NotesCont
for (const selectedId of selectedUuids) {
if (!activeNoteUuids.includes(selectedId)) {
this.selectionController.deselectItem({ uuid: selectedId })
this.itemListController.deselectItem({ uuid: selectedId })
}
}
}),
)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === ApplicationEvent.PreferencesChanged) {
this.shouldLinkToParentFolders = this.preferences.getValue(
PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders],
)
} else if (event.type === CrossControllerEvent.UnselectAllNotes) {
this.unselectNotes()
}
}
override deinit() {
super.deinit()
;(this.lastSelectedNote as unknown) = undefined
;(this.itemListController as unknown) = undefined
;(this.navigationController as unknown) = undefined
destroyAllObjectProperties(this)
}
public get selectedNotes(): SNNote[] {
return this.selectionController.getFilteredSelectedItems<SNNote>(ContentType.TYPES.Note)
return this.itemListController.getFilteredSelectedItems<SNNote>(ContentType.TYPES.Note)
}
get firstSelectedNote(): SNNote | undefined {
@@ -135,7 +164,7 @@ export class NotesController extends AbstractViewController implements NotesCont
}
get trashedNotesCount(): number {
return this.application.items.trashedItems.length
return this.items.trashedItems.length
}
setContextMenuOpen = (open: boolean) => {
@@ -202,8 +231,8 @@ export class NotesController extends AbstractViewController implements NotesCont
}
async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> {
await this.application.mutator.changeItems(this.getSelectedNotesList(), mutate, MutationType.NoUpdateUserTimestamps)
this.application.sync.sync().catch(console.error)
await this.mutator.changeItems(this.getSelectedNotesList(), mutate, MutationType.NoUpdateUserTimestamps)
this.sync.sync().catch(console.error)
}
setHideSelectedNotePreviews(hide: boolean): void {
@@ -243,7 +272,7 @@ export class NotesController extends AbstractViewController implements NotesCont
async deleteNotes(permanently: boolean): Promise<boolean> {
if (this.getSelectedNotesList().some((note) => note.locked)) {
const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount)
this.application.alerts.alert(text).catch(console.error)
this.alerts.alert(text).catch(console.error)
return false
}
@@ -262,10 +291,10 @@ export class NotesController extends AbstractViewController implements NotesCont
confirmButtonStyle: 'danger',
})
) {
this.selectionController.selectNextItem()
this.itemListController.selectNextItem()
if (permanently) {
await this.application.mutator.deleteItems(this.getSelectedNotesList())
void this.application.sync.sync()
await this.mutator.deleteItems(this.getSelectedNotesList())
void this.sync.sync()
} else {
await this.changeSelectedNotes((mutator) => {
mutator.trashed = true
@@ -313,9 +342,7 @@ export class NotesController extends AbstractViewController implements NotesCont
async setArchiveSelectedNotes(archived: boolean): Promise<void> {
if (this.getSelectedNotesList().some((note) => note.locked)) {
this.application.alerts
.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount))
.catch(console.error)
this.alerts.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount)).catch(console.error)
return
}
@@ -324,7 +351,7 @@ export class NotesController extends AbstractViewController implements NotesCont
})
runInAction(() => {
this.selectionController.deselectAll()
this.itemListController.deselectAll()
this.contextMenuOpen = false
})
}
@@ -332,76 +359,77 @@ export class NotesController extends AbstractViewController implements NotesCont
async setProtectSelectedNotes(protect: boolean): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
if (protect) {
await this.application.protections.protectNotes(selectedNotes)
await this.protections.protectNotes(selectedNotes)
this.setShowProtectedWarning(true)
} else {
await this.application.protections.unprotectNotes(selectedNotes)
await this.protections.unprotectNotes(selectedNotes)
this.setShowProtectedWarning(false)
}
void this.application.sync.sync()
void this.sync.sync()
}
unselectNotes(): void {
this.selectionController.deselectAll()
this.itemListController.deselectAll()
}
getSpellcheckStateForNote(note: SNNote) {
return note.spellcheck != undefined ? note.spellcheck : this.application.isGlobalSpellcheckEnabled()
return note.spellcheck != undefined ? note.spellcheck : this._isGlobalSpellcheckEnabled.execute().getValue()
}
async toggleGlobalSpellcheckForNote(note: SNNote) {
await this.application.mutator.changeItem<NoteMutator>(
await this.mutator.changeItem<NoteMutator>(
note,
(mutator) => {
mutator.toggleSpellcheck()
},
MutationType.NoUpdateUserTimestamps,
)
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
}
getEditorWidthForNote(note: SNNote) {
return (
note.editorWidth ?? this.application.getPreference(PrefKey.EditorLineWidth, PrefDefaults[PrefKey.EditorLineWidth])
)
return note.editorWidth ?? this.preferences.getValue(PrefKey.EditorLineWidth, PrefDefaults[PrefKey.EditorLineWidth])
}
async setNoteEditorWidth(note: SNNote, editorWidth: EditorLineWidth) {
await this.application.mutator.changeItem<NoteMutator>(
await this.mutator.changeItem<NoteMutator>(
note,
(mutator) => {
mutator.editorWidth = editorWidth
},
MutationType.NoUpdateUserTimestamps,
)
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
}
async addTagToSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
await Promise.all(
selectedNotes.map(async (note) => {
await this.application.mutator.addTagToNote(note, tag, this.shouldLinkToParentFolders)
await this.mutator.addTagToNote(note, tag, this.shouldLinkToParentFolders)
}),
)
this.application.sync.sync().catch(console.error)
this.sync.sync().catch(console.error)
}
async removeTagFromSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
await this.application.mutator.changeItem(tag, (mutator) => {
await this.mutator.changeItem(tag, (mutator) => {
for (const note of selectedNotes) {
mutator.removeItemAsRelationship(note)
}
})
this.application.sync.sync().catch(console.error)
this.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),
this._getItemTags
.execute(note)
.getValue()
.find((noteTag) => noteTag.uuid === tag.uuid),
)
}
@@ -416,8 +444,8 @@ export class NotesController extends AbstractViewController implements NotesCont
confirmButtonStyle: 'danger',
})
) {
await this.application.mutator.emptyTrash()
this.application.sync.sync().catch(console.error)
await this.mutator.emptyTrash()
this.sync.sync().catch(console.error)
}
}

View File

@@ -1,4 +1,11 @@
import { PanesForLayout } from './../../Application/UseCase/PanesForLayout'
import {
InternalEventHandlerInterface,
InternalEventInterface,
PreferenceServiceInterface,
} from '@standardnotes/services'
import {
KeyboardService,
TOGGLE_FOCUS_MODE_COMMAND,
TOGGLE_LIST_PANE_KEYBOARD_COMMAND,
TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND,
@@ -15,12 +22,10 @@ import { isMobileScreen } from '@/Utils'
import { makeObservable, observable, action, computed } from 'mobx'
import { Disposer } from '@/Types/Disposer'
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { WebApplication } from '@/Application/WebApplication'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { log, LoggingDomain } from '@/Logging'
import { PaneLayout } from './PaneLayout'
import { panesForLayout } from './panesForLayout'
import { getIsTabletOrMobileScreen } from '@/Hooks/useIsTabletOrMobileScreen'
import { IsTabletOrMobileScreen } from '@/Application/UseCase/IsTabletOrMobileScreen'
const MinimumNavPanelWidth = PrefDefaults[PrefKey.TagsPanelWidth]
const MinimumNotesPanelWidth = PrefDefaults[PrefKey.NotesPanelWidth]
@@ -28,7 +33,7 @@ const FOCUS_MODE_CLASS_NAME = 'focus-mode'
const DISABLING_FOCUS_MODE_CLASS_NAME = 'disable-focus-mode'
const FOCUS_MODE_ANIMATION_DURATION = 1255
export class PaneController extends AbstractViewController {
export class PaneController extends AbstractViewController implements InternalEventHandlerInterface {
isInMobileView = isMobileScreen()
protected disposers: Disposer[] = []
panes: AppPaneId[] = []
@@ -40,8 +45,14 @@ export class PaneController extends AbstractViewController {
listPaneExplicitelyCollapsed = false
navigationPaneExplicitelyCollapsed = false
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private preferences: PreferenceServiceInterface,
private keyboardService: KeyboardService,
private _isTabletOrMobileScreen: IsTabletOrMobileScreen,
private _panesForLayout: PanesForLayout,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
makeObservable(this, {
panes: observable,
@@ -70,10 +81,10 @@ export class PaneController extends AbstractViewController {
setFocusModeEnabled: action,
})
this.setCurrentNavPanelWidth(application.getPreference(PrefKey.TagsPanelWidth, MinimumNavPanelWidth))
this.setCurrentItemsPanelWidth(application.getPreference(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth))
this.setCurrentNavPanelWidth(preferences.getValue(PrefKey.TagsPanelWidth, MinimumNavPanelWidth))
this.setCurrentItemsPanelWidth(preferences.getValue(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth))
const screen = getIsTabletOrMobileScreen(application)
const screen = this._isTabletOrMobileScreen.execute().getValue()
this.panes = screen.isTabletOrMobile
? [AppPaneId.Navigation, AppPaneId.Items]
@@ -86,13 +97,10 @@ export class PaneController extends AbstractViewController {
mediaQuery.addListener(this.mediumScreenMQHandler)
}
this.disposers.push(
application.addEventObserver(async () => {
this.setCurrentNavPanelWidth(application.getPreference(PrefKey.TagsPanelWidth, MinimumNavPanelWidth))
this.setCurrentItemsPanelWidth(application.getPreference(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth))
}, ApplicationEvent.PreferencesChanged),
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
application.keyboardService.addCommandHandler({
this.disposers.push(
keyboardService.addCommandHandler({
command: TOGGLE_FOCUS_MODE_COMMAND,
onKeyDown: (event) => {
event.preventDefault()
@@ -100,14 +108,14 @@ export class PaneController extends AbstractViewController {
return true
},
}),
application.keyboardService.addCommandHandler({
keyboardService.addCommandHandler({
command: TOGGLE_LIST_PANE_KEYBOARD_COMMAND,
onKeyDown: (event) => {
event.preventDefault()
this.toggleListPane()
},
}),
application.keyboardService.addCommandHandler({
keyboardService.addCommandHandler({
command: TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND,
onKeyDown: (event) => {
event.preventDefault()
@@ -117,6 +125,13 @@ export class PaneController extends AbstractViewController {
)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === ApplicationEvent.PreferencesChanged) {
this.setCurrentNavPanelWidth(this.preferences.getValue(PrefKey.TagsPanelWidth, MinimumNavPanelWidth))
this.setCurrentItemsPanelWidth(this.preferences.getValue(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth))
}
}
setCurrentNavPanelWidth(width: number) {
this.currentNavPanelWidth = width
}
@@ -158,7 +173,7 @@ export class PaneController extends AbstractViewController {
setPaneLayout = (layout: PaneLayout) => {
log(LoggingDomain.Panes, 'Set pane layout', layout)
const panes = panesForLayout(layout, this.application)
const panes = this._panesForLayout.execute(layout).getValue()
if (panes.includes(AppPaneId.Items) && this.listPaneExplicitelyCollapsed) {
removeFromArray(panes, AppPaneId.Items)

View File

@@ -1,34 +1,4 @@
import { AppPaneId } from '../../Components/Panes/AppPaneMetadata'
import { PaneLayout } from './PaneLayout'
import { WebApplication } from '@/Application/WebApplication'
import { getIsTabletOrMobileScreen } from '@/Hooks/useIsTabletOrMobileScreen'
export function panesForLayout(layout: PaneLayout, application: WebApplication): AppPaneId[] {
const screen = getIsTabletOrMobileScreen(application)
if (screen.isTablet) {
if (layout === PaneLayout.TagSelection || layout === PaneLayout.TableView) {
return [AppPaneId.Navigation, AppPaneId.Items]
} else if (layout === PaneLayout.ItemSelection || layout === PaneLayout.Editing) {
return [AppPaneId.Items, AppPaneId.Editor]
}
} else if (screen.isMobile) {
if (layout === PaneLayout.TagSelection) {
return [AppPaneId.Navigation]
} else if (layout === PaneLayout.ItemSelection || layout === PaneLayout.TableView) {
return [AppPaneId.Navigation, AppPaneId.Items]
} else if (layout === PaneLayout.Editing) {
return [AppPaneId.Navigation, AppPaneId.Items, AppPaneId.Editor]
}
} else {
if (layout === PaneLayout.TableView) {
return [AppPaneId.Navigation, AppPaneId.Items]
} else {
return [AppPaneId.Navigation, AppPaneId.Items, AppPaneId.Editor]
}
}
throw Error('Unhandled pane layout')
}
export function isPanesChangeLeafDismiss(from: AppPaneId[], to: AppPaneId[]): boolean {
const fromWithoutLast = from.slice(0, from.length - 1)

View File

@@ -1,8 +1,7 @@
import { InternalEventBusInterface } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { PreferenceId, RootQueryParam } from '@standardnotes/ui-services'
import { PreferenceId, RootQueryParam, RouteServiceInterface } from '@standardnotes/ui-services'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { WebApplication } from '@/Application/WebApplication'
const DEFAULT_PANE: PreferenceId = 'account'
@@ -10,8 +9,11 @@ export class PreferencesController extends AbstractViewController {
private _open = false
currentPane: PreferenceId = DEFAULT_PANE
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private routeService: RouteServiceInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
makeObservable<PreferencesController, '_open'>(this, {
_open: observable,
@@ -34,7 +36,7 @@ export class PreferencesController extends AbstractViewController {
closePreferences = (): void => {
this._open = false
this.currentPane = DEFAULT_PANE
this.application.routeService.removeQueryParameterFromURL(RootQueryParam.Settings)
this.routeService.removeQueryParameterFromURL(RootQueryParam.Settings)
}
get isOpen(): boolean {

View File

@@ -1,17 +1,33 @@
import {
AlertService,
LegacyApiServiceInterface,
MobileDeviceInterface,
SessionsClientInterface,
SubscriptionManagerInterface,
} from '@standardnotes/services'
import { LoggingDomain, log } from '@/Logging'
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
import { AppleIAPProductId, InternalEventBusInterface } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { PurchaseFlowPane } from './PurchaseFlowPane'
import { LoadPurchaseFlowUrl } from '@/Application/UseCase/LoadPurchaseFlowUrl'
import { IsNativeIOS } from '@standardnotes/ui-services'
export class PurchaseFlowController extends AbstractViewController {
isOpen = false
currentPane = PurchaseFlowPane.CreateAccount
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private sessions: SessionsClientInterface,
private subscriptions: SubscriptionManagerInterface,
private legacyApi: LegacyApiServiceInterface,
private alerts: AlertService,
private mobileDevice: MobileDeviceInterface | undefined,
private _loadPurchaseFlowUrl: LoadPurchaseFlowUrl,
private _isNativeIOS: IsNativeIOS,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
makeObservable(this, {
isOpen: observable,
@@ -28,45 +44,46 @@ export class PurchaseFlowController extends AbstractViewController {
}
openPurchaseFlow = async (plan = AppleIAPProductId.ProPlanYearly) => {
const user = this.application.getUser()
const user = this.sessions.getUser()
if (!user) {
this.isOpen = true
return
}
if (this.application.isNativeIOS()) {
if (this._isNativeIOS.execute().getValue()) {
await this.beginIosIapPurchaseFlow(plan)
} else {
await loadPurchaseFlowUrl(this.application)
await this._loadPurchaseFlowUrl.execute()
}
}
openPurchaseWebpage = () => {
loadPurchaseFlowUrl(this.application).catch((err) => {
console.error(err)
this.application.alerts.alert(err).catch(console.error)
})
openPurchaseWebpage = async () => {
const result = await this._loadPurchaseFlowUrl.execute()
if (result.isFailed()) {
console.error(result.getError())
void this.alerts.alert(result.getError())
}
}
beginIosIapPurchaseFlow = async (plan: AppleIAPProductId): Promise<void> => {
const result = await this.application.mobileDevice().purchaseSubscriptionIAP(plan)
const result = await this.mobileDevice?.purchaseSubscriptionIAP(plan)
log(LoggingDomain.Purchasing, 'BeginIosIapPurchaseFlow result', result)
if (!result) {
void this.application.alerts.alert('Your purchase was canceled or failed. Please try again.')
void this.alerts.alert('Your purchase was canceled or failed. Please try again.')
return
}
const showGenericError = () => {
void this.application.alerts.alert(
void this.alerts.alert(
'There was an error confirming your purchase. Please contact support at help@standardnotes.com.',
)
}
log(LoggingDomain.Purchasing, 'Confirming result with our server')
const token = await this.application.getNewSubscriptionToken()
const token = await this.legacyApi.getNewSubscriptionToken()
if (!token) {
log(LoggingDomain.Purchasing, 'Unable to generate subscription token')
@@ -74,12 +91,12 @@ export class PurchaseFlowController extends AbstractViewController {
return
}
const confirmResult = await this.application.subscriptions.confirmAppleIAP(result, token)
const confirmResult = await this.subscriptions.confirmAppleIAP(result, token)
log(LoggingDomain.Purchasing, 'Server confirm result', confirmResult)
if (confirmResult) {
void this.application.alerts.alert(
void this.alerts.alert(
'Please allow a few minutes for your subscription benefits to activate. You will see a confirmation alert in the app when your subscription is ready.',
'Your purchase was successful!',
)

View File

@@ -1,5 +1,4 @@
import { InternalEventBusInterface } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/WebApplication'
import { action, makeObservable, observable } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
@@ -7,8 +6,8 @@ export class QuickSettingsController extends AbstractViewController {
open = false
shouldAnimateCloseMenu = false
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(eventBus: InternalEventBusInterface) {
super(eventBus)
makeObservable(this, {
open: observable,

View File

@@ -1,15 +1,23 @@
import { ApplicationEvent, InternalEventBusInterface } from '@standardnotes/snjs'
import { ProtectionsClientInterface } from '@standardnotes/services'
import {
ApplicationEvent,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
} from '@standardnotes/snjs'
import { makeObservable, observable, action, runInAction } from 'mobx'
import { WebApplication } from '../Application/WebApplication'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class SearchOptionsController extends AbstractViewController {
export class SearchOptionsController extends AbstractViewController implements InternalEventHandlerInterface {
includeProtectedContents = false
includeArchived = false
includeTrashed = false
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(
private protections: ProtectionsClientInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
makeObservable(this, {
includeProtectedContents: observable,
@@ -22,14 +30,16 @@ export class SearchOptionsController extends AbstractViewController {
refreshIncludeProtectedContents: action,
})
this.disposers.push(
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents()
}, ApplicationEvent.UnprotectedSessionBegan),
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents()
}, ApplicationEvent.UnprotectedSessionExpired),
)
eventBus.addEventHandler(this, ApplicationEvent.UnprotectedSessionBegan)
eventBus.addEventHandler(this, ApplicationEvent.UnprotectedSessionExpired)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === ApplicationEvent.UnprotectedSessionBegan) {
this.refreshIncludeProtectedContents()
} else if (event.type === ApplicationEvent.UnprotectedSessionExpired) {
this.refreshIncludeProtectedContents()
}
}
toggleIncludeArchived = (): void => {
@@ -41,14 +51,14 @@ export class SearchOptionsController extends AbstractViewController {
}
refreshIncludeProtectedContents = (): void => {
this.includeProtectedContents = this.application.hasUnprotectedAccessSession()
this.includeProtectedContents = this.protections.hasUnprotectedAccessSession()
}
toggleIncludeProtectedContents = async (): Promise<void> => {
if (this.includeProtectedContents) {
this.includeProtectedContents = false
} else {
await this.application.authorizeSearchingProtectedNotesText()
await this.protections.authorizeSearchingProtectedNotesText()
runInAction(() => {
this.refreshIncludeProtectedContents()
})

View File

@@ -1,407 +0,0 @@
import { isMobileScreen } from '@/Utils'
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import { log, LoggingDomain } from '@/Logging'
import {
ChallengeReason,
ContentType,
KeyboardModifier,
FileItem,
SNNote,
UuidString,
isFile,
Uuids,
isNote,
InternalEventBusInterface,
} from '@standardnotes/snjs'
import { SelectionControllerPersistableValue } from '@standardnotes/ui-services'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../Application/WebApplication'
import { AbstractViewController } from './Abstract/AbstractViewController'
import { Persistable } from './Abstract/Persistable'
import { CrossControllerEvent } from './CrossControllerEvent'
import { ItemListController } from './ItemList/ItemListController'
import { PaneLayout } from './PaneController/PaneLayout'
import { requestCloseAllOpenModalsAndPopovers } from '@/Utils/CloseOpenModalsAndPopovers'
export class SelectedItemsController
extends AbstractViewController
implements Persistable<SelectionControllerPersistableValue>
{
lastSelectedItem: ListableContentItem | undefined
selectedUuids: Set<UuidString> = observable(new Set<UuidString>())
selectedItems: Record<UuidString, ListableContentItem> = {}
private itemListController!: ItemListController
override deinit(): void {
super.deinit()
;(this.itemListController as unknown) = undefined
}
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
makeObservable(this, {
selectedUuids: observable,
selectedItems: observable,
selectedItemsCount: computed,
selectedFiles: computed,
selectedFilesCount: computed,
firstSelectedItem: computed,
selectItem: action,
setSelectedUuids: action,
setSelectedItems: action,
hydrateFromPersistedValue: action,
})
this.disposers.push(
reaction(
() => this.selectedUuids,
() => {
eventBus.publish({
type: CrossControllerEvent.RequestValuePersistence,
payload: undefined,
})
},
),
)
}
getPersistableValue = (): SelectionControllerPersistableValue => {
return {
selectedUuids: Array.from(this.selectedUuids),
}
}
hydrateFromPersistedValue = (state: SelectionControllerPersistableValue | undefined): void => {
if (!state) {
return
}
if (!this.selectedUuids.size && state.selectedUuids.length > 0) {
if (!this.application.options.allowNoteSelectionStatePersistence) {
const items = this.application.items.findItems(state.selectedUuids).filter((item) => !isNote(item))
void this.selectUuids(Uuids(items))
} else {
void this.selectUuids(state.selectedUuids)
}
}
}
public setServicesPostConstruction(itemListController: ItemListController) {
this.itemListController = itemListController
this.disposers.push(
this.application.streamItems<SNNote | FileItem>(
[ContentType.TYPES.Note, ContentType.TYPES.File],
({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedItem of removed) {
this.removeSelectedItem(removedItem.uuid)
}
for (const item of [...changed, ...inserted]) {
if (this.selectedItems[item.uuid]) {
this.selectedItems[item.uuid] = item
}
}
})
},
),
)
}
private get keyboardService() {
return this.application.keyboardService
}
get selectedItemsCount(): number {
return Object.keys(this.selectedItems).length
}
get selectedFiles(): FileItem[] {
return this.getFilteredSelectedItems<FileItem>(ContentType.TYPES.File)
}
get selectedFilesCount(): number {
return this.selectedFiles.length
}
get firstSelectedItem() {
return Object.values(this.selectedItems)[0]
}
getSelectedItems = () => {
const uuids = Array.from(this.selectedUuids)
return uuids.map((uuid) => this.application.items.findSureItem<SNNote | FileItem>(uuid)).filter((item) => !!item)
}
getFilteredSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: string): T[] => {
return Object.values(this.selectedItems).filter((item) => {
return !contentType ? true : item.content_type === contentType
}) as T[]
}
setSelectedItems = () => {
this.selectedItems = Object.fromEntries(this.getSelectedItems().map((item) => [item.uuid, item]))
}
setSelectedUuids = (selectedUuids: Set<UuidString>) => {
log(LoggingDomain.Selection, 'Setting selected uuids', selectedUuids)
this.selectedUuids = new Set(selectedUuids)
this.setSelectedItems()
}
private removeSelectedItem = (uuid: UuidString) => {
this.selectedUuids.delete(uuid)
this.setSelectedUuids(this.selectedUuids)
delete this.selectedItems[uuid]
}
public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => {
log(LoggingDomain.Selection, 'Deselecting item', item.uuid)
this.removeSelectedItem(item.uuid)
if (item.uuid === this.lastSelectedItem?.uuid) {
this.lastSelectedItem = undefined
}
}
public isItemSelected = (item: ListableContentItem): boolean => {
return this.selectedUuids.has(item.uuid)
}
private selectItemsRange = async ({
selectedItem,
startingIndex,
endingIndex,
}: {
selectedItem?: ListableContentItem
startingIndex?: number
endingIndex?: number
}): Promise<void> => {
const items = this.itemListController.renderedItems
const lastSelectedItemIndex = startingIndex ?? items.findIndex((item) => item.uuid == this.lastSelectedItem?.uuid)
const selectedItemIndex = endingIndex ?? 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.setSelectedUuids(this.selectedUuids.add(item.uuid))
this.lastSelectedItem = item
})
}
}
cancelMultipleSelection = () => {
this.keyboardService.cancelAllKeyboardModifiers()
const firstSelectedItem = this.firstSelectedItem
if (firstSelectedItem) {
this.replaceSelection(firstSelectedItem)
} else {
this.deselectAll()
}
}
private replaceSelection = (item: ListableContentItem): void => {
this.deselectAll()
runInAction(() => this.setSelectedUuids(this.selectedUuids.add(item.uuid)))
this.lastSelectedItem = item
}
selectAll = () => {
void this.selectItemsRange({
startingIndex: 0,
endingIndex: this.itemListController.listLength - 1,
})
}
deselectAll = (): void => {
this.selectedUuids.clear()
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = undefined
}
openSingleSelectedItem = async ({ userTriggered } = { userTriggered: true }) => {
if (this.selectedItemsCount === 1) {
const item = this.firstSelectedItem
if (item.content_type === ContentType.TYPES.Note) {
await this.itemListController.openNote(item.uuid)
} else if (item.content_type === ContentType.TYPES.File) {
await this.itemListController.openFile(item.uuid)
}
if (!this.application.paneController.isInMobileView || userTriggered) {
void this.application.paneController.setPaneLayout(PaneLayout.Editing)
}
if (this.application.paneController.isInMobileView && userTriggered) {
requestCloseAllOpenModalsAndPopovers()
}
}
}
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
): Promise<{
didSelect: boolean
}> => {
const item = this.application.items.findItem<ListableContentItem>(uuid)
if (!item) {
return {
didSelect: false,
}
}
log(LoggingDomain.Selection, 'Select item', item.uuid)
const supportsMultipleSelection = this.application.options.allowMultipleSelection
const hasMeta = this.keyboardService.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.keyboardService.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.keyboardService.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1
const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item)
if (supportsMultipleSelection && userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) {
this.removeSelectedItem(uuid)
} else if (isAuthorizedForAccess) {
this.selectedUuids.add(uuid)
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = item
}
} else if (supportsMultipleSelection && userTriggered && hasShift) {
await this.selectItemsRange({ selectedItem: item })
} else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid)
if (shouldSelectNote && isAuthorizedForAccess) {
this.replaceSelection(item)
}
}
await this.openSingleSelectedItem({ userTriggered: userTriggered ?? false })
return {
didSelect: this.selectedUuids.has(uuid),
}
}
selectItemWithScrollHandling = async (
item: {
uuid: ListableContentItem['uuid']
},
{ userTriggered = false, scrollIntoView = true, animated = true },
): Promise<void> => {
const { didSelect } = await this.selectItem(item.uuid, userTriggered)
const avoidMobileScrollingDueToIncompatibilityWithPaneAnimations = isMobileScreen()
if (didSelect && scrollIntoView && !avoidMobileScrollingDueToIncompatibilityWithPaneAnimations) {
this.scrollToItem(item, animated)
}
}
scrollToItem = (item: { uuid: ListableContentItem['uuid'] }, animated = true): void => {
const itemElement = document.getElementById(item.uuid)
itemElement?.scrollIntoView({
behavior: animated ? 'smooth' : 'auto',
})
}
selectUuids = async (uuids: UuidString[], userTriggered = false) => {
const itemsForUuids = this.application.items.findItems(uuids).filter((item) => !isFile(item))
if (itemsForUuids.length < 1) {
return
}
if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) {
return
}
this.setSelectedUuids(new Set(Uuids(itemsForUuids)))
if (itemsForUuids.length === 1) {
void this.openSingleSelectedItem({ userTriggered })
}
}
selectNextItem = ({ userTriggered } = { userTriggered: true }) => {
const displayableItems = this.itemListController.items
const currentIndex = displayableItems.findIndex((candidate) => {
return candidate.uuid === this.lastSelectedItem?.uuid
})
let nextIndex = currentIndex + 1
while (nextIndex < displayableItems.length) {
const nextItem = displayableItems[nextIndex]
nextIndex++
if (nextItem.protected) {
continue
}
this.selectItemWithScrollHandling(nextItem, { userTriggered }).catch(console.error)
const nextNoteElement = document.getElementById(nextItem.uuid)
nextNoteElement?.focus()
return
}
}
selectPreviousItem = () => {
const displayableItems = this.itemListController.items
if (!this.lastSelectedItem) {
return
}
const currentIndex = displayableItems.indexOf(this.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
}
}
}

View File

@@ -2,37 +2,34 @@ import { Subscription } from '@standardnotes/responses'
import { destroyAllObjectProperties } from '@/Utils'
import {
ApplicationEvent,
FeaturesClientInterface,
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
Invitation,
InvitationStatus,
SessionsClientInterface,
SubscriptionManagerEvent,
SubscriptionManagerInterface,
} from '@standardnotes/snjs'
import { computed, makeObservable, observable, runInAction } from 'mobx'
import { WebApplication } from '../../Application/WebApplication'
import { AbstractViewController } from '../Abstract/AbstractViewController'
export class SubscriptionController extends AbstractViewController {
export class SubscriptionController extends AbstractViewController implements InternalEventHandlerInterface {
private readonly ALLOWED_SUBSCRIPTION_INVITATIONS = 5
subscriptionInvitations: Invitation[] | undefined = undefined
hasAccount: boolean
onlineSubscription: Subscription | undefined = undefined
override deinit() {
super.deinit()
;(this.subscriptionInvitations as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(
application: WebApplication,
private subscriptions: SubscriptionManagerInterface,
private sessions: SessionsClientInterface,
private features: FeaturesClientInterface,
eventBus: InternalEventBusInterface,
private subscriptionManager: SubscriptionManagerInterface,
) {
super(application, eventBus)
this.hasAccount = application.hasAccount()
super(eventBus)
this.hasAccount = sessions.isSignedIn()
makeObservable(this, {
subscriptionInvitations: observable,
@@ -45,52 +42,62 @@ export class SubscriptionController extends AbstractViewController {
allInvitationsUsed: computed,
})
this.disposers.push(
application.addEventObserver(async () => {
if (application.hasAccount()) {
eventBus.addEventHandler(this, ApplicationEvent.Launched)
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
eventBus.addEventHandler(this, ApplicationEvent.UserRolesChanged)
eventBus.addEventHandler(this, SubscriptionManagerEvent.DidFetchSubscription)
}
override deinit() {
super.deinit()
;(this.subscriptionInvitations as unknown) = undefined
destroyAllObjectProperties(this)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.Launched: {
if (this.sessions.isSignedIn()) {
this.reloadSubscriptionInvitations().catch(console.error)
}
runInAction(() => {
this.hasAccount = application.hasAccount()
this.hasAccount = this.sessions.isSignedIn()
})
}, ApplicationEvent.Launched),
)
break
}
this.disposers.push(
application.addEventObserver(async () => {
case ApplicationEvent.SignedIn: {
this.reloadSubscriptionInvitations().catch(console.error)
runInAction(() => {
this.hasAccount = application.hasAccount()
this.hasAccount = this.sessions.isSignedIn()
})
}, ApplicationEvent.SignedIn),
)
break
}
this.disposers.push(
application.subscriptions.addEventObserver(async (event) => {
if (event === SubscriptionManagerEvent.DidFetchSubscription) {
runInAction(() => {
this.onlineSubscription = application.subscriptions.getOnlineSubscription()
})
}
}),
)
case SubscriptionManagerEvent.DidFetchSubscription: {
runInAction(() => {
this.onlineSubscription = this.subscriptions.getOnlineSubscription()
})
break
}
this.disposers.push(
application.addEventObserver(async () => {
case ApplicationEvent.UserRolesChanged: {
this.reloadSubscriptionInvitations().catch(console.error)
}, ApplicationEvent.UserRolesChanged),
)
break
}
}
}
get hasFirstPartyOnlineOrOfflineSubscription(): boolean {
if (this.application.sessions.isSignedIn()) {
if (!this.application.sessions.isSignedIntoFirstPartyServer()) {
if (this.sessions.isSignedIn()) {
if (!this.sessions.isSignedIntoFirstPartyServer()) {
return false
}
return this.application.subscriptions.getOnlineSubscription() !== undefined
return this.subscriptions.getOnlineSubscription() !== undefined
} else {
return this.application.features.hasFirstPartyOfflineSubscription()
return this.features.hasFirstPartyOfflineSubscription()
}
}
@@ -111,7 +118,7 @@ export class SubscriptionController extends AbstractViewController {
}
async sendSubscriptionInvitation(inviteeEmail: string): Promise<boolean> {
const success = await this.subscriptionManager.inviteToSubscription(inviteeEmail)
const success = await this.subscriptions.inviteToSubscription(inviteeEmail)
if (success) {
await this.reloadSubscriptionInvitations()
@@ -121,7 +128,7 @@ export class SubscriptionController extends AbstractViewController {
}
async cancelSubscriptionInvitation(invitationUuid: string): Promise<boolean> {
const success = await this.subscriptionManager.cancelInvitation(invitationUuid)
const success = await this.subscriptions.cancelInvitation(invitationUuid)
if (success) {
await this.reloadSubscriptionInvitations()
@@ -131,6 +138,6 @@ export class SubscriptionController extends AbstractViewController {
}
private async reloadSubscriptionInvitations(): Promise<void> {
this.subscriptionInvitations = await this.subscriptionManager.listSubscriptionInvitations()
this.subscriptionInvitations = await this.subscriptions.listSubscriptionInvitations()
}
}

View File

@@ -1,5 +1,4 @@
import { InternalEventBusInterface } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/WebApplication'
import { action, makeObservable, observable } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
@@ -7,8 +6,8 @@ export class VaultSelectionMenuController extends AbstractViewController {
open = false
shouldAnimateCloseMenu = false
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
super(application, eventBus)
constructor(eventBus: InternalEventBusInterface) {
super(eventBus)
makeObservable(this, {
open: observable,

View File

@@ -1,322 +0,0 @@
import { PaneController } from './PaneController/PaneController'
import {
PersistedStateValue,
PersistenceKey,
storage,
StorageKey,
ToastService,
ToastServiceInterface,
} from '@standardnotes/ui-services'
import { WebApplication } from '@/Application/WebApplication'
import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController'
import { destroyAllObjectProperties } from '@/Utils'
import {
DeinitSource,
WebOrDesktopDeviceInterface,
SubscriptionManagerInterface,
InternalEventHandlerInterface,
InternalEventInterface,
} from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
import { ActionsMenuController } from './ActionsMenuController'
import { FeaturesController } from './FeaturesController'
import { FilesController } from './FilesController'
import { NotesController } from './NotesController/NotesController'
import { ItemListController } from './ItemList/ItemListController'
import { NoAccountWarningController } from './NoAccountWarningController'
import { PreferencesController } from './PreferencesController'
import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController'
import { QuickSettingsController } from './QuickSettingsController'
import { SearchOptionsController } from './SearchOptionsController'
import { SubscriptionController } from './Subscription/SubscriptionController'
import { SyncStatusController } from './SyncStatusController'
import { NavigationController } from './Navigation/NavigationController'
import { FilePreviewModalController } from './FilePreviewModalController'
import { SelectedItemsController } from './SelectedItemsController'
import { HistoryModalController } from './NoteHistory/HistoryModalController'
import { LinkingController } from './LinkingController'
import { PersistenceService } from './Abstract/PersistenceService'
import { CrossControllerEvent } from './CrossControllerEvent'
import { EventObserverInterface } from '@/Event/EventObserverInterface'
import { ApplicationEventObserver } from '@/Event/ApplicationEventObserver'
import { ImportModalController } from './ImportModalController'
import { VaultSelectionMenuController } from './VaultSelectionMenuController'
export class ViewControllerManager implements InternalEventHandlerInterface {
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
private unsubAppEventObserver!: () => void
showBetaWarning: boolean
public dealloced = false
readonly accountMenuController: AccountMenuController
readonly actionsMenuController = new ActionsMenuController()
readonly featuresController: FeaturesController
readonly filePreviewModalController: FilePreviewModalController
readonly filesController: FilesController
readonly noAccountWarningController: NoAccountWarningController
readonly notesController: NotesController
readonly itemListController: ItemListController
readonly preferencesController: PreferencesController
readonly purchaseFlowController: PurchaseFlowController
readonly quickSettingsMenuController: QuickSettingsController
readonly vaultSelectionController: VaultSelectionMenuController
readonly searchOptionsController: SearchOptionsController
readonly subscriptionController: SubscriptionController
readonly syncStatusController = new SyncStatusController()
readonly navigationController: NavigationController
readonly selectionController: SelectedItemsController
readonly historyModalController: HistoryModalController
readonly linkingController: LinkingController
readonly paneController: PaneController
readonly importModalController: ImportModalController
public isSessionsModalVisible = false
private appEventObserverRemovers: (() => void)[] = []
private subscriptionManager: SubscriptionManagerInterface
private persistenceService: PersistenceService
private applicationEventObserver: EventObserverInterface
private toastService: ToastServiceInterface
constructor(
public application: WebApplication,
private device: WebOrDesktopDeviceInterface,
) {
const eventBus = application.events
this.persistenceService = new PersistenceService(application, eventBus)
eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues)
eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence)
this.subscriptionManager = application.subscriptions
this.filePreviewModalController = new FilePreviewModalController(application)
this.quickSettingsMenuController = new QuickSettingsController(application, eventBus)
this.vaultSelectionController = new VaultSelectionMenuController(application, eventBus)
this.paneController = new PaneController(application, eventBus)
this.preferencesController = new PreferencesController(application, eventBus)
this.selectionController = new SelectedItemsController(application, eventBus)
this.featuresController = new FeaturesController(application, eventBus)
this.navigationController = new NavigationController(application, this.featuresController, eventBus)
this.notesController = new NotesController(
application,
this.selectionController,
this.navigationController,
eventBus,
)
this.searchOptionsController = new SearchOptionsController(application, eventBus)
this.linkingController = new LinkingController(
application,
this.navigationController,
this.selectionController,
eventBus,
)
this.itemListController = new ItemListController(
application,
this.navigationController,
this.searchOptionsController,
this.selectionController,
this.notesController,
eventBus,
)
this.notesController.setServicesPostConstruction(this.itemListController)
this.selectionController.setServicesPostConstruction(this.itemListController)
this.noAccountWarningController = new NoAccountWarningController(application, eventBus)
this.accountMenuController = new AccountMenuController(application, eventBus)
this.subscriptionController = new SubscriptionController(application, eventBus, this.subscriptionManager)
this.purchaseFlowController = new PurchaseFlowController(application, eventBus)
this.filesController = new FilesController(
application,
this.notesController,
this.filePreviewModalController,
eventBus,
)
this.linkingController.setServicesPostConstruction(
this.itemListController,
this.filesController,
this.subscriptionController,
)
this.historyModalController = new HistoryModalController(this.application, eventBus, this.notesController)
this.importModalController = new ImportModalController(this.application, this.navigationController)
this.toastService = new ToastService()
this.applicationEventObserver = new ApplicationEventObserver(
application,
application.routeService,
this.purchaseFlowController,
this.accountMenuController,
this.preferencesController,
this.syncStatusController,
application.sync,
application.sessions,
application.subscriptions,
this.toastService,
application.user,
)
this.addAppEventObserver()
if (this.device.appVersion.includes('-beta')) {
this.showBetaWarning = storage.get(StorageKey.ShowBetaWarning) ?? true
} else {
this.showBetaWarning = false
}
makeObservable(this, {
showBetaWarning: observable,
isSessionsModalVisible: observable,
preferencesController: observable,
openSessionsModal: action,
closeSessionsModal: action,
})
}
deinit(source: DeinitSource): void {
this.dealloced = true
;(this.application as unknown) = undefined
if (source === DeinitSource.SignOut) {
storage.remove(StorageKey.ShowBetaWarning)
this.noAccountWarningController.reset()
}
this.unsubAppEventObserver?.()
;(this.unsubAppEventObserver as unknown) = undefined
this.appEventObserverRemovers.forEach((remover) => remover())
this.appEventObserverRemovers.length = 0
;(this.device as unknown) = undefined
this.filePreviewModalController.deinit()
;(this.filePreviewModalController as unknown) = undefined
;(this.preferencesController as unknown) = undefined
;(this.quickSettingsMenuController as unknown) = undefined
;(this.vaultSelectionController as unknown) = undefined
;(this.syncStatusController as unknown) = undefined
this.persistenceService.deinit()
;(this.persistenceService as unknown) = undefined
this.actionsMenuController.reset()
;(this.actionsMenuController as unknown) = undefined
this.featuresController.deinit()
;(this.featuresController as unknown) = undefined
this.accountMenuController.deinit()
;(this.accountMenuController as unknown) = undefined
this.filesController.deinit()
;(this.filesController as unknown) = undefined
this.noAccountWarningController.deinit()
;(this.noAccountWarningController as unknown) = undefined
this.notesController.deinit()
;(this.notesController as unknown) = undefined
this.itemListController.deinit()
;(this.itemListController as unknown) = undefined
this.linkingController.deinit()
;(this.linkingController as unknown) = undefined
this.purchaseFlowController.deinit()
;(this.purchaseFlowController as unknown) = undefined
this.searchOptionsController.deinit()
;(this.searchOptionsController as unknown) = undefined
this.subscriptionController.deinit()
;(this.subscriptionController as unknown) = undefined
this.navigationController.deinit()
;(this.navigationController as unknown) = undefined
this.historyModalController.deinit()
;(this.historyModalController as unknown) = undefined
this.paneController.deinit()
;(this.paneController as unknown) = undefined
destroyAllObjectProperties(this)
}
openSessionsModal = () => {
this.isSessionsModalVisible = true
}
closeSessionsModal = () => {
this.isSessionsModalVisible = false
}
addAppEventObserver() {
this.unsubAppEventObserver = this.application.addEventObserver(
this.applicationEventObserver.handle.bind(this.applicationEventObserver),
)
}
persistValues = (): void => {
const values: PersistedStateValue = {
[PersistenceKey.SelectedItemsController]: this.selectionController.getPersistableValue(),
[PersistenceKey.NavigationController]: this.navigationController.getPersistableValue(),
}
this.persistenceService.persistValues(values)
const selectedItemsState = values['selected-items-controller']
const navigationSelectionState = values['navigation-controller']
const launchPriorityUuids: string[] = []
if (selectedItemsState.selectedUuids.length) {
launchPriorityUuids.push(...selectedItemsState.selectedUuids)
}
if (navigationSelectionState.selectedTagUuid) {
launchPriorityUuids.push(navigationSelectionState.selectedTagUuid)
}
this.application.sync.setLaunchPriorityUuids(launchPriorityUuids)
}
clearPersistedValues = (): void => {
this.persistenceService.clearPersistedValues()
}
hydrateFromPersistedValues = (values: PersistedStateValue | undefined): void => {
const navigationState = values?.[PersistenceKey.NavigationController]
this.navigationController.hydrateFromPersistedValue(navigationState)
const selectedItemsState = values?.[PersistenceKey.SelectedItemsController]
this.selectionController.hydrateFromPersistedValue(selectedItemsState)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.HydrateFromPersistedValues) {
this.hydrateFromPersistedValues(event.payload as PersistedStateValue | undefined)
} else if (event.type === CrossControllerEvent.RequestValuePersistence) {
this.persistValues()
}
}
}