refactor: component manager usecases (#2354)

This commit is contained in:
Mo 2023-07-13 05:46:52 -05:00 committed by GitHub
parent ecc5b5e503
commit 2c68ea1d76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1454 additions and 1078 deletions

View File

@ -9,6 +9,7 @@ import { experimentalFeatures } from '../Lists/ExperimentalFeatures'
import { IframeEditors } from '../Lists/IframeEditors' import { IframeEditors } from '../Lists/IframeEditors'
import { themes } from '../Lists/Themes' import { themes } from '../Lists/Themes'
import { nativeEditors } from '../Lists/NativeEditors' import { nativeEditors } from '../Lists/NativeEditors'
import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription'
export function GetFeatures(): AnyFeatureDescription[] { export function GetFeatures(): AnyFeatureDescription[] {
return [ return [
@ -30,10 +31,14 @@ export function FindNativeTheme(identifier: FeatureIdentifier): ThemeFeatureDesc
return themes().find((t) => t.identifier === identifier) return themes().find((t) => t.identifier === identifier)
} }
export function GetIframeAndNativeEditors(): EditorFeatureDescription[] { export function GetIframeAndNativeEditors(): (IframeComponentFeatureDescription | EditorFeatureDescription)[] {
return [...IframeEditors(), ...nativeEditors()] return [...IframeEditors(), ...nativeEditors()]
} }
export function GetIframeEditors(): IframeComponentFeatureDescription[] {
return IframeEditors()
}
export function GetSuperNoteFeature(): EditorFeatureDescription { export function GetSuperNoteFeature(): EditorFeatureDescription {
return FindNativeFeature(FeatureIdentifier.SuperEditor) as EditorFeatureDescription return FindNativeFeature(FeatureIdentifier.SuperEditor) as EditorFeatureDescription
} }

View File

@ -0,0 +1,63 @@
import { ContentType } from '@standardnotes/domain-core'
import { AnyFeatureDescription } from './AnyFeatureDescription'
import { ComponentArea } from '../Component/ComponentArea'
import {
isThemeFeatureDescription,
isIframeComponentFeatureDescription,
isEditorFeatureDescription,
} from './TypeGuards'
import { ThemeFeatureDescription } from './ThemeFeatureDescription'
import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription'
describe('TypeGuards', () => {
describe('isThemeFeatureDescription', () => {
it('should return true if feature is ThemeFeatureDescription', () => {
const feature = {
content_type: ContentType.TYPES.Theme,
} as jest.Mocked<ThemeFeatureDescription>
expect(isThemeFeatureDescription(feature)).toBe(true)
})
it('should return false if feature is not ThemeFeatureDescription', () => {
const feature = {
content_type: ContentType.TYPES.Component,
} as jest.Mocked<ThemeFeatureDescription>
expect(isThemeFeatureDescription(feature)).toBe(false)
})
})
describe('isIframeComponentFeatureDescription', () => {
it('should return true if feature is IframeComponentFeatureDescription', () => {
const feature = {
content_type: ContentType.TYPES.Component,
area: ComponentArea.Editor,
} as jest.Mocked<IframeComponentFeatureDescription>
expect(isIframeComponentFeatureDescription(feature)).toBe(true)
})
it('should return false if feature is not IframeComponentFeatureDescription', () => {
const feature = {
content_type: ContentType.TYPES.Theme,
} as jest.Mocked<IframeComponentFeatureDescription>
expect(isIframeComponentFeatureDescription(feature)).toBe(false)
})
})
describe('isEditorFeatureDescription', () => {
it('should return true if feature is EditorFeatureDescription', () => {
const feature = {
note_type: 'test',
area: ComponentArea.Editor,
} as unknown as jest.Mocked<AnyFeatureDescription>
expect(isEditorFeatureDescription(feature)).toBe(true)
})
it('should return false if feature is not EditorFeatureDescription', () => {
const feature = {
content_type: ContentType.TYPES.Theme,
} as jest.Mocked<AnyFeatureDescription>
expect(isEditorFeatureDescription(feature)).toBe(false)
})
})
})

View File

@ -512,15 +512,15 @@ export class MobileDevice implements MobileDeviceInterface {
) )
} }
addComponentUrl(componentUuid: UuidString, componentUrl: string) { registerComponentUrl(componentUuid: UuidString, componentUrl: string) {
this.componentUrls.set(componentUuid, componentUrl) this.componentUrls.set(componentUuid, componentUrl)
} }
removeComponentUrl(componentUuid: UuidString) { deregisterComponentUrl(componentUuid: UuidString) {
this.componentUrls.delete(componentUuid) this.componentUrls.delete(componentUuid)
} }
isUrlComponentUrl(url: string): boolean { isUrlRegisteredComponentUrl(url: string): boolean {
return Array.from(this.componentUrls.values()).includes(url) return Array.from(this.componentUrls.values()).includes(url)
} }

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ApplicationEvent, ReactNativeToWebEvent } from '@standardnotes/snjs' import { ApplicationEvent, ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Keyboard, Platform, Text, View } from 'react-native' import { Button, Keyboard, Platform, Text, View } from 'react-native'
@ -239,6 +241,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args) void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args)
} catch (error) { } catch (error) {
if (LoggingEnabled) { if (LoggingEnabled) {
// eslint-disable-next-line no-console
console.log('onGeneralMessage', JSON.stringify(message)) console.log('onGeneralMessage', JSON.stringify(message))
} }
} }
@ -247,6 +250,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const onFunctionMessage = async (functionName: string, messageId: string, args: any) => { const onFunctionMessage = async (functionName: string, messageId: string, args: any) => {
const returnValue = await (device as any)[functionName](...args) const returnValue = await (device as any)[functionName](...args)
if (LoggingEnabled && functionName !== 'consoleLog') { if (LoggingEnabled && functionName !== 'consoleLog') {
// eslint-disable-next-line no-console
console.log(`Native device function ${functionName} called`) console.log(`Native device function ${functionName} called`)
} }
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' })) webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' }))
@ -270,7 +274,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
(Platform.OS === 'ios' && request.navigationType === 'click') || (Platform.OS === 'ios' && request.navigationType === 'click') ||
(Platform.OS === 'android' && request.url !== sourceUri) (Platform.OS === 'android' && request.url !== sourceUri)
const isComponentUrl = device.isUrlComponentUrl(request.url) const isComponentUrl = device.isUrlRegisteredComponentUrl(request.url)
if (shouldStopRequest && !isComponentUrl) { if (shouldStopRequest && !isComponentUrl) {
device.openUrl(request.url) device.openUrl(request.url)

View File

@ -0,0 +1,77 @@
import {
AnyFeatureDescription,
ComponentArea,
EditorFeatureDescription,
IframeComponentFeatureDescription,
NoteType,
UIFeatureDescriptionTypes,
} from '@standardnotes/features'
import {
isUIFeatureAnIframeFeature,
isComponentOrFeatureDescriptionAComponent,
isComponentOrFeatureDescriptionAFeatureDescription,
} from './TypeGuards'
import { UIFeature } from './UIFeature'
import { ComponentInterface } from '../../Syncable/Component'
import { ContentType } from '@standardnotes/domain-core'
describe('TypeGuards', () => {
describe('isUIFeatureAnIframeFeature', () => {
it('should return true if feature is IframeUIFeature', () => {
const x: UIFeature<IframeComponentFeatureDescription> = {
featureDescription: {
content_type: ContentType.TYPES.Component,
area: ComponentArea.Editor,
},
} as jest.Mocked<UIFeature<IframeComponentFeatureDescription>>
expect(isUIFeatureAnIframeFeature(x)).toBe(true)
})
it('should return false if feature is not IframeUIFeature', () => {
const x: UIFeature<EditorFeatureDescription> = {
featureDescription: {
note_type: NoteType.Super,
},
} as jest.Mocked<UIFeature<EditorFeatureDescription>>
expect(isUIFeatureAnIframeFeature(x)).toBe(false)
})
})
describe('isFeatureAComponent', () => {
it('should return true if feature is a Component', () => {
const x: ComponentInterface | UIFeatureDescriptionTypes = {
uuid: 'abc-123',
} as ComponentInterface
expect(isComponentOrFeatureDescriptionAComponent(x)).toBe(true)
})
it('should return false if feature description is not a component', () => {
const x: EditorFeatureDescription = {
note_type: NoteType.Super,
} as jest.Mocked<EditorFeatureDescription>
expect(isComponentOrFeatureDescriptionAComponent(x)).toBe(false)
})
})
describe('isComponentOrFeatureDescriptionAFeatureDescription', () => {
it('should return true if x is a feature description', () => {
const x: AnyFeatureDescription = {
content_type: 'TestContentType',
} as AnyFeatureDescription
expect(isComponentOrFeatureDescriptionAFeatureDescription(x)).toBe(true)
})
it('should return false if x is a component', () => {
const x: ComponentInterface = {
uuid: 'abc-123',
} as ComponentInterface
expect(isComponentOrFeatureDescriptionAFeatureDescription(x)).toBe(false)
})
})
})

View File

@ -0,0 +1,27 @@
import {
AnyFeatureDescription,
EditorFeatureDescription,
IframeComponentFeatureDescription,
UIFeatureDescriptionTypes,
isIframeComponentFeatureDescription,
} from '@standardnotes/features'
import { UIFeatureInterface } from './UIFeatureInterface'
import { ComponentInterface } from '../../Syncable/Component'
export function isUIFeatureAnIframeFeature(
x: UIFeatureInterface<EditorFeatureDescription | IframeComponentFeatureDescription>,
): x is UIFeatureInterface<IframeComponentFeatureDescription> {
return isIframeComponentFeatureDescription(x.featureDescription)
}
export function isComponentOrFeatureDescriptionAComponent(
x: ComponentInterface | UIFeatureDescriptionTypes,
): x is ComponentInterface {
return 'uuid' in x
}
export function isComponentOrFeatureDescriptionAFeatureDescription(
x: ComponentInterface | AnyFeatureDescription,
): x is AnyFeatureDescription {
return !('uuid' in x)
}

View File

@ -1,10 +1,8 @@
import { import {
AnyFeatureDescription,
ComponentArea, ComponentArea,
ComponentPermission, ComponentPermission,
EditorFeatureDescription, EditorFeatureDescription,
FeatureIdentifier, FeatureIdentifier,
IframeComponentFeatureDescription,
NoteType, NoteType,
ThemeDockIcon, ThemeDockIcon,
UIFeatureDescriptionTypes, UIFeatureDescriptionTypes,
@ -12,40 +10,31 @@ import {
isIframeComponentFeatureDescription, isIframeComponentFeatureDescription,
isThemeFeatureDescription, isThemeFeatureDescription,
} from '@standardnotes/features' } from '@standardnotes/features'
import { ComponentInterface } from './ComponentInterface' import { ComponentInterface } from '../../Syncable/Component/ComponentInterface'
import { isTheme } from '../Theme' import { isTheme } from '../../Syncable/Theme'
import {
isComponentOrFeatureDescriptionAComponent,
isComponentOrFeatureDescriptionAFeatureDescription,
} from './TypeGuards'
import { UIFeatureInterface } from './UIFeatureInterface'
function isComponent(x: ComponentInterface | UIFeatureDescriptionTypes): x is ComponentInterface { export class UIFeature<F extends UIFeatureDescriptionTypes> implements UIFeatureInterface<F> {
return 'uuid' in x
}
function isFeatureDescription(x: ComponentInterface | AnyFeatureDescription): x is AnyFeatureDescription {
return !('uuid' in x)
}
export function isIframeUIFeature(
x: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>,
): x is ComponentOrNativeFeature<IframeComponentFeatureDescription> {
return isIframeComponentFeatureDescription(x.featureDescription)
}
export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
constructor(public readonly item: ComponentInterface | F) {} constructor(public readonly item: ComponentInterface | F) {}
get isComponent(): boolean { get isComponent(): boolean {
return isComponent(this.item) return isComponentOrFeatureDescriptionAComponent(this.item)
} }
get isFeatureDescription(): boolean { get isFeatureDescription(): boolean {
return isFeatureDescription(this.item) return isComponentOrFeatureDescriptionAFeatureDescription(this.item)
} }
get isThemeComponent(): boolean { get isThemeComponent(): boolean {
return isComponent(this.item) && isTheme(this.item) return isComponentOrFeatureDescriptionAComponent(this.item) && isTheme(this.item)
} }
get asComponent(): ComponentInterface { get asComponent(): ComponentInterface {
if (isComponent(this.item)) { if (isComponentOrFeatureDescriptionAComponent(this.item)) {
return this.item return this.item
} }
@ -53,7 +42,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get asFeatureDescription(): F { get asFeatureDescription(): F {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item return this.item
} }
@ -61,7 +50,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get uniqueIdentifier(): string { get uniqueIdentifier(): string {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item.identifier return this.item.identifier
} else { } else {
return this.item.uuid return this.item.uuid
@ -73,9 +62,9 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get noteType(): NoteType { get noteType(): NoteType {
if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) {
return this.item.note_type ?? NoteType.Unknown return this.item.note_type ?? NoteType.Unknown
} else if (isComponent(this.item)) { } else if (isComponentOrFeatureDescriptionAComponent(this.item)) {
return this.item.noteType return this.item.noteType
} }
@ -83,9 +72,12 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get fileType(): EditorFeatureDescription['file_type'] { get fileType(): EditorFeatureDescription['file_type'] {
if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) {
return this.item.file_type return this.item.file_type
} else if (isComponent(this.item) && isEditorFeatureDescription(this.item.package_info)) { } else if (
isComponentOrFeatureDescriptionAComponent(this.item) &&
isEditorFeatureDescription(this.item.package_info)
) {
return this.item.package_info?.file_type ?? 'txt' return this.item.package_info?.file_type ?? 'txt'
} }
@ -93,7 +85,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get displayName(): string { get displayName(): string {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item.name ?? '' return this.item.name ?? ''
} else { } else {
return this.item.displayName return this.item.displayName
@ -101,7 +93,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get description(): string { get description(): string {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item.description ?? '' return this.item.description ?? ''
} else { } else {
return this.item.package_info.description ?? '' return this.item.package_info.description ?? ''
@ -109,7 +101,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get deprecationMessage(): string | undefined { get deprecationMessage(): string | undefined {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item.deprecation_message return this.item.deprecation_message
} else { } else {
return this.item.deprecationMessage return this.item.deprecationMessage
@ -117,7 +109,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get expirationDate(): Date | undefined { get expirationDate(): Date | undefined {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item.expires_at ? new Date(this.item.expires_at) : undefined return this.item.expires_at ? new Date(this.item.expires_at) : undefined
} else { } else {
return this.item.valid_until return this.item.valid_until
@ -125,7 +117,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get featureDescription(): F { get featureDescription(): F {
if (isFeatureDescription(this.item)) { if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) {
return this.item return this.item
} else { } else {
return this.item.package_info as F return this.item.package_info as F
@ -133,9 +125,12 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get acquiredPermissions(): ComponentPermission[] { get acquiredPermissions(): ComponentPermission[] {
if (isFeatureDescription(this.item) && isIframeComponentFeatureDescription(this.item)) { if (
isComponentOrFeatureDescriptionAFeatureDescription(this.item) &&
isIframeComponentFeatureDescription(this.item)
) {
return this.item.component_permissions ?? [] return this.item.component_permissions ?? []
} else if (isComponent(this.item)) { } else if (isComponentOrFeatureDescriptionAComponent(this.item)) {
return this.item.permissions return this.item.permissions
} }
@ -151,7 +146,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get layerable(): boolean { get layerable(): boolean {
if (isComponent(this.item) && isTheme(this.item)) { if (isComponentOrFeatureDescriptionAComponent(this.item) && isTheme(this.item)) {
return this.item.layerable return this.item.layerable
} else if (isThemeFeatureDescription(this.asFeatureDescription)) { } else if (isThemeFeatureDescription(this.asFeatureDescription)) {
return this.asFeatureDescription.layerable ?? false return this.asFeatureDescription.layerable ?? false
@ -161,7 +156,7 @@ export class ComponentOrNativeFeature<F extends UIFeatureDescriptionTypes> {
} }
get dockIcon(): ThemeDockIcon | undefined { get dockIcon(): ThemeDockIcon | undefined {
if (isComponent(this.item) && isTheme(this.item)) { if (isComponentOrFeatureDescriptionAComponent(this.item) && isTheme(this.item)) {
return this.item.package_info.dock_icon return this.item.package_info.dock_icon
} else if (isThemeFeatureDescription(this.asFeatureDescription)) { } else if (isThemeFeatureDescription(this.asFeatureDescription)) {
return this.asFeatureDescription.dock_icon return this.asFeatureDescription.dock_icon

View File

@ -0,0 +1,32 @@
import {
ComponentArea,
ComponentPermission,
EditorFeatureDescription,
FeatureIdentifier,
NoteType,
ThemeDockIcon,
UIFeatureDescriptionTypes,
} from '@standardnotes/features'
import { ComponentInterface } from '../../Syncable/Component'
export interface UIFeatureInterface<F extends UIFeatureDescriptionTypes> {
item: ComponentInterface | F
get isComponent(): boolean
get isFeatureDescription(): boolean
get isThemeComponent(): boolean
get asComponent(): ComponentInterface
get asFeatureDescription(): F
get uniqueIdentifier(): string
get featureIdentifier(): FeatureIdentifier
get noteType(): NoteType
get fileType(): EditorFeatureDescription['file_type']
get displayName(): string
get description(): string
get deprecationMessage(): string | undefined
get expirationDate(): Date | undefined
get featureDescription(): F
get acquiredPermissions(): ComponentPermission[]
get area(): ComponentArea
get layerable(): boolean
get dockIcon(): ThemeDockIcon | undefined
}

View File

@ -2,5 +2,5 @@ export * from './Component'
export * from './ComponentMutator' export * from './ComponentMutator'
export * from './ComponentContent' export * from './ComponentContent'
export * from './ComponentInterface' export * from './ComponentInterface'
export * from './ComponentOrNativeFeature' export * from '../../Runtime/Feature/UIFeature'
export * from './PackageInfo' export * from './PackageInfo'

View File

@ -43,6 +43,10 @@ export * from './Local/RootKey/RootKeyContent'
export * from './Local/RootKey/RootKeyInterface' export * from './Local/RootKey/RootKeyInterface'
export * from './Local/RootKey/RootKeyWithKeyPairsInterface' export * from './Local/RootKey/RootKeyWithKeyPairsInterface'
export * from './Runtime/Feature/TypeGuards'
export * from './Runtime/Feature/UIFeature'
export * from './Runtime/Feature/UIFeatureInterface'
export * from './Runtime/Collection/CollectionSort' export * from './Runtime/Collection/CollectionSort'
export * from './Runtime/Collection/Item/ItemCollection' export * from './Runtime/Collection/Item/ItemCollection'
export * from './Runtime/Collection/Item/ItemCounter' export * from './Runtime/Collection/Item/ItemCounter'

View File

@ -3,43 +3,39 @@ import {
ComponentArea, ComponentArea,
ComponentFeatureDescription, ComponentFeatureDescription,
EditorFeatureDescription, EditorFeatureDescription,
EditorIdentifier,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
ThemeFeatureDescription, ThemeFeatureDescription,
} from '@standardnotes/features' } from '@standardnotes/features'
import { import { ActionObserver, ComponentInterface, UIFeature, PermissionDialog, SNNote, SNTag } from '@standardnotes/models'
ActionObserver,
ComponentInterface,
ComponentOrNativeFeature,
PermissionDialog,
SNNote,
} from '@standardnotes/models'
import { DesktopManagerInterface } from '../Device/DesktopManagerInterface' import { DesktopManagerInterface } from '../Device/DesktopManagerInterface'
import { ComponentViewerInterface } from './ComponentViewerInterface' import { ComponentViewerInterface } from './ComponentViewerInterface'
export interface ComponentManagerInterface { export interface ComponentManagerInterface {
urlForComponent(uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>): string | undefined urlForFeature(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined
setDesktopManager(desktopManager: DesktopManagerInterface): void setDesktopManager(desktopManager: DesktopManagerInterface): void
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[]
editorForNote(note: SNNote): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
doesEditorChangeRequireAlert( doesEditorChangeRequireAlert(
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined, from: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined, to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean ): boolean
showEditorChangeAlert(): Promise<boolean> showEditorChangeAlert(): Promise<boolean>
destroyComponentViewer(viewer: ComponentViewerInterface): void destroyComponentViewer(viewer: ComponentViewerInterface): void
createComponentViewer( createComponentViewer(
uiFeature: ComponentOrNativeFeature<IframeComponentFeatureDescription>, uiFeature: UIFeature<IframeComponentFeatureDescription>,
item: ComponentViewerItem, item: ComponentViewerItem,
actionObserver?: ActionObserver, actionObserver?: ActionObserver,
urlOverride?: string, urlOverride?: string,
): ComponentViewerInterface ): ComponentViewerInterface
presentPermissionsDialog(_dialog: PermissionDialog): void
legacyGetDefaultEditor(): ComponentInterface | undefined
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void
toggleTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void>
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] editorForNote(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
getDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier
isThemeActive(theme: UIFeature<ThemeFeatureDescription>): boolean
toggleTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void>
getActiveThemes(): UIFeature<ThemeFeatureDescription>[]
getActiveThemesIdentifiers(): string[] getActiveThemesIdentifiers(): string[]
isComponentActive(component: ComponentInterface): boolean isComponentActive(component: ComponentInterface): boolean

View File

@ -1,9 +1,4 @@
import { import { ActionObserver, ComponentEventObserver, ComponentMessage, UIFeature } from '@standardnotes/models'
ActionObserver,
ComponentEventObserver,
ComponentMessage,
ComponentOrNativeFeature,
} from '@standardnotes/models'
import { FeatureStatus } from '../Feature/FeatureStatus' import { FeatureStatus } from '../Feature/FeatureStatus'
import { ComponentViewerError } from './ComponentViewerError' import { ComponentViewerError } from './ComponentViewerError'
import { IframeComponentFeatureDescription } from '@standardnotes/features' import { IframeComponentFeatureDescription } from '@standardnotes/features'
@ -16,7 +11,7 @@ export interface ComponentViewerInterface {
get url(): string get url(): string
get componentUniqueIdentifier(): string get componentUniqueIdentifier(): string
getComponentOrFeatureItem(): ComponentOrNativeFeature<IframeComponentFeatureDescription> getComponentOrFeatureItem(): UIFeature<IframeComponentFeatureDescription>
destroy(): void destroy(): void
setReadonly(readonly: boolean): void setReadonly(readonly: boolean): void

View File

@ -15,16 +15,20 @@ export interface MobileDeviceInterface extends DeviceInterface {
authenticateWithBiometrics(): Promise<boolean> authenticateWithBiometrics(): Promise<boolean>
hideMobileInterfaceFromScreenshots(): void hideMobileInterfaceFromScreenshots(): void
stopHidingMobileInterfaceFromScreenshots(): void stopHidingMobileInterfaceFromScreenshots(): void
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
consoleLog(...args: any[]): void consoleLog(...args: any[]): void
handleThemeSchemeChange(isDark: boolean, bgColor: string): void handleThemeSchemeChange(isDark: boolean, bgColor: string): void
shareBase64AsFile(base64: string, filename: string): Promise<void> shareBase64AsFile(base64: string, filename: string): Promise<void>
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined> downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
previewFile(base64: string, filename: string): Promise<boolean> previewFile(base64: string, filename: string): Promise<boolean>
exitApp(confirm?: boolean): void exitApp(confirm?: boolean): void
addComponentUrl(componentUuid: string, componentUrl: string): void
removeComponentUrl(componentUuid: string): void registerComponentUrl(componentUuid: string, componentUrl: string): void
isUrlComponentUrl(url: string): boolean deregisterComponentUrl(componentUuid: string): void
isUrlRegisteredComponentUrl(url: string): boolean
getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'> getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'>
getColorScheme(): Promise<'light' | 'dark' | null | undefined> getColorScheme(): Promise<'light' | 'dark' | null | undefined>
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>

View File

@ -1,32 +1,6 @@
/**
* @jest-environment jsdom
*/
import { SNPreferencesService } from '../Preferences/PreferencesService' import { SNPreferencesService } from '../Preferences/PreferencesService'
import { createNote } from './../../Spec/SpecUtils' import { GenericItem, Environment, Platform } from '@standardnotes/models'
import { import {
ComponentAction,
ComponentPermission,
FindNativeFeature,
FeatureIdentifier,
NoteType,
UIFeatureDescriptionTypes,
IframeComponentFeatureDescription,
} from '@standardnotes/features'
import { ContentType } from '@standardnotes/domain-core'
import {
GenericItem,
SNComponent,
Environment,
Platform,
ComponentInterface,
ComponentOrNativeFeature,
ComponentContent,
DecryptedPayload,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import {
DesktopManagerInterface,
InternalEventBusInterface, InternalEventBusInterface,
AlertService, AlertService,
DeviceInterface, DeviceInterface,
@ -39,7 +13,6 @@ import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { SNComponentManager } from './ComponentManager' import { SNComponentManager } from './ComponentManager'
import { SNSyncService } from '../Sync/SyncService' import { SNSyncService } from '../Sync/SyncService'
import { ComponentPackageInfo } from '@standardnotes/models'
describe('featuresService', () => { describe('featuresService', () => {
let items: ItemManagerInterface let items: ItemManagerInterface
@ -51,12 +24,6 @@ describe('featuresService', () => {
let eventBus: InternalEventBusInterface let eventBus: InternalEventBusInterface
let device: DeviceInterface let device: DeviceInterface
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
return new ComponentOrNativeFeature(FindNativeFeature<F>(identifier)!)
}
const desktopExtHost = 'http://localhost:123'
const createManager = (environment: Environment, platform: Platform) => { const createManager = (environment: Environment, platform: Platform) => {
const manager = new SNComponentManager( const manager = new SNComponentManager(
items, items,
@ -71,23 +38,15 @@ describe('featuresService', () => {
eventBus, eventBus,
) )
if (environment === Environment.Desktop) {
const desktopManager: DesktopManagerInterface = {
syncComponentsInstallation() {},
registerUpdateObserver(_callback: (component: ComponentInterface) => void) {
return () => {}
},
getExtServerHost() {
return desktopExtHost
},
}
manager.setDesktopManager(desktopManager)
}
return manager return manager
} }
beforeEach(() => { beforeEach(() => {
global.window = {
addEventListener: jest.fn(),
attachEvent: jest.fn(),
} as unknown as Window & typeof globalThis
sync = {} as jest.Mocked<SNSyncService> sync = {} as jest.Mocked<SNSyncService>
sync.sync = jest.fn() sync.sync = jest.fn()
@ -117,336 +76,9 @@ describe('featuresService', () => {
device = {} as jest.Mocked<DeviceInterface> device = {} as jest.Mocked<DeviceInterface>
}) })
const thirdPartyFeature = () => { it('should create manager', () => {
const component = new SNComponent( const manager = createManager(Environment.Web, Platform.MacWeb)
new DecryptedPayload({
uuid: '789',
content_type: ContentType.TYPES.Component,
...PayloadTimestampDefaults(),
content: {
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier' as FeatureIdentifier,
expires_at: new Date().getTime(),
availableInRoles: [],
} as unknown as jest.Mocked<ComponentPackageInfo>,
} as unknown as jest.Mocked<ComponentContent>,
}),
)
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component) expect(manager).toBeDefined()
}
describe('permissions', () => {
it('editor should be able to to stream single note', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamContextItem,
content_types: [ContentType.TYPES.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(true)
})
it('no extension should be able to stream multiple notes', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple notes or tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('some valid and some invalid permissions should still return invalid permissions', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.FilesafeFileMetadata],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(false)
})
it('filesafe should be able to stream its files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(true)
})
it('bold editor should be able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor),
permissions,
),
).toEqual(true)
})
it('non bold editor should not able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions),
).toEqual(false)
})
})
describe('urlForComponent', () => {
describe('desktop', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)!
const url = manager.urlForComponent(feature)
expect(url).toEqual(
`${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns native path for deprecated native component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.DeprecatedBoldEditor,
)!
const url = manager.urlForComponent(feature)
expect(url).toEqual(
`${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns nonnative path for third party component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const feature = thirdPartyFeature()
const url = manager.urlForComponent(feature)
expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`)
})
it('returns hosted url for third party component with no local_url', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = new SNComponent({
uuid: '789',
content_type: ContentType.TYPES.Component,
content: {
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier',
valid_until: new Date(),
},
},
} as never)
const feature = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
const url = manager.urlForComponent(feature)
expect(url).toEqual('https://example.com/component')
})
})
describe('web', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)
const url = manager.urlForComponent(feature)
expect(url).toEqual(
`http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns hosted path for third party component', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const feature = thirdPartyFeature()
const url = manager.urlForComponent(feature)
expect(url).toEqual(feature.asComponent.hosted_url)
})
})
})
describe('editors', () => {
it('getEditorForNote should return plain notes is note type is plain', () => {
const note = createNote({
noteType: NoteType.Plain,
})
const manager = createManager(Environment.Web, Platform.MacWeb)
expect(manager.editorForNote(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
})
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
const note = createNote({})
const manager = createManager(Environment.Web, Platform.MacWeb)
manager['legacyGetEditorForNote'] = jest.fn()
manager.editorForNote(note)
expect(manager['legacyGetEditorForNote']).toHaveBeenCalled()
})
})
describe('editor change alert', () => {
it('should not require alert switching from plain editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)!
const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to plain editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)!
const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from a markdown editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to a markdown editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from & to a html editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should require alert switching from a html editor to custom editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.TokenVaultEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to html editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.TokenVaultEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to custom editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.TokenVaultEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
expect(requiresAlert).toBe(true)
})
}) })
}) })

View File

@ -1,49 +1,37 @@
import { AllowedBatchStreaming } from './Types'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { ContentType } from '@standardnotes/domain-core' import { ContentType } from '@standardnotes/domain-core'
import { import {
ActionObserver, ActionObserver,
SNNote,
ComponentMutator,
PayloadEmitSource, PayloadEmitSource,
PermissionDialog, PermissionDialog,
Environment, Environment,
Platform, Platform,
ComponentMessage, ComponentMessage,
ComponentOrNativeFeature, UIFeature,
ComponentInterface, ComponentInterface,
PrefKey, PrefKey,
ThemeInterface, ThemeInterface,
ComponentPreferencesEntry, ComponentPreferencesEntry,
AllComponentPreferences, AllComponentPreferences,
SNNote,
SNTag,
DeletedItemInterface,
EncryptedItemInterface,
} from '@standardnotes/models' } from '@standardnotes/models'
import { import {
ComponentArea, ComponentArea,
ComponentAction,
ComponentPermission,
FindNativeFeature, FindNativeFeature,
NoteType,
FeatureIdentifier, FeatureIdentifier,
EditorFeatureDescription, EditorFeatureDescription,
GetIframeAndNativeEditors,
FindNativeTheme, FindNativeTheme,
UIFeatureDescriptionTypes,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
GetPlainNoteFeature,
GetSuperNoteFeature,
ComponentFeatureDescription, ComponentFeatureDescription,
ThemeFeatureDescription, ThemeFeatureDescription,
EditorIdentifier,
GetIframeEditors,
GetNativeThemes,
} from '@standardnotes/features' } from '@standardnotes/features'
import { import { Copy, removeFromArray, sleep, isNotUndefined } from '@standardnotes/utils'
Copy,
filterFromArray,
removeFromArray,
sleep,
assert,
uniqueArray,
isNotUndefined,
} from '@standardnotes/utils'
import { AllowedBatchContentTypes } from '@Lib/Services/ComponentManager/Types'
import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer' import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer'
import { import {
AbstractService, AbstractService,
@ -62,12 +50,13 @@ import {
SyncServiceInterface, SyncServiceInterface,
FeatureStatus, FeatureStatus,
} from '@standardnotes/services' } from '@standardnotes/services'
import { permissionsStringForPermissions } from './permissionsStringForPermissions' import { GetFeatureUrl } from './UseCase/GetFeatureUrl'
import { ComponentManagerEventData } from './ComponentManagerEventData'
const DESKTOP_URL_PREFIX = 'sn://' import { ComponentManagerEvent } from './ComponentManagerEvent'
const LOCAL_HOST = 'localhost' import { RunWithPermissionsUseCase } from './UseCase/RunWithPermissionsUseCase'
const CUSTOM_LOCAL_HOST = 'sn.local' import { EditorForNoteUseCase } from './UseCase/EditorForNote'
const ANDROID_LOCAL_HOST = '10.0.2.2' import { GetDefaultEditorIdentifier } from './UseCase/GetDefaultEditorIdentifier'
import { DoesEditorChangeRequireAlertUseCase } from './UseCase/DoesEditorChangeRequireAlert'
declare global { declare global {
interface Window { interface Window {
@ -77,27 +66,29 @@ declare global {
} }
} }
export enum ComponentManagerEvent {
ViewerDidFocus = 'ViewerDidFocus',
}
export type EventData = {
componentViewer?: ComponentViewerInterface
}
/** /**
* Responsible for orchestrating component functionality, including editors, themes, * Responsible for orchestrating component functionality, including editors, themes,
* and other components. The component manager primarily deals with iframes, and orchestrates * and other components. The component manager primarily deals with iframes, and orchestrates
* sending and receiving messages to and from frames via the postMessage API. * sending and receiving messages to and from frames via the postMessage API.
*/ */
export class SNComponentManager export class SNComponentManager
extends AbstractService<ComponentManagerEvent, EventData> extends AbstractService<ComponentManagerEvent, ComponentManagerEventData>
implements ComponentManagerInterface implements ComponentManagerInterface
{ {
private desktopManager?: DesktopManagerInterface private desktopManager?: DesktopManagerInterface
private viewers: ComponentViewerInterface[] = [] private viewers: ComponentViewerInterface[] = []
private removeItemObserver!: () => void
private permissionDialogs: PermissionDialog[] = [] 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( constructor(
private items: ItemManagerInterface, private items: ItemManagerInterface,
@ -114,7 +105,8 @@ export class SNComponentManager
super(internalEventBus) super(internalEventBus)
this.loggingEnabled = false this.loggingEnabled = false
this.addItemObserver() this.addSyncedComponentItemObserver()
this.registerMobileNativeComponentUrls()
this.eventDisposers.push( this.eventDisposers.push(
preferences.addEventObserver((event) => { preferences.addEventObserver((event) => {
@ -160,7 +152,7 @@ export class SNComponentManager
} }
this.viewers.length = 0 this.viewers.length = 0
this.permissionDialogs.length = 0 this.runWithPermissionsUseCase.deinit()
this.desktopManager = undefined this.desktopManager = undefined
;(this.items as unknown) = undefined ;(this.items as unknown) = undefined
@ -168,9 +160,7 @@ export class SNComponentManager
;(this.sync as unknown) = undefined ;(this.sync as unknown) = undefined
;(this.alerts as unknown) = undefined ;(this.alerts as unknown) = undefined
;(this.preferences as unknown) = undefined ;(this.preferences as unknown) = undefined
;(this.permissionDialogUIHandler as unknown) = undefined
this.removeItemObserver?.()
;(this.removeItemObserver as unknown) = undefined
if (window) { if (window) {
window.removeEventListener('focus', this.detectFocusChange, true) window.removeEventListener('focus', this.detectFocusChange, true)
@ -182,8 +172,13 @@ export class SNComponentManager
;(this.onWindowMessage as unknown) = undefined ;(this.onWindowMessage as unknown) = undefined
} }
setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void {
this.permissionDialogUIHandler = handler
this.runWithPermissionsUseCase.setPermissionDialogUIHandler(handler)
}
public createComponentViewer( public createComponentViewer(
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>, component: UIFeature<IframeComponentFeatureDescription>,
item: ComponentViewerItem, item: ComponentViewerItem,
actionObserver?: ActionObserver, actionObserver?: ActionObserver,
): ComponentViewerInterface { ): ComponentViewerInterface {
@ -198,7 +193,7 @@ export class SNComponentManager
features: this.features, features: this.features,
}, },
{ {
url: this.urlForComponent(component) ?? '', url: this.urlForFeature(component) ?? '',
item, item,
actionObserver, actionObserver,
}, },
@ -206,7 +201,7 @@ export class SNComponentManager
environment: this.environment, environment: this.environment,
platform: this.platform, platform: this.platform,
componentManagerFunctions: { componentManagerFunctions: {
runWithPermissions: this.runWithPermissions.bind(this), runWithPermissionsUseCase: this.runWithPermissionsUseCase,
urlsForActiveThemes: this.urlsForActiveThemes.bind(this), urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
setComponentPreferences: this.setComponentPreferences.bind(this), setComponentPreferences: this.setComponentPreferences.bind(this),
getComponentPreferences: this.getComponentPreferences.bind(this), getComponentPreferences: this.getComponentPreferences.bind(this),
@ -255,40 +250,68 @@ export class SNComponentManager
} }
} }
private addItemObserver(): void { private addSyncedComponentItemObserver(): void {
this.removeItemObserver = this.items.addObserver<ComponentInterface>( this.eventDisposers.push(
[ContentType.TYPES.Component, ContentType.TYPES.Theme], this.items.addObserver<ComponentInterface>(
({ changed, inserted, removed, source }) => { [ContentType.TYPES.Component, ContentType.TYPES.Theme],
const items = [...changed, ...inserted] ({ changed, inserted, removed, source }) => {
this.handleChangedComponents(items, source) const items = [...changed, ...inserted]
const device = this.device this.handleChangedComponents(items, source)
if (isMobileDevice(device) && 'addComponentUrl' in device) {
inserted.forEach((component) => {
const url = this.urlForComponent(new ComponentOrNativeFeature<ComponentFeatureDescription>(component))
if (url) {
device.addComponentUrl(component.uuid, url)
}
})
removed.forEach((component) => { this.updateMobileRegisteredComponentUrls(inserted, removed)
device.removeComponentUrl(component.uuid) },
}) ),
}
},
) )
} }
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, url)
}
}
}
detectFocusChange = (): void => { detectFocusChange = (): void => {
const activeIframes = this.allComponentIframes() const activeIframes = this.allComponentIframes()
for (const iframe of activeIframes) { for (const iframe of activeIframes) {
if (document.activeElement === iframe) { if (document.activeElement === iframe) {
setTimeout(() => { setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const viewer = this.findComponentViewer( const viewer = this.findComponentViewer(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion iframe.dataset.componentViewerId as string,
iframe.dataset.componentViewerId!, ) as ComponentViewerInterface
)!
void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, { void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, {
componentViewer: viewer, componentViewer: viewer,
}) })
@ -301,6 +324,7 @@ export class SNComponentManager
onWindowMessage = (event: MessageEvent): void => { onWindowMessage = (event: MessageEvent): void => {
/** Make sure this message is for us */ /** Make sure this message is for us */
const data = event.data as ComponentMessage const data = event.data as ComponentMessage
if (data.sessionKey) { if (data.sessionKey) {
this.log('Component manager received message', data) this.log('Component manager received message', data)
this.componentViewerForSessionKey(data.sessionKey)?.handleMessage(data) this.componentViewerForSessionKey(data.sessionKey)?.handleMessage(data)
@ -324,65 +348,16 @@ export class SNComponentManager
} }
} }
private urlForComponentOnDesktop( urlForFeature(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined {
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>, const usecase = new GetFeatureUrl(this.desktopManager, this.environment, this.platform)
): string | undefined { return usecase.execute(uiFeature)
assert(this.desktopManager)
if (uiFeature.isFeatureDescription) {
return `${this.desktopManager.getExtServerHost()}/components/${uiFeature.featureIdentifier}/${
uiFeature.asFeatureDescription.index_path
}`
} else {
if (uiFeature.asComponent.local_url) {
return uiFeature.asComponent.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
}
return uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
}
}
private urlForNativeComponent(feature: ComponentFeatureDescription): string {
if (this.isMobile) {
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${feature.identifier}/${feature.index_path}`
} else {
const baseUrlRequiredForThemesInsideEditors = window.location.origin
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${feature.identifier}/${feature.index_path}`
}
}
urlForComponent(uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>): string | undefined {
if (this.desktopManager) {
return this.urlForComponentOnDesktop(uiFeature)
}
if (uiFeature.isFeatureDescription) {
return this.urlForNativeComponent(uiFeature.asFeatureDescription)
}
if (uiFeature.asComponent.offlineOnly) {
return undefined
}
const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
if (!url) {
return undefined
}
if (this.isMobile) {
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
}
return url
} }
urlsForActiveThemes(): string[] { urlsForActiveThemes(): string[] {
const themes = this.getActiveThemes() const themes = this.getActiveThemes()
const urls = [] const urls = []
for (const theme of themes) { for (const theme of themes) {
const url = this.urlForComponent(theme) const url = this.urlForFeature(theme)
if (url) { if (url) {
urls.push(url) urls.push(url)
} }
@ -390,222 +365,15 @@ export class SNComponentManager
return urls return urls
} }
private findComponent(uuid: string): ComponentInterface | undefined { private findComponentViewer(identifier: string): ComponentViewerInterface | undefined {
return this.items.findItem<ComponentInterface>(uuid)
}
private findComponentOrNativeFeature(
identifier: string,
): ComponentOrNativeFeature<ComponentFeatureDescription> | undefined {
const nativeFeature = FindNativeFeature<ComponentFeatureDescription>(identifier as FeatureIdentifier)
if (nativeFeature) {
return new ComponentOrNativeFeature(nativeFeature)
}
const componentItem = this.items.findItem<ComponentInterface>(identifier)
if (componentItem) {
return new ComponentOrNativeFeature<ComponentFeatureDescription>(componentItem)
}
return undefined
}
findComponentViewer(identifier: string): ComponentViewerInterface | undefined {
return this.viewers.find((viewer) => viewer.identifier === identifier) return this.viewers.find((viewer) => viewer.identifier === identifier)
} }
componentViewerForSessionKey(key: string): ComponentViewerInterface | undefined { private componentViewerForSessionKey(key: string): ComponentViewerInterface | undefined {
return this.viewers.find((viewer) => viewer.sessionKey === key) return this.viewers.find((viewer) => viewer.sessionKey === key)
} }
areRequestedPermissionsValid( async toggleTheme(uiFeature: UIFeature<ThemeFeatureDescription>): Promise<void> {
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
permissions: ComponentPermission[],
): boolean {
for (const permission of permissions) {
if (permission.name === ComponentAction.StreamItems) {
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
return false
}
const hasNonAllowedBatchPermission = permission.content_types?.some(
(type) => !AllowedBatchContentTypes.includes(type),
)
if (hasNonAllowedBatchPermission) {
return false
}
}
}
return true
}
runWithPermissions(
componentIdentifier: string,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
): void {
const uiFeature = this.findComponentOrNativeFeature(componentIdentifier)
if (!uiFeature) {
void this.alerts.alert(
`Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`,
'An unexpected error occurred',
)
return
}
if (uiFeature.isFeatureDescription) {
runFunction()
return
}
if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) {
console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions)
return
}
const acquiredPermissions = uiFeature.acquiredPermissions
/* Make copy as not to mutate input values */
requiredPermissions = Copy(requiredPermissions) as ComponentPermission[]
for (const required of requiredPermissions.slice()) {
/* Remove anything we already have */
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
if (!respectiveAcquired) {
continue
}
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
const requiredContentTypes = required.content_types
if (!requiredContentTypes) {
/* If this permission does not require any content types (i.e stream-context-item)
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
filterFromArray(requiredPermissions, required)
continue
}
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
removeFromArray(requiredContentTypes, acquiredContentType)
}
if (requiredContentTypes.length === 0) {
/* We've removed all acquired and end up with zero, means we already have all these permissions */
filterFromArray(requiredPermissions, required)
}
}
if (requiredPermissions.length > 0) {
this.promptForPermissionsWithDeferredRendering(
uiFeature.asComponent,
requiredPermissions,
// eslint-disable-next-line @typescript-eslint/require-await
async (approved) => {
if (approved) {
runFunction()
}
},
)
} else {
runFunction()
}
}
promptForPermissionsWithDeferredRendering(
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
setTimeout(() => {
this.promptForPermissions(component, permissions, callback)
})
}
promptForPermissions(
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
const params: PermissionDialog = {
component: component,
permissions: permissions,
permissionsString: permissionsStringForPermissions(permissions, component),
actionBlock: callback,
callback: async (approved: boolean) => {
const latestComponent = this.findComponent(component.uuid)
if (!latestComponent) {
return
}
if (approved) {
this.log('Changing component to expand permissions', component)
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
for (const permission of permissions) {
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
if (!matchingPermission) {
componentPermissions.push(permission)
} else {
/* Permission already exists, but content_types may have been expanded */
const contentTypes = matchingPermission.content_types || []
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
}
}
await this.mutator.changeItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.permissions = componentPermissions
})
void this.sync.sync()
}
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
/* Remove self */
if (pendingDialog === params) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
return false
}
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
}
if (pendingDialog.component === component) {
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
if (
pendingDialog.permissions === permissions ||
containsObjectSubset(permissions, pendingDialog.permissions)
) {
/* If approved, run the action block. Otherwise, if canceled, cancel any
pending ones as well, since the user was explicit in their intentions */
if (approved) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
}
return false
}
}
return true
})
if (this.permissionDialogs.length > 0) {
this.presentPermissionsDialog(this.permissionDialogs[0])
}
},
}
/**
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
* We only want to present one and trigger all callbacks based on one modal result
*/
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
this.permissionDialogs.push(params)
if (!existingDialog) {
this.presentPermissionsDialog(params)
} else {
this.log('Existing dialog, not presenting.')
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
presentPermissionsDialog(_dialog: PermissionDialog): void {
throw 'Must override SNComponentManager.presentPermissionsDialog'
}
async toggleTheme(uiFeature: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
this.log('Toggling theme', uiFeature.uniqueIdentifier) this.log('Toggling theme', uiFeature.uniqueIdentifier)
if (this.isThemeActive(uiFeature)) { if (this.isThemeActive(uiFeature)) {
@ -638,11 +406,11 @@ export class SNComponentManager
} }
} }
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] { getActiveThemes(): UIFeature<ThemeFeatureDescription>[] {
const activeThemesIdentifiers = this.getActiveThemesIdentifiers() const activeThemesIdentifiers = this.getActiveThemesIdentifiers()
const thirdPartyThemes = this.items.findItems<ThemeInterface>(activeThemesIdentifiers).map((item) => { const thirdPartyThemes = this.items.findItems<ThemeInterface>(activeThemesIdentifiers).map((item) => {
return new ComponentOrNativeFeature<ThemeFeatureDescription>(item) return new UIFeature<ThemeFeatureDescription>(item)
}) })
const nativeThemes = activeThemesIdentifiers const nativeThemes = activeThemesIdentifiers
@ -650,7 +418,7 @@ export class SNComponentManager
return FindNativeTheme(identifier as FeatureIdentifier) return FindNativeTheme(identifier as FeatureIdentifier)
}) })
.filter(isNotUndefined) .filter(isNotUndefined)
.map((theme) => new ComponentOrNativeFeature(theme)) .map((theme) => new UIFeature(theme))
const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => { const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => {
return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled
@ -681,104 +449,22 @@ export class SNComponentManager
return viewer.getIframe() return viewer.getIframe()
} }
componentOrNativeFeatureForIdentifier<F extends UIFeatureDescriptionTypes>( editorForNote(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
identifier: FeatureIdentifier | string, const usecase = new EditorForNoteUseCase(this.items)
): ComponentOrNativeFeature<F> | undefined { return usecase.execute(note)
const nativeFeature = FindNativeFeature<F>(identifier as FeatureIdentifier)
if (nativeFeature) {
return new ComponentOrNativeFeature(nativeFeature)
}
const component = this.thirdPartyComponents.find((component) => {
return component.identifier === identifier
})
if (component) {
return new ComponentOrNativeFeature<F>(component)
}
return undefined
} }
editorForNote(note: SNNote): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> { getDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier {
if (note.noteType === NoteType.Plain) { const usecase = new GetDefaultEditorIdentifier(this.preferences, this.items)
return new ComponentOrNativeFeature(GetPlainNoteFeature()) return usecase.execute(currentTag).getValue()
}
if (note.noteType === NoteType.Super) {
return new ComponentOrNativeFeature(GetSuperNoteFeature())
}
if (note.editorIdentifier) {
const result = this.componentOrNativeFeatureForIdentifier<
EditorFeatureDescription | IframeComponentFeatureDescription
>(note.editorIdentifier)
if (result) {
return result
}
}
if (note.noteType && note.noteType !== NoteType.Unknown) {
const result = this.nativeEditorForNoteType(note.noteType)
if (result) {
return new ComponentOrNativeFeature(result)
}
}
const legacyResult = this.legacyGetEditorForNote(note)
if (legacyResult) {
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(legacyResult)
}
return new ComponentOrNativeFeature(GetPlainNoteFeature())
}
private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined {
const nativeEditors = GetIframeAndNativeEditors()
return nativeEditors.find((editor) => editor.note_type === noteType)
}
/**
* Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly.
*/
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
for (const editor of editors) {
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
return editor
}
}
const defaultEditor = this.legacyGetDefaultEditor()
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
return defaultEditor
} else {
return undefined
}
}
legacyGetDefaultEditor(): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
} }
doesEditorChangeRequireAlert( doesEditorChangeRequireAlert(
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined, from: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined, to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean { ): boolean {
if (!from || !to) { const usecase = new DoesEditorChangeRequireAlertUseCase()
return false return usecase.execute(from, to)
}
const fromFileType = from.fileType
const toFileType = to.fileType
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
if (isEitherMarkdown || areBothHtml) {
return false
} else {
return true
}
} }
async showEditorChangeAlert(): Promise<boolean> { async showEditorChangeAlert(): Promise<boolean> {
@ -792,7 +478,7 @@ export class SNComponentManager
} }
async setComponentPreferences( async setComponentPreferences(
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>, uiFeature: UIFeature<ComponentFeatureDescription>,
preferences: ComponentPreferencesEntry, preferences: ComponentPreferencesEntry,
): Promise<void> { ): Promise<void> {
const mutablePreferencesValue = Copy<AllComponentPreferences>( const mutablePreferencesValue = Copy<AllComponentPreferences>(
@ -806,9 +492,7 @@ export class SNComponentManager
await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue) await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue)
} }
getComponentPreferences( getComponentPreferences(component: UIFeature<ComponentFeatureDescription>): ComponentPreferencesEntry | undefined {
component: ComponentOrNativeFeature<ComponentFeatureDescription>,
): ComponentPreferencesEntry | undefined {
const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined) const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined)
if (!preferences) { if (!preferences) {
@ -820,7 +504,7 @@ export class SNComponentManager
return preferences[preferencesLookupKey] return preferences[preferencesLookupKey]
} }
async addActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> { async addActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice() const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice()
activeThemes.push(theme.uniqueIdentifier) activeThemes.push(theme.uniqueIdentifier)
@ -828,11 +512,11 @@ export class SNComponentManager
await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes) await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes)
} }
async replaceActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> { async replaceActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier]) await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier])
} }
async removeActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> { async removeActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier) const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier)
@ -840,7 +524,7 @@ export class SNComponentManager
await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes) await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes)
} }
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean { isThemeActive(theme: UIFeature<ThemeFeatureDescription>): boolean {
if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) { if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) {
return false return false
} }

View File

@ -0,0 +1,3 @@
export enum ComponentManagerEvent {
ViewerDidFocus = 'ViewerDidFocus',
}

View File

@ -0,0 +1,5 @@
import { ComponentViewerInterface } from '@standardnotes/services'
export type ComponentManagerEventData = {
componentViewer?: ComponentViewerInterface
}

View File

@ -40,7 +40,7 @@ import {
Platform, Platform,
OutgoingItemMessagePayload, OutgoingItemMessagePayload,
ComponentPreferencesEntry, ComponentPreferencesEntry,
ComponentOrNativeFeature, UIFeature,
ComponentInterface, ComponentInterface,
} from '@standardnotes/models' } from '@standardnotes/models'
import { environmentToString, platformToString } from '@Lib/Application/Platforms' import { environmentToString, platformToString } from '@Lib/Application/Platforms'
@ -52,7 +52,7 @@ import {
MessageReplyData, MessageReplyData,
ReadwriteActions, ReadwriteActions,
} from './Types' } from './Types'
import { ComponentViewerRequiresComponentManagerFunctions } from './ComponentViewerRequiresComponentManagerFunctions' import { ComponentViewerRequiresComponentManagerProperties } from './ComponentViewerRequiresComponentManagerFunctions'
import { import {
ComponentAction, ComponentAction,
ComponentPermission, ComponentPermission,
@ -94,7 +94,7 @@ export class ComponentViewer implements ComponentViewerInterface {
public sessionKey?: string public sessionKey?: string
constructor( constructor(
private componentOrFeature: ComponentOrNativeFeature<IframeComponentFeatureDescription>, private componentOrFeature: UIFeature<IframeComponentFeatureDescription>,
private services: { private services: {
items: ItemManagerInterface items: ItemManagerInterface
mutator: MutatorClientInterface mutator: MutatorClientInterface
@ -111,7 +111,7 @@ export class ComponentViewer implements ComponentViewerInterface {
private config: { private config: {
environment: Environment environment: Environment
platform: Platform platform: Platform
componentManagerFunctions: ComponentViewerRequiresComponentManagerFunctions componentManagerFunctions: ComponentViewerRequiresComponentManagerProperties
}, },
) { ) {
if (isComponentViewerItemReadonlyItem(options.item)) { if (isComponentViewerItemReadonlyItem(options.item)) {
@ -152,7 +152,7 @@ export class ComponentViewer implements ComponentViewerInterface {
this.log('Constructor', this) this.log('Constructor', this)
} }
public getComponentOrFeatureItem(): ComponentOrNativeFeature<IframeComponentFeatureDescription> { public getComponentOrFeatureItem(): UIFeature<IframeComponentFeatureDescription> {
return this.componentOrFeature return this.componentOrFeature
} }
@ -269,7 +269,7 @@ export class ComponentViewer implements ComponentViewerInterface {
return return
} }
const item = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(updatedComponent) const item = new UIFeature<IframeComponentFeatureDescription>(updatedComponent)
this.componentOrFeature = item this.componentOrFeature = item
} }
@ -320,7 +320,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}, },
] ]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
() => { () => {
@ -335,7 +335,7 @@ export class ComponentViewer implements ComponentViewerInterface {
name: ComponentAction.StreamContextItem, name: ComponentAction.StreamContextItem,
}, },
] as ComponentPermission[] ] as ComponentPermission[]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredContextPermissions, requiredContextPermissions,
() => { () => {
@ -625,7 +625,7 @@ export class ComponentViewer implements ComponentViewerInterface {
content_types: types, content_types: types,
}, },
] ]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
() => { () => {
@ -650,7 +650,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}, },
] ]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
() => { () => {
@ -707,7 +707,7 @@ export class ComponentViewer implements ComponentViewerInterface {
} as ComponentPermission) } as ComponentPermission)
} }
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
@ -830,7 +830,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}, },
] ]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
async () => { async () => {
@ -897,7 +897,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}, },
] ]
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
requiredPermissions, requiredPermissions,
async () => { async () => {
@ -934,7 +934,7 @@ export class ComponentViewer implements ComponentViewerInterface {
handleSetComponentPreferencesMessage(message: ComponentMessage): void { handleSetComponentPreferencesMessage(message: ComponentMessage): void {
const noPermissionsRequired: ComponentPermission[] = [] const noPermissionsRequired: ComponentPermission[] = []
this.config.componentManagerFunctions.runWithPermissions( this.config.componentManagerFunctions.runWithPermissionsUseCase.execute(
this.componentUniqueIdentifier, this.componentUniqueIdentifier,
noPermissionsRequired, noPermissionsRequired,
async () => { async () => {

View File

@ -1,15 +1,18 @@
import { ComponentOrNativeFeature, ComponentPreferencesEntry } from '@standardnotes/models' import { UIFeature, ComponentPreferencesEntry } from '@standardnotes/models'
import { RunWithPermissionsCallback } from './Types'
import { IframeComponentFeatureDescription } from '@standardnotes/features' import { IframeComponentFeatureDescription } from '@standardnotes/features'
import { RunWithPermissionsUseCase } from './UseCase/RunWithPermissionsUseCase'
export interface ComponentViewerRequiresComponentManagerProperties {
runWithPermissionsUseCase: RunWithPermissionsUseCase
export interface ComponentViewerRequiresComponentManagerFunctions {
runWithPermissions: RunWithPermissionsCallback
urlsForActiveThemes: () => string[] urlsForActiveThemes: () => string[]
setComponentPreferences( setComponentPreferences(
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>, component: UIFeature<IframeComponentFeatureDescription>,
preferences: ComponentPreferencesEntry, preferences: ComponentPreferencesEntry,
): Promise<void> ): Promise<void>
getComponentPreferences( getComponentPreferences(
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>, component: UIFeature<IframeComponentFeatureDescription>,
): ComponentPreferencesEntry | undefined ): ComponentPreferencesEntry | undefined
} }

View File

@ -1,20 +1,8 @@
import { import { ComponentArea, ComponentAction, FeatureIdentifier, LegacyFileSafeIdentifier } from '@standardnotes/features'
ComponentArea,
ComponentAction,
FeatureIdentifier,
LegacyFileSafeIdentifier,
ComponentPermission,
} from '@standardnotes/features'
import { ComponentMessage, MessageData, OutgoingItemMessagePayload } from '@standardnotes/models' import { ComponentMessage, MessageData, OutgoingItemMessagePayload } from '@standardnotes/models'
import { UuidString } from '@Lib/Types/UuidString' import { UuidString } from '@Lib/Types/UuidString'
import { ContentType } from '@standardnotes/domain-core' import { ContentType } from '@standardnotes/domain-core'
export type RunWithPermissionsCallback = (
componentUuid: UuidString,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
) => void
export const ReadwriteActions = [ export const ReadwriteActions = [
ComponentAction.SaveItems, ComponentAction.SaveItems,
ComponentAction.CreateItem, ComponentAction.CreateItem,

View File

@ -0,0 +1,76 @@
import {
FeatureIdentifier,
FindNativeFeature,
IframeComponentFeatureDescription,
UIFeatureDescriptionTypes,
} from '@standardnotes/features'
import { DoesEditorChangeRequireAlertUseCase } from './DoesEditorChangeRequireAlert'
import { UIFeature } from '@standardnotes/models'
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
return new UIFeature(FindNativeFeature<F>(identifier)!)
}
describe('editor change alert', () => {
let usecase: DoesEditorChangeRequireAlertUseCase
beforeEach(() => {
usecase = new DoesEditorChangeRequireAlertUseCase()
})
it('should not require alert switching from plain editor', () => {
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
const requiresAlert = usecase.execute(undefined, component)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to plain editor', () => {
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
const requiresAlert = usecase.execute(component, undefined)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from a markdown editor', () => {
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)
const requiresAlert = usecase.execute(markdownEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to a markdown editor', () => {
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.MarkdownProEditor,
)
const requiresAlert = usecase.execute(htmlEditor, markdownEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from & to a html editor', () => {
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const requiresAlert = usecase.execute(htmlEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should require alert switching from a html editor to custom editor', () => {
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
const requiresAlert = usecase.execute(htmlEditor, customEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to html editor', () => {
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
const requiresAlert = usecase.execute(customEditor, htmlEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to custom editor', () => {
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
const requiresAlert = usecase.execute(customEditor, customEditor)
expect(requiresAlert).toBe(true)
})
})

View File

@ -0,0 +1,24 @@
import { EditorFeatureDescription, IframeComponentFeatureDescription } from '@standardnotes/features'
import { UIFeature } from '@standardnotes/models'
export class DoesEditorChangeRequireAlertUseCase {
execute(
from: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean {
if (!from || !to) {
return false
}
const fromFileType = from.fileType
const toFileType = to.fileType
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
if (isEitherMarkdown || areBothHtml) {
return false
} else {
return true
}
}
}

View File

@ -0,0 +1,31 @@
import { createNote } from '@Lib/Spec/SpecUtils'
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
import { EditorForNoteUseCase } from './EditorForNote'
import { ItemManagerInterface } from '@standardnotes/services'
describe('EditorForNote', () => {
let usecase: EditorForNoteUseCase
let items: ItemManagerInterface
beforeEach(() => {
items = {} as jest.Mocked<ItemManagerInterface>
usecase = new EditorForNoteUseCase(items)
})
it('getEditorForNote should return plain notes is note type is plain', () => {
const note = createNote({
noteType: NoteType.Plain,
})
expect(usecase.execute(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
})
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
const note = createNote({})
usecase['legacyGetEditorForNote'] = jest.fn()
usecase.execute(note)
expect(usecase['legacyGetEditorForNote']).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,102 @@
import {
ComponentArea,
EditorFeatureDescription,
FeatureIdentifier,
FindNativeFeature,
GetIframeAndNativeEditors,
GetPlainNoteFeature,
GetSuperNoteFeature,
IframeComponentFeatureDescription,
NoteType,
} from '@standardnotes/features'
import { ComponentInterface, SNNote, UIFeature } from '@standardnotes/models'
import { ItemManagerInterface } from '@standardnotes/services'
export class EditorForNoteUseCase {
constructor(private items: ItemManagerInterface) {}
execute(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
if (note.noteType === NoteType.Plain) {
return new UIFeature(GetPlainNoteFeature())
}
if (note.noteType === NoteType.Super) {
return new UIFeature(GetSuperNoteFeature())
}
if (note.editorIdentifier) {
const result = this.componentOrNativeFeatureForIdentifier(note.editorIdentifier)
if (result) {
return result
}
}
if (note.noteType && note.noteType !== NoteType.Unknown) {
const result = this.nativeEditorForNoteType(note.noteType)
if (result) {
return new UIFeature(result)
}
}
const legacyResult = this.legacyGetEditorForNote(note)
if (legacyResult) {
return new UIFeature<IframeComponentFeatureDescription>(legacyResult)
}
return new UIFeature(GetPlainNoteFeature())
}
private componentOrNativeFeatureForIdentifier(
identifier: FeatureIdentifier | string,
): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> | undefined {
const nativeFeature = FindNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>(
identifier as FeatureIdentifier,
)
if (nativeFeature) {
return new UIFeature(nativeFeature)
}
const component = this.items.getDisplayableComponents().find((component) => {
return component.identifier === identifier
})
if (component) {
return new UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>(component)
}
return undefined
}
private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined {
const nativeEditors = GetIframeAndNativeEditors()
return nativeEditors.find((editor) => editor.note_type === noteType)
}
/** Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly. */
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
for (const editor of editors) {
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
return editor
}
}
const defaultEditor = this.legacyGetDefaultEditor()
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
return defaultEditor
} else {
return undefined
}
}
private legacyGetDefaultEditor(): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
}
private thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
return this.items.getDisplayableComponents().filter((component) => {
return component.area === area
})
}
}

View File

@ -0,0 +1,60 @@
import { ItemManagerInterface, PreferenceServiceInterface } from '@standardnotes/services'
import { GetDefaultEditorIdentifier } from './GetDefaultEditorIdentifier'
import { ComponentArea, FeatureIdentifier } from '@standardnotes/features'
import { SNComponent, SNTag } from '@standardnotes/models'
describe('getDefaultEditorIdentifier', () => {
let usecase: GetDefaultEditorIdentifier
let preferences: PreferenceServiceInterface
let items: ItemManagerInterface
beforeEach(() => {
preferences = {} as jest.Mocked<PreferenceServiceInterface>
preferences.getValue = jest.fn()
items = {} as jest.Mocked<ItemManagerInterface>
items.getDisplayableComponents = jest.fn().mockReturnValue([])
usecase = new GetDefaultEditorIdentifier(preferences, items)
})
it('should return plain editor if no default tag editor or component editor', () => {
const editorIdentifier = usecase.execute().getValue()
expect(editorIdentifier).toEqual(FeatureIdentifier.PlainEditor)
})
it('should return pref key based value if available', () => {
preferences.getValue = jest.fn().mockReturnValue(FeatureIdentifier.SuperEditor)
const editorIdentifier = usecase.execute().getValue()
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return default tag identifier if tag supplied', () => {
const tag = {
preferences: {
editorIdentifier: FeatureIdentifier.SuperEditor,
},
} as jest.Mocked<SNTag>
const editorIdentifier = usecase.execute(tag).getValue()
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return legacy editor identifier', () => {
const editor = {
legacyIsDefaultEditor: jest.fn().mockReturnValue(true),
identifier: FeatureIdentifier.MarkdownProEditor,
area: ComponentArea.Editor,
} as unknown as jest.Mocked<SNComponent>
items.getDisplayableComponents = jest.fn().mockReturnValue([editor])
const editorIdentifier = usecase.execute().getValue()
expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor)
})
})

View File

@ -0,0 +1,36 @@
import { Result, SyncUseCaseInterface } from '@standardnotes/domain-core'
import { ComponentArea, EditorIdentifier, FeatureIdentifier } from '@standardnotes/features'
import { ComponentInterface, PrefKey, SNTag } from '@standardnotes/models'
import { ItemManagerInterface, PreferenceServiceInterface } from '@standardnotes/services'
export class GetDefaultEditorIdentifier implements SyncUseCaseInterface<EditorIdentifier> {
constructor(private preferences: PreferenceServiceInterface, private items: ItemManagerInterface) {}
execute(currentTag?: SNTag): Result<EditorIdentifier> {
if (currentTag) {
const editorIdentifier = currentTag?.preferences?.editorIdentifier
if (editorIdentifier) {
return Result.ok(editorIdentifier)
}
}
const preferenceValue = this.preferences.getValue(PrefKey.DefaultEditorIdentifier)
if (preferenceValue) {
return Result.ok(preferenceValue)
}
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
const matchingEditor = editors.filter((e) => e.legacyIsDefaultEditor())[0]
if (matchingEditor) {
return Result.ok(matchingEditor.identifier)
}
return Result.ok(FeatureIdentifier.PlainEditor)
}
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
return this.items.getDisplayableComponents().filter((component) => {
return component.area === area
})
}
}

View File

@ -0,0 +1,138 @@
import { ContentType } from '@standardnotes/domain-core'
import {
FeatureIdentifier,
FindNativeFeature,
IframeComponentFeatureDescription,
UIFeatureDescriptionTypes,
} from '@standardnotes/features'
import {
ComponentContent,
ComponentInterface,
ComponentPackageInfo,
DecryptedPayload,
Environment,
PayloadTimestampDefaults,
Platform,
SNComponent,
UIFeature,
} from '@standardnotes/models'
import { DesktopManagerInterface } from '@standardnotes/services'
import { GetFeatureUrl } from './GetFeatureUrl'
const desktopExtHost = 'http://localhost:123'
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
return new UIFeature(FindNativeFeature<F>(identifier)!)
}
const thirdPartyFeature = () => {
const component = new SNComponent(
new DecryptedPayload({
uuid: '789',
content_type: ContentType.TYPES.Component,
...PayloadTimestampDefaults(),
content: {
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier' as FeatureIdentifier,
expires_at: new Date().getTime(),
availableInRoles: [],
} as unknown as jest.Mocked<ComponentPackageInfo>,
} as unknown as jest.Mocked<ComponentContent>,
}),
)
return new UIFeature<IframeComponentFeatureDescription>(component)
}
describe('GetFeatureUrl', () => {
let usecase: GetFeatureUrl
beforeEach(() => {
global.window = {
location: {
origin: 'http://localhost',
},
} as Window & typeof globalThis
})
describe('desktop', () => {
let desktopManager: DesktopManagerInterface | undefined
beforeEach(() => {
desktopManager = {
syncComponentsInstallation() {},
registerUpdateObserver(_callback: (component: ComponentInterface) => void) {
return () => {}
},
getExtServerHost() {
return desktopExtHost
},
}
usecase = new GetFeatureUrl(desktopManager, Environment.Desktop, Platform.MacDesktop)
})
it('returns native path for native component', () => {
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
const url = usecase.execute(feature)
expect(url).toEqual(
`${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns native path for deprecated native component', () => {
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.DeprecatedBoldEditor,
)!
const url = usecase.execute(feature)
expect(url).toEqual(
`${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns nonnative path for third party component', () => {
const feature = thirdPartyFeature()
const url = usecase.execute(feature)
expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`)
})
it('returns hosted url for third party component with no local_url', () => {
const component = new SNComponent({
uuid: '789',
content_type: ContentType.TYPES.Component,
content: {
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier',
valid_until: new Date(),
},
},
} as never)
const feature = new UIFeature<IframeComponentFeatureDescription>(component)
const url = usecase.execute(feature)
expect(url).toEqual('https://example.com/component')
})
})
describe('web', () => {
beforeEach(() => {
usecase = new GetFeatureUrl(undefined, Environment.Web, Platform.MacWeb)
})
it('returns native path for native feature', () => {
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)
const url = usecase.execute(feature)
expect(url).toEqual(
`http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
)
})
it('returns hosted path for third party component', () => {
const feature = thirdPartyFeature()
const url = usecase.execute(feature)
expect(url).toEqual(feature.asComponent.hosted_url)
})
})
})

View File

@ -0,0 +1,74 @@
import { ComponentFeatureDescription } from '@standardnotes/features'
import { Environment, Platform, UIFeature } from '@standardnotes/models'
import { DesktopManagerInterface } from '@standardnotes/services'
const DESKTOP_URL_PREFIX = 'sn://'
const LOCAL_HOST = 'localhost'
const CUSTOM_LOCAL_HOST = 'sn.local'
const ANDROID_LOCAL_HOST = '10.0.2.2'
export class GetFeatureUrl {
constructor(
private desktopManager: DesktopManagerInterface | undefined,
private environment: Environment,
private platform: Platform,
) {}
execute(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined {
if (this.desktopManager) {
return this.urlForFeatureOnDesktop(uiFeature)
}
if (uiFeature.isFeatureDescription) {
return this.urlForNativeComponent(uiFeature.asFeatureDescription)
}
if (uiFeature.asComponent.offlineOnly) {
return undefined
}
const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
if (!url) {
return undefined
}
if (this.isMobile) {
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
}
return url
}
private urlForFeatureOnDesktop(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined {
if (!this.desktopManager) {
throw new Error('Desktop manager is not defined')
}
if (uiFeature.isFeatureDescription) {
return `${this.desktopManager.getExtServerHost()}/components/${uiFeature.featureIdentifier}/${
uiFeature.asFeatureDescription.index_path
}`
} else {
if (uiFeature.asComponent.local_url) {
return uiFeature.asComponent.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
}
return uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
}
}
private urlForNativeComponent(feature: ComponentFeatureDescription): string {
if (this.isMobile) {
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${feature.identifier}/${feature.index_path}`
} else {
const baseUrlRequiredForThemesInsideEditors = window.location.origin
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${feature.identifier}/${feature.index_path}`
}
}
get isMobile(): boolean {
return this.environment === Environment.Mobile
}
}

View File

@ -0,0 +1,173 @@
import { ContentType } from '@standardnotes/domain-core'
import {
ComponentAction,
ComponentPermission,
FeatureIdentifier,
FindNativeFeature,
UIFeatureDescriptionTypes,
} from '@standardnotes/features'
import { UIFeature } from '@standardnotes/models'
import { RunWithPermissionsUseCase } from './RunWithPermissionsUseCase'
import {
AlertService,
ItemManagerInterface,
MutatorClientInterface,
SyncServiceInterface,
} from '@standardnotes/services'
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
return new UIFeature(FindNativeFeature<F>(identifier)!)
}
describe('RunWithPermissionsUseCase', () => {
let usecase: RunWithPermissionsUseCase
beforeEach(() => {
usecase = new RunWithPermissionsUseCase(
() => {},
{} as jest.Mocked<AlertService>,
{} as jest.Mocked<MutatorClientInterface>,
{} as jest.Mocked<SyncServiceInterface>,
{} as jest.Mocked<ItemManagerInterface>,
)
})
describe('permissions', () => {
it('editor should be able to to stream single note', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamContextItem,
content_types: [ContentType.TYPES.Note],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(true)
})
it('no extension should be able to stream multiple notes', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Note],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple notes or tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.Note],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('some valid and some invalid permissions should still return invalid permissions', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.FilesafeFileMetadata],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(false)
})
it('filesafe should be able to stream its files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(true)
})
it('bold editor should be able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
expect(
usecase.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor),
permissions,
),
).toEqual(true)
})
it('non bold editor should not able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.TYPES.FilesafeFileMetadata,
ContentType.TYPES.FilesafeCredentials,
ContentType.TYPES.FilesafeIntegration,
],
},
]
expect(
usecase.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions),
).toEqual(false)
})
})
})

