import { FeaturesService } from '@Lib/Services/Features/FeaturesService' import { ContentType, Uuid } from '@standardnotes/domain-core' import { ActionObserver, PayloadEmitSource, PermissionDialog, Environment, Platform, ComponentMessage, UIFeature, ComponentInterface, PrefKey, ComponentPreferencesEntry, AllComponentPreferences, SNNote, SNTag, DeletedItemInterface, EncryptedItemInterface, } from '@standardnotes/models' import { ComponentArea, FindNativeFeature, EditorFeatureDescription, FindNativeTheme, IframeComponentFeatureDescription, ComponentFeatureDescription, ThemeFeatureDescription, GetIframeEditors, GetNativeThemes, NativeFeatureIdentifier, } from '@standardnotes/features' import { Copy, removeFromArray, sleep, isNotUndefined, LoggerInterface } from '@standardnotes/utils' import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer' import { AbstractService, ComponentManagerInterface, ComponentViewerInterface, DesktopManagerInterface, InternalEventBusInterface, AlertService, DeviceInterface, isMobileDevice, MutatorClientInterface, PreferenceServiceInterface, ComponentViewerItem, PreferencesServiceEvent, ItemManagerInterface, SyncServiceInterface, FeatureStatus, } from '@standardnotes/services' import { GetFeatureUrl } from './UseCase/GetFeatureUrl' import { ComponentManagerEventData } from './ComponentManagerEventData' import { ComponentManagerEvent } from './ComponentManagerEvent' import { RunWithPermissionsUseCase } from './UseCase/RunWithPermissionsUseCase' import { EditorForNoteUseCase } from './UseCase/EditorForNote' import { GetDefaultEditorIdentifier } from './UseCase/GetDefaultEditorIdentifier' import { DoesEditorChangeRequireAlertUseCase } from './UseCase/DoesEditorChangeRequireAlert' declare global { interface Window { /** IE Handlers */ attachEvent(event: string, listener: EventListener): boolean detachEvent(event: string, listener: EventListener): void } } /** * Responsible for orchestrating component functionality, including editors, themes, * and other components. The component manager primarily deals with iframes, and orchestrates * sending and receiving messages to and from frames via the postMessage API. */ export class ComponentManager extends AbstractService implements ComponentManagerInterface { private desktopManager?: DesktopManagerInterface private viewers: ComponentViewerInterface[] = [] private permissionDialogUIHandler: (dialog: PermissionDialog) => void = () => { throw 'Must call setPermissionDialogUIHandler' } private readonly runWithPermissionsUseCase = new RunWithPermissionsUseCase( this.permissionDialogUIHandler, this.alerts, this.mutator, this.sync, this.items, ) constructor( private items: ItemManagerInterface, private mutator: MutatorClientInterface, private sync: SyncServiceInterface, private features: FeaturesService, private preferences: PreferenceServiceInterface, protected alerts: AlertService, private environment: Environment, private platform: Platform, private device: DeviceInterface, private logger: LoggerInterface, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) this.loggingEnabled = false this.addSyncedComponentItemObserver() this.registerMobileNativeComponentUrls() this.eventDisposers.push( preferences.addEventObserver((event) => { if (event === PreferencesServiceEvent.PreferencesChanged) { this.postActiveThemesToAllViewers() } }), ) window.addEventListener ? window.addEventListener('focus', this.detectFocusChange, true) : window.attachEvent('onfocusout', this.detectFocusChange) window.addEventListener ? window.addEventListener('blur', this.detectFocusChange, true) : window.attachEvent('onblur', this.detectFocusChange) window.addEventListener('message', this.onWindowMessage, true) } override deinit(): void { super.deinit() for (const viewer of this.viewers) { viewer.destroy() } this.viewers.length = 0 this.runWithPermissionsUseCase.deinit() this.desktopManager = undefined ;(this.items as unknown) = undefined ;(this.features as unknown) = undefined ;(this.sync as unknown) = undefined ;(this.alerts as unknown) = undefined ;(this.preferences as unknown) = undefined ;(this.permissionDialogUIHandler as unknown) = undefined if (window) { window.removeEventListener('focus', this.detectFocusChange, true) window.removeEventListener('blur', this.detectFocusChange, true) window.removeEventListener('message', this.onWindowMessage, true) } ;(this.detectFocusChange as unknown) = undefined ;(this.onWindowMessage as unknown) = undefined } public setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void { this.permissionDialogUIHandler = handler this.runWithPermissionsUseCase.setPermissionDialogUIHandler(handler) } public thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] { return this.items.getDisplayableComponents().filter((component) => { return component.area === area }) } public createComponentViewer( component: UIFeature, item: ComponentViewerItem, actionObserver?: ActionObserver, ): ComponentViewerInterface { const viewer = new ComponentViewer( component, { items: this.items, mutator: this.mutator, sync: this.sync, alerts: this.alerts, preferences: this.preferences, features: this.features, logger: this.logger, }, { url: this.urlForFeature(component) ?? '', item, actionObserver, }, { environment: this.environment, platform: this.platform, componentManagerFunctions: { runWithPermissionsUseCase: this.runWithPermissionsUseCase, urlsForActiveThemes: this.urlsForActiveThemes.bind(this), setComponentPreferences: this.setComponentPreferences.bind(this), getComponentPreferences: this.getComponentPreferences.bind(this), }, }, ) this.viewers.push(viewer) return viewer } public destroyComponentViewer(viewer: ComponentViewerInterface): void { viewer.destroy() removeFromArray(this.viewers, viewer) } public setDesktopManager(desktopManager: DesktopManagerInterface): void { this.desktopManager = desktopManager this.configureForDesktop() } private handleChangedComponents(components: ComponentInterface[], source: PayloadEmitSource): void { const acceptableSources = [ PayloadEmitSource.LocalChanged, PayloadEmitSource.RemoteRetrieved, PayloadEmitSource.LocalDatabaseLoaded, PayloadEmitSource.LocalInserted, ] if (components.length === 0 || !acceptableSources.includes(source)) { return } if (this.desktopManager) { const thirdPartyComponents = components.filter((component) => { const nativeFeature = FindNativeFeature(component.identifier) return nativeFeature ? false : true }) if (thirdPartyComponents.length > 0) { this.desktopManager.syncComponentsInstallation(thirdPartyComponents) } } const themes = components.filter((c) => c.isTheme()) if (themes.length > 0) { this.postActiveThemesToAllViewers() } } private addSyncedComponentItemObserver(): void { this.eventDisposers.push( this.items.addObserver( [ContentType.TYPES.Component, ContentType.TYPES.Theme], ({ changed, inserted, removed, source }) => { const items = [...changed, ...inserted] this.handleChangedComponents(items, source) this.updateMobileRegisteredComponentUrls(inserted, removed) }, ), ) } private updateMobileRegisteredComponentUrls( inserted: ComponentInterface[], removed: (EncryptedItemInterface | DeletedItemInterface)[], ): void { if (!isMobileDevice(this.device)) { return } for (const component of inserted) { const feature = new UIFeature(component) const url = this.urlForFeature(feature) if (url) { this.device.registerComponentUrl(component.uuid, url) } } for (const component of removed) { this.device.deregisterComponentUrl(component.uuid) } } private registerMobileNativeComponentUrls(): void { if (!isMobileDevice(this.device)) { return } const nativeComponents = [...GetIframeEditors(), ...GetNativeThemes()] for (const component of nativeComponents) { const feature = new UIFeature(component) const url = this.urlForFeature(feature) if (url) { this.device.registerComponentUrl(feature.uniqueIdentifier.value, url) } } } detectFocusChange = (): void => { const activeIframes = Array.from(document.getElementsByTagName('iframe')) for (const iframe of activeIframes) { if (document.activeElement === iframe) { setTimeout(() => { const viewer = this.findComponentViewer( iframe.dataset.componentViewerId as string, ) as ComponentViewerInterface void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, { componentViewer: viewer, }) }) return } } } onWindowMessage = (event: MessageEvent): void => { const data = event.data as ComponentMessage if (data.sessionKey) { this.logger.info('Component manager received message', data) this.componentViewerForSessionKey(data.sessionKey)?.handleMessage(data) } } private configureForDesktop(): void { if (!this.desktopManager) { throw new Error('Desktop manager is not defined') } this.desktopManager.registerUpdateObserver((component: ComponentInterface) => { const activeComponents = this.getActiveComponents() const isComponentActive = activeComponents.find((candidate) => candidate.uuid === component.uuid) if (isComponentActive && component.isTheme()) { this.postActiveThemesToAllViewers() } }) } private postActiveThemesToAllViewers(): void { for (const viewer of this.viewers) { viewer.postActiveThemes() } } public urlForFeature(uiFeature: UIFeature): string | undefined { const usecase = new GetFeatureUrl(this.desktopManager, this.environment, this.platform) return usecase.execute(uiFeature) } public urlsForActiveThemes(): string[] { const themes = this.getActiveThemes() const urls = [] for (const theme of themes) { const url = this.urlForFeature(theme) if (url) { urls.push(url) } } return urls } private findComponentViewer(identifier: string): ComponentViewerInterface | undefined { return this.viewers.find((viewer) => viewer.identifier === identifier) } public findComponentWithPackageIdentifier(identifier: string): ComponentInterface | undefined { return this.items.getDisplayableComponents().find((component) => { return component.identifier === identifier }) } private componentViewerForSessionKey(key: string): ComponentViewerInterface | undefined { return this.viewers.find((viewer) => viewer.sessionKey === key) } public async toggleTheme(uiFeature: UIFeature): Promise { this.logger.info('Toggling theme', uiFeature.uniqueIdentifier) if (this.isThemeActive(uiFeature)) { await this.removeActiveTheme(uiFeature) return } const featureStatus = this.features.getFeatureStatus(uiFeature.uniqueIdentifier) if (featureStatus !== FeatureStatus.Entitled) { return } /* Activate current before deactivating others, so as not to flicker */ await this.addActiveTheme(uiFeature) /* Deactive currently active theme(s) if new theme is not layerable */ if (!uiFeature.layerable) { await sleep(10) const activeThemes = this.getActiveThemes() for (const candidate of activeThemes) { if (candidate.featureIdentifier === uiFeature.featureIdentifier) { continue } if (!candidate.layerable) { await this.removeActiveTheme(candidate) } } } } public getActiveThemes(): UIFeature[] { const { features, uuids } = this.getActiveThemesIdentifiers() const thirdPartyThemes = uuids .map((uuid) => { const component = this.items.findItem(uuid.value) if (component) { return new UIFeature(component) } return undefined }) .filter(isNotUndefined) const nativeThemes = features .map((identifier) => { return FindNativeTheme(identifier.value) }) .filter(isNotUndefined) .map((theme) => new UIFeature(theme)) const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => { return this.features.getFeatureStatus(theme.uniqueIdentifier) === FeatureStatus.Entitled }) return entitledThemes } public getActiveThemesIdentifiers(): { features: NativeFeatureIdentifier[]; uuids: Uuid[] } { const features: NativeFeatureIdentifier[] = [] const uuids: Uuid[] = [] const strings = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] for (const string of strings) { const nativeIdentifier = NativeFeatureIdentifier.create(string) if (!nativeIdentifier.isFailed()) { features.push(nativeIdentifier.getValue()) } const uuid = Uuid.create(string) if (!uuid.isFailed()) { uuids.push(uuid.getValue()) } } return { features, uuids } } public async toggleComponent(component: ComponentInterface): Promise { this.logger.info('Toggling component', component.uuid) if (this.isComponentActive(component)) { await this.removeActiveComponent(component) } else { await this.addActiveComponent(component) } } editorForNote(note: SNNote): UIFeature { const usecase = new EditorForNoteUseCase(this.items) return usecase.execute(note) } getDefaultEditorIdentifier(currentTag?: SNTag): string { const usecase = new GetDefaultEditorIdentifier(this.preferences, this.items) return usecase.execute(currentTag).getValue() } doesEditorChangeRequireAlert( from: UIFeature | undefined, to: UIFeature | undefined, ): boolean { const usecase = new DoesEditorChangeRequireAlertUseCase() return usecase.execute(from, to) } async showEditorChangeAlert(): Promise { const shouldChangeEditor = await this.alerts.confirm( 'Doing so might result in minor formatting changes.', "Are you sure you want to change this note's type?", 'Yes, change it', ) return shouldChangeEditor } async setComponentPreferences( uiFeature: UIFeature, preferences: ComponentPreferencesEntry, ): Promise { const mutablePreferencesValue = Copy( this.preferences.getValue(PrefKey.ComponentPreferences, undefined) ?? {}, ) const preferencesLookupKey = uiFeature.uniqueIdentifier.value mutablePreferencesValue[preferencesLookupKey] = preferences await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue) } getComponentPreferences(component: UIFeature): ComponentPreferencesEntry | undefined { const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined) if (!preferences) { return undefined } const preferencesLookupKey = component.uniqueIdentifier.value return preferences[preferencesLookupKey] } async addActiveTheme(theme: UIFeature): Promise { const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice() activeThemes.push(theme.uniqueIdentifier.value) await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes) } async replaceActiveTheme(theme: UIFeature): Promise { await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier.value]) } async removeActiveTheme(theme: UIFeature): Promise { const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier.value) await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes) } isThemeActive(theme: UIFeature): boolean { if (this.features.getFeatureStatus(theme.uniqueIdentifier) !== FeatureStatus.Entitled) { return false } const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] return activeThemes.includes(theme.uniqueIdentifier.value) } async addActiveComponent(component: ComponentInterface): Promise { const activeComponents = (this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []).slice() activeComponents.push(component.uuid) await this.preferences.setValue(PrefKey.ActiveComponents, activeComponents) } async removeActiveComponent(component: ComponentInterface): Promise { const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] const filteredComponents = activeComponents.filter((activeComponent) => activeComponent !== component.uuid) await this.preferences.setValue(PrefKey.ActiveComponents, filteredComponents) } getActiveComponents(): ComponentInterface[] { const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] return this.items.findItems(activeComponents) } isComponentActive(component: ComponentInterface): boolean { const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] return activeComponents.includes(component.uuid) } }