Files
standardnotes-app-web/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts

576 lines
18 KiB
TypeScript

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<ComponentManagerEvent, ComponentManagerEventData>
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<IframeComponentFeatureDescription>,
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<ComponentInterface>(
[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<ComponentFeatureDescription>(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<ComponentFeatureDescription>(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<ComponentFeatureDescription>): 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<ThemeFeatureDescription>): Promise<void> {
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<ThemeFeatureDescription>[] {
const { features, uuids } = this.getActiveThemesIdentifiers()
const thirdPartyThemes = uuids
.map((uuid) => {
const component = this.items.findItem<ComponentInterface>(uuid.value)
if (component) {
return new UIFeature<ThemeFeatureDescription>(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<void> {
this.logger.info('Toggling component', component.uuid)
if (this.isComponentActive(component)) {
await this.removeActiveComponent(component)
} else {
await this.addActiveComponent(component)
}
}
editorForNote(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
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<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean {
const usecase = new DoesEditorChangeRequireAlertUseCase()
return usecase.execute(from, to)
}
async showEditorChangeAlert(): Promise<boolean> {
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<ComponentFeatureDescription>,
preferences: ComponentPreferencesEntry,
): Promise<void> {
const mutablePreferencesValue = Copy<AllComponentPreferences>(
this.preferences.getValue(PrefKey.ComponentPreferences, undefined) ?? {},
)
const preferencesLookupKey = uiFeature.uniqueIdentifier.value
mutablePreferencesValue[preferencesLookupKey] = preferences
await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue)
}
getComponentPreferences(component: UIFeature<ComponentFeatureDescription>): 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<ThemeFeatureDescription>): Promise<void> {
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<ThemeFeatureDescription>): Promise<void> {
await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier.value])
}
async removeActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
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<ThemeFeatureDescription>): 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<void> {
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<void> {
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)
}
}