View File

@ -0,0 +1,243 @@
import {
ComponentAction,
ComponentFeatureDescription,
ComponentPermission,
FeatureIdentifier,
FindNativeFeature,
} from '@standardnotes/features'
import { ComponentInterface, ComponentMutator, PermissionDialog, UIFeature } from '@standardnotes/models'
import {
AlertService,
ItemManagerInterface,
MutatorClientInterface,
SyncServiceInterface,
} from '@standardnotes/services'
import { AllowedBatchContentTypes, AllowedBatchStreaming } from '../Types'
import { Copy, filterFromArray, removeFromArray, uniqueArray } from '@standardnotes/utils'
import { permissionsStringForPermissions } from '../permissionsStringForPermissions'
export class RunWithPermissionsUseCase {
private permissionDialogs: PermissionDialog[] = []
private pendingErrorAlerts: Set<string> = new Set()
constructor(
private permissionDialogUIHandler: (dialog: PermissionDialog) => void,
private alerts: AlertService,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private items: ItemManagerInterface,
) {}
deinit() {
this.permissionDialogs = []
;(this.permissionDialogUIHandler as unknown) = undefined
;(this.alerts as unknown) = undefined
;(this.mutator as unknown) = undefined
;(this.sync as unknown) = undefined
;(this.items as unknown) = undefined
}
public execute(
componentIdentifier: string,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
): void {
const uiFeature = this.findUIFeature(componentIdentifier)
if (!uiFeature) {
if (!this.pendingErrorAlerts.has(componentIdentifier)) {
this.pendingErrorAlerts.add(componentIdentifier)
void this.alerts
.alert(
`Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`,
'An unexpected error occurred',
)
.then(() => {
this.pendingErrorAlerts.delete(componentIdentifier)
})
}
return
}
if (uiFeature.isFeatureDescription) {
runFunction()
return
}
if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) {
console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions)
return
}
const acquiredPermissions = uiFeature.acquiredPermissions
/* Make copy as not to mutate input values */
requiredPermissions = Copy<ComponentPermission[]>(requiredPermissions)
for (const required of requiredPermissions.slice()) {
/* Remove anything we already have */
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
if (!respectiveAcquired) {
continue
}
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
const requiredContentTypes = required.content_types
if (!requiredContentTypes) {
/* If this permission does not require any content types (i.e stream-context-item)
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
filterFromArray(requiredPermissions, required)
continue
}
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
removeFromArray(requiredContentTypes, acquiredContentType)
}
if (requiredContentTypes.length === 0) {
/* We've removed all acquired and end up with zero, means we already have all these permissions */
filterFromArray(requiredPermissions, required)
}
}
if (requiredPermissions.length > 0) {
this.promptForPermissionsWithDeferredRendering(
uiFeature.asComponent,
requiredPermissions,
// eslint-disable-next-line @typescript-eslint/require-await
async (approved) => {
if (approved) {
runFunction()
}
},
)
} else {
runFunction()
}
}
setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void {
this.permissionDialogUIHandler = handler
}
areRequestedPermissionsValid(
uiFeature: UIFeature<ComponentFeatureDescription>,
permissions: ComponentPermission[],
): boolean {
for (const permission of permissions) {
if (permission.name === ComponentAction.StreamItems) {
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
return false
}
const hasNonAllowedBatchPermission = permission.content_types?.some(
(type) => !AllowedBatchContentTypes.includes(type),
)
if (hasNonAllowedBatchPermission) {
return false
}
}
}
return true
}
private promptForPermissionsWithDeferredRendering(
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
setTimeout(() => {
this.promptForPermissions(component, permissions, callback)
})
}
private promptForPermissions(
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
const params: PermissionDialog = {
component: component,
permissions: permissions,
permissionsString: permissionsStringForPermissions(permissions, component),
actionBlock: callback,
callback: async (approved: boolean) => {
const latestComponent = this.items.findItem<ComponentInterface>(component.uuid)
if (!latestComponent) {
return
}
if (approved) {
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
for (const permission of permissions) {
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
if (!matchingPermission) {
componentPermissions.push(permission)
} else {
/* Permission already exists, but content_types may have been expanded */
const contentTypes = matchingPermission.content_types || []
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
}
}
await this.mutator.changeItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.permissions = componentPermissions
})
void this.sync.sync()
}
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
/* Remove self */
if (pendingDialog === params) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
return false
}
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
}
if (pendingDialog.component === component) {
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
if (
pendingDialog.permissions === permissions ||
containsObjectSubset(permissions, pendingDialog.permissions)
) {
/* If approved, run the action block. Otherwise, if canceled, cancel any
pending ones as well, since the user was explicit in their intentions */
if (approved) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
}
return false
}
}
return true
})
if (this.permissionDialogs.length > 0) {
this.permissionDialogUIHandler(this.permissionDialogs[0])
}
},
}
/**
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
* We only want to present one and trigger all callbacks based on one modal result
*/
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
this.permissionDialogs.push(params)
if (!existingDialog) {
this.permissionDialogUIHandler(params)
}
}
private findUIFeature(identifier: string): UIFeature<ComponentFeatureDescription> | undefined {
const nativeFeature = FindNativeFeature<ComponentFeatureDescription>(identifier as FeatureIdentifier)
if (nativeFeature) {
return new UIFeature(nativeFeature)
}
const componentItem = this.items.findItem<ComponentInterface>(identifier)
if (componentItem) {
return new UIFeature<ComponentFeatureDescription>(componentItem)
}
return undefined
}
}

