refactor: component manager usecases (#2354)
This commit is contained in:
@@ -1,32 +1,6 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import { createNote } from './../../Spec/SpecUtils'
|
||||
import { GenericItem, Environment, Platform } from '@standardnotes/models'
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
FindNativeFeature,
|
||||
FeatureIdentifier,
|
||||
NoteType,
|
||||
UIFeatureDescriptionTypes,
|
||||
IframeComponentFeatureDescription,
|
||||
} from '@standardnotes/features'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import {
|
||||
GenericItem,
|
||||
SNComponent,
|
||||
Environment,
|
||||
Platform,
|
||||
ComponentInterface,
|
||||
ComponentOrNativeFeature,
|
||||
ComponentContent,
|
||||
DecryptedPayload,
|
||||
PayloadTimestampDefaults,
|
||||
} from '@standardnotes/models'
|
||||
import {
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
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 = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
|
||||
return new ComponentOrNativeFeature(FindNativeFeature<F>(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<SNSyncService>
|
||||
sync.sync = jest.fn()
|
||||
|
||||
@@ -117,336 +76,9 @@ describe('featuresService', () => {
|
||||
device = {} as jest.Mocked<DeviceInterface>
|
||||
})
|
||||
|
||||
const thirdPartyFeature = () => {
|
||||
const component = new SNComponent(
|
||||
new DecryptedPayload({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
...PayloadTimestampDefaults(),
|
||||
content: {
|
||||
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier' as FeatureIdentifier,
|
||||
expires_at: new Date().getTime(),
|
||||
availableInRoles: [],
|
||||
} as unknown as jest.Mocked<ComponentPackageInfo>,
|
||||
} as unknown as jest.Mocked<ComponentContent>,
|
||||
}),
|
||||
)
|
||||
it('should create manager', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
|
||||
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(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<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns native path for deprecated native component', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.DeprecatedBoldEditor,
|
||||
)!
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nonnative path for third party component', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const feature = thirdPartyFeature()
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`)
|
||||
})
|
||||
|
||||
it('returns hosted url for third party component with no local_url', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const component = new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const feature = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual('https://example.com/component')
|
||||
})
|
||||
})
|
||||
|
||||
describe('web', () => {
|
||||
it('returns native path for native component', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns hosted path for third party component', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const feature = thirdPartyFeature()
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(feature.asComponent.hosted_url)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('editors', () => {
|
||||
it('getEditorForNote should return plain notes is note type is plain', () => {
|
||||
const note = createNote({
|
||||
noteType: NoteType.Plain,
|
||||
})
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
|
||||
expect(manager.editorForNote(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
|
||||
})
|
||||
|
||||
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
|
||||
const note = createNote({})
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
manager['legacyGetEditorForNote'] = jest.fn()
|
||||
manager.editorForNote(note)
|
||||
|
||||
expect(manager['legacyGetEditorForNote']).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor change alert', () => {
|
||||
it('should not require alert switching from plain editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to plain editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from a markdown editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to a markdown editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from & to a html editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should require alert switching from a html editor to custom editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to html editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to custom editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
expect(manager).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ComponentManagerEvent, EventData>
|
||||
extends AbstractService<ComponentManagerEvent, ComponentManagerEventData>
|
||||
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<IframeComponentFeatureDescription>,
|
||||
component: UIFeature<IframeComponentFeatureDescription>,
|
||||
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<ComponentInterface>(
|
||||
[ContentType.TYPES.Component, ContentType.TYPES.Theme],
|
||||
({ changed, inserted, removed, source }) => {
|
||||
const items = [...changed, ...inserted]
|
||||
this.handleChangedComponents(items, source)
|
||||
private addSyncedComponentItemObserver(): void {
|
||||
this.eventDisposers.push(
|
||||
this.items.addObserver<ComponentInterface>(
|
||||
[ContentType.TYPES.Component, ContentType.TYPES.Theme],
|
||||
({ changed, inserted, removed, source }) => {
|
||||
const items = [...changed, ...inserted]
|
||||
|
||||
const device = this.device
|
||||
if (isMobileDevice(device) && 'addComponentUrl' in device) {
|
||||
inserted.forEach((component) => {
|
||||
const url = this.urlForComponent(new ComponentOrNativeFeature<ComponentFeatureDescription>(component))
|
||||
if (url) {
|
||||
device.addComponentUrl(component.uuid, url)
|
||||
}
|
||||
})
|
||||
this.handleChangedComponents(items, source)
|
||||
|
||||
removed.forEach((component) => {
|
||||
device.removeComponentUrl(component.uuid)
|
||||
})
|
||||
}
|
||||
},
|
||||
this.updateMobileRegisteredComponentUrls(inserted, removed)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private updateMobileRegisteredComponentUrls(
|
||||
inserted: ComponentInterface[],
|
||||
removed: (EncryptedItemInterface | DeletedItemInterface)[],
|
||||
): void {
|
||||
if (!isMobileDevice(this.device)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const component of inserted) {
|
||||
const feature = new UIFeature<ComponentFeatureDescription>(component)
|
||||
const url = this.urlForFeature(feature)
|
||||
if (url) {
|
||||
this.device.registerComponentUrl(component.uuid, url)
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of removed) {
|
||||
this.device.deregisterComponentUrl(component.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
private registerMobileNativeComponentUrls(): void {
|
||||
if (!isMobileDevice(this.device)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nativeComponents = [...GetIframeEditors(), ...GetNativeThemes()]
|
||||
|
||||
for (const component of nativeComponents) {
|
||||
const feature = new UIFeature<ComponentFeatureDescription>(component)
|
||||
const url = this.urlForFeature(feature)
|
||||
|
||||
if (url) {
|
||||
this.device.registerComponentUrl(feature.uniqueIdentifier, 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<ComponentFeatureDescription>,
|
||||
): 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<ComponentFeatureDescription>): string | undefined {
|
||||
if (this.desktopManager) {
|
||||
return this.urlForComponentOnDesktop(uiFeature)
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
return this.urlForNativeComponent(uiFeature.asFeatureDescription)
|
||||
}
|
||||
|
||||
if (uiFeature.asComponent.offlineOnly) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isMobile) {
|
||||
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
|
||||
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
|
||||
}
|
||||
|
||||
return url
|
||||
urlForFeature(uiFeature: UIFeature<ComponentFeatureDescription>): 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<ComponentInterface>(uuid)
|
||||
}
|
||||
|
||||
private findComponentOrNativeFeature(
|
||||
identifier: string,
|
||||
): ComponentOrNativeFeature<ComponentFeatureDescription> | undefined {
|
||||
const nativeFeature = FindNativeFeature<ComponentFeatureDescription>(identifier as FeatureIdentifier)
|
||||
if (nativeFeature) {
|
||||
return new ComponentOrNativeFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const componentItem = this.items.findItem<ComponentInterface>(identifier)
|
||||
if (componentItem) {
|
||||
return new ComponentOrNativeFeature<ComponentFeatureDescription>(componentItem)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
findComponentViewer(identifier: string): ComponentViewerInterface | undefined {
|
||||
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<ComponentFeatureDescription>,
|
||||
permissions: ComponentPermission[],
|
||||
): boolean {
|
||||
for (const permission of permissions) {
|
||||
if (permission.name === ComponentAction.StreamItems) {
|
||||
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
|
||||
return false
|
||||
}
|
||||
const hasNonAllowedBatchPermission = permission.content_types?.some(
|
||||
(type) => !AllowedBatchContentTypes.includes(type),
|
||||
)
|
||||
if (hasNonAllowedBatchPermission) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
runWithPermissions(
|
||||
componentIdentifier: string,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
): void {
|
||||
const uiFeature = this.findComponentOrNativeFeature(componentIdentifier)
|
||||
|
||||
if (!uiFeature) {
|
||||
void this.alerts.alert(
|
||||
`Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`,
|
||||
'An unexpected error occurred',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
runFunction()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) {
|
||||
console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions)
|
||||
return
|
||||
}
|
||||
|
||||
const acquiredPermissions = uiFeature.acquiredPermissions
|
||||
|
||||
/* Make copy as not to mutate input values */
|
||||
requiredPermissions = Copy(requiredPermissions) as ComponentPermission[]
|
||||
for (const required of requiredPermissions.slice()) {
|
||||
/* Remove anything we already have */
|
||||
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
|
||||
if (!respectiveAcquired) {
|
||||
continue
|
||||
}
|
||||
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
|
||||
const requiredContentTypes = required.content_types
|
||||
if (!requiredContentTypes) {
|
||||
/* If this permission does not require any content types (i.e stream-context-item)
|
||||
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
|
||||
filterFromArray(requiredPermissions, required)
|
||||
continue
|
||||
}
|
||||
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
|
||||
removeFromArray(requiredContentTypes, acquiredContentType)
|
||||
}
|
||||
if (requiredContentTypes.length === 0) {
|
||||
/* We've removed all acquired and end up with zero, means we already have all these permissions */
|
||||
filterFromArray(requiredPermissions, required)
|
||||
}
|
||||
}
|
||||
if (requiredPermissions.length > 0) {
|
||||
this.promptForPermissionsWithDeferredRendering(
|
||||
uiFeature.asComponent,
|
||||
requiredPermissions,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (approved) => {
|
||||
if (approved) {
|
||||
runFunction()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
runFunction()
|
||||
}
|
||||
}
|
||||
|
||||
promptForPermissionsWithDeferredRendering(
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
setTimeout(() => {
|
||||
this.promptForPermissions(component, permissions, callback)
|
||||
})
|
||||
}
|
||||
|
||||
promptForPermissions(
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
const params: PermissionDialog = {
|
||||
component: component,
|
||||
permissions: permissions,
|
||||
permissionsString: permissionsStringForPermissions(permissions, component),
|
||||
actionBlock: callback,
|
||||
callback: async (approved: boolean) => {
|
||||
const latestComponent = this.findComponent(component.uuid)
|
||||
|
||||
if (!latestComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
this.log('Changing component to expand permissions', component)
|
||||
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
|
||||
for (const permission of permissions) {
|
||||
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
|
||||
if (!matchingPermission) {
|
||||
componentPermissions.push(permission)
|
||||
} else {
|
||||
/* Permission already exists, but content_types may have been expanded */
|
||||
const contentTypes = matchingPermission.content_types || []
|
||||
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
|
||||
}
|
||||
}
|
||||
|
||||
await this.mutator.changeItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.permissions = componentPermissions
|
||||
})
|
||||
|
||||
void this.sync.sync()
|
||||
}
|
||||
|
||||
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
|
||||
/* Remove self */
|
||||
if (pendingDialog === params) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
return false
|
||||
}
|
||||
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
|
||||
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
|
||||
}
|
||||
if (pendingDialog.component === component) {
|
||||
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
|
||||
if (
|
||||
pendingDialog.permissions === permissions ||
|
||||
containsObjectSubset(permissions, pendingDialog.permissions)
|
||||
) {
|
||||
/* If approved, run the action block. Otherwise, if canceled, cancel any
|
||||
pending ones as well, since the user was explicit in their intentions */
|
||||
if (approved) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (this.permissionDialogs.length > 0) {
|
||||
this.presentPermissionsDialog(this.permissionDialogs[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
/**
|
||||
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
|
||||
* We only want to present one and trigger all callbacks based on one modal result
|
||||
*/
|
||||
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
|
||||
this.permissionDialogs.push(params)
|
||||
if (!existingDialog) {
|
||||
this.presentPermissionsDialog(params)
|
||||
} else {
|
||||
this.log('Existing dialog, not presenting.')
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
presentPermissionsDialog(_dialog: PermissionDialog): void {
|
||||
throw 'Must override SNComponentManager.presentPermissionsDialog'
|
||||
}
|
||||
|
||||
async toggleTheme(uiFeature: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
async toggleTheme(uiFeature: UIFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
this.log('Toggling theme', uiFeature.uniqueIdentifier)
|
||||
|
||||
if (this.isThemeActive(uiFeature)) {
|
||||
@@ -638,11 +406,11 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] {
|
||||
getActiveThemes(): UIFeature<ThemeFeatureDescription>[] {
|
||||
const activeThemesIdentifiers = this.getActiveThemesIdentifiers()
|
||||
|
||||
const thirdPartyThemes = this.items.findItems<ThemeInterface>(activeThemesIdentifiers).map((item) => {
|
||||
return new ComponentOrNativeFeature<ThemeFeatureDescription>(item)
|
||||
return new UIFeature<ThemeFeatureDescription>(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<F extends UIFeatureDescriptionTypes>(
|
||||
identifier: FeatureIdentifier | string,
|
||||
): ComponentOrNativeFeature<F> | undefined {
|
||||
const nativeFeature = FindNativeFeature<F>(identifier as FeatureIdentifier)
|
||||
if (nativeFeature) {
|
||||
return new ComponentOrNativeFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const component = this.thirdPartyComponents.find((component) => {
|
||||
return component.identifier === identifier
|
||||
})
|
||||
if (component) {
|
||||
return new ComponentOrNativeFeature<F>(component)
|
||||
}
|
||||
|
||||
return undefined
|
||||
editorForNote(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
|
||||
const usecase = new EditorForNoteUseCase(this.items)
|
||||
return usecase.execute(note)
|
||||
}
|
||||
|
||||
editorForNote(note: SNNote): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
|
||||
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<IframeComponentFeatureDescription>(legacyResult)
|
||||
}
|
||||
|
||||
return new ComponentOrNativeFeature(GetPlainNoteFeature())
|
||||
}
|
||||
|
||||
private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined {
|
||||
const nativeEditors = GetIframeAndNativeEditors()
|
||||
return nativeEditors.find((editor) => editor.note_type === noteType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly.
|
||||
*/
|
||||
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
for (const editor of editors) {
|
||||
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
return editor
|
||||
}
|
||||
}
|
||||
const defaultEditor = this.legacyGetDefaultEditor()
|
||||
|
||||
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
|
||||
return defaultEditor
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
legacyGetDefaultEditor(): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
|
||||
getDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier {
|
||||
const usecase = new GetDefaultEditorIdentifier(this.preferences, this.items)
|
||||
return usecase.execute(currentTag).getValue()
|
||||
}
|
||||
|
||||
doesEditorChangeRequireAlert(
|
||||
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
from: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
): boolean {
|
||||
if (!from || !to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fromFileType = from.fileType
|
||||
const toFileType = to.fileType
|
||||
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
|
||||
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
|
||||
|
||||
if (isEitherMarkdown || areBothHtml) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
const usecase = new DoesEditorChangeRequireAlertUseCase()
|
||||
return usecase.execute(from, to)
|
||||
}
|
||||
|
||||
async showEditorChangeAlert(): Promise<boolean> {
|
||||
@@ -792,7 +478,7 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
async setComponentPreferences(
|
||||
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
uiFeature: UIFeature<ComponentFeatureDescription>,
|
||||
preferences: ComponentPreferencesEntry,
|
||||
): Promise<void> {
|
||||
const mutablePreferencesValue = Copy<AllComponentPreferences>(
|
||||
@@ -806,9 +492,7 @@ export class SNComponentManager
|
||||
await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue)
|
||||
}
|
||||
|
||||
getComponentPreferences(
|
||||
component: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
): ComponentPreferencesEntry | undefined {
|
||||
getComponentPreferences(component: UIFeature<ComponentFeatureDescription>): 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<ThemeFeatureDescription>): Promise<void> {
|
||||
async addActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
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<ThemeFeatureDescription>): Promise<void> {
|
||||
async replaceActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier])
|
||||
}
|
||||
|
||||
async removeActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
async removeActiveTheme(theme: UIFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
|
||||
|
||||
const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier)
|
||||
@@ -840,7 +524,7 @@ export class SNComponentManager
|
||||
await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes)
|
||||
}
|
||||
|
||||
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean {
|
||||
isThemeActive(theme: UIFeature<ThemeFeatureDescription>): boolean {
|
||||
if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum ComponentManagerEvent {
|
||||
ViewerDidFocus = 'ViewerDidFocus',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ComponentViewerInterface } from '@standardnotes/services'
|
||||
|
||||
export type ComponentManagerEventData = {
|
||||
componentViewer?: ComponentViewerInterface
|
||||
}
|
||||
@@ -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<IframeComponentFeatureDescription>,
|
||||
private componentOrFeature: UIFeature<IframeComponentFeatureDescription>,
|
||||
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<IframeComponentFeatureDescription> {
|
||||
public getComponentOrFeatureItem(): UIFeature<IframeComponentFeatureDescription> {
|
||||
return this.componentOrFeature
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
return
|
||||
}
|
||||
|
||||
const item = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(updatedComponent)
|
||||
const item = new UIFeature<IframeComponentFeatureDescription>(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 () => {
|
||||
|
||||
@@ -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<IframeComponentFeatureDescription>,
|
||||
component: UIFeature<IframeComponentFeatureDescription>,
|
||||
preferences: ComponentPreferencesEntry,
|
||||
): Promise<void>
|
||||
|
||||
getComponentPreferences(
|
||||
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
|
||||
component: UIFeature<IframeComponentFeatureDescription>,
|
||||
): ComponentPreferencesEntry | undefined
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
FeatureIdentifier,
|
||||
FindNativeFeature,
|
||||
IframeComponentFeatureDescription,
|
||||
UIFeatureDescriptionTypes,
|
||||
} from '@standardnotes/features'
|
||||
import { DoesEditorChangeRequireAlertUseCase } from './DoesEditorChangeRequireAlert'
|
||||
import { UIFeature } from '@standardnotes/models'
|
||||
|
||||
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
|
||||
return new UIFeature(FindNativeFeature<F>(identifier)!)
|
||||
}
|
||||
|
||||
describe('editor change alert', () => {
|
||||
let usecase: DoesEditorChangeRequireAlertUseCase
|
||||
|
||||
beforeEach(() => {
|
||||
usecase = new DoesEditorChangeRequireAlertUseCase()
|
||||
})
|
||||
|
||||
it('should not require alert switching from plain editor', () => {
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
|
||||
const requiresAlert = usecase.execute(undefined, component)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to plain editor', () => {
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
|
||||
const requiresAlert = usecase.execute(component, undefined)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from a markdown editor', () => {
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = usecase.execute(markdownEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to a markdown editor', () => {
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = usecase.execute(htmlEditor, markdownEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from & to a html editor', () => {
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const requiresAlert = usecase.execute(htmlEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should require alert switching from a html editor to custom editor', () => {
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
|
||||
const requiresAlert = usecase.execute(htmlEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to html editor', () => {
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
|
||||
const requiresAlert = usecase.execute(customEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to custom editor', () => {
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.TokenVaultEditor)
|
||||
const requiresAlert = usecase.execute(customEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EditorFeatureDescription, IframeComponentFeatureDescription } from '@standardnotes/features'
|
||||
import { UIFeature } from '@standardnotes/models'
|
||||
|
||||
export class DoesEditorChangeRequireAlertUseCase {
|
||||
execute(
|
||||
from: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
to: UIFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
): boolean {
|
||||
if (!from || !to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fromFileType = from.fileType
|
||||
const toFileType = to.fileType
|
||||
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
|
||||
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
|
||||
|
||||
if (isEitherMarkdown || areBothHtml) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createNote } from '@Lib/Spec/SpecUtils'
|
||||
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
|
||||
import { EditorForNoteUseCase } from './EditorForNote'
|
||||
import { ItemManagerInterface } from '@standardnotes/services'
|
||||
|
||||
describe('EditorForNote', () => {
|
||||
let usecase: EditorForNoteUseCase
|
||||
let items: ItemManagerInterface
|
||||
|
||||
beforeEach(() => {
|
||||
items = {} as jest.Mocked<ItemManagerInterface>
|
||||
usecase = new EditorForNoteUseCase(items)
|
||||
})
|
||||
|
||||
it('getEditorForNote should return plain notes is note type is plain', () => {
|
||||
const note = createNote({
|
||||
noteType: NoteType.Plain,
|
||||
})
|
||||
|
||||
expect(usecase.execute(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
|
||||
})
|
||||
|
||||
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
|
||||
const note = createNote({})
|
||||
|
||||
usecase['legacyGetEditorForNote'] = jest.fn()
|
||||
usecase.execute(note)
|
||||
|
||||
expect(usecase['legacyGetEditorForNote']).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ComponentArea,
|
||||
EditorFeatureDescription,
|
||||
FeatureIdentifier,
|
||||
FindNativeFeature,
|
||||
GetIframeAndNativeEditors,
|
||||
GetPlainNoteFeature,
|
||||
GetSuperNoteFeature,
|
||||
IframeComponentFeatureDescription,
|
||||
NoteType,
|
||||
} from '@standardnotes/features'
|
||||
import { ComponentInterface, SNNote, UIFeature } from '@standardnotes/models'
|
||||
import { ItemManagerInterface } from '@standardnotes/services'
|
||||
|
||||
export class EditorForNoteUseCase {
|
||||
constructor(private items: ItemManagerInterface) {}
|
||||
|
||||
execute(note: SNNote): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
|
||||
if (note.noteType === NoteType.Plain) {
|
||||
return new UIFeature(GetPlainNoteFeature())
|
||||
}
|
||||
|
||||
if (note.noteType === NoteType.Super) {
|
||||
return new UIFeature(GetSuperNoteFeature())
|
||||
}
|
||||
|
||||
if (note.editorIdentifier) {
|
||||
const result = this.componentOrNativeFeatureForIdentifier(note.editorIdentifier)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (note.noteType && note.noteType !== NoteType.Unknown) {
|
||||
const result = this.nativeEditorForNoteType(note.noteType)
|
||||
if (result) {
|
||||
return new UIFeature(result)
|
||||
}
|
||||
}
|
||||
|
||||
const legacyResult = this.legacyGetEditorForNote(note)
|
||||
if (legacyResult) {
|
||||
return new UIFeature<IframeComponentFeatureDescription>(legacyResult)
|
||||
}
|
||||
|
||||
return new UIFeature(GetPlainNoteFeature())
|
||||
}
|
||||
|
||||
private componentOrNativeFeatureForIdentifier(
|
||||
identifier: FeatureIdentifier | string,
|
||||
): UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> | undefined {
|
||||
const nativeFeature = FindNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>(
|
||||
identifier as FeatureIdentifier,
|
||||
)
|
||||
if (nativeFeature) {
|
||||
return new UIFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const component = this.items.getDisplayableComponents().find((component) => {
|
||||
return component.identifier === identifier
|
||||
})
|
||||
if (component) {
|
||||
return new UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>(component)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined {
|
||||
const nativeEditors = GetIframeAndNativeEditors()
|
||||
return nativeEditors.find((editor) => editor.note_type === noteType)
|
||||
}
|
||||
|
||||
/** Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly. */
|
||||
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
for (const editor of editors) {
|
||||
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
return editor
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEditor = this.legacyGetDefaultEditor()
|
||||
|
||||
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
|
||||
return defaultEditor
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private legacyGetDefaultEditor(): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
|
||||
}
|
||||
|
||||
private thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
|
||||
return this.items.getDisplayableComponents().filter((component) => {
|
||||
return component.area === area
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ItemManagerInterface, PreferenceServiceInterface } from '@standardnotes/services'
|
||||
import { GetDefaultEditorIdentifier } from './GetDefaultEditorIdentifier'
|
||||
import { ComponentArea, FeatureIdentifier } from '@standardnotes/features'
|
||||
import { SNComponent, SNTag } from '@standardnotes/models'
|
||||
|
||||
describe('getDefaultEditorIdentifier', () => {
|
||||
let usecase: GetDefaultEditorIdentifier
|
||||
let preferences: PreferenceServiceInterface
|
||||
let items: ItemManagerInterface
|
||||
|
||||
beforeEach(() => {
|
||||
preferences = {} as jest.Mocked<PreferenceServiceInterface>
|
||||
preferences.getValue = jest.fn()
|
||||
|
||||
items = {} as jest.Mocked<ItemManagerInterface>
|
||||
items.getDisplayableComponents = jest.fn().mockReturnValue([])
|
||||
|
||||
usecase = new GetDefaultEditorIdentifier(preferences, items)
|
||||
})
|
||||
|
||||
it('should return plain editor if no default tag editor or component editor', () => {
|
||||
const editorIdentifier = usecase.execute().getValue()
|
||||
|
||||
expect(editorIdentifier).toEqual(FeatureIdentifier.PlainEditor)
|
||||
})
|
||||
|
||||
it('should return pref key based value if available', () => {
|
||||
preferences.getValue = jest.fn().mockReturnValue(FeatureIdentifier.SuperEditor)
|
||||
|
||||
const editorIdentifier = usecase.execute().getValue()
|
||||
|
||||
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
|
||||
})
|
||||
|
||||
it('should return default tag identifier if tag supplied', () => {
|
||||
const tag = {
|
||||
preferences: {
|
||||
editorIdentifier: FeatureIdentifier.SuperEditor,
|
||||
},
|
||||
} as jest.Mocked<SNTag>
|
||||
|
||||
const editorIdentifier = usecase.execute(tag).getValue()
|
||||
|
||||
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
|
||||
})
|
||||
|
||||
it('should return legacy editor identifier', () => {
|
||||
const editor = {
|
||||
legacyIsDefaultEditor: jest.fn().mockReturnValue(true),
|
||||
identifier: FeatureIdentifier.MarkdownProEditor,
|
||||
area: ComponentArea.Editor,
|
||||
} as unknown as jest.Mocked<SNComponent>
|
||||
|
||||
items.getDisplayableComponents = jest.fn().mockReturnValue([editor])
|
||||
|
||||
const editorIdentifier = usecase.execute().getValue()
|
||||
|
||||
expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Result, SyncUseCaseInterface } from '@standardnotes/domain-core'
|
||||
import { ComponentArea, EditorIdentifier, FeatureIdentifier } from '@standardnotes/features'
|
||||
import { ComponentInterface, PrefKey, SNTag } from '@standardnotes/models'
|
||||
import { ItemManagerInterface, PreferenceServiceInterface } from '@standardnotes/services'
|
||||
|
||||
export class GetDefaultEditorIdentifier implements SyncUseCaseInterface<EditorIdentifier> {
|
||||
constructor(private preferences: PreferenceServiceInterface, private items: ItemManagerInterface) {}
|
||||
|
||||
execute(currentTag?: SNTag): Result<EditorIdentifier> {
|
||||
if (currentTag) {
|
||||
const editorIdentifier = currentTag?.preferences?.editorIdentifier
|
||||
if (editorIdentifier) {
|
||||
return Result.ok(editorIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
const preferenceValue = this.preferences.getValue(PrefKey.DefaultEditorIdentifier)
|
||||
if (preferenceValue) {
|
||||
return Result.ok(preferenceValue)
|
||||
}
|
||||
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
const matchingEditor = editors.filter((e) => e.legacyIsDefaultEditor())[0]
|
||||
if (matchingEditor) {
|
||||
return Result.ok(matchingEditor.identifier)
|
||||
}
|
||||
|
||||
return Result.ok(FeatureIdentifier.PlainEditor)
|
||||
}
|
||||
|
||||
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
|
||||
return this.items.getDisplayableComponents().filter((component) => {
|
||||
return component.area === area
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import {
|
||||
FeatureIdentifier,
|
||||
FindNativeFeature,
|
||||
IframeComponentFeatureDescription,
|
||||
UIFeatureDescriptionTypes,
|
||||
} from '@standardnotes/features'
|
||||
import {
|
||||
ComponentContent,
|
||||
ComponentInterface,
|
||||
ComponentPackageInfo,
|
||||
DecryptedPayload,
|
||||
Environment,
|
||||
PayloadTimestampDefaults,
|
||||
Platform,
|
||||
SNComponent,
|
||||
UIFeature,
|
||||
} from '@standardnotes/models'
|
||||
import { DesktopManagerInterface } from '@standardnotes/services'
|
||||
import { GetFeatureUrl } from './GetFeatureUrl'
|
||||
|
||||
const desktopExtHost = 'http://localhost:123'
|
||||
|
||||
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
|
||||
return new UIFeature(FindNativeFeature<F>(identifier)!)
|
||||
}
|
||||
|
||||
const thirdPartyFeature = () => {
|
||||
const component = new SNComponent(
|
||||
new DecryptedPayload({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
...PayloadTimestampDefaults(),
|
||||
content: {
|
||||
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier' as FeatureIdentifier,
|
||||
expires_at: new Date().getTime(),
|
||||
availableInRoles: [],
|
||||
} as unknown as jest.Mocked<ComponentPackageInfo>,
|
||||
} as unknown as jest.Mocked<ComponentContent>,
|
||||
}),
|
||||
)
|
||||
|
||||
return new UIFeature<IframeComponentFeatureDescription>(component)
|
||||
}
|
||||
|
||||
describe('GetFeatureUrl', () => {
|
||||
let usecase: GetFeatureUrl
|
||||
|
||||
beforeEach(() => {
|
||||
global.window = {
|
||||
location: {
|
||||
origin: 'http://localhost',
|
||||
},
|
||||
} as Window & typeof globalThis
|
||||
})
|
||||
|
||||
describe('desktop', () => {
|
||||
let desktopManager: DesktopManagerInterface | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
desktopManager = {
|
||||
syncComponentsInstallation() {},
|
||||
registerUpdateObserver(_callback: (component: ComponentInterface) => void) {
|
||||
return () => {}
|
||||
},
|
||||
getExtServerHost() {
|
||||
return desktopExtHost
|
||||
},
|
||||
}
|
||||
|
||||
usecase = new GetFeatureUrl(desktopManager, Environment.Desktop, Platform.MacDesktop)
|
||||
})
|
||||
|
||||
it('returns native path for native component', () => {
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)!
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns native path for deprecated native component', () => {
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.DeprecatedBoldEditor,
|
||||
)!
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nonnative path for third party component', () => {
|
||||
const feature = thirdPartyFeature()
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`)
|
||||
})
|
||||
|
||||
it('returns hosted url for third party component with no local_url', () => {
|
||||
const component = new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const feature = new UIFeature<IframeComponentFeatureDescription>(component)
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual('https://example.com/component')
|
||||
})
|
||||
})
|
||||
|
||||
describe('web', () => {
|
||||
beforeEach(() => {
|
||||
usecase = new GetFeatureUrl(undefined, Environment.Web, Platform.MacWeb)
|
||||
})
|
||||
|
||||
it('returns native path for native feature', () => {
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(
|
||||
`http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns hosted path for third party component', () => {
|
||||
const feature = thirdPartyFeature()
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(feature.asComponent.hosted_url)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentFeatureDescription } from '@standardnotes/features'
|
||||
import { Environment, Platform, UIFeature } from '@standardnotes/models'
|
||||
import { DesktopManagerInterface } from '@standardnotes/services'
|
||||
|
||||
const DESKTOP_URL_PREFIX = 'sn://'
|
||||
const LOCAL_HOST = 'localhost'
|
||||
const CUSTOM_LOCAL_HOST = 'sn.local'
|
||||
const ANDROID_LOCAL_HOST = '10.0.2.2'
|
||||
|
||||
export class GetFeatureUrl {
|
||||
constructor(
|
||||
private desktopManager: DesktopManagerInterface | undefined,
|
||||
private environment: Environment,
|
||||
private platform: Platform,
|
||||
) {}
|
||||
|
||||
execute(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined {
|
||||
if (this.desktopManager) {
|
||||
return this.urlForFeatureOnDesktop(uiFeature)
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
return this.urlForNativeComponent(uiFeature.asFeatureDescription)
|
||||
}
|
||||
|
||||
if (uiFeature.asComponent.offlineOnly) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isMobile) {
|
||||
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
|
||||
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private urlForFeatureOnDesktop(uiFeature: UIFeature<ComponentFeatureDescription>): string | undefined {
|
||||
if (!this.desktopManager) {
|
||||
throw new Error('Desktop manager is not defined')
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
return `${this.desktopManager.getExtServerHost()}/components/${uiFeature.featureIdentifier}/${
|
||||
uiFeature.asFeatureDescription.index_path
|
||||
}`
|
||||
} else {
|
||||
if (uiFeature.asComponent.local_url) {
|
||||
return uiFeature.asComponent.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
|
||||
}
|
||||
|
||||
return uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
|
||||
}
|
||||
}
|
||||
|
||||
private urlForNativeComponent(feature: ComponentFeatureDescription): string {
|
||||
if (this.isMobile) {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${feature.identifier}/${feature.index_path}`
|
||||
} else {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.origin
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${feature.identifier}/${feature.index_path}`
|
||||
}
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.environment === Environment.Mobile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
FeatureIdentifier,
|
||||
FindNativeFeature,
|
||||
UIFeatureDescriptionTypes,
|
||||
} from '@standardnotes/features'
|
||||
import { UIFeature } from '@standardnotes/models'
|
||||
import { RunWithPermissionsUseCase } from './RunWithPermissionsUseCase'
|
||||
import {
|
||||
AlertService,
|
||||
ItemManagerInterface,
|
||||
MutatorClientInterface,
|
||||
SyncServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
|
||||
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
|
||||
return new UIFeature(FindNativeFeature<F>(identifier)!)
|
||||
}
|
||||
|
||||
describe('RunWithPermissionsUseCase', () => {
|
||||
let usecase: RunWithPermissionsUseCase
|
||||
|
||||
beforeEach(() => {
|
||||
usecase = new RunWithPermissionsUseCase(
|
||||
() => {},
|
||||
{} as jest.Mocked<AlertService>,
|
||||
{} as jest.Mocked<MutatorClientInterface>,
|
||||
{} as jest.Mocked<SyncServiceInterface>,
|
||||
{} as jest.Mocked<ItemManagerInterface>,
|
||||
)
|
||||
})
|
||||
|
||||
describe('permissions', () => {
|
||||
it('editor should be able to to stream single note', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamContextItem,
|
||||
content_types: [ContentType.TYPES.Note],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple notes', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.TYPES.Note],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple tags', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.TYPES.Tag],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple notes or tags', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.Note],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('some valid and some invalid permissions should still return invalid permissions', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.TYPES.Tag, ContentType.TYPES.FilesafeFileMetadata],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('filesafe should be able to stream its files', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.TYPES.FilesafeFileMetadata,
|
||||
ContentType.TYPES.FilesafeCredentials,
|
||||
ContentType.TYPES.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
it('bold editor should be able to stream filesafe files', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.TYPES.FilesafeFileMetadata,
|
||||
ContentType.TYPES.FilesafeCredentials,
|
||||
ContentType.TYPES.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
it('non bold editor should not able to stream filesafe files', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.TYPES.FilesafeFileMetadata,
|
||||
ContentType.TYPES.FilesafeCredentials,
|
||||
ContentType.TYPES.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
usecase.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions),
|
||||
).toEqual(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentFeatureDescription,
|
||||
ComponentPermission,
|
||||
FeatureIdentifier,
|
||||
FindNativeFeature,
|
||||
} from '@standardnotes/features'
|
||||
import { ComponentInterface, ComponentMutator, PermissionDialog, UIFeature } from '@standardnotes/models'
|
||||
import {
|
||||
AlertService,
|
||||
ItemManagerInterface,
|
||||
MutatorClientInterface,
|
||||
SyncServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { AllowedBatchContentTypes, AllowedBatchStreaming } from '../Types'
|
||||
import { Copy, filterFromArray, removeFromArray, uniqueArray } from '@standardnotes/utils'
|
||||
import { permissionsStringForPermissions } from '../permissionsStringForPermissions'
|
||||
|
||||
export class RunWithPermissionsUseCase {
|
||||
private permissionDialogs: PermissionDialog[] = []
|
||||
private pendingErrorAlerts: Set<string> = new Set()
|
||||
|
||||
constructor(
|
||||
private permissionDialogUIHandler: (dialog: PermissionDialog) => void,
|
||||
private alerts: AlertService,
|
||||
private mutator: MutatorClientInterface,
|
||||
private sync: SyncServiceInterface,
|
||||
private items: ItemManagerInterface,
|
||||
) {}
|
||||
|
||||
deinit() {
|
||||
this.permissionDialogs = []
|
||||
;(this.permissionDialogUIHandler as unknown) = undefined
|
||||
;(this.alerts as unknown) = undefined
|
||||
;(this.mutator as unknown) = undefined
|
||||
;(this.sync as unknown) = undefined
|
||||
;(this.items as unknown) = undefined
|
||||
}
|
||||
|
||||
public execute(
|
||||
componentIdentifier: string,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
): void {
|
||||
const uiFeature = this.findUIFeature(componentIdentifier)
|
||||
|
||||
if (!uiFeature) {
|
||||
if (!this.pendingErrorAlerts.has(componentIdentifier)) {
|
||||
this.pendingErrorAlerts.add(componentIdentifier)
|
||||
void this.alerts
|
||||
.alert(
|
||||
`Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`,
|
||||
'An unexpected error occurred',
|
||||
)
|
||||
.then(() => {
|
||||
this.pendingErrorAlerts.delete(componentIdentifier)
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
runFunction()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) {
|
||||
console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions)
|
||||
return
|
||||
}
|
||||
|
||||
const acquiredPermissions = uiFeature.acquiredPermissions
|
||||
|
||||
/* Make copy as not to mutate input values */
|
||||
requiredPermissions = Copy<ComponentPermission[]>(requiredPermissions)
|
||||
for (const required of requiredPermissions.slice()) {
|
||||
/* Remove anything we already have */
|
||||
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
|
||||
if (!respectiveAcquired) {
|
||||
continue
|
||||
}
|
||||
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
|
||||
const requiredContentTypes = required.content_types
|
||||
if (!requiredContentTypes) {
|
||||
/* If this permission does not require any content types (i.e stream-context-item)
|
||||
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
|
||||
filterFromArray(requiredPermissions, required)
|
||||
continue
|
||||
}
|
||||
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
|
||||
removeFromArray(requiredContentTypes, acquiredContentType)
|
||||
}
|
||||
if (requiredContentTypes.length === 0) {
|
||||
/* We've removed all acquired and end up with zero, means we already have all these permissions */
|
||||
filterFromArray(requiredPermissions, required)
|
||||
}
|
||||
}
|
||||
if (requiredPermissions.length > 0) {
|
||||
this.promptForPermissionsWithDeferredRendering(
|
||||
uiFeature.asComponent,
|
||||
requiredPermissions,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (approved) => {
|
||||
if (approved) {
|
||||
runFunction()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
runFunction()
|
||||
}
|
||||
}
|
||||
|
||||
setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void {
|
||||
this.permissionDialogUIHandler = handler
|
||||
}
|
||||
|
||||
areRequestedPermissionsValid(
|
||||
uiFeature: UIFeature<ComponentFeatureDescription>,
|
||||
permissions: ComponentPermission[],
|
||||
): boolean {
|
||||
for (const permission of permissions) {
|
||||
if (permission.name === ComponentAction.StreamItems) {
|
||||
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
|
||||
return false
|
||||
}
|
||||
const hasNonAllowedBatchPermission = permission.content_types?.some(
|
||||
(type) => !AllowedBatchContentTypes.includes(type),
|
||||
)
|
||||
if (hasNonAllowedBatchPermission) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private promptForPermissionsWithDeferredRendering(
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
setTimeout(() => {
|
||||
this.promptForPermissions(component, permissions, callback)
|
||||
})
|
||||
}
|
||||
|
||||
private promptForPermissions(
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
const params: PermissionDialog = {
|
||||
component: component,
|
||||
permissions: permissions,
|
||||
permissionsString: permissionsStringForPermissions(permissions, component),
|
||||
actionBlock: callback,
|
||||
callback: async (approved: boolean) => {
|
||||
const latestComponent = this.items.findItem<ComponentInterface>(component.uuid)
|
||||
|
||||
if (!latestComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
|
||||
for (const permission of permissions) {
|
||||
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
|
||||
if (!matchingPermission) {
|
||||
componentPermissions.push(permission)
|
||||
} else {
|
||||
/* Permission already exists, but content_types may have been expanded */
|
||||
const contentTypes = matchingPermission.content_types || []
|
||||
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
|
||||
}
|
||||
}
|
||||
|
||||
await this.mutator.changeItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.permissions = componentPermissions
|
||||
})
|
||||
|
||||
void this.sync.sync()
|
||||
}
|
||||
|
||||
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
|
||||
/* Remove self */
|
||||
if (pendingDialog === params) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
return false
|
||||
}
|
||||
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
|
||||
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
|
||||
}
|
||||
if (pendingDialog.component === component) {
|
||||
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
|
||||
if (
|
||||
pendingDialog.permissions === permissions ||
|
||||
containsObjectSubset(permissions, pendingDialog.permissions)
|
||||
) {
|
||||
/* If approved, run the action block. Otherwise, if canceled, cancel any
|
||||
pending ones as well, since the user was explicit in their intentions */
|
||||
if (approved) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (this.permissionDialogs.length > 0) {
|
||||
this.permissionDialogUIHandler(this.permissionDialogs[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
/**
|
||||
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
|
||||
* We only want to present one and trigger all callbacks based on one modal result
|
||||
*/
|
||||
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
|
||||
this.permissionDialogs.push(params)
|
||||
if (!existingDialog) {
|
||||
this.permissionDialogUIHandler(params)
|
||||
}
|
||||
}
|
||||
|
||||
private findUIFeature(identifier: string): UIFeature<ComponentFeatureDescription> | undefined {
|
||||
const nativeFeature = FindNativeFeature<ComponentFeatureDescription>(identifier as FeatureIdentifier)
|
||||
if (nativeFeature) {
|
||||
return new UIFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const componentItem = this.items.findItem<ComponentInterface>(identifier)
|
||||
if (componentItem) {
|
||||
return new UIFeature<ComponentFeatureDescription>(componentItem)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user