refactor: component manager usecases (#2354)

This commit is contained in:
Mo
2023-07-13 05:46:52 -05:00
committed by GitHub
parent ecc5b5e503
commit 2c68ea1d76
52 changed files with 1454 additions and 1078 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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