View File

@ -1,13 +1,13 @@
import { FindNativeTheme, GetNativeThemes, ThemeFeatureDescription } from '@standardnotes/features' import { FindNativeTheme, GetNativeThemes, ThemeFeatureDescription } from '@standardnotes/features'
import { ComponentOrNativeFeature, ThemeInterface } from '@standardnotes/models' import { UIFeature, ThemeInterface } from '@standardnotes/models'
import { ItemManagerInterface } from '@standardnotes/services' import { ItemManagerInterface } from '@standardnotes/services'
export class GetAllThemesUseCase { export class GetAllThemesUseCase {
constructor(private readonly items: ItemManagerInterface) {} constructor(private readonly items: ItemManagerInterface) {}
execute(options: { excludeLayerable: boolean }): { execute(options: { excludeLayerable: boolean }): {
thirdParty: ComponentOrNativeFeature<ThemeFeatureDescription>[] thirdParty: UIFeature<ThemeFeatureDescription>[]
native: ComponentOrNativeFeature<ThemeFeatureDescription>[] native: UIFeature<ThemeFeatureDescription>[]
} { } {
const nativeThemes = GetNativeThemes().filter((feature) => (options.excludeLayerable ? !feature.layerable : true)) const nativeThemes = GetNativeThemes().filter((feature) => (options.excludeLayerable ? !feature.layerable : true))
@ -22,8 +22,8 @@ export class GetAllThemesUseCase {
}) })
return { return {
thirdParty: filteredThirdPartyThemes.map((theme) => new ComponentOrNativeFeature<ThemeFeatureDescription>(theme)), thirdParty: filteredThirdPartyThemes.map((theme) => new UIFeature<ThemeFeatureDescription>(theme)),
native: nativeThemes.map((theme) => new ComponentOrNativeFeature(theme)), native: nativeThemes.map((theme) => new UIFeature(theme)),
} }
} }
} }

