diff --git a/packages/features/src/Domain/Feature/Features.ts b/packages/features/src/Domain/Feature/Features.ts index a4bd050a60c..7a7dec6dfff 100644 --- a/packages/features/src/Domain/Feature/Features.ts +++ b/packages/features/src/Domain/Feature/Features.ts @@ -9,6 +9,7 @@ import { experimentalFeatures } from '../Lists/ExperimentalFeatures' import { IframeEditors } from '../Lists/IframeEditors' import { themes } from '../Lists/Themes' import { nativeEditors } from '../Lists/NativeEditors' +import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription' export function GetFeatures(): AnyFeatureDescription[] { return [ @@ -30,10 +31,14 @@ export function FindNativeTheme(identifier: FeatureIdentifier): ThemeFeatureDesc return themes().find((t) => t.identifier === identifier) } -export function GetIframeAndNativeEditors(): EditorFeatureDescription[] { +export function GetIframeAndNativeEditors(): (IframeComponentFeatureDescription | EditorFeatureDescription)[] { return [...IframeEditors(), ...nativeEditors()] } +export function GetIframeEditors(): IframeComponentFeatureDescription[] { + return IframeEditors() +} + export function GetSuperNoteFeature(): EditorFeatureDescription { return FindNativeFeature(FeatureIdentifier.SuperEditor) as EditorFeatureDescription } diff --git a/packages/features/src/Domain/Feature/TypeGuards.spec.ts b/packages/features/src/Domain/Feature/TypeGuards.spec.ts new file mode 100644 index 00000000000..e02af0dee1c --- /dev/null +++ b/packages/features/src/Domain/Feature/TypeGuards.spec.ts @@ -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 + expect(isThemeFeatureDescription(feature)).toBe(true) + }) + + it('should return false if feature is not ThemeFeatureDescription', () => { + const feature = { + content_type: ContentType.TYPES.Component, + } as jest.Mocked + 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 + expect(isIframeComponentFeatureDescription(feature)).toBe(true) + }) + + it('should return false if feature is not IframeComponentFeatureDescription', () => { + const feature = { + content_type: ContentType.TYPES.Theme, + } as jest.Mocked + 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 + expect(isEditorFeatureDescription(feature)).toBe(true) + }) + + it('should return false if feature is not EditorFeatureDescription', () => { + const feature = { + content_type: ContentType.TYPES.Theme, + } as jest.Mocked + expect(isEditorFeatureDescription(feature)).toBe(false) + }) + }) +}) diff --git a/packages/mobile/src/Lib/MobileDevice.ts b/packages/mobile/src/Lib/MobileDevice.ts index 306995216d6..1e6cb18d79b 100644 --- a/packages/mobile/src/Lib/MobileDevice.ts +++ b/packages/mobile/src/Lib/MobileDevice.ts @@ -512,15 +512,15 @@ export class MobileDevice implements MobileDeviceInterface { ) } - addComponentUrl(componentUuid: UuidString, componentUrl: string) { + registerComponentUrl(componentUuid: UuidString, componentUrl: string) { this.componentUrls.set(componentUuid, componentUrl) } - removeComponentUrl(componentUuid: UuidString) { + deregisterComponentUrl(componentUuid: UuidString) { this.componentUrls.delete(componentUuid) } - isUrlComponentUrl(url: string): boolean { + isUrlRegisteredComponentUrl(url: string): boolean { return Array.from(this.componentUrls.values()).includes(url) } diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index cdbedb1bc0b..4c5b549579b 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { ApplicationEvent, ReactNativeToWebEvent } from '@standardnotes/snjs' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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) } catch (error) { if (LoggingEnabled) { + // eslint-disable-next-line no-console 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 returnValue = await (device as any)[functionName](...args) if (LoggingEnabled && functionName !== 'consoleLog') { + // eslint-disable-next-line no-console console.log(`Native device function ${functionName} called`) } 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 === 'android' && request.url !== sourceUri) - const isComponentUrl = device.isUrlComponentUrl(request.url) + const isComponentUrl = device.isUrlRegisteredComponentUrl(request.url) if (shouldStopRequest && !isComponentUrl) { device.openUrl(request.url) diff --git a/packages/models/src/Domain/Runtime/Feature/TypeGuards.spec.ts b/packages/models/src/Domain/Runtime/Feature/TypeGuards.spec.ts new file mode 100644 index 00000000000..5e8a8a00a56 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Feature/TypeGuards.spec.ts @@ -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 = { + featureDescription: { + content_type: ContentType.TYPES.Component, + area: ComponentArea.Editor, + }, + } as jest.Mocked> + + expect(isUIFeatureAnIframeFeature(x)).toBe(true) + }) + + it('should return false if feature is not IframeUIFeature', () => { + const x: UIFeature = { + featureDescription: { + note_type: NoteType.Super, + }, + } as jest.Mocked> + + 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 + + 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) + }) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Feature/TypeGuards.ts b/packages/models/src/Domain/Runtime/Feature/TypeGuards.ts new file mode 100644 index 00000000000..e79d8366099 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Feature/TypeGuards.ts @@ -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, +): x is UIFeatureInterface { + 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) +} diff --git a/packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts b/packages/models/src/Domain/Runtime/Feature/UIFeature.ts similarity index 58% rename from packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts rename to packages/models/src/Domain/Runtime/Feature/UIFeature.ts index a86a9ae157b..bda5c3ad3a6 100644 --- a/packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts +++ b/packages/models/src/Domain/Runtime/Feature/UIFeature.ts @@ -1,10 +1,8 @@ import { - AnyFeatureDescription, ComponentArea, ComponentPermission, EditorFeatureDescription, FeatureIdentifier, - IframeComponentFeatureDescription, NoteType, ThemeDockIcon, UIFeatureDescriptionTypes, @@ -12,40 +10,31 @@ import { isIframeComponentFeatureDescription, isThemeFeatureDescription, } from '@standardnotes/features' -import { ComponentInterface } from './ComponentInterface' -import { isTheme } from '../Theme' - -function isComponent(x: ComponentInterface | UIFeatureDescriptionTypes): x is ComponentInterface { - return 'uuid' in x -} - -function isFeatureDescription(x: ComponentInterface | AnyFeatureDescription): x is AnyFeatureDescription { - return !('uuid' in x) -} - -export function isIframeUIFeature( - x: ComponentOrNativeFeature, -): x is ComponentOrNativeFeature { - return isIframeComponentFeatureDescription(x.featureDescription) -} +import { ComponentInterface } from '../../Syncable/Component/ComponentInterface' +import { isTheme } from '../../Syncable/Theme' +import { + isComponentOrFeatureDescriptionAComponent, + isComponentOrFeatureDescriptionAFeatureDescription, +} from './TypeGuards' +import { UIFeatureInterface } from './UIFeatureInterface' -export class ComponentOrNativeFeature { +export class UIFeature implements UIFeatureInterface { constructor(public readonly item: ComponentInterface | F) {} get isComponent(): boolean { - return isComponent(this.item) + return isComponentOrFeatureDescriptionAComponent(this.item) } get isFeatureDescription(): boolean { - return isFeatureDescription(this.item) + return isComponentOrFeatureDescriptionAFeatureDescription(this.item) } get isThemeComponent(): boolean { - return isComponent(this.item) && isTheme(this.item) + return isComponentOrFeatureDescriptionAComponent(this.item) && isTheme(this.item) } get asComponent(): ComponentInterface { - if (isComponent(this.item)) { + if (isComponentOrFeatureDescriptionAComponent(this.item)) { return this.item } @@ -53,7 +42,7 @@ export class ComponentOrNativeFeature { } get asFeatureDescription(): F { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item } @@ -61,7 +50,7 @@ export class ComponentOrNativeFeature { } get uniqueIdentifier(): string { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item.identifier } else { return this.item.uuid @@ -73,9 +62,9 @@ export class ComponentOrNativeFeature { } get noteType(): NoteType { - if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { return this.item.note_type ?? NoteType.Unknown - } else if (isComponent(this.item)) { + } else if (isComponentOrFeatureDescriptionAComponent(this.item)) { return this.item.noteType } @@ -83,9 +72,12 @@ export class ComponentOrNativeFeature { } get fileType(): EditorFeatureDescription['file_type'] { - if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { 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' } @@ -93,7 +85,7 @@ export class ComponentOrNativeFeature { } get displayName(): string { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item.name ?? '' } else { return this.item.displayName @@ -101,7 +93,7 @@ export class ComponentOrNativeFeature { } get description(): string { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item.description ?? '' } else { return this.item.package_info.description ?? '' @@ -109,7 +101,7 @@ export class ComponentOrNativeFeature { } get deprecationMessage(): string | undefined { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item.deprecation_message } else { return this.item.deprecationMessage @@ -117,7 +109,7 @@ export class ComponentOrNativeFeature { } get expirationDate(): Date | undefined { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item.expires_at ? new Date(this.item.expires_at) : undefined } else { return this.item.valid_until @@ -125,7 +117,7 @@ export class ComponentOrNativeFeature { } get featureDescription(): F { - if (isFeatureDescription(this.item)) { + if (isComponentOrFeatureDescriptionAFeatureDescription(this.item)) { return this.item } else { return this.item.package_info as F @@ -133,9 +125,12 @@ export class ComponentOrNativeFeature { } get acquiredPermissions(): ComponentPermission[] { - if (isFeatureDescription(this.item) && isIframeComponentFeatureDescription(this.item)) { + if ( + isComponentOrFeatureDescriptionAFeatureDescription(this.item) && + isIframeComponentFeatureDescription(this.item) + ) { return this.item.component_permissions ?? [] - } else if (isComponent(this.item)) { + } else if (isComponentOrFeatureDescriptionAComponent(this.item)) { return this.item.permissions } @@ -151,7 +146,7 @@ export class ComponentOrNativeFeature { } get layerable(): boolean { - if (isComponent(this.item) && isTheme(this.item)) { + if (isComponentOrFeatureDescriptionAComponent(this.item) && isTheme(this.item)) { return this.item.layerable } else if (isThemeFeatureDescription(this.asFeatureDescription)) { return this.asFeatureDescription.layerable ?? false @@ -161,7 +156,7 @@ export class ComponentOrNativeFeature { } 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 } else if (isThemeFeatureDescription(this.asFeatureDescription)) { return this.asFeatureDescription.dock_icon diff --git a/packages/models/src/Domain/Runtime/Feature/UIFeatureInterface.ts b/packages/models/src/Domain/Runtime/Feature/UIFeatureInterface.ts new file mode 100644 index 00000000000..61c87e9e9e3 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Feature/UIFeatureInterface.ts @@ -0,0 +1,32 @@ +import { + ComponentArea, + ComponentPermission, + EditorFeatureDescription, + FeatureIdentifier, + NoteType, + ThemeDockIcon, + UIFeatureDescriptionTypes, +} from '@standardnotes/features' +import { ComponentInterface } from '../../Syncable/Component' + +export interface UIFeatureInterface { + 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 +} diff --git a/packages/models/src/Domain/Syncable/Component/index.ts b/packages/models/src/Domain/Syncable/Component/index.ts index 23ec98dd1e0..686a0266f54 100644 --- a/packages/models/src/Domain/Syncable/Component/index.ts +++ b/packages/models/src/Domain/Syncable/Component/index.ts @@ -2,5 +2,5 @@ export * from './Component' export * from './ComponentMutator' export * from './ComponentContent' export * from './ComponentInterface' -export * from './ComponentOrNativeFeature' +export * from '../../Runtime/Feature/UIFeature' export * from './PackageInfo' diff --git a/packages/models/src/Domain/index.ts b/packages/models/src/Domain/index.ts index c6dcc78dcb5..8836d2685dc 100644 --- a/packages/models/src/Domain/index.ts +++ b/packages/models/src/Domain/index.ts @@ -43,6 +43,10 @@ export * from './Local/RootKey/RootKeyContent' export * from './Local/RootKey/RootKeyInterface' 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/Item/ItemCollection' export * from './Runtime/Collection/Item/ItemCounter' diff --git a/packages/services/src/Domain/Component/ComponentManagerInterface.ts b/packages/services/src/Domain/Component/ComponentManagerInterface.ts index a06a5c92153..f19ce9dcfe8 100644 --- a/packages/services/src/Domain/Component/ComponentManagerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentManagerInterface.ts @@ -3,43 +3,39 @@ import { ComponentArea, ComponentFeatureDescription, EditorFeatureDescription, + EditorIdentifier, IframeComponentFeatureDescription, ThemeFeatureDescription, } from '@standardnotes/features' -import { - ActionObserver, - ComponentInterface, - ComponentOrNativeFeature, - PermissionDialog, - SNNote, -} from '@standardnotes/models' - +import { ActionObserver, ComponentInterface, UIFeature, PermissionDialog, SNNote, SNTag } from '@standardnotes/models' import { DesktopManagerInterface } from '../Device/DesktopManagerInterface' import { ComponentViewerInterface } from './ComponentViewerInterface' export interface ComponentManagerInterface { - urlForComponent(uiFeature: ComponentOrNativeFeature): string | undefined + urlForFeature(uiFeature: UIFeature): string | undefined setDesktopManager(desktopManager: DesktopManagerInterface): void thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] - editorForNote(note: SNNote): ComponentOrNativeFeature doesEditorChangeRequireAlert( - from: ComponentOrNativeFeature | undefined, - to: ComponentOrNativeFeature | undefined, + from: UIFeature | undefined, + to: UIFeature | undefined, ): boolean showEditorChangeAlert(): Promise destroyComponentViewer(viewer: ComponentViewerInterface): void createComponentViewer( - uiFeature: ComponentOrNativeFeature, + uiFeature: UIFeature, item: ComponentViewerItem, actionObserver?: ActionObserver, urlOverride?: string, ): ComponentViewerInterface - presentPermissionsDialog(_dialog: PermissionDialog): void - legacyGetDefaultEditor(): ComponentInterface | undefined - isThemeActive(theme: ComponentOrNativeFeature): boolean - toggleTheme(theme: ComponentOrNativeFeature): Promise - getActiveThemes(): ComponentOrNativeFeature[] + setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void + + editorForNote(note: SNNote): UIFeature + getDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier + + isThemeActive(theme: UIFeature): boolean + toggleTheme(theme: UIFeature): Promise + getActiveThemes(): UIFeature[] getActiveThemesIdentifiers(): string[] isComponentActive(component: ComponentInterface): boolean diff --git a/packages/services/src/Domain/Component/ComponentViewerInterface.ts b/packages/services/src/Domain/Component/ComponentViewerInterface.ts index 78b55f4ae4e..a5784a8a10f 100644 --- a/packages/services/src/Domain/Component/ComponentViewerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentViewerInterface.ts @@ -1,9 +1,4 @@ -import { - ActionObserver, - ComponentEventObserver, - ComponentMessage, - ComponentOrNativeFeature, -} from '@standardnotes/models' +import { ActionObserver, ComponentEventObserver, ComponentMessage, UIFeature } from '@standardnotes/models' import { FeatureStatus } from '../Feature/FeatureStatus' import { ComponentViewerError } from './ComponentViewerError' import { IframeComponentFeatureDescription } from '@standardnotes/features' @@ -16,7 +11,7 @@ export interface ComponentViewerInterface { get url(): string get componentUniqueIdentifier(): string - getComponentOrFeatureItem(): ComponentOrNativeFeature + getComponentOrFeatureItem(): UIFeature destroy(): void setReadonly(readonly: boolean): void diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 6013a90f570..00ab6a2c6a8 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -15,16 +15,20 @@ export interface MobileDeviceInterface extends DeviceInterface { authenticateWithBiometrics(): Promise hideMobileInterfaceFromScreenshots(): void stopHidingMobileInterfaceFromScreenshots(): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any consoleLog(...args: any[]): void + handleThemeSchemeChange(isDark: boolean, bgColor: string): void shareBase64AsFile(base64: string, filename: string): Promise downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise previewFile(base64: string, filename: string): Promise exitApp(confirm?: boolean): void - addComponentUrl(componentUuid: string, componentUrl: string): void - removeComponentUrl(componentUuid: string): void - isUrlComponentUrl(url: string): boolean + + registerComponentUrl(componentUuid: string, componentUrl: string): void + deregisterComponentUrl(componentUuid: string): void + isUrlRegisteredComponentUrl(url: string): boolean + getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'> getColorScheme(): Promise<'light' | 'dark' | null | undefined> purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts index 0c5a7846c8d..1d832292916 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts @@ -1,32 +1,6 @@ -/** - * @jest-environment jsdom - */ - import { SNPreferencesService } from '../Preferences/PreferencesService' -import { createNote } from './../../Spec/SpecUtils' -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 { GenericItem, Environment, Platform } from '@standardnotes/models' import { - DesktopManagerInterface, InternalEventBusInterface, AlertService, DeviceInterface, @@ -39,7 +13,6 @@ import { ItemManager } from '@Lib/Services/Items/ItemManager' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { SNComponentManager } from './ComponentManager' import { SNSyncService } from '../Sync/SyncService' -import { ComponentPackageInfo } from '@standardnotes/models' describe('featuresService', () => { let items: ItemManagerInterface @@ -51,12 +24,6 @@ describe('featuresService', () => { let eventBus: InternalEventBusInterface let device: DeviceInterface - const nativeFeatureAsUIFeature = (identifier: FeatureIdentifier) => { - return new ComponentOrNativeFeature(FindNativeFeature(identifier)!) - } - - const desktopExtHost = 'http://localhost:123' - const createManager = (environment: Environment, platform: Platform) => { const manager = new SNComponentManager( items, @@ -71,23 +38,15 @@ describe('featuresService', () => { eventBus, ) - if (environment === Environment.Desktop) { - const desktopManager: DesktopManagerInterface = { - syncComponentsInstallation() {}, - registerUpdateObserver(_callback: (component: ComponentInterface) => void) { - return () => {} - }, - getExtServerHost() { - return desktopExtHost - }, - } - manager.setDesktopManager(desktopManager) - } - return manager } beforeEach(() => { + global.window = { + addEventListener: jest.fn(), + attachEvent: jest.fn(), + } as unknown as Window & typeof globalThis + sync = {} as jest.Mocked sync.sync = jest.fn() @@ -117,336 +76,9 @@ describe('featuresService', () => { device = {} as jest.Mocked }) - 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, - } as unknown as jest.Mocked, - }), - ) - - return new ComponentOrNativeFeature(component) - } - - 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( - 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( - 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(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(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( - 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( - 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(FeatureIdentifier.PlusEditor)! - const markdownEditor = nativeFeatureAsUIFeature( - 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(FeatureIdentifier.PlusEditor)! - const markdownEditor = nativeFeatureAsUIFeature( - 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(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(FeatureIdentifier.PlusEditor)! - const customEditor = nativeFeatureAsUIFeature( - 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(FeatureIdentifier.PlusEditor)! - const customEditor = nativeFeatureAsUIFeature( - FeatureIdentifier.TokenVaultEditor, - ) - const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor) - expect(requiresAlert).toBe(true) - }) + it('should create manager', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) - it('should require alert switching from a custom editor to custom editor', () => { - const manager = createManager(Environment.Web, Platform.MacWeb) - const customEditor = nativeFeatureAsUIFeature( - FeatureIdentifier.TokenVaultEditor, - ) - const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor) - expect(requiresAlert).toBe(true) - }) + expect(manager).toBeDefined() }) }) diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts index 34c030f8e89..ff0c311c6d8 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts @@ -1,49 +1,37 @@ -import { AllowedBatchStreaming } from './Types' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { ContentType } from '@standardnotes/domain-core' import { ActionObserver, - SNNote, - ComponentMutator, PayloadEmitSource, PermissionDialog, Environment, Platform, ComponentMessage, - ComponentOrNativeFeature, + UIFeature, ComponentInterface, PrefKey, ThemeInterface, ComponentPreferencesEntry, AllComponentPreferences, + SNNote, + SNTag, + DeletedItemInterface, + EncryptedItemInterface, } from '@standardnotes/models' import { ComponentArea, - ComponentAction, - ComponentPermission, FindNativeFeature, - NoteType, FeatureIdentifier, EditorFeatureDescription, - GetIframeAndNativeEditors, FindNativeTheme, - UIFeatureDescriptionTypes, IframeComponentFeatureDescription, - GetPlainNoteFeature, - GetSuperNoteFeature, ComponentFeatureDescription, ThemeFeatureDescription, + EditorIdentifier, + GetIframeEditors, + GetNativeThemes, } from '@standardnotes/features' -import { - Copy, - filterFromArray, - removeFromArray, - sleep, - assert, - uniqueArray, - isNotUndefined, -} from '@standardnotes/utils' -import { AllowedBatchContentTypes } from '@Lib/Services/ComponentManager/Types' +import { Copy, removeFromArray, sleep, isNotUndefined } from '@standardnotes/utils' import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer' import { AbstractService, @@ -62,12 +50,13 @@ import { SyncServiceInterface, FeatureStatus, } from '@standardnotes/services' -import { permissionsStringForPermissions } from './permissionsStringForPermissions' - -const DESKTOP_URL_PREFIX = 'sn://' -const LOCAL_HOST = 'localhost' -const CUSTOM_LOCAL_HOST = 'sn.local' -const ANDROID_LOCAL_HOST = '10.0.2.2' +import { GetFeatureUrl } from './UseCase/GetFeatureUrl' +import { ComponentManagerEventData } from './ComponentManagerEventData' +import { ComponentManagerEvent } from './ComponentManagerEvent' +import { RunWithPermissionsUseCase } from './UseCase/RunWithPermissionsUseCase' +import { EditorForNoteUseCase } from './UseCase/EditorForNote' +import { GetDefaultEditorIdentifier } from './UseCase/GetDefaultEditorIdentifier' +import { DoesEditorChangeRequireAlertUseCase } from './UseCase/DoesEditorChangeRequireAlert' declare global { interface Window { @@ -77,27 +66,29 @@ declare global { } } -export enum ComponentManagerEvent { - ViewerDidFocus = 'ViewerDidFocus', -} - -export type EventData = { - componentViewer?: ComponentViewerInterface -} - /** * Responsible for orchestrating component functionality, including editors, themes, * and other components. The component manager primarily deals with iframes, and orchestrates * sending and receiving messages to and from frames via the postMessage API. */ export class SNComponentManager - extends AbstractService + extends AbstractService implements ComponentManagerInterface { private desktopManager?: DesktopManagerInterface 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( private items: ItemManagerInterface, @@ -114,7 +105,8 @@ export class SNComponentManager super(internalEventBus) this.loggingEnabled = false - this.addItemObserver() + this.addSyncedComponentItemObserver() + this.registerMobileNativeComponentUrls() this.eventDisposers.push( preferences.addEventObserver((event) => { @@ -160,7 +152,7 @@ export class SNComponentManager } this.viewers.length = 0 - this.permissionDialogs.length = 0 + this.runWithPermissionsUseCase.deinit() this.desktopManager = undefined ;(this.items as unknown) = undefined @@ -168,9 +160,7 @@ export class SNComponentManager ;(this.sync as unknown) = undefined ;(this.alerts as unknown) = undefined ;(this.preferences as unknown) = undefined - - this.removeItemObserver?.() - ;(this.removeItemObserver as unknown) = undefined + ;(this.permissionDialogUIHandler as unknown) = undefined if (window) { window.removeEventListener('focus', this.detectFocusChange, true) @@ -182,8 +172,13 @@ export class SNComponentManager ;(this.onWindowMessage as unknown) = undefined } + setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void { + this.permissionDialogUIHandler = handler + this.runWithPermissionsUseCase.setPermissionDialogUIHandler(handler) + } + public createComponentViewer( - component: ComponentOrNativeFeature, + component: UIFeature, item: ComponentViewerItem, actionObserver?: ActionObserver, ): ComponentViewerInterface { @@ -198,7 +193,7 @@ export class SNComponentManager features: this.features, }, { - url: this.urlForComponent(component) ?? '', + url: this.urlForFeature(component) ?? '', item, actionObserver, }, @@ -206,7 +201,7 @@ export class SNComponentManager environment: this.environment, platform: this.platform, componentManagerFunctions: { - runWithPermissions: this.runWithPermissions.bind(this), + runWithPermissionsUseCase: this.runWithPermissionsUseCase, urlsForActiveThemes: this.urlsForActiveThemes.bind(this), setComponentPreferences: this.setComponentPreferences.bind(this), getComponentPreferences: this.getComponentPreferences.bind(this), @@ -255,40 +250,68 @@ export class SNComponentManager } } - private addItemObserver(): void { - this.removeItemObserver = this.items.addObserver( - [ContentType.TYPES.Component, ContentType.TYPES.Theme], - ({ changed, inserted, removed, source }) => { - const items = [...changed, ...inserted] - this.handleChangedComponents(items, source) - - const device = this.device - if (isMobileDevice(device) && 'addComponentUrl' in device) { - inserted.forEach((component) => { - const url = this.urlForComponent(new ComponentOrNativeFeature(component)) - if (url) { - device.addComponentUrl(component.uuid, url) - } - }) + private addSyncedComponentItemObserver(): void { + this.eventDisposers.push( + this.items.addObserver( + [ContentType.TYPES.Component, ContentType.TYPES.Theme], + ({ changed, inserted, removed, source }) => { + const items = [...changed, ...inserted] - removed.forEach((component) => { - device.removeComponentUrl(component.uuid) - }) - } - }, + this.handleChangedComponents(items, source) + + this.updateMobileRegisteredComponentUrls(inserted, removed) + }, + ), ) } + private updateMobileRegisteredComponentUrls( + inserted: ComponentInterface[], + removed: (EncryptedItemInterface | DeletedItemInterface)[], + ): void { + if (!isMobileDevice(this.device)) { + return + } + + for (const component of inserted) { + const feature = new UIFeature(component) + const url = this.urlForFeature(feature) + if (url) { + this.device.registerComponentUrl(component.uuid, url) + } + } + + for (const component of removed) { + this.device.deregisterComponentUrl(component.uuid) + } + } + + private registerMobileNativeComponentUrls(): void { + if (!isMobileDevice(this.device)) { + return + } + + const nativeComponents = [...GetIframeEditors(), ...GetNativeThemes()] + + for (const component of nativeComponents) { + const feature = new UIFeature(component) + const url = this.urlForFeature(feature) + + if (url) { + this.device.registerComponentUrl(feature.uniqueIdentifier, url) + } + } + } + detectFocusChange = (): void => { const activeIframes = this.allComponentIframes() for (const iframe of activeIframes) { if (document.activeElement === iframe) { setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const viewer = this.findComponentViewer( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - iframe.dataset.componentViewerId!, - )! + iframe.dataset.componentViewerId as string, + ) as ComponentViewerInterface + void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, { componentViewer: viewer, }) @@ -301,6 +324,7 @@ export class SNComponentManager onWindowMessage = (event: MessageEvent): void => { /** Make sure this message is for us */ const data = event.data as ComponentMessage + if (data.sessionKey) { this.log('Component manager received message', data) this.componentViewerForSessionKey(data.sessionKey)?.handleMessage(data) @@ -324,65 +348,16 @@ export class SNComponentManager } } - private urlForComponentOnDesktop( - uiFeature: ComponentOrNativeFeature, - ): string | undefined { - 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): 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 + urlForFeature(uiFeature: UIFeature): string | undefined { + const usecase = new GetFeatureUrl(this.desktopManager, this.environment, this.platform) + return usecase.execute(uiFeature) } urlsForActiveThemes(): string[] { const themes = this.getActiveThemes() const urls = [] for (const theme of themes) { - const url = this.urlForComponent(theme) + const url = this.urlForFeature(theme) if (url) { urls.push(url) } @@ -390,222 +365,15 @@ export class SNComponentManager return urls } - private findComponent(uuid: string): ComponentInterface | undefined { - return this.items.findItem(uuid) - } - - private findComponentOrNativeFeature( - identifier: string, - ): ComponentOrNativeFeature | undefined { - const nativeFeature = FindNativeFeature(identifier as FeatureIdentifier) - if (nativeFeature) { - return new ComponentOrNativeFeature(nativeFeature) - } - - const componentItem = this.items.findItem(identifier) - if (componentItem) { - return new ComponentOrNativeFeature(componentItem) - } - - return undefined - } - - findComponentViewer(identifier: string): ComponentViewerInterface | undefined { + private findComponentViewer(identifier: string): ComponentViewerInterface | undefined { 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) } - areRequestedPermissionsValid( - uiFeature: ComponentOrNativeFeature, - 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 { - setTimeout(() => { - this.promptForPermissions(component, permissions, callback) - }) - } - - promptForPermissions( - component: ComponentInterface, - permissions: ComponentPermission[], - callback: (approved: boolean) => Promise, - ): 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): Promise { + async toggleTheme(uiFeature: UIFeature): Promise { this.log('Toggling theme', uiFeature.uniqueIdentifier) if (this.isThemeActive(uiFeature)) { @@ -638,11 +406,11 @@ export class SNComponentManager } } - getActiveThemes(): ComponentOrNativeFeature[] { + getActiveThemes(): UIFeature[] { const activeThemesIdentifiers = this.getActiveThemesIdentifiers() const thirdPartyThemes = this.items.findItems(activeThemesIdentifiers).map((item) => { - return new ComponentOrNativeFeature(item) + return new UIFeature(item) }) const nativeThemes = activeThemesIdentifiers @@ -650,7 +418,7 @@ export class SNComponentManager return FindNativeTheme(identifier as FeatureIdentifier) }) .filter(isNotUndefined) - .map((theme) => new ComponentOrNativeFeature(theme)) + .map((theme) => new UIFeature(theme)) const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => { return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled @@ -681,104 +449,22 @@ export class SNComponentManager return viewer.getIframe() } - componentOrNativeFeatureForIdentifier( - identifier: FeatureIdentifier | string, - ): ComponentOrNativeFeature | undefined { - const nativeFeature = FindNativeFeature(identifier as FeatureIdentifier) - if (nativeFeature) { - return new ComponentOrNativeFeature(nativeFeature) - } - - const component = this.thirdPartyComponents.find((component) => { - return component.identifier === identifier - }) - if (component) { - return new ComponentOrNativeFeature(component) - } - - return undefined + editorForNote(note: SNNote): UIFeature { + const usecase = new EditorForNoteUseCase(this.items) + return usecase.execute(note) } - editorForNote(note: SNNote): ComponentOrNativeFeature { - if (note.noteType === NoteType.Plain) { - return new ComponentOrNativeFeature(GetPlainNoteFeature()) - } - - 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(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] + getDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier { + const usecase = new GetDefaultEditorIdentifier(this.preferences, this.items) + return usecase.execute(currentTag).getValue() } doesEditorChangeRequireAlert( - from: ComponentOrNativeFeature | undefined, - to: ComponentOrNativeFeature | undefined, + from: UIFeature | undefined, + to: UIFeature | 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 - } + const usecase = new DoesEditorChangeRequireAlertUseCase() + return usecase.execute(from, to) } async showEditorChangeAlert(): Promise { @@ -792,7 +478,7 @@ export class SNComponentManager } async setComponentPreferences( - uiFeature: ComponentOrNativeFeature, + uiFeature: UIFeature, preferences: ComponentPreferencesEntry, ): Promise { const mutablePreferencesValue = Copy( @@ -806,9 +492,7 @@ export class SNComponentManager await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue) } - getComponentPreferences( - component: ComponentOrNativeFeature, - ): ComponentPreferencesEntry | undefined { + getComponentPreferences(component: UIFeature): ComponentPreferencesEntry | undefined { const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined) if (!preferences) { @@ -820,7 +504,7 @@ export class SNComponentManager return preferences[preferencesLookupKey] } - async addActiveTheme(theme: ComponentOrNativeFeature): Promise { + async addActiveTheme(theme: UIFeature): Promise { const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice() activeThemes.push(theme.uniqueIdentifier) @@ -828,11 +512,11 @@ export class SNComponentManager await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes) } - async replaceActiveTheme(theme: ComponentOrNativeFeature): Promise { + async replaceActiveTheme(theme: UIFeature): Promise { await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier]) } - async removeActiveTheme(theme: ComponentOrNativeFeature): Promise { + async removeActiveTheme(theme: UIFeature): Promise { const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier) @@ -840,7 +524,7 @@ export class SNComponentManager await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes) } - isThemeActive(theme: ComponentOrNativeFeature): boolean { + isThemeActive(theme: UIFeature): boolean { if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) { return false } diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManagerEvent.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManagerEvent.ts new file mode 100644 index 00000000000..002788b7cff --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManagerEvent.ts @@ -0,0 +1,3 @@ +export enum ComponentManagerEvent { + ViewerDidFocus = 'ViewerDidFocus', +} diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManagerEventData.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManagerEventData.ts new file mode 100644 index 00000000000..5eec8dd8d64 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManagerEventData.ts @@ -0,0 +1,5 @@ +import { ComponentViewerInterface } from '@standardnotes/services' + +export type ComponentManagerEventData = { + componentViewer?: ComponentViewerInterface +} diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts index 73a80aec2b2..46e3721f59c 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts @@ -40,7 +40,7 @@ import { Platform, OutgoingItemMessagePayload, ComponentPreferencesEntry, - ComponentOrNativeFeature, + UIFeature, ComponentInterface, } from '@standardnotes/models' import { environmentToString, platformToString } from '@Lib/Application/Platforms' @@ -52,7 +52,7 @@ import { MessageReplyData, ReadwriteActions, } from './Types' -import { ComponentViewerRequiresComponentManagerFunctions } from './ComponentViewerRequiresComponentManagerFunctions' +import { ComponentViewerRequiresComponentManagerProperties } from './ComponentViewerRequiresComponentManagerFunctions' import { ComponentAction, ComponentPermission, @@ -94,7 +94,7 @@ export class ComponentViewer implements ComponentViewerInterface { public sessionKey?: string constructor( - private componentOrFeature: ComponentOrNativeFeature, + private componentOrFeature: UIFeature, private services: { items: ItemManagerInterface mutator: MutatorClientInterface @@ -111,7 +111,7 @@ export class ComponentViewer implements ComponentViewerInterface { private config: { environment: Environment platform: Platform - componentManagerFunctions: ComponentViewerRequiresComponentManagerFunctions + componentManagerFunctions: ComponentViewerRequiresComponentManagerProperties }, ) { if (isComponentViewerItemReadonlyItem(options.item)) { @@ -152,7 +152,7 @@ export class ComponentViewer implements ComponentViewerInterface { this.log('Constructor', this) } - public getComponentOrFeatureItem(): ComponentOrNativeFeature { + public getComponentOrFeatureItem(): UIFeature { return this.componentOrFeature } @@ -269,7 +269,7 @@ export class ComponentViewer implements ComponentViewerInterface { return } - const item = new ComponentOrNativeFeature(updatedComponent) + const item = new UIFeature(updatedComponent) this.componentOrFeature = item } @@ -320,7 +320,7 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, () => { @@ -335,7 +335,7 @@ export class ComponentViewer implements ComponentViewerInterface { name: ComponentAction.StreamContextItem, }, ] as ComponentPermission[] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredContextPermissions, () => { @@ -625,7 +625,7 @@ export class ComponentViewer implements ComponentViewerInterface { content_types: types, }, ] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, () => { @@ -650,7 +650,7 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, () => { @@ -707,7 +707,7 @@ export class ComponentViewer implements ComponentViewerInterface { } as ComponentPermission) } - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, @@ -830,7 +830,7 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, async () => { @@ -897,7 +897,7 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, requiredPermissions, async () => { @@ -934,7 +934,7 @@ export class ComponentViewer implements ComponentViewerInterface { handleSetComponentPreferencesMessage(message: ComponentMessage): void { const noPermissionsRequired: ComponentPermission[] = [] - this.config.componentManagerFunctions.runWithPermissions( + this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, noPermissionsRequired, async () => { diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts index 99dce34d075..8eb1a85f26d 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts @@ -1,15 +1,18 @@ -import { ComponentOrNativeFeature, ComponentPreferencesEntry } from '@standardnotes/models' -import { RunWithPermissionsCallback } from './Types' +import { UIFeature, ComponentPreferencesEntry } from '@standardnotes/models' import { IframeComponentFeatureDescription } from '@standardnotes/features' +import { RunWithPermissionsUseCase } from './UseCase/RunWithPermissionsUseCase' + +export interface ComponentViewerRequiresComponentManagerProperties { + runWithPermissionsUseCase: RunWithPermissionsUseCase -export interface ComponentViewerRequiresComponentManagerFunctions { - runWithPermissions: RunWithPermissionsCallback urlsForActiveThemes: () => string[] + setComponentPreferences( - component: ComponentOrNativeFeature, + component: UIFeature, preferences: ComponentPreferencesEntry, ): Promise + getComponentPreferences( - component: ComponentOrNativeFeature, + component: UIFeature, ): ComponentPreferencesEntry | undefined } diff --git a/packages/snjs/lib/Services/ComponentManager/Types.ts b/packages/snjs/lib/Services/ComponentManager/Types.ts index 35a8e7d8039..7ea63bbca00 100644 --- a/packages/snjs/lib/Services/ComponentManager/Types.ts +++ b/packages/snjs/lib/Services/ComponentManager/Types.ts @@ -1,20 +1,8 @@ -import { - ComponentArea, - ComponentAction, - FeatureIdentifier, - LegacyFileSafeIdentifier, - ComponentPermission, -} from '@standardnotes/features' +import { ComponentArea, ComponentAction, FeatureIdentifier, LegacyFileSafeIdentifier } from '@standardnotes/features' import { ComponentMessage, MessageData, OutgoingItemMessagePayload } from '@standardnotes/models' import { UuidString } from '@Lib/Types/UuidString' import { ContentType } from '@standardnotes/domain-core' -export type RunWithPermissionsCallback = ( - componentUuid: UuidString, - requiredPermissions: ComponentPermission[], - runFunction: () => void, -) => void - export const ReadwriteActions = [ ComponentAction.SaveItems, ComponentAction.CreateItem, diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts new file mode 100644 index 00000000000..42917f91bba --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts @@ -0,0 +1,76 @@ +import { + FeatureIdentifier, + FindNativeFeature, + IframeComponentFeatureDescription, + UIFeatureDescriptionTypes, +} from '@standardnotes/features' +import { DoesEditorChangeRequireAlertUseCase } from './DoesEditorChangeRequireAlert' +import { UIFeature } from '@standardnotes/models' + +const nativeFeatureAsUIFeature = (identifier: FeatureIdentifier) => { + return new UIFeature(FindNativeFeature(identifier)!) +} + +describe('editor change alert', () => { + let usecase: DoesEditorChangeRequireAlertUseCase + + beforeEach(() => { + usecase = new DoesEditorChangeRequireAlertUseCase() + }) + + it('should not require alert switching from plain editor', () => { + const component = nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor)! + const requiresAlert = usecase.execute(undefined, component) + expect(requiresAlert).toBe(false) + }) + + it('should not require alert switching to plain editor', () => { + const component = nativeFeatureAsUIFeature(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(FeatureIdentifier.PlusEditor)! + const markdownEditor = nativeFeatureAsUIFeature( + 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(FeatureIdentifier.PlusEditor)! + const markdownEditor = nativeFeatureAsUIFeature( + 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(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(FeatureIdentifier.PlusEditor)! + const customEditor = nativeFeatureAsUIFeature(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(FeatureIdentifier.PlusEditor)! + const customEditor = nativeFeatureAsUIFeature(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(FeatureIdentifier.TokenVaultEditor) + const requiresAlert = usecase.execute(customEditor, customEditor) + expect(requiresAlert).toBe(true) + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.ts new file mode 100644 index 00000000000..0d727c7d11e --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.ts @@ -0,0 +1,24 @@ +import { EditorFeatureDescription, IframeComponentFeatureDescription } from '@standardnotes/features' +import { UIFeature } from '@standardnotes/models' + +export class DoesEditorChangeRequireAlertUseCase { + execute( + from: UIFeature | undefined, + to: UIFeature | 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 + } + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.spec.ts new file mode 100644 index 00000000000..1652b33da58 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.spec.ts @@ -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 + 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() + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.ts new file mode 100644 index 00000000000..d99a0e5a460 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/EditorForNote.ts @@ -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 { + 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(legacyResult) + } + + return new UIFeature(GetPlainNoteFeature()) + } + + private componentOrNativeFeatureForIdentifier( + identifier: FeatureIdentifier | string, + ): UIFeature | undefined { + const nativeFeature = FindNativeFeature( + 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(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 + }) + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts new file mode 100644 index 00000000000..81b955479b1 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts @@ -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 + preferences.getValue = jest.fn() + + items = {} as jest.Mocked + 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 + + 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 + + items.getDisplayableComponents = jest.fn().mockReturnValue([editor]) + + const editorIdentifier = usecase.execute().getValue() + + expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor) + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.ts new file mode 100644 index 00000000000..9021f6f473d --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.ts @@ -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 { + constructor(private preferences: PreferenceServiceInterface, private items: ItemManagerInterface) {} + + execute(currentTag?: SNTag): Result { + 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 + }) + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts new file mode 100644 index 00000000000..e08afdd666a --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts @@ -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 = (identifier: FeatureIdentifier) => { + return new UIFeature(FindNativeFeature(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, + } as unknown as jest.Mocked, + }), + ) + + return new UIFeature(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(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( + 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(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(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) + }) + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.ts new file mode 100644 index 00000000000..2d55eca4a29 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.ts @@ -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): 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): 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 + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts new file mode 100644 index 00000000000..cf088299409 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts @@ -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 = (identifier: FeatureIdentifier) => { + return new UIFeature(FindNativeFeature(identifier)!) +} + +describe('RunWithPermissionsUseCase', () => { + let usecase: RunWithPermissionsUseCase + + beforeEach(() => { + usecase = new RunWithPermissionsUseCase( + () => {}, + {} as jest.Mocked, + {} as jest.Mocked, + {} as jest.Mocked, + {} as jest.Mocked, + ) + }) + + 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) + }) + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.ts new file mode 100644 index 00000000000..decbbe55e1c --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.ts @@ -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 = 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(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, + 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 { + setTimeout(() => { + this.promptForPermissions(component, permissions, callback) + }) + } + + private promptForPermissions( + component: ComponentInterface, + permissions: ComponentPermission[], + callback: (approved: boolean) => Promise, + ): void { + const params: PermissionDialog = { + component: component, + permissions: permissions, + permissionsString: permissionsStringForPermissions(permissions, component), + actionBlock: callback, + callback: async (approved: boolean) => { + const latestComponent = this.items.findItem(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 | undefined { + const nativeFeature = FindNativeFeature(identifier as FeatureIdentifier) + if (nativeFeature) { + return new UIFeature(nativeFeature) + } + + const componentItem = this.items.findItem(identifier) + if (componentItem) { + return new UIFeature(componentItem) + } + + return undefined + } +} diff --git a/packages/ui-services/src/Theme/GetAllThemesUseCase.ts b/packages/ui-services/src/Theme/GetAllThemesUseCase.ts index 8d290a291c2..c99634f48ab 100644 --- a/packages/ui-services/src/Theme/GetAllThemesUseCase.ts +++ b/packages/ui-services/src/Theme/GetAllThemesUseCase.ts @@ -1,13 +1,13 @@ import { FindNativeTheme, GetNativeThemes, ThemeFeatureDescription } from '@standardnotes/features' -import { ComponentOrNativeFeature, ThemeInterface } from '@standardnotes/models' +import { UIFeature, ThemeInterface } from '@standardnotes/models' import { ItemManagerInterface } from '@standardnotes/services' export class GetAllThemesUseCase { constructor(private readonly items: ItemManagerInterface) {} execute(options: { excludeLayerable: boolean }): { - thirdParty: ComponentOrNativeFeature[] - native: ComponentOrNativeFeature[] + thirdParty: UIFeature[] + native: UIFeature[] } { const nativeThemes = GetNativeThemes().filter((feature) => (options.excludeLayerable ? !feature.layerable : true)) @@ -22,8 +22,8 @@ export class GetAllThemesUseCase { }) return { - thirdParty: filteredThirdPartyThemes.map((theme) => new ComponentOrNativeFeature(theme)), - native: nativeThemes.map((theme) => new ComponentOrNativeFeature(theme)), + thirdParty: filteredThirdPartyThemes.map((theme) => new UIFeature(theme)), + native: nativeThemes.map((theme) => new UIFeature(theme)), } } } diff --git a/packages/ui-services/src/Theme/ThemeManager.ts b/packages/ui-services/src/Theme/ThemeManager.ts index ae3d9fc1af9..25799e405b6 100644 --- a/packages/ui-services/src/Theme/ThemeManager.ts +++ b/packages/ui-services/src/Theme/ThemeManager.ts @@ -1,6 +1,6 @@ import { dismissToast, ToastType, addTimedToast } from '@standardnotes/toast' import { - ComponentOrNativeFeature, + UIFeature, CreateDecryptedLocalStorageContextPayload, LocalStorageDecryptedContextualPayload, PrefKey, @@ -44,7 +44,7 @@ export class ThemeManager extends AbstractUIServicee { if (desktopService) { this.eventDisposers.push( desktopService.registerUpdateObserver((component) => { - const uiFeature = new ComponentOrNativeFeature(component) + const uiFeature = new UIFeature(component) if (uiFeature.isThemeComponent) { if (this.components.isThemeActive(uiFeature)) { this.deactivateThemeInTheUI(uiFeature.uniqueIdentifier) @@ -81,7 +81,7 @@ export class ThemeManager extends AbstractUIServicee { this.application.items.findItem(activeTheme) if (theme) { - const uiFeature = new ComponentOrNativeFeature(theme) + const uiFeature = new UIFeature(theme) this.activateTheme(uiFeature) hasChange = true } @@ -296,7 +296,7 @@ export class ThemeManager extends AbstractUIServicee { } } - private activateTheme(theme: ComponentOrNativeFeature, skipEntitlementCheck = false) { + private activateTheme(theme: UIFeature, skipEntitlementCheck = false) { if (this.themesActiveInTheUI.find((uuid) => uuid === theme.uniqueIdentifier)) { return } @@ -308,7 +308,7 @@ export class ThemeManager extends AbstractUIServicee { return } - const url = this.application.componentManager.urlForComponent(theme) + const url = this.application.componentManager.urlForFeature(theme) if (!url) { return } @@ -383,7 +383,7 @@ export class ThemeManager extends AbstractUIServicee { return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped) } - private getCachedThemes(): ComponentOrNativeFeature[] { + private getCachedThemes(): UIFeature[] { const cachedThemes = this.application.getValue( CachedThemesKey, StorageValueModes.Nonwrapped, @@ -396,7 +396,7 @@ export class ThemeManager extends AbstractUIServicee { const theme = this.application.items.createItemFromPayload(payload) themes.push(theme) } - return themes.map((theme) => new ComponentOrNativeFeature(theme)) + return themes.map((theme) => new UIFeature(theme)) } else { return [] } diff --git a/packages/web/src/javascripts/Application/WebApplication.spec.ts b/packages/web/src/javascripts/Application/WebApplication.spec.ts index 97913ac14a4..513e44c46cd 100644 --- a/packages/web/src/javascripts/Application/WebApplication.spec.ts +++ b/packages/web/src/javascripts/Application/WebApplication.spec.ts @@ -1,24 +1,9 @@ -/** - * @jest-environment jsdom - */ - -import { - Environment, - FeatureIdentifier, - namespacedKey, - Platform, - RawStorageKey, - SNComponent, - SNComponentManager, - SNLog, - SNTag, -} from '@standardnotes/snjs' +import { Environment, namespacedKey, Platform, RawStorageKey, SNLog } from '@standardnotes/snjs' import { WebApplication } from '@/Application/WebApplication' import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' describe('web application', () => { let application: WebApplication - let componentManager: SNComponentManager // eslint-disable-next-line no-console SNLog.onLog = console.log @@ -45,51 +30,10 @@ describe('web application', () => { application = new WebApplication(device, Platform.MacWeb, identifier, 'https://sync', 'https://socket') - componentManager = {} as jest.Mocked - componentManager.legacyGetDefaultEditor = jest.fn() - Object.defineProperty(application, 'componentManager', { value: componentManager }) - await application.prepareForLaunch({ receiveChallenge: jest.fn() }) }) - describe('geDefaultEditorIdentifier', () => { - it('should return plain editor if no default tag editor or component editor', () => { - 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 - - 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 - - componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue(editor) - - const editorIdentifier = application.geDefaultEditorIdentifier() - - expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor) - }) + it('should create application', () => { + expect(application).toBeTruthy() }) }) diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index 8aa26f89d91..81433a0c6bd 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -17,8 +17,6 @@ import { MobileDeviceInterface, MobileUnlockTiming, DecryptedItem, - EditorIdentifier, - FeatureIdentifier, Environment, ApplicationOptionsDefaults, BackupServiceInterface, @@ -511,15 +509,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter 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 { this.controllers.preferencesController.openPreferences() if (pane) { diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index fa1d1d9b3a9..d3c38019c89 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -33,7 +33,7 @@ const ChangeEditorButton: FunctionComponent = ({ const noteType = noteViewController?.isTemplateNote ? noteTypeForEditorIdentifier( - application.geDefaultEditorIdentifier( + application.componentManager.getDefaultEditorIdentifier( noteViewController.templateNoteOptions?.tag ? application.items.findItem(noteViewController.templateNoteOptions.tag) : undefined, diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index dee0426df5a..a17a93d56ad 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -4,7 +4,7 @@ import { usePremiumModal } from '@/Hooks/usePremiumModal' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { WebApplication } from '@/Application/WebApplication' import { - ComponentOrNativeFeature, + UIFeature, EditorFeatureDescription, FeatureIdentifier, IframeComponentFeatureDescription, @@ -30,7 +30,7 @@ type ChangeEditorMenuProps = { closeMenu: () => void isVisible: boolean note: SNNote | undefined - onSelect?: (component: ComponentOrNativeFeature) => void + onSelect?: (component: UIFeature) => void setDisableClickOutside?: (value: boolean) => void } @@ -46,7 +46,7 @@ const ChangeEditorMenu: FunctionComponent = ({ }) => { const groups = useMemo(() => createEditorMenuGroups(application), [application]) const [currentFeature, setCurrentFeature] = - useState>() + useState>() const [pendingConversionItem, setPendingConversionItem] = useState(null) const showSuperNoteImporter = @@ -83,10 +83,7 @@ const ChangeEditorMenu: FunctionComponent = ({ ) const selectComponent = useCallback( - async ( - uiFeature: ComponentOrNativeFeature, - note: SNNote, - ) => { + async (uiFeature: UIFeature, note: SNNote) => { if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { mutator.conflictOf = undefined diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx index ecc0b6ba0e8..998f50777c2 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx @@ -3,7 +3,7 @@ import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { createEditorMenuGroups } from '@/Utils/createEditorMenuGroups' import { - ComponentOrNativeFeature, + UIFeature, EditorFeatureDescription, IframeComponentFeatureDescription, NoteMutator, @@ -40,10 +40,7 @@ const ChangeEditorMultipleMenu = ({ application, notes, setDisableClickOutside } const groups = useMemo(() => createEditorMenuGroups(application), [application]) const selectComponent = useCallback( - async ( - uiFeature: ComponentOrNativeFeature, - note: SNNote, - ) => { + async (uiFeature: UIFeature, note: SNNote) => { if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { mutator.conflictOf = undefined diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx index 712b5351f29..d3c46790e7e 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx @@ -66,7 +66,7 @@ const NewNotePreferences: FunctionComponent = ({ const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('') const getGlobalEditorDefaultIdentifier = useCallback((): string => { - return application.geDefaultEditorIdentifier() + return application.componentManager.getDefaultEditorIdentifier() }, [application]) const reloadPreferences = useCallback(() => { diff --git a/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx b/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx index ecc7c96f086..85f07924e59 100644 --- a/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx +++ b/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx @@ -1,6 +1,6 @@ import { WebApplication } from '@/Application/WebApplication' 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 { classNames } from '@standardnotes/utils' import { useEffect, useRef } from 'react' @@ -25,7 +25,7 @@ const QuickSettingsButton = ({ application, isOpen, toggleMenu, quickSettingsMen return commandService.addCommandHandler({ command: TOGGLE_DARK_MODE_COMMAND, onKeyDown: () => { - void application.componentManager.toggleTheme(new ComponentOrNativeFeature(GetDarkThemeFeature())) + void application.componentManager.toggleTheme(new UIFeature(GetDarkThemeFeature())) }, }) }, [application, commandService]) diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts index daa86d79444..50daa491c96 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts @@ -33,7 +33,6 @@ describe('note view controller', () => { application.sync.sync = jest.fn().mockReturnValue(Promise.resolve()) componentManager = {} as jest.Mocked - componentManager.legacyGetDefaultEditor = jest.fn() Object.defineProperty(application, 'componentManager', { value: componentManager }) const mutator = {} as jest.Mocked @@ -41,7 +40,7 @@ describe('note view controller', () => { }) 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) await controller.initialize() @@ -54,15 +53,15 @@ describe('note view controller', () => { }) it('should create notes with markdown note type', async () => { - componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue({ - identifier: FeatureIdentifier.MarkdownProEditor, - } as SNComponent) + application.items.getDisplayableComponents = jest.fn().mockReturnValue([ + { + identifier: FeatureIdentifier.MarkdownProEditor, + } as SNComponent, + ]) - componentManager.componentOrNativeFeatureForIdentifier = jest.fn().mockReturnValue({ - identifier: FeatureIdentifier.MarkdownProEditor, - } as SNComponent) - - application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.MarkdownProEditor) + application.componentManager.getDefaultEditorIdentifier = jest + .fn() + .mockReturnValue(FeatureIdentifier.MarkdownProEditor) const controller = new NoteViewController(application) await controller.initialize() @@ -75,7 +74,7 @@ describe('note view controller', () => { }) 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 = { uuid: 'tag-uuid', diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts index 8aea65bb151..673bb0fcb52 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts @@ -94,7 +94,7 @@ export class NoteViewController implements ItemViewControllerInterface { if (!this.item) { 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) diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index c3846e9b9ac..869129e69a3 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -13,12 +13,12 @@ import { ApplicationEvent, ComponentArea, ComponentInterface, - ComponentOrNativeFeature, + UIFeature, ComponentViewerInterface, ContentType, EditorLineWidth, IframeComponentFeatureDescription, - isIframeUIFeature, + isUIFeatureAnIframeFeature, isPayloadSourceInternalChange, isPayloadSourceRetrieved, NoteType, @@ -456,7 +456,7 @@ class NoteView extends AbstractComponent { }) } - private createComponentViewer(component: ComponentOrNativeFeature) { + private createComponentViewer(component: UIFeature) { if (!component) { throw Error('Cannot create component viewer for undefined component') } @@ -516,7 +516,7 @@ class NoteView extends AbstractComponent { const newUIFeature = this.application.componentManager.editorForNote(this.note) /** 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() } @@ -529,7 +529,7 @@ class NoteView extends AbstractComponent { } } - if (isIframeUIFeature(newUIFeature)) { + if (isUIFeatureAnIframeFeature(newUIFeature)) { this.setState({ editorComponentViewer: this.createComponentViewer(newUIFeature), editorStateDidLoad: true, @@ -767,7 +767,7 @@ class NoteView extends AbstractComponent { for (const component of needsNewViewer) { newViewers.push( this.application.componentManager.createComponentViewer( - new ComponentOrNativeFeature(component), + new UIFeature(component), { uuid: this.note.uuid, }, diff --git a/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx b/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx index 54df19e7e95..48278717b5e 100644 --- a/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx +++ b/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx @@ -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 { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { useApplication } from '../ApplicationProvider' @@ -31,7 +31,7 @@ export const ReadonlyNoteContent = ({ const componentViewer = useMemo(() => { const editorForCurrentNote = application.componentManager.editorForNote(note) - if (!isIframeUIFeature(editorForCurrentNote)) { + if (!isUIFeatureAnIframeFeature(editorForCurrentNote)) { return undefined } diff --git a/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx b/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx index 479114bed1e..d51f97b80df 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx @@ -1,11 +1,7 @@ -import { - ComponentOrNativeFeature, - EditorFeatureDescription, - IframeComponentFeatureDescription, -} from '@standardnotes/snjs' +import { UIFeature, EditorFeatureDescription, IframeComponentFeatureDescription } from '@standardnotes/snjs' export type EditorMenuItem = { - uiFeature: ComponentOrNativeFeature + uiFeature: UIFeature isEntitled: boolean isLabs?: boolean } diff --git a/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx index a778d1fe5f6..afe4105395c 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx @@ -1,17 +1,12 @@ import Icon from '@/Components/Icon/Icon' import { FunctionComponent } from 'react' -import { - ComponentOrNativeFeature, - EditorFeatureDescription, - IframeComponentFeatureDescription, - SNNote, -} from '@standardnotes/snjs' +import { UIFeature, EditorFeatureDescription, IframeComponentFeatureDescription, SNNote } from '@standardnotes/snjs' import { NotesController } from '@/Controllers/NotesController/NotesController' import { iconClass } from './ClassNames' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' export const SpellcheckOptions: FunctionComponent<{ - editorForNote: ComponentOrNativeFeature + editorForNote: UIFeature notesController: NotesController note: SNNote }> = ({ editorForNote, notesController, note }) => { diff --git a/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModal.tsx b/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModal.tsx index b063d58130d..2f98987deca 100644 --- a/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModal.tsx +++ b/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModal.tsx @@ -24,7 +24,7 @@ const PermissionsModal = ({ callback, component, dismiss, permissionsString }: P return (

- Components use an offline messaging system to communicate. Learn more at{' '} - - https://standardnotes.com/permissions. - + Plugins use an offline messaging system to communicate and can only access the current note.

diff --git a/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModalWrapper.tsx b/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModalWrapper.tsx index f2a84c1396a..30094dafc5a 100644 --- a/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModalWrapper.tsx +++ b/packages/web/src/javascripts/Components/PermissionsModal/PermissionsModalWrapper.tsx @@ -20,11 +20,7 @@ const PermissionsModalWrapper: FunctionComponent = ({ application }) => { }, []) const onAppStart = useCallback(() => { - application.componentManager.presentPermissionsDialog = presentPermissionsDialog - - return () => { - ;(application.componentManager.presentPermissionsDialog as unknown) = undefined - } + application.componentManager.setPermissionDialogUIHandler(presentPermissionsDialog) }, [application, presentPermissionsDialog]) useEffect(() => { diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx index aab7d608096..b6a08bd09d2 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -1,7 +1,7 @@ import { ComponentArea, ComponentInterface, - ComponentOrNativeFeature, + UIFeature, ContentType, FeatureIdentifier, PreferencesServiceEvent, @@ -31,7 +31,7 @@ const QuickSettingsMenu: FunctionComponent = ({ quickSettingsMenuCont const { focusModeEnabled, setFocusModeEnabled } = application.paneController const { closeQuickSettingsMenu } = quickSettingsMenuController - const [themes, setThemes] = useState[]>([]) + const [themes, setThemes] = useState[]>([]) const [editorStackComponents, setEditorStackComponents] = useState([]) const activeThemes = application.componentManager.getActiveThemes() diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx index 6bf565b7fc6..e04e4c370e2 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx @@ -1,9 +1,4 @@ -import { - ComponentOrNativeFeature, - FeatureIdentifier, - FeatureStatus, - ThemeFeatureDescription, -} from '@standardnotes/snjs' +import { UIFeature, FeatureIdentifier, FeatureStatus, ThemeFeatureDescription } from '@standardnotes/snjs' import { FunctionComponent, MouseEventHandler, useCallback, useMemo } from 'react' import Icon from '@/Components/Icon/Icon' import { usePremiumModal } from '@/Hooks/usePremiumModal' @@ -18,7 +13,7 @@ import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/Keyboard import { useApplication } from '../ApplicationProvider' type Props = { - uiFeature: ComponentOrNativeFeature + uiFeature: UIFeature } const ThemesMenuButton: FunctionComponent = ({ uiFeature }) => { diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx index 25ee983aa04..1314008d17d 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx @@ -3,7 +3,7 @@ import { NoteContent, NoteType, SNNote, - isIframeUIFeature, + isUIFeatureAnIframeFeature, spaceSeparatedStrings, } from '@standardnotes/snjs' import { useCallback, useEffect, useMemo } from 'react' @@ -61,7 +61,7 @@ const SuperNoteConverter = ({ }, [format, note]) const componentViewer = useMemo(() => { - if (!uiFeature || !isIframeUIFeature(uiFeature)) { + if (!uiFeature || !isUIFeatureAnIframeFeature(uiFeature)) { return undefined } diff --git a/packages/web/src/javascripts/Utils/SortThemes.ts b/packages/web/src/javascripts/Utils/SortThemes.ts index 3929c9f09e4..4eca5dc1740 100644 --- a/packages/web/src/javascripts/Utils/SortThemes.ts +++ b/packages/web/src/javascripts/Utils/SortThemes.ts @@ -1,12 +1,9 @@ -import { ComponentOrNativeFeature, FeatureIdentifier, ThemeFeatureDescription } from '@standardnotes/snjs' +import { UIFeature, FeatureIdentifier, ThemeFeatureDescription } from '@standardnotes/snjs' -const isDarkModeTheme = (theme: ComponentOrNativeFeature) => +const isDarkModeTheme = (theme: UIFeature) => theme.featureIdentifier === FeatureIdentifier.DarkTheme -export const sortThemes = ( - a: ComponentOrNativeFeature, - b: ComponentOrNativeFeature, -) => { +export const sortThemes = (a: UIFeature, b: UIFeature) => { const aIsLayerable = a.layerable const bIsLayerable = b.layerable diff --git a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts index 00aafac6ca0..eed21cb3e26 100644 --- a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts +++ b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts @@ -5,7 +5,7 @@ import { GetIframeAndNativeEditors, ComponentArea, GetSuperNoteFeature, - ComponentOrNativeFeature, + UIFeature, IframeComponentFeatureDescription, } from '@standardnotes/snjs' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' @@ -30,7 +30,7 @@ const insertNativeEditorsInMap = (map: NoteTypeToEditorRowsMap, application: Web const noteType = editorFeature.note_type map[noteType].push({ 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 editorItem: EditorMenuItem = { - uiFeature: new ComponentOrNativeFeature(editor), + uiFeature: new UIFeature(editor), isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, }