View File

@ -1,6 +1,6 @@
import { dismissToast, ToastType, addTimedToast } from '@standardnotes/toast' import { dismissToast, ToastType, addTimedToast } from '@standardnotes/toast'
import { import {
ComponentOrNativeFeature, UIFeature,
CreateDecryptedLocalStorageContextPayload, CreateDecryptedLocalStorageContextPayload,
LocalStorageDecryptedContextualPayload, LocalStorageDecryptedContextualPayload,
PrefKey, PrefKey,
@ -44,7 +44,7 @@ export class ThemeManager extends AbstractUIServicee {
if (desktopService) { if (desktopService) {
this.eventDisposers.push( this.eventDisposers.push(
desktopService.registerUpdateObserver((component) => { desktopService.registerUpdateObserver((component) => {
const uiFeature = new ComponentOrNativeFeature<ThemeFeatureDescription>(component) const uiFeature = new UIFeature<ThemeFeatureDescription>(component)
if (uiFeature.isThemeComponent) { if (uiFeature.isThemeComponent) {
if (this.components.isThemeActive(uiFeature)) { if (this.components.isThemeActive(uiFeature)) {
this.deactivateThemeInTheUI(uiFeature.uniqueIdentifier) this.deactivateThemeInTheUI(uiFeature.uniqueIdentifier)
@ -81,7 +81,7 @@ export class ThemeManager extends AbstractUIServicee {
this.application.items.findItem<ThemeInterface>(activeTheme) this.application.items.findItem<ThemeInterface>(activeTheme)
if (theme) { if (theme) {
const uiFeature = new ComponentOrNativeFeature<ThemeFeatureDescription>(theme) const uiFeature = new UIFeature<ThemeFeatureDescription>(theme)
this.activateTheme(uiFeature) this.activateTheme(uiFeature)
hasChange = true hasChange = true
} }
@ -296,7 +296,7 @@ export class ThemeManager extends AbstractUIServicee {
} }
} }
private activateTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>, skipEntitlementCheck = false) { private activateTheme(theme: UIFeature<ThemeFeatureDescription>, skipEntitlementCheck = false) {
if (this.themesActiveInTheUI.find((uuid) => uuid === theme.uniqueIdentifier)) { if (this.themesActiveInTheUI.find((uuid) => uuid === theme.uniqueIdentifier)) {
return return
} }
@ -308,7 +308,7 @@ export class ThemeManager extends AbstractUIServicee {
return return
} }
const url = this.application.componentManager.urlForComponent(theme) const url = this.application.componentManager.urlForFeature(theme)
if (!url) { if (!url) {
return return
} }
@ -383,7 +383,7 @@ export class ThemeManager extends AbstractUIServicee {
return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped) return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped)
} }
private getCachedThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] { private getCachedThemes(): UIFeature<ThemeFeatureDescription>[] {
const cachedThemes = this.application.getValue<LocalStorageDecryptedContextualPayload[]>( const cachedThemes = this.application.getValue<LocalStorageDecryptedContextualPayload[]>(
CachedThemesKey, CachedThemesKey,
StorageValueModes.Nonwrapped, StorageValueModes.Nonwrapped,
@ -396,7 +396,7 @@ export class ThemeManager extends AbstractUIServicee {
const theme = this.application.items.createItemFromPayload<ThemeInterface>(payload) const theme = this.application.items.createItemFromPayload<ThemeInterface>(payload)
themes.push(theme) themes.push(theme)
} }
return themes.map((theme) => new ComponentOrNativeFeature<ThemeFeatureDescription>(theme)) return themes.map((theme) => new UIFeature<ThemeFeatureDescription>(theme))
} else { } else {
return [] return []
} }

View File

@ -1,24 +1,9 @@
/** import { Environment, namespacedKey, Platform, RawStorageKey, SNLog } from '@standardnotes/snjs'
* @jest-environment jsdom
*/
import {
Environment,
FeatureIdentifier,
namespacedKey,
Platform,
RawStorageKey,
SNComponent,
SNComponentManager,
SNLog,
SNTag,
} from '@standardnotes/snjs'
import { WebApplication } from '@/Application/WebApplication' import { WebApplication } from '@/Application/WebApplication'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
describe('web application', () => { describe('web application', () => {
let application: WebApplication let application: WebApplication
let componentManager: SNComponentManager
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
SNLog.onLog = console.log SNLog.onLog = console.log
@ -45,51 +30,10 @@ describe('web application', () => {
application = new WebApplication(device, Platform.MacWeb, identifier, 'https://sync', 'https://socket') application = new WebApplication(device, Platform.MacWeb, identifier, 'https://sync', 'https://socket')
componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
await application.prepareForLaunch({ receiveChallenge: jest.fn() }) await application.prepareForLaunch({ receiveChallenge: jest.fn() })
}) })
describe('geDefaultEditorIdentifier', () => { it('should create application', () => {
it('should return plain editor if no default tag editor or component editor', () => { expect(application).toBeTruthy()
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.PlainEditor)
})
it('should return pref key based value if available', () => {
application.getPreference = jest.fn().mockReturnValue(FeatureIdentifier.SuperEditor)
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return default tag identifier if tag supplied', () => {
const tag = {
preferences: {
editorIdentifier: FeatureIdentifier.SuperEditor,
},
} as jest.Mocked<SNTag>
const editorIdentifier = application.geDefaultEditorIdentifier(tag)
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return legacy editor identifier', () => {
const editor = {
legacyIsDefaultEditor: jest.fn().mockReturnValue(true),
identifier: FeatureIdentifier.MarkdownProEditor,
} as unknown as jest.Mocked<SNComponent>
componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue(editor)
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor)
})
}) })
}) })

View File

@ -17,8 +17,6 @@ import {
MobileDeviceInterface, MobileDeviceInterface,
MobileUnlockTiming, MobileUnlockTiming,
DecryptedItem, DecryptedItem,
EditorIdentifier,
FeatureIdentifier,
Environment, Environment,
ApplicationOptionsDefaults, ApplicationOptionsDefaults,
BackupServiceInterface, BackupServiceInterface,
@ -511,15 +509,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.environment === Environment.Web return this.environment === Environment.Web
} }
geDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier {
return (
currentTag?.preferences?.editorIdentifier ||
this.getPreference(PrefKey.DefaultEditorIdentifier) ||
this.componentManager.legacyGetDefaultEditor()?.identifier ||
FeatureIdentifier.PlainEditor
)
}
openPreferences(pane?: PreferenceId): void { openPreferences(pane?: PreferenceId): void {
this.controllers.preferencesController.openPreferences() this.controllers.preferencesController.openPreferences()
if (pane) { if (pane) {

View File

@ -33,7 +33,7 @@ const ChangeEditorButton: FunctionComponent<Props> = ({
const noteType = noteViewController?.isTemplateNote const noteType = noteViewController?.isTemplateNote
? noteTypeForEditorIdentifier( ? noteTypeForEditorIdentifier(
application.geDefaultEditorIdentifier( application.componentManager.getDefaultEditorIdentifier(
noteViewController.templateNoteOptions?.tag noteViewController.templateNoteOptions?.tag
? application.items.findItem(noteViewController.templateNoteOptions.tag) ? application.items.findItem(noteViewController.templateNoteOptions.tag)
: undefined, : undefined,

View File

@ -4,7 +4,7 @@ import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings'
import { WebApplication } from '@/Application/WebApplication' import { WebApplication } from '@/Application/WebApplication'
import { import {
ComponentOrNativeFeature, UIFeature,
EditorFeatureDescription, EditorFeatureDescription,
FeatureIdentifier, FeatureIdentifier,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
@ -30,7 +30,7 @@ type ChangeEditorMenuProps = {
closeMenu: () => void closeMenu: () => void
isVisible: boolean isVisible: boolean
note: SNNote | undefined note: SNNote | undefined
onSelect?: (component: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>) => void onSelect?: (component: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>) => void
setDisableClickOutside?: (value: boolean) => void setDisableClickOutside?: (value: boolean) => void
} }
@ -46,7 +46,7 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
}) => { }) => {
const groups = useMemo(() => createEditorMenuGroups(application), [application]) const groups = useMemo(() => createEditorMenuGroups(application), [application])
const [currentFeature, setCurrentFeature] = const [currentFeature, setCurrentFeature] =
useState<ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>>() useState<UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>>()
const [pendingConversionItem, setPendingConversionItem] = useState<EditorMenuItem | null>(null) const [pendingConversionItem, setPendingConversionItem] = useState<EditorMenuItem | null>(null)
const showSuperNoteImporter = const showSuperNoteImporter =
@ -83,10 +83,7 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
) )
const selectComponent = useCallback( const selectComponent = useCallback(
async ( async (uiFeature: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>, note: SNNote) => {
uiFeature: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>,
note: SNNote,
) => {
if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) {
void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => {
mutator.conflictOf = undefined mutator.conflictOf = undefined

View File

@ -3,7 +3,7 @@ import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { createEditorMenuGroups } from '@/Utils/createEditorMenuGroups' import { createEditorMenuGroups } from '@/Utils/createEditorMenuGroups'
import { import {
ComponentOrNativeFeature, UIFeature,
EditorFeatureDescription, EditorFeatureDescription,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
NoteMutator, NoteMutator,
@ -40,10 +40,7 @@ const ChangeEditorMultipleMenu = ({ application, notes, setDisableClickOutside }
const groups = useMemo(() => createEditorMenuGroups(application), [application]) const groups = useMemo(() => createEditorMenuGroups(application), [application])
const selectComponent = useCallback( const selectComponent = useCallback(
async ( async (uiFeature: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>, note: SNNote) => {
uiFeature: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>,
note: SNNote,
) => {
if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) {
void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => {
mutator.conflictOf = undefined mutator.conflictOf = undefined

View File

@ -66,7 +66,7 @@ const NewNotePreferences: FunctionComponent<Props> = ({
const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('') const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('')
const getGlobalEditorDefaultIdentifier = useCallback((): string => { const getGlobalEditorDefaultIdentifier = useCallback((): string => {
return application.geDefaultEditorIdentifier() return application.componentManager.getDefaultEditorIdentifier()
}, [application]) }, [application])
const reloadPreferences = useCallback(() => { const reloadPreferences = useCallback(() => {

View File

@ -1,6 +1,6 @@
import { WebApplication } from '@/Application/WebApplication' import { WebApplication } from '@/Application/WebApplication'
import { QuickSettingsController } from '@/Controllers/QuickSettingsController' import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
import { ComponentOrNativeFeature, GetDarkThemeFeature } from '@standardnotes/snjs' import { UIFeature, GetDarkThemeFeature } from '@standardnotes/snjs'
import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services' import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@ -25,7 +25,7 @@ const QuickSettingsButton = ({ application, isOpen, toggleMenu, quickSettingsMen
return commandService.addCommandHandler({ return commandService.addCommandHandler({
command: TOGGLE_DARK_MODE_COMMAND, command: TOGGLE_DARK_MODE_COMMAND,
onKeyDown: () => { onKeyDown: () => {
void application.componentManager.toggleTheme(new ComponentOrNativeFeature(GetDarkThemeFeature())) void application.componentManager.toggleTheme(new UIFeature(GetDarkThemeFeature()))
}, },
}) })
}, [application, commandService]) }, [application, commandService])

View File

@ -33,7 +33,6 @@ describe('note view controller', () => {
application.sync.sync = jest.fn().mockReturnValue(Promise.resolve()) application.sync.sync = jest.fn().mockReturnValue(Promise.resolve())
componentManager = {} as jest.Mocked<SNComponentManager> componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager }) Object.defineProperty(application, 'componentManager', { value: componentManager })
const mutator = {} as jest.Mocked<MutatorClientInterface> const mutator = {} as jest.Mocked<MutatorClientInterface>
@ -41,7 +40,7 @@ describe('note view controller', () => {
}) })
it('should create notes with plaintext note type', async () => { it('should create notes with plaintext note type', async () => {
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor) application.componentManager.getDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
const controller = new NoteViewController(application) const controller = new NoteViewController(application)
await controller.initialize() await controller.initialize()
@ -54,15 +53,15 @@ describe('note view controller', () => {
}) })
it('should create notes with markdown note type', async () => { it('should create notes with markdown note type', async () => {
componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue({ application.items.getDisplayableComponents = jest.fn().mockReturnValue([
identifier: FeatureIdentifier.MarkdownProEditor, {
} as SNComponent) identifier: FeatureIdentifier.MarkdownProEditor,
} as SNComponent,
])
componentManager.componentOrNativeFeatureForIdentifier = jest.fn().mockReturnValue({ application.componentManager.getDefaultEditorIdentifier = jest
identifier: FeatureIdentifier.MarkdownProEditor, .fn()
} as SNComponent) .mockReturnValue(FeatureIdentifier.MarkdownProEditor)
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.MarkdownProEditor)
const controller = new NoteViewController(application) const controller = new NoteViewController(application)
await controller.initialize() await controller.initialize()
@ -75,7 +74,7 @@ describe('note view controller', () => {
}) })
it('should add tag to note if default tag is set', async () => { it('should add tag to note if default tag is set', async () => {
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor) application.componentManager.getDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
const tag = { const tag = {
uuid: 'tag-uuid', uuid: 'tag-uuid',

View File

@ -94,7 +94,7 @@ export class NoteViewController implements ItemViewControllerInterface {
if (!this.item) { if (!this.item) {
log(LoggingDomain.NoteView, 'Initializing as template note') log(LoggingDomain.NoteView, 'Initializing as template note')
const editorIdentifier = this.application.geDefaultEditorIdentifier(this.defaultTag) const editorIdentifier = this.application.componentManager.getDefaultEditorIdentifier(this.defaultTag)
const noteType = noteTypeForEditorIdentifier(editorIdentifier) const noteType = noteTypeForEditorIdentifier(editorIdentifier)

View File

@ -13,12 +13,12 @@ import {
ApplicationEvent, ApplicationEvent,
ComponentArea, ComponentArea,
ComponentInterface, ComponentInterface,
ComponentOrNativeFeature, UIFeature,
ComponentViewerInterface, ComponentViewerInterface,
ContentType, ContentType,
EditorLineWidth, EditorLineWidth,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
isIframeUIFeature, isUIFeatureAnIframeFeature,
isPayloadSourceInternalChange, isPayloadSourceInternalChange,
isPayloadSourceRetrieved, isPayloadSourceRetrieved,
NoteType, NoteType,
@ -456,7 +456,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}) })
} }
private createComponentViewer(component: ComponentOrNativeFeature<IframeComponentFeatureDescription>) { private createComponentViewer(component: UIFeature<IframeComponentFeatureDescription>) {
if (!component) { if (!component) {
throw Error('Cannot create component viewer for undefined component') throw Error('Cannot create component viewer for undefined component')
} }
@ -516,7 +516,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
const newUIFeature = this.application.componentManager.editorForNote(this.note) const newUIFeature = this.application.componentManager.editorForNote(this.note)
/** Component editors cannot interact with template notes so the note must be inserted */ /** Component editors cannot interact with template notes so the note must be inserted */
if (isIframeUIFeature(newUIFeature) && this.controller.isTemplateNote) { if (isUIFeatureAnIframeFeature(newUIFeature) && this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote() await this.controller.insertTemplatedNote()
} }
@ -529,7 +529,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
} }
} }
if (isIframeUIFeature(newUIFeature)) { if (isUIFeatureAnIframeFeature(newUIFeature)) {
this.setState({ this.setState({
editorComponentViewer: this.createComponentViewer(newUIFeature), editorComponentViewer: this.createComponentViewer(newUIFeature),
editorStateDidLoad: true, editorStateDidLoad: true,
@ -767,7 +767,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
for (const component of needsNewViewer) { for (const component of needsNewViewer) {
newViewers.push( newViewers.push(
this.application.componentManager.createComponentViewer( this.application.componentManager.createComponentViewer(
new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component), new UIFeature<IframeComponentFeatureDescription>(component),
{ {
uuid: this.note.uuid, uuid: this.note.uuid,
}, },

View File

@ -1,4 +1,4 @@
import { ContentType, NoteContent, NoteType, SNNote, classNames, isIframeUIFeature } from '@standardnotes/snjs' import { ContentType, NoteContent, NoteType, SNNote, classNames, isUIFeatureAnIframeFeature } from '@standardnotes/snjs'
import { UIEventHandler, useEffect, useMemo, useRef } from 'react' import { UIEventHandler, useEffect, useMemo, useRef } from 'react'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
@ -31,7 +31,7 @@ export const ReadonlyNoteContent = ({
const componentViewer = useMemo(() => { const componentViewer = useMemo(() => {
const editorForCurrentNote = application.componentManager.editorForNote(note) const editorForCurrentNote = application.componentManager.editorForNote(note)
if (!isIframeUIFeature(editorForCurrentNote)) { if (!isUIFeatureAnIframeFeature(editorForCurrentNote)) {
return undefined return undefined
} }

View File

@ -1,11 +1,7 @@
import { import { UIFeature, EditorFeatureDescription, IframeComponentFeatureDescription } from '@standardnotes/snjs'
ComponentOrNativeFeature,
EditorFeatureDescription,
IframeComponentFeatureDescription,
} from '@standardnotes/snjs'
export type EditorMenuItem = { export type EditorMenuItem = {
uiFeature: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> uiFeature: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
isEntitled: boolean isEntitled: boolean
isLabs?: boolean isLabs?: boolean
} }

View File

@ -1,17 +1,12 @@
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { FunctionComponent } from 'react' import { FunctionComponent } from 'react'
import { import { UIFeature, EditorFeatureDescription, IframeComponentFeatureDescription, SNNote } from '@standardnotes/snjs'
ComponentOrNativeFeature,
EditorFeatureDescription,
IframeComponentFeatureDescription,
SNNote,
} from '@standardnotes/snjs'
import { NotesController } from '@/Controllers/NotesController/NotesController' import { NotesController } from '@/Controllers/NotesController/NotesController'
import { iconClass } from './ClassNames' import { iconClass } from './ClassNames'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
export const SpellcheckOptions: FunctionComponent<{ export const SpellcheckOptions: FunctionComponent<{
editorForNote: ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> editorForNote: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
notesController: NotesController notesController: NotesController
note: SNNote note: SNNote
}> = ({ editorForNote, notesController, note }) => { }> = ({ editorForNote, notesController, note }) => {

View File

@ -24,7 +24,7 @@ const PermissionsModal = ({ callback, component, dismiss, permissionsString }: P
return ( return (
<Modal <Modal
title="Activate Component" title="Activate Plugin"
close={deny} close={deny}
actions={[ actions={[
{ label: 'Cancel', onClick: deny, type: 'cancel', mobileSlot: 'left' }, { label: 'Cancel', onClick: deny, type: 'cancel', mobileSlot: 'left' },
@ -52,10 +52,7 @@ const PermissionsModal = ({ callback, component, dismiss, permissionsString }: P
</div> </div>
<div className="sk-panel-row [word-break:break-word]"> <div className="sk-panel-row [word-break:break-word]">
<p className="sk-p"> <p className="sk-p">
Components use an offline messaging system to communicate. Learn more at{' '} Plugins use an offline messaging system to communicate and can only access the current note.
<a href="https://standardnotes.com/permissions" rel="noopener" target="_blank" className="sk-a info">
https://standardnotes.com/permissions.
</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -20,11 +20,7 @@ const PermissionsModalWrapper: FunctionComponent<Props> = ({ application }) => {
}, []) }, [])
const onAppStart = useCallback(() => { const onAppStart = useCallback(() => {
application.componentManager.presentPermissionsDialog = presentPermissionsDialog application.componentManager.setPermissionDialogUIHandler(presentPermissionsDialog)
return () => {
;(application.componentManager.presentPermissionsDialog as unknown) = undefined
}
}, [application, presentPermissionsDialog]) }, [application, presentPermissionsDialog])
useEffect(() => { useEffect(() => {

View File

@ -1,7 +1,7 @@
import { import {
ComponentArea, ComponentArea,
ComponentInterface, ComponentInterface,
ComponentOrNativeFeature, UIFeature,
ContentType, ContentType,
FeatureIdentifier, FeatureIdentifier,
PreferencesServiceEvent, PreferencesServiceEvent,
@ -31,7 +31,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ quickSettingsMenuCont
const { focusModeEnabled, setFocusModeEnabled } = application.paneController const { focusModeEnabled, setFocusModeEnabled } = application.paneController
const { closeQuickSettingsMenu } = quickSettingsMenuController const { closeQuickSettingsMenu } = quickSettingsMenuController
const [themes, setThemes] = useState<ComponentOrNativeFeature<ThemeFeatureDescription>[]>([]) const [themes, setThemes] = useState<UIFeature<ThemeFeatureDescription>[]>([])
const [editorStackComponents, setEditorStackComponents] = useState<ComponentInterface[]>([]) const [editorStackComponents, setEditorStackComponents] = useState<ComponentInterface[]>([])
const activeThemes = application.componentManager.getActiveThemes() const activeThemes = application.componentManager.getActiveThemes()

View File

@ -1,9 +1,4 @@
import { import { UIFeature, FeatureIdentifier, FeatureStatus, ThemeFeatureDescription } from '@standardnotes/snjs'
ComponentOrNativeFeature,
FeatureIdentifier,
FeatureStatus,
ThemeFeatureDescription,
} from '@standardnotes/snjs'
import { FunctionComponent, MouseEventHandler, useCallback, useMemo } from 'react' import { FunctionComponent, MouseEventHandler, useCallback, useMemo } from 'react'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
@ -18,7 +13,7 @@ import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/Keyboard
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
type Props = { type Props = {
uiFeature: ComponentOrNativeFeature<ThemeFeatureDescription> uiFeature: UIFeature<ThemeFeatureDescription>
} }
const ThemesMenuButton: FunctionComponent<Props> = ({ uiFeature }) => { const ThemesMenuButton: FunctionComponent<Props> = ({ uiFeature }) => {

View File

@ -3,7 +3,7 @@ import {
NoteContent, NoteContent,
NoteType, NoteType,
SNNote, SNNote,
isIframeUIFeature, isUIFeatureAnIframeFeature,
spaceSeparatedStrings, spaceSeparatedStrings,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect, useMemo } from 'react'
@ -61,7 +61,7 @@ const SuperNoteConverter = ({
}, [format, note]) }, [format, note])
const componentViewer = useMemo(() => { const componentViewer = useMemo(() => {
if (!uiFeature || !isIframeUIFeature(uiFeature)) { if (!uiFeature || !isUIFeatureAnIframeFeature(uiFeature)) {
return undefined return undefined
} }

View File

@ -1,12 +1,9 @@
import { ComponentOrNativeFeature, FeatureIdentifier, ThemeFeatureDescription } from '@standardnotes/snjs' import { UIFeature, FeatureIdentifier, ThemeFeatureDescription } from '@standardnotes/snjs'
const isDarkModeTheme = (theme: ComponentOrNativeFeature<ThemeFeatureDescription>) => const isDarkModeTheme = (theme: UIFeature<ThemeFeatureDescription>) =>
theme.featureIdentifier === FeatureIdentifier.DarkTheme theme.featureIdentifier === FeatureIdentifier.DarkTheme
export const sortThemes = ( export const sortThemes = (a: UIFeature<ThemeFeatureDescription>, b: UIFeature<ThemeFeatureDescription>) => {
a: ComponentOrNativeFeature<ThemeFeatureDescription>,
b: ComponentOrNativeFeature<ThemeFeatureDescription>,
) => {
const aIsLayerable = a.layerable const aIsLayerable = a.layerable
const bIsLayerable = b.layerable const bIsLayerable = b.layerable

View File

@ -5,7 +5,7 @@ import {
GetIframeAndNativeEditors, GetIframeAndNativeEditors,
ComponentArea, ComponentArea,
GetSuperNoteFeature, GetSuperNoteFeature,
ComponentOrNativeFeature, UIFeature,
IframeComponentFeatureDescription, IframeComponentFeatureDescription,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
@ -30,7 +30,7 @@ const insertNativeEditorsInMap = (map: NoteTypeToEditorRowsMap, application: Web
const noteType = editorFeature.note_type const noteType = editorFeature.note_type
map[noteType].push({ map[noteType].push({
isEntitled: application.features.getFeatureStatus(editorFeature.identifier) === FeatureStatus.Entitled, isEntitled: application.features.getFeatureStatus(editorFeature.identifier) === FeatureStatus.Entitled,
uiFeature: new ComponentOrNativeFeature(editorFeature), uiFeature: new UIFeature(editorFeature),
}) })
} }
} }
@ -51,7 +51,7 @@ const insertInstalledComponentsInMap = (map: NoteTypeToEditorRowsMap, applicatio
const noteType = editor.noteType const noteType = editor.noteType
const editorItem: EditorMenuItem = { const editorItem: EditorMenuItem = {
uiFeature: new ComponentOrNativeFeature<IframeComponentFeatureDescription>(editor), uiFeature: new UIFeature<IframeComponentFeatureDescription>(editor),
isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
} }