refactor: native feature management (#2350)

This commit is contained in:
Mo
2023-07-12 12:56:08 -05:00
committed by GitHub
parent 49f7581cd8
commit 078ef3772c
223 changed files with 3996 additions and 3438 deletions

View File

@@ -7,74 +7,93 @@ import { createNote } from './../../Spec/SpecUtils'
import {
ComponentAction,
ComponentPermission,
FeatureDescription,
FindNativeFeature,
FeatureIdentifier,
NoteType,
UIFeatureDescriptionTypes,
IframeComponentFeatureDescription,
} from '@standardnotes/features'
import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models'
import { ContentType } from '@standardnotes/domain-core'
import {
GenericItem,
SNComponent,
Environment,
Platform,
ComponentInterface,
ComponentOrNativeFeature,
ComponentContent,
DecryptedPayload,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import {
DesktopManagerInterface,
InternalEventBusInterface,
AlertService,
DeviceInterface,
MutatorClientInterface,
ItemManagerInterface,
SyncServiceInterface,
PreferenceServiceInterface,
} from '@standardnotes/services'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { SNComponentManager } from './ComponentManager'
import { SNSyncService } from '../Sync/SyncService'
import { ContentType } from '@standardnotes/domain-core'
import { ComponentPackageInfo } from '@standardnotes/models'
describe('featuresService', () => {
let itemManager: ItemManager
let items: ItemManagerInterface
let mutator: MutatorClientInterface
let featureService: SNFeaturesService
let alertService: AlertService
let syncService: SNSyncService
let prefsService: SNPreferencesService
let internalEventBus: InternalEventBusInterface
let features: SNFeaturesService
let alerts: AlertService
let sync: SyncServiceInterface
let prefs: PreferenceServiceInterface
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 desktopManager: DesktopManagerInterface = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
syncComponentsInstallation() {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
registerUpdateObserver(_callback: (component: SNComponent) => void) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {}
},
getExtServerHost() {
return desktopExtHost
},
}
const manager = new SNComponentManager(
itemManager,
items,
mutator,
syncService,
featureService,
prefsService,
alertService,
sync,
features,
prefs,
alerts,
environment,
platform,
internalEventBus,
device,
eventBus,
)
manager.setDesktopManager(desktopManager)
if (environment === Environment.Desktop) {
const desktopManager: DesktopManagerInterface = {
syncComponentsInstallation() {},
registerUpdateObserver(_callback: (component: ComponentInterface) => void) {
return () => {}
},
getExtServerHost() {
return desktopExtHost
},
}
manager.setDesktopManager(desktopManager)
}
return manager
}
beforeEach(() => {
syncService = {} as jest.Mocked<SNSyncService>
syncService.sync = jest.fn()
sync = {} as jest.Mocked<SNSyncService>
sync.sync = jest.fn()
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue([])
itemManager.addObserver = jest.fn()
items = {} as jest.Mocked<ItemManager>
items.getItems = jest.fn().mockReturnValue([])
items.addObserver = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
@@ -83,62 +102,40 @@ describe('featuresService', () => {
mutator.changeItem = jest.fn()
mutator.changeFeatureRepo = jest.fn()
featureService = {} as jest.Mocked<SNFeaturesService>
features = {} as jest.Mocked<SNFeaturesService>
prefsService = {} as jest.Mocked<SNPreferencesService>
prefs = {} as jest.Mocked<SNPreferencesService>
prefs.addEventObserver = jest.fn()
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn()
alertService.alert = jest.fn()
alerts = {} as jest.Mocked<AlertService>
alerts.confirm = jest.fn()
alerts.alert = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
eventBus = {} as jest.Mocked<InternalEventBusInterface>
eventBus.publish = jest.fn()
device = {} as jest.Mocked<DeviceInterface>
})
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {
return new SNComponent({
uuid: '789',
content_type: ContentType.TYPES.Component,
content: {
package_info: {
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',
identifier: identifier || FeatureIdentifier.PlusEditor,
file_type: file_type ?? 'html',
valid_until: new Date(),
},
},
} as never)
}
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>,
}),
)
const deprecatedComponent = () => {
return new SNComponent({
uuid: '789',
content_type: ContentType.TYPES.Component,
content: {
package_info: {
hosted_url: 'https://example.com/component',
identifier: FeatureIdentifier.DeprecatedFileSafe,
valid_until: new Date(),
},
},
} as never)
}
const thirdPartyComponent = () => {
return new SNComponent({
uuid: '789',
content_type: ContentType.TYPES.Component,
content: {
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier',
valid_until: new Date(),
},
},
} as never)
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
}
describe('permissions', () => {
@@ -152,7 +149,10 @@ describe('featuresService', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownProEditor), permissions),
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(true)
})
@@ -165,7 +165,12 @@ describe('featuresService', () => {
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple tags', () => {
@@ -177,7 +182,12 @@ describe('featuresService', () => {
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('no extension should be able to stream multiple notes or tags', () => {
@@ -189,7 +199,12 @@ describe('featuresService', () => {
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
expect(
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
permissions,
),
).toEqual(false)
})
it('some valid and some invalid permissions should still return invalid permissions', () => {
@@ -202,7 +217,10 @@ describe('featuresService', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(false)
})
@@ -220,7 +238,10 @@ describe('featuresService', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
permissions,
),
).toEqual(true)
})
@@ -238,7 +259,10 @@ describe('featuresService', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedBoldEditor), permissions),
manager.areRequestedPermissionsValid(
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor),
permissions,
),
).toEqual(true)
})
@@ -255,9 +279,9 @@ describe('featuresService', () => {
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual(
false,
)
expect(
manager.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions),
).toEqual(false)
})
})
@@ -265,25 +289,31 @@ describe('featuresService', () => {
describe('desktop', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = nativeComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier)
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
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 component = deprecatedComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier)
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
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 component = thirdPartyComponent()
const url = manager.urlForComponent(component)
expect(url).toEqual(`${desktopExtHost}/Extensions/${component.identifier}/dist/index.html`)
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', () => {
@@ -299,7 +329,8 @@ describe('featuresService', () => {
},
},
} as never)
const url = manager.urlForComponent(component)
const feature = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
const url = manager.urlForComponent(feature)
expect(url).toEqual('https://example.com/component')
})
})
@@ -307,29 +338,30 @@ describe('featuresService', () => {
describe('web', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier) as FeatureDescription
expect(url).toEqual(`http://localhost/components/assets/${component.identifier}/${feature.index_path}`)
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 component = thirdPartyComponent()
const url = manager.urlForComponent(component)
expect(url).toEqual(component.hosted_url)
const feature = thirdPartyFeature()
const url = manager.urlForComponent(feature)
expect(url).toEqual(feature.asComponent.hosted_url)
})
})
})
describe('editors', () => {
it('getEditorForNote should return undefined is note type is plain', () => {
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)).toBe(undefined)
expect(manager.editorForNote(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
})
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
@@ -345,60 +377,74 @@ describe('featuresService', () => {
describe('editor change alert', () => {
it('should not require alert switching from plain editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeComponent()
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 = nativeComponent()
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 = nativeComponent()
const markdownEditor = nativeComponent(undefined, 'md')
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 = nativeComponent()
const markdownEditor = nativeComponent(undefined, 'md')
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 = nativeComponent()
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 = nativeComponent()
const customEditor = nativeComponent(undefined, 'json')
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 = nativeComponent()
const customEditor = nativeComponent(undefined, 'json')
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 = nativeComponent(undefined, 'json')
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
FeatureIdentifier.TokenVaultEditor,
)
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
expect(requiresAlert).toBe(true)
})

View File

@@ -1,22 +1,22 @@
import { AllowedBatchStreaming } from './Types'
import { SNPreferencesService } from '../Preferences/PreferencesService'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { ContentType } from '@standardnotes/domain-core'
import {
ActionObserver,
SNNote,
SNTheme,
SNComponent,
ComponentMutator,
PayloadEmitSource,
PermissionDialog,
Environment,
Platform,
ComponentMessage,
ComponentOrNativeFeature,
ComponentInterface,
PrefKey,
ThemeInterface,
ComponentPreferencesEntry,
AllComponentPreferences,
} from '@standardnotes/models'
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
import find from 'lodash/find'
import uniq from 'lodash/uniq'
import {
ComponentArea,
ComponentAction,
@@ -24,9 +24,25 @@ import {
FindNativeFeature,
NoteType,
FeatureIdentifier,
EditorFeatureDescription,
GetIframeAndNativeEditors,
FindNativeTheme,
UIFeatureDescriptionTypes,
IframeComponentFeatureDescription,
GetPlainNoteFeature,
GetSuperNoteFeature,
ComponentFeatureDescription,
ThemeFeatureDescription,
} from '@standardnotes/features'
import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils'
import { UuidString } from '@Lib/Types/UuidString'
import {
Copy,
filterFromArray,
removeFromArray,
sleep,
assert,
uniqueArray,
isNotUndefined,
} from '@standardnotes/utils'
import { AllowedBatchContentTypes } from '@Lib/Services/ComponentManager/Types'
import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer'
import {
@@ -39,8 +55,14 @@ import {
DeviceInterface,
isMobileDevice,
MutatorClientInterface,
PreferenceServiceInterface,
ComponentViewerItem,
PreferencesServiceEvent,
ItemManagerInterface,
SyncServiceInterface,
FeatureStatus,
} from '@standardnotes/services'
import { ContentType } from '@standardnotes/domain-core'
import { permissionsStringForPermissions } from './permissionsStringForPermissions'
const DESKTOP_URL_PREFIX = 'sn://'
const LOCAL_HOST = 'localhost'
@@ -78,22 +100,30 @@ export class SNComponentManager
private permissionDialogs: PermissionDialog[] = []
constructor(
private itemManager: ItemManager,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private syncService: SNSyncService,
private featuresService: SNFeaturesService,
private preferencesSerivce: SNPreferencesService,
protected alertService: AlertService,
private sync: SyncServiceInterface,
private features: SNFeaturesService,
private preferences: PreferenceServiceInterface,
protected alerts: AlertService,
private environment: Environment,
private platform: Platform,
protected override internalEventBus: InternalEventBusInterface,
private device: DeviceInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.loggingEnabled = false
this.addItemObserver()
this.eventDisposers.push(
preferences.addEventObserver((event) => {
if (event === PreferencesServiceEvent.PreferencesChanged) {
this.postActiveThemesToAllViewers()
}
}),
)
window.addEventListener
? window.addEventListener('focus', this.detectFocusChange, true)
: window.attachEvent('onfocusout', this.detectFocusChange)
@@ -112,20 +142,16 @@ export class SNComponentManager
return this.environment === Environment.Mobile
}
get components(): SNComponent[] {
return this.itemManager.getDisplayableComponents()
get thirdPartyComponents(): ComponentInterface[] {
return this.items.getDisplayableComponents()
}
componentsForArea(area: ComponentArea): SNComponent[] {
return this.components.filter((component) => {
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
return this.thirdPartyComponents.filter((component) => {
return component.area === area
})
}
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined {
return this.components.find((component) => component.identifier === identifier)
}
override deinit(): void {
super.deinit()
@@ -137,11 +163,11 @@ export class SNComponentManager
this.permissionDialogs.length = 0
this.desktopManager = undefined
;(this.itemManager as unknown) = undefined
;(this.featuresService as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.preferencesSerivce as unknown) = undefined
;(this.items as unknown) = undefined
;(this.features as unknown) = undefined
;(this.sync as unknown) = undefined
;(this.alerts as unknown) = undefined
;(this.preferences as unknown) = undefined
this.removeItemObserver?.()
;(this.removeItemObserver as unknown) = undefined
@@ -157,27 +183,35 @@ export class SNComponentManager
}
public createComponentViewer(
component: SNComponent,
contextItem?: UuidString,
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
item: ComponentViewerItem,
actionObserver?: ActionObserver,
): ComponentViewerInterface {
const viewer = new ComponentViewer(
component,
this.itemManager,
this.mutator,
this.syncService,
this.alertService,
this.preferencesSerivce,
this.featuresService,
this.environment,
this.platform,
{
runWithPermissions: this.runWithPermissions.bind(this),
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
items: this.items,
mutator: this.mutator,
sync: this.sync,
alerts: this.alerts,
preferences: this.preferences,
features: this.features,
},
{
url: this.urlForComponent(component) ?? '',
item,
actionObserver,
},
{
environment: this.environment,
platform: this.platform,
componentManagerFunctions: {
runWithPermissions: this.runWithPermissions.bind(this),
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
setComponentPreferences: this.setComponentPreferences.bind(this),
getComponentPreferences: this.getComponentPreferences.bind(this),
},
},
this.urlForComponent(component),
contextItem,
actionObserver,
)
this.viewers.push(viewer)
return viewer
@@ -193,7 +227,7 @@ export class SNComponentManager
this.configureForDesktop()
}
handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void {
private handleChangedComponents(components: ComponentInterface[], source: PayloadEmitSource): void {
const acceptableSources = [
PayloadEmitSource.LocalChanged,
PayloadEmitSource.RemoteRetrieved,
@@ -221,8 +255,8 @@ export class SNComponentManager
}
}
addItemObserver(): void {
this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
private addItemObserver(): void {
this.removeItemObserver = this.items.addObserver<ComponentInterface>(
[ContentType.TYPES.Component, ContentType.TYPES.Theme],
({ changed, inserted, removed, source }) => {
const items = [...changed, ...inserted]
@@ -231,7 +265,7 @@ export class SNComponentManager
const device = this.device
if (isMobileDevice(device) && 'addComponentUrl' in device) {
inserted.forEach((component) => {
const url = this.urlForComponent(component)
const url = this.urlForComponent(new ComponentOrNativeFeature<ComponentFeatureDescription>(component))
if (url) {
device.addComponentUrl(component.uuid, url)
}
@@ -274,9 +308,11 @@ export class SNComponentManager
}
configureForDesktop(): void {
this.desktopManager?.registerUpdateObserver((component: SNComponent) => {
this.desktopManager?.registerUpdateObserver((component: ComponentInterface) => {
/* Reload theme if active */
if (component.active && component.isTheme()) {
const activeComponents = this.getActiveComponents()
const isComponentActive = activeComponents.find((candidate) => candidate.uuid === component.uuid)
if (isComponentActive && component.isTheme()) {
this.postActiveThemesToAllViewers()
}
})
@@ -288,53 +324,57 @@ export class SNComponentManager
}
}
getActiveThemes(): SNTheme[] {
return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
return theme.active
}) as SNTheme[]
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
}
}
urlForComponent(component: SNComponent): string | undefined {
const platformSupportsOfflineOnly = this.isDesktop
if (component.offlineOnly && !platformSupportsOfflineOnly) {
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 nativeFeature = FindNativeFeature(component.identifier)
if (this.isDesktop) {
assert(this.desktopManager)
if (nativeFeature) {
return `${this.desktopManager.getExtServerHost()}/components/${component.identifier}/${
nativeFeature.index_path
}`
} else if (component.local_url) {
return component.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
} else {
return component.hosted_url || component.legacy_url
}
}
const isMobile = this.environment === Environment.Mobile
if (nativeFeature) {
if (isMobile) {
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${component.identifier}/${nativeFeature.index_path}`
} else {
const baseUrlRequiredForThemesInsideEditors = window.location.origin
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}`
}
}
let url = component.hosted_url || component.legacy_url
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
url = url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
}
return url
}
@@ -350,8 +390,24 @@ export class SNComponentManager
return urls
}
private findComponent(uuid: UuidString): SNComponent | undefined {
return this.itemManager.findItem<SNComponent>(uuid)
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 {
@@ -362,10 +418,13 @@ export class SNComponentManager
return this.viewers.find((viewer) => viewer.sessionKey === key)
}
areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean {
areRequestedPermissionsValid(
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
permissions: ComponentPermission[],
): boolean {
for (const permission of permissions) {
if (permission.name === ComponentAction.StreamItems) {
if (!AllowedBatchStreaming.includes(component.identifier)) {
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
return false
}
const hasNonAllowedBatchPermission = permission.content_types?.some(
@@ -381,28 +440,32 @@ export class SNComponentManager
}
runWithPermissions(
componentUuid: UuidString,
componentIdentifier: string,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
): void {
const component = this.findComponent(componentUuid)
const uiFeature = this.findComponentOrNativeFeature(componentIdentifier)
if (!component) {
void this.alertService.alert(
`Unable to find component with ID ${componentUuid}. Please restart the app and try again.`,
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 (!this.areRequestedPermissionsValid(component, requiredPermissions)) {
console.error('Component is requesting invalid permissions', componentUuid, requiredPermissions)
if (uiFeature.isFeatureDescription) {
runFunction()
return
}
const nativeFeature = FindNativeFeature(component.identifier)
const acquiredPermissions = nativeFeature?.component_permissions || component.permissions
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[]
@@ -420,7 +483,7 @@ export class SNComponentManager
filterFromArray(requiredPermissions, required)
continue
}
for (const acquiredContentType of respectiveAcquired.content_types!) {
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
removeFromArray(requiredContentTypes, acquiredContentType)
}
if (requiredContentTypes.length === 0) {
@@ -429,8 +492,8 @@ export class SNComponentManager
}
}
if (requiredPermissions.length > 0) {
this.promptForPermissionsWithAngularAsyncRendering(
component,
this.promptForPermissionsWithDeferredRendering(
uiFeature.asComponent,
requiredPermissions,
// eslint-disable-next-line @typescript-eslint/require-await
async (approved) => {
@@ -444,8 +507,8 @@ export class SNComponentManager
}
}
promptForPermissionsWithAngularAsyncRendering(
component: SNComponent,
promptForPermissionsWithDeferredRendering(
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
@@ -455,14 +518,14 @@ export class SNComponentManager
}
promptForPermissions(
component: SNComponent,
component: ComponentInterface,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
const params: PermissionDialog = {
component: component,
permissions: permissions,
permissionsString: this.permissionsStringForPermissions(permissions, component),
permissionsString: permissionsStringForPermissions(permissions, component),
actionBlock: callback,
callback: async (approved: boolean) => {
const latestComponent = this.findComponent(component.uuid)
@@ -481,7 +544,7 @@ export class SNComponentManager
} else {
/* Permission already exists, but content_types may have been expanded */
const contentTypes = matchingPermission.content_types || []
matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!))
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
}
}
@@ -490,7 +553,7 @@ export class SNComponentManager
mutator.permissions = componentPermissions
})
void this.syncService.sync()
void this.sync.sync()
}
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
@@ -528,9 +591,7 @@ export class SNComponentManager
* 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 = find(this.permissionDialogs, {
component: component,
})
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
this.permissionDialogs.push(params)
if (!existingDialog) {
this.presentPermissionsDialog(params)
@@ -544,56 +605,72 @@ export class SNComponentManager
throw 'Must override SNComponentManager.presentPermissionsDialog'
}
async toggleTheme(uuid: UuidString): Promise<void> {
this.log('Toggling theme', uuid)
async toggleTheme(uiFeature: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
this.log('Toggling theme', uiFeature.uniqueIdentifier)
const theme = this.findComponent(uuid) as SNTheme
if (theme.active) {
await this.mutator.changeComponent(theme, (mutator) => {
mutator.active = false
})
} else {
const activeThemes = this.getActiveThemes()
/* Activate current before deactivating others, so as not to flicker */
await this.mutator.changeComponent(theme, (mutator) => {
mutator.active = true
})
/* Deactive currently active theme(s) if new theme is not layerable */
if (!theme.isLayerable()) {
await sleep(10)
for (const candidate of activeThemes) {
if (candidate && !candidate.isLayerable()) {
await this.mutator.changeComponent(candidate, (mutator) => {
mutator.active = false
})
}
}
}
}
void this.syncService.sync()
}
async toggleComponent(uuid: UuidString): Promise<void> {
this.log('Toggling component', uuid)
const component = this.findComponent(uuid)
if (!component) {
if (this.isThemeActive(uiFeature)) {
await this.removeActiveTheme(uiFeature)
return
}
await this.mutator.changeComponent(component, (mutator) => {
mutator.active = !(mutator.getItem() as SNComponent).active
})
const featureStatus = this.features.getFeatureStatus(uiFeature.featureIdentifier)
if (featureStatus !== FeatureStatus.Entitled) {
return
}
void this.syncService.sync()
/* Activate current before deactivating others, so as not to flicker */
await this.addActiveTheme(uiFeature)
/* Deactive currently active theme(s) if new theme is not layerable */
if (!uiFeature.layerable) {
await sleep(10)
const activeThemes = this.getActiveThemes()
for (const candidate of activeThemes) {
if (candidate.featureIdentifier === uiFeature.featureIdentifier) {
continue
}
if (!candidate.layerable) {
await this.removeActiveTheme(candidate)
}
}
}
}
isComponentActive(component: SNComponent): boolean {
return component.active
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] {
const activeThemesIdentifiers = this.getActiveThemesIdentifiers()
const thirdPartyThemes = this.items.findItems<ThemeInterface>(activeThemesIdentifiers).map((item) => {
return new ComponentOrNativeFeature<ThemeFeatureDescription>(item)
})
const nativeThemes = activeThemesIdentifiers
.map((identifier) => {
return FindNativeTheme(identifier as FeatureIdentifier)
})
.filter(isNotUndefined)
.map((theme) => new ComponentOrNativeFeature(theme))
const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => {
return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled
})
return entitledThemes
}
getActiveThemesIdentifiers(): string[] {
return this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
}
async toggleComponent(component: ComponentInterface): Promise<void> {
this.log('Toggling component', component.uuid)
if (this.isComponentActive(component)) {
await this.removeActiveComponent(component)
} else {
await this.addActiveComponent(component)
}
}
allComponentIframes(): HTMLIFrameElement[] {
@@ -604,23 +681,67 @@ export class SNComponentManager
return viewer.getIframe()
}
editorForNote(note: SNNote): SNComponent | undefined {
if (note.noteType === NoteType.Plain || note.noteType === NoteType.Super) {
return undefined
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): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
if (note.noteType === NoteType.Plain) {
return new ComponentOrNativeFeature(GetPlainNoteFeature())
}
if (note.noteType === NoteType.Super) {
return new ComponentOrNativeFeature(GetSuperNoteFeature())
}
if (note.editorIdentifier) {
return this.componentWithIdentifier(note.editorIdentifier)
const result = this.componentOrNativeFeatureForIdentifier<
EditorFeatureDescription | IframeComponentFeatureDescription
>(note.editorIdentifier)
if (result) {
return result
}
}
return this.legacyGetEditorForNote(note)
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): SNComponent | undefined {
const editors = this.componentsForArea(ComponentArea.Editor)
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
for (const editor of editors) {
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
return editor
@@ -635,67 +756,25 @@ export class SNComponentManager
}
}
legacyGetDefaultEditor(): SNComponent | undefined {
const editors = this.componentsForArea(ComponentArea.Editor)
legacyGetDefaultEditor(): ComponentInterface | undefined {
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
}
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string {
if (permissions.length === 0) {
return '.'
doesEditorChangeRequireAlert(
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean {
if (!from || !to) {
return false
}
let contentTypeStrings: string[] = []
let contextAreaStrings: string[] = []
const fromFileType = from.fileType
const toFileType = to.fileType
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
permissions.forEach((permission) => {
switch (permission.name) {
case ComponentAction.StreamItems:
if (!permission.content_types) {
return
}
permission.content_types.forEach((contentTypeString: string) => {
const contentTypeOrError = ContentType.create(contentTypeString)
if (contentTypeOrError.isFailed()) {
return
}
const contentType = contentTypeOrError.getValue()
const desc = contentType.getDisplayName()
if (desc) {
contentTypeStrings.push(`${desc}s`)
} else {
contentTypeStrings.push(`items of type ${contentType.value}`)
}
})
break
case ComponentAction.StreamContextItem:
{
const componentAreaMapping = {
[ComponentArea.EditorStack]: 'working note',
[ComponentArea.Editor]: 'working note',
[ComponentArea.Themes]: 'Unknown',
}
contextAreaStrings.push(componentAreaMapping[component.area])
}
break
}
})
contentTypeStrings = uniq(contentTypeStrings)
contextAreaStrings = uniq(contextAreaStrings)
if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) {
return '.'
}
return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.'
}
doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean {
const isEitherPlainEditor = !from || !to
const isEitherMarkdown = from?.package_info.file_type === 'md' || to?.package_info.file_type === 'md'
const areBothHtml = from?.package_info.file_type === 'html' && to?.package_info.file_type === 'html'
if (isEitherPlainEditor || isEitherMarkdown || areBothHtml) {
if (isEitherMarkdown || areBothHtml) {
return false
} else {
return true
@@ -703,7 +782,7 @@ export class SNComponentManager
}
async showEditorChangeAlert(): Promise<boolean> {
const shouldChangeEditor = await this.alertService.confirm(
const shouldChangeEditor = await this.alerts.confirm(
'Doing so might result in minor formatting changes.',
"Are you sure you want to change this note's type?",
'Yes, change it',
@@ -711,4 +790,91 @@ export class SNComponentManager
return shouldChangeEditor
}
async setComponentPreferences(
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
preferences: ComponentPreferencesEntry,
): Promise<void> {
const mutablePreferencesValue = Copy<AllComponentPreferences>(
this.preferences.getValue(PrefKey.ComponentPreferences, undefined) ?? {},
)
const preferencesLookupKey = uiFeature.uniqueIdentifier
mutablePreferencesValue[preferencesLookupKey] = preferences
await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue)
}
getComponentPreferences(
component: ComponentOrNativeFeature<ComponentFeatureDescription>,
): ComponentPreferencesEntry | undefined {
const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined)
if (!preferences) {
return undefined
}
const preferencesLookupKey = component.uniqueIdentifier
return preferences[preferencesLookupKey]
}
async addActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice()
activeThemes.push(theme.uniqueIdentifier)
await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes)
}
async replaceActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier])
}
async removeActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier)
await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes)
}
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean {
if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) {
return false
}
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
return activeThemes.includes(theme.uniqueIdentifier)
}
async addActiveComponent(component: ComponentInterface): Promise<void> {
const activeComponents = (this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []).slice()
activeComponents.push(component.uuid)
await this.preferences.setValue(PrefKey.ActiveComponents, activeComponents)
}
async removeActiveComponent(component: ComponentInterface): Promise<void> {
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
const filteredComponents = activeComponents.filter((activeComponent) => activeComponent !== component.uuid)
await this.preferences.setValue(PrefKey.ActiveComponents, filteredComponents)
}
getActiveComponents(): ComponentInterface[] {
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
return this.items.findItems(activeComponents)
}
isComponentActive(component: ComponentInterface): boolean {
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
return activeComponents.includes(component.uuid)
}
}

View File

@@ -1,4 +1,3 @@
import { SNPreferencesService } from '../Preferences/PreferencesService'
import {
ComponentViewerInterface,
ComponentViewerError,
@@ -6,6 +5,11 @@ import {
FeaturesEvent,
AlertService,
MutatorClientInterface,
PreferenceServiceInterface,
ComponentViewerItem,
isComponentViewerItemReadonlyItem,
ItemManagerInterface,
SyncServiceInterface,
} from '@standardnotes/services'
import { SNFeaturesService } from '@Lib/Services'
import {
@@ -13,7 +17,6 @@ import {
ComponentEventObserver,
ComponentViewerEvent,
ComponentMessage,
SNComponent,
PrefKey,
NoteContent,
MutationType,
@@ -36,11 +39,10 @@ import {
Environment,
Platform,
OutgoingItemMessagePayload,
ComponentPreferencesEntry,
ComponentOrNativeFeature,
ComponentInterface,
} from '@standardnotes/models'
import find from 'lodash/find'
import uniq from 'lodash/uniq'
import remove from 'lodash/remove'
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
import { environmentToString, platformToString } from '@Lib/Application/Platforms'
import {
MessageReply,
@@ -48,10 +50,15 @@ import {
AllowedBatchContentTypes,
DeleteItemsMessageData,
MessageReplyData,
ReadwriteActions,
} from './Types'
import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { UuidString } from '@Lib/Types/UuidString'
import { ComponentViewerRequiresComponentManagerFunctions } from './ComponentViewerRequiresComponentManagerFunctions'
import {
ComponentAction,
ComponentPermission,
ComponentArea,
IframeComponentFeatureDescription,
} from '@standardnotes/features'
import {
isString,
extendArray,
@@ -63,30 +70,10 @@ import {
Uuids,
sureSearchArray,
isNotUndefined,
uniqueArray,
} from '@standardnotes/utils'
import { ContentType } from '@standardnotes/domain-core'
type RunWithPermissionsCallback = (
componentUuid: UuidString,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
) => void
type ComponentManagerFunctions = {
runWithPermissions: RunWithPermissionsCallback
urlsForActiveThemes: () => string[]
}
const ReadwriteActions = [
ComponentAction.SaveItems,
ComponentAction.CreateItem,
ComponentAction.CreateItems,
ComponentAction.DeleteItems,
ComponentAction.SetComponentData,
]
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
export class ComponentViewer implements ComponentViewerInterface {
private streamItems?: string[]
private streamContextItemOriginalMessage?: ComponentMessage
@@ -95,7 +82,6 @@ export class ComponentViewer implements ComponentViewerInterface {
private loggingEnabled = false
public identifier = nonSecureRandomIdentifier()
private actionObservers: ActionObserver[] = []
public overrideContextItem?: DecryptedItemInterface
private featureStatus: FeatureStatus
private removeFeaturesObserver: () => void
private eventObservers: ComponentEventObserver[] = []
@@ -108,21 +94,31 @@ export class ComponentViewer implements ComponentViewerInterface {
public sessionKey?: string
constructor(
public readonly component: SNComponent,
private itemManager: ItemManager,
private mutator: MutatorClientInterface,
private syncService: SNSyncService,
private alertService: AlertService,
private preferencesSerivce: SNPreferencesService,
featuresService: SNFeaturesService,
private environment: Environment,
private platform: Platform,
private componentManagerFunctions: ComponentManagerFunctions,
public readonly url?: string,
private contextItemUuid?: UuidString,
actionObserver?: ActionObserver,
private componentOrFeature: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
private services: {
items: ItemManagerInterface
mutator: MutatorClientInterface
sync: SyncServiceInterface
alerts: AlertService
preferences: PreferenceServiceInterface
features: SNFeaturesService
},
private options: {
item: ComponentViewerItem
url: string
actionObserver?: ActionObserver
},
private config: {
environment: Environment
platform: Platform
componentManagerFunctions: ComponentViewerRequiresComponentManagerFunctions
},
) {
this.removeItemObserver = this.itemManager.addObserver(
if (isComponentViewerItemReadonlyItem(options.item)) {
this.setReadonly(true)
this.lockReadonly = true
}
this.removeItemObserver = this.services.items.addObserver(
ContentType.TYPES.Any,
({ changed, inserted, removed, source, sourceKey }) => {
if (this.dealloced) {
@@ -132,21 +128,22 @@ export class ComponentViewer implements ComponentViewerInterface {
this.handleChangesInItems(items, source, sourceKey)
},
)
if (actionObserver) {
this.actionObservers.push(actionObserver)
if (options.actionObserver) {
this.actionObservers.push(options.actionObserver)
}
this.featureStatus = featuresService.getFeatureStatus(component.identifier)
this.featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier)
this.removeFeaturesObserver = featuresService.addEventObserver((event) => {
this.removeFeaturesObserver = services.features.addEventObserver((event) => {
if (this.dealloced) {
return
}
if (event === FeaturesEvent.FeaturesUpdated) {
const featureStatus = featuresService.getFeatureStatus(component.identifier)
if (event === FeaturesEvent.FeaturesAvailabilityChanged) {
const featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier)
if (featureStatus !== this.featureStatus) {
this.featureStatus = featureStatus
this.postActiveThemes()
this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated)
}
}
@@ -155,12 +152,20 @@ export class ComponentViewer implements ComponentViewerInterface {
this.log('Constructor', this)
}
public getComponentOrFeatureItem(): ComponentOrNativeFeature<IframeComponentFeatureDescription> {
return this.componentOrFeature
}
get url(): string {
return this.options.url
}
get isDesktop(): boolean {
return this.environment === Environment.Desktop
return this.config.environment === Environment.Desktop
}
get isMobile(): boolean {
return this.environment === Environment.Mobile
return this.config.environment === Environment.Mobile
}
public destroy(): void {
@@ -170,12 +175,10 @@ export class ComponentViewer implements ComponentViewerInterface {
private deinit(): void {
this.dealloced = true
;(this.component as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.preferencesSerivce as unknown) = undefined
;(this.componentManagerFunctions as unknown) = undefined
;(this.componentOrFeature as unknown) = undefined
;(this.services as unknown) = undefined
;(this.config as unknown) = undefined
;(this.options as unknown) = undefined
this.eventObservers.length = 0
this.actionObservers.length = 0
@@ -218,8 +221,8 @@ export class ComponentViewer implements ComponentViewerInterface {
this.readonly = readonly
}
get componentUuid(): string {
return this.component.uuid
get componentUniqueIdentifier(): string {
return this.componentOrFeature.uniqueIdentifier
}
public getFeatureStatus(): FeatureStatus {
@@ -227,20 +230,17 @@ export class ComponentViewer implements ComponentViewerInterface {
}
private isOfflineRestricted(): boolean {
return this.component.offlineOnly && !this.isDesktop
}
private isNativeFeature(): boolean {
return !!FindNativeFeature(this.component.identifier)
return this.componentOrFeature.isComponent && this.componentOrFeature.asComponent.offlineOnly && !this.isDesktop
}
private hasUrlError(): boolean {
if (this.isNativeFeature()) {
if (!this.componentOrFeature.isComponent) {
return false
}
return this.isDesktop
? !this.component.local_url && !this.component.hasValidHostedUrl()
: !this.component.hasValidHostedUrl()
? !this.componentOrFeature.asComponent.local_url && !this.componentOrFeature.asComponent.hasValidHostedUrl
: !this.componentOrFeature.asComponent.hasValidHostedUrl
}
public shouldRender(): boolean {
@@ -251,6 +251,7 @@ export class ComponentViewer implements ComponentViewerInterface {
if (this.isOfflineRestricted()) {
return ComponentViewerError.OfflineRestricted
}
if (this.hasUrlError()) {
return ComponentViewerError.MissingUrl
}
@@ -259,10 +260,18 @@ export class ComponentViewer implements ComponentViewerInterface {
}
private updateOurComponentRefFromChangedItems(items: DecryptedItemInterface[]): void {
const updatedComponent = items.find((item) => item.uuid === this.component.uuid)
if (updatedComponent && isDecryptedItem(updatedComponent)) {
;(this.component as Writeable<SNComponent>) = updatedComponent as SNComponent
if (!this.componentOrFeature.isComponent) {
return
}
const updatedComponent = items.find((item) => item.uuid === this.componentUniqueIdentifier) as ComponentInterface
if (!updatedComponent) {
return
}
const item = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(updatedComponent)
this.componentOrFeature = item
}
handleChangesInItems(
@@ -275,7 +284,7 @@ export class ComponentViewer implements ComponentViewerInterface {
this.updateOurComponentRefFromChangedItems(nondeletedItems)
const areWeOriginator = sourceKey && sourceKey === this.component.uuid
const areWeOriginator = sourceKey && sourceKey === this.componentUniqueIdentifier
if (areWeOriginator) {
return
}
@@ -291,7 +300,12 @@ export class ComponentViewer implements ComponentViewerInterface {
}
if (this.streamContextItemOriginalMessage) {
const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid })
const optionsItem = this.options.item
if (isComponentViewerItemReadonlyItem(optionsItem)) {
return
}
const matchingItem = nondeletedItems.find((item) => item.uuid === optionsItem.uuid)
if (matchingItem) {
this.sendContextItemThroughBridge(matchingItem, source)
}
@@ -302,13 +316,17 @@ export class ComponentViewer implements ComponentViewerInterface {
const requiredPermissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: this.streamItems!.sort(),
content_types: this.streamItems?.sort(),
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
this.sendItemsInReply(items, this.streamItemsOriginalMessage!)
})
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
() => {
this.sendItemsInReply(items, this.streamItemsOriginalMessage as ComponentMessage)
},
)
}
sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void {
@@ -317,21 +335,25 @@ export class ComponentViewer implements ComponentViewerInterface {
name: ComponentAction.StreamContextItem,
},
] as ComponentPermission[]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredContextPermissions, () => {
this.log(
'Send context item in reply',
'component:',
this.component,
'item: ',
item,
'originalMessage: ',
this.streamContextItemOriginalMessage,
)
const response: MessageReplyData = {
item: this.jsonForItem(item, source),
}
this.replyToMessage(this.streamContextItemOriginalMessage!, response)
})
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredContextPermissions,
() => {
this.log(
'Send context item in reply',
'component:',
this.componentOrFeature,
'item: ',
item,
'originalMessage: ',
this.streamContextItemOriginalMessage,
)
const response: MessageReplyData = {
item: this.jsonForItem(item, source),
}
this.replyToMessage(this.streamContextItemOriginalMessage as ComponentMessage, response)
},
)
}
private log(message: string, ...args: unknown[]): void {
@@ -345,7 +367,7 @@ export class ComponentViewer implements ComponentViewerInterface {
message: ComponentMessage,
source?: PayloadEmitSource,
): void {
this.log('Send items in reply', this.component, items, message)
this.log('Send items in reply', this.componentOrFeature, items, message)
const responseData: MessageReplyData = {}
@@ -377,9 +399,7 @@ export class ComponentViewer implements ComponentViewerInterface {
if (isDecryptedItem(item)) {
params.content = this.contentForItem(item)
const globalComponentData = item.getDomainData(ComponentDataDomain) || {}
const thisComponentData = globalComponentData[this.component.getClientDataKey()] || {}
params.clientData = thisComponentData as Record<string, unknown>
params.clientData = this.getClientData(item)
} else {
params.deleted = true
}
@@ -387,13 +407,19 @@ export class ComponentViewer implements ComponentViewerInterface {
return this.responseItemsByRemovingPrivateProperties([params])[0]
}
private getClientData(item: DecryptedItemInterface): Record<string, unknown> {
const globalComponentData = item.getDomainData(ComponentDataDomain) || {}
const thisComponentData = globalComponentData[this.componentUniqueIdentifier] || {}
return thisComponentData as Record<string, unknown>
}
contentForItem(item: DecryptedItemInterface): ItemContent | undefined {
if (isNote(item)) {
const content = item.content
const spellcheck =
item.spellcheck != undefined
? item.spellcheck
: this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true)
: this.services.preferences.getValue(PrefKey.EditorSpellcheck, true)
return {
...content,
@@ -421,21 +447,21 @@ export class ComponentViewer implements ComponentViewerInterface {
const permissibleActionsWhileHidden = [ComponentAction.ComponentRegistered, ComponentAction.ActivateThemes]
if (this.hidden && !permissibleActionsWhileHidden.includes(message.action)) {
this.log('Component disabled for current item, ignoring messages.', this.component.name)
this.log('Component disabled for current item, ignoring messages.', this.componentOrFeature.displayName)
return
}
if (!this.window && message.action === ComponentAction.Reply) {
this.log('Component has been deallocated in between message send and reply', this.component, message)
this.log('Component has been deallocated in between message send and reply', this.componentOrFeature, message)
return
}
this.log('Send message to component', this.component, 'message: ', message)
this.log('Send message to component', this.componentOrFeature, 'message: ', message)
let origin = this.url
let origin = this.options.url
if (!origin || !this.window) {
if (essential) {
void this.alertService.alert(
`Standard Notes is trying to communicate with ${this.component.name}, ` +
void this.services.alerts.alert(
`Standard Notes is trying to communicate with ${this.componentOrFeature.displayName}, ` +
'but an error is occurring. Please restart this extension and try again.',
)
}
@@ -498,20 +524,22 @@ export class ComponentViewer implements ComponentViewerInterface {
throw Error('Attempting to override component viewer window. Create a new component viewer instead.')
}
this.log('setWindow', 'component: ', this.component, 'window: ', window)
this.log('setWindow', 'component: ', this.componentOrFeature, 'window: ', window)
this.window = window
this.sessionKey = UuidGenerator.GenerateUuid()
const componentData = this.config.componentManagerFunctions.getComponentPreferences(this.componentOrFeature) ?? {}
this.sendMessage({
action: ComponentAction.ComponentRegistered,
sessionKey: this.sessionKey,
componentData: this.component.componentData,
componentData: componentData,
data: {
uuid: this.component.uuid,
environment: environmentToString(this.environment),
platform: platformToString(this.platform),
activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(),
uuid: this.componentUniqueIdentifier,
environment: environmentToString(this.config.environment),
platform: platformToString(this.config.platform),
activeThemeUrls: this.config.componentManagerFunctions.urlsForActiveThemes(),
},
})
@@ -521,7 +549,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}
postActiveThemes(): void {
const urls = this.componentManagerFunctions.urlsForActiveThemes()
const urls = this.config.componentManagerFunctions.urlsForActiveThemes()
const data: MessageData = {
themes: urls,
}
@@ -547,24 +575,24 @@ export class ComponentViewer implements ComponentViewerInterface {
}
if (this.streamItems) {
this.handleStreamItemsMessage(this.streamItemsOriginalMessage!)
this.handleStreamItemsMessage(this.streamItemsOriginalMessage as ComponentMessage)
}
}
}
handleMessage(message: ComponentMessage): void {
this.log('Handle message', message, this)
if (!this.component) {
if (!this.componentOrFeature) {
this.log('Component not defined for message, returning', message)
void this.alertService.alert(
void this.services.alerts.alert(
'A component is trying to communicate with Standard Notes, ' +
'but there is an error establishing a bridge. Please restart the app and try again.',
)
return
}
if (this.readonly && ReadwriteActions.includes(message.action)) {
void this.alertService.alert(
`${this.component.name} is trying to save, but it is in a locked state and cannot accept changes.`,
void this.services.alerts.alert(
`${this.componentOrFeature.displayName} is trying to save, but it is in a locked state and cannot accept changes.`,
)
return
}
@@ -572,7 +600,7 @@ export class ComponentViewer implements ComponentViewerInterface {
const messageHandlers: Partial<Record<ComponentAction, (message: ComponentMessage) => void>> = {
[ComponentAction.StreamItems]: this.handleStreamItemsMessage.bind(this),
[ComponentAction.StreamContextItem]: this.handleStreamContextItemMessage.bind(this),
[ComponentAction.SetComponentData]: this.handleSetComponentDataMessage.bind(this),
[ComponentAction.SetComponentData]: this.handleSetComponentPreferencesMessage.bind(this),
[ComponentAction.DeleteItems]: this.handleDeleteItemsMessage.bind(this),
[ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this),
[ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this),
@@ -597,18 +625,22 @@ export class ComponentViewer implements ComponentViewerInterface {
content_types: types,
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
if (!this.streamItems) {
this.streamItems = types
this.streamItemsOriginalMessage = message
}
/* Push immediately now */
const items: DecryptedItemInterface[] = []
for (const contentType of types) {
extendArray(items, this.itemManager.getItems(contentType))
}
this.sendItemsInReply(items, message)
})
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
() => {
if (!this.streamItems) {
this.streamItems = types
this.streamItemsOriginalMessage = message
}
/* Push immediately now */
const items: DecryptedItemInterface[] = []
for (const contentType of types) {
extendArray(items, this.services.items.getItems(contentType))
}
this.sendItemsInReply(items, message)
},
)
}
handleStreamContextItemMessage(message: ComponentMessage): void {
@@ -618,15 +650,21 @@ export class ComponentViewer implements ComponentViewerInterface {
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
if (!this.streamContextItemOriginalMessage) {
this.streamContextItemOriginalMessage = message
}
const matchingItem = this.overrideContextItem || this.itemManager.findItem(this.contextItemUuid!)
if (matchingItem) {
this.sendContextItemThroughBridge(matchingItem)
}
})
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
() => {
if (!this.streamContextItemOriginalMessage) {
this.streamContextItemOriginalMessage = message
}
const matchingItem = isComponentViewerItemReadonlyItem(this.options.item)
? this.options.item.readonlyItem
: this.services.items.findItem(this.options.item.uuid)
if (matchingItem) {
this.sendContextItemThroughBridge(matchingItem)
}
},
)
}
/**
@@ -640,8 +678,12 @@ export class ComponentViewer implements ComponentViewerInterface {
/* Pending as in needed to be accounted for in permissions. */
const pendingResponseItems = responsePayloads.slice()
if (isComponentViewerItemReadonlyItem(this.options.item)) {
return
}
for (const responseItem of responsePayloads.slice()) {
if (responseItem.uuid === this.contextItemUuid) {
if (responseItem.uuid === this.options.item.uuid) {
requiredPermissions.push({
name: ComponentAction.StreamContextItem,
})
@@ -653,7 +695,7 @@ export class ComponentViewer implements ComponentViewerInterface {
/* Check to see if additional privileges are required */
if (pendingResponseItems.length > 0) {
const requiredContentTypes = uniq(
const requiredContentTypes = uniqueArray(
pendingResponseItems.map((item) => {
return item.content_type
}),
@@ -665,8 +707,8 @@ export class ComponentViewer implements ComponentViewerInterface {
} as ComponentPermission)
}
this.componentManagerFunctions.runWithPermissions(
this.component.uuid,
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
async () => {
@@ -674,7 +716,7 @@ export class ComponentViewer implements ComponentViewerInterface {
/* Filter locked items */
const uuids = Uuids(responsePayloads)
const items = this.itemManager.findItemsIncludingBlanks(uuids)
const items = this.services.items.findItemsIncludingBlanks(uuids)
let lockedCount = 0
let lockedNoteCount = 0
@@ -684,7 +726,9 @@ export class ComponentViewer implements ComponentViewerInterface {
}
if (item.locked) {
remove(responsePayloads, { uuid: item.uuid })
responsePayloads = responsePayloads.filter((responseItem) => {
return responseItem.uuid !== item.uuid
})
lockedCount++
if (item.content_type === ContentType.TYPES.Note) {
lockedNoteCount++
@@ -693,7 +737,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}
if (lockedNoteCount === 1) {
void this.alertService.alert(
void this.services.alerts.alert(
'The note you are attempting to save has editing disabled',
'Note has Editing Disabled',
)
@@ -701,7 +745,7 @@ export class ComponentViewer implements ComponentViewerInterface {
} else if (lockedCount > 0) {
const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items'
const auxVerb = lockedCount === 1 ? 'has' : 'have'
void this.alertService.alert(
void this.services.alerts.alert(
`${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`,
'Items have Editing Disabled',
)
@@ -714,14 +758,14 @@ export class ComponentViewer implements ComponentViewerInterface {
})
for (const contextualPayload of contextualPayloads) {
const item = this.itemManager.findItem(contextualPayload.uuid)
const item = this.services.items.findItem(contextualPayload.uuid)
if (!item) {
const payload = new DecryptedPayload({
...PayloadTimestampDefaults(),
...contextualPayload,
})
const template = CreateDecryptedItemFromPayload(payload)
await this.mutator.insertItem(template)
await this.services.mutator.insertItem(template)
} else {
if (contextualPayload.content_type !== item.content_type) {
throw Error('Extension is trying to modify content type of item.')
@@ -729,7 +773,7 @@ export class ComponentViewer implements ComponentViewerInterface {
}
}
await this.mutator.changeItems(
await this.services.mutator.changeItems(
items.filter(isNotUndefined),
(mutator) => {
const contextualPayload = sureSearchArray(contextualPayloads, {
@@ -743,17 +787,19 @@ export class ComponentViewer implements ComponentViewerInterface {
})
if (responseItem.clientData) {
const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {})
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
const allComponentData = Copy<Record<string, unknown>>(
mutator.getItem().getDomainData(ComponentDataDomain) || {},
)
allComponentData[this.componentUniqueIdentifier] = responseItem.clientData
mutator.setDomainData(allComponentData, ComponentDataDomain)
}
},
MutationType.UpdateUserTimestamps,
PayloadEmitSource.ComponentRetrieved,
this.component.uuid,
this.componentUniqueIdentifier,
)
this.syncService
this.services.sync
.sync({
onPresyncSave: () => {
this.replyToMessage(message, {})
@@ -771,7 +817,7 @@ export class ComponentViewer implements ComponentViewerInterface {
handleCreateItemsMessage(message: ComponentMessage): void {
let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[]
const uniqueContentTypes = uniq(
const uniqueContentTypes = uniqueArray(
responseItems.map((item) => {
return item.content_type
}),
@@ -784,59 +830,65 @@ export class ComponentViewer implements ComponentViewerInterface {
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
const processedItems = []
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
async () => {
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
const processedItems = []
for (const responseItem of responseItems) {
if (!responseItem.uuid) {
responseItem.uuid = UuidGenerator.GenerateUuid()
for (const responseItem of responseItems) {
if (!responseItem.uuid) {
responseItem.uuid = UuidGenerator.GenerateUuid()
}
const contextualPayload = createComponentCreatedContextPayload(responseItem)
const payload = new DecryptedPayload({
...PayloadTimestampDefaults(),
...contextualPayload,
})
const template = CreateDecryptedItemFromPayload(payload)
const item = await this.services.mutator.insertItem(template)
await this.services.mutator.changeItem(
item,
(mutator) => {
if (responseItem.clientData) {
const allComponentClientData = Copy<Record<string, unknown>>(
item.getDomainData(ComponentDataDomain) || {},
)
allComponentClientData[this.componentUniqueIdentifier] = responseItem.clientData
mutator.setDomainData(allComponentClientData, ComponentDataDomain)
}
},
MutationType.UpdateUserTimestamps,
PayloadEmitSource.ComponentCreated,
this.componentUniqueIdentifier,
)
processedItems.push(item)
}
const contextualPayload = createComponentCreatedContextPayload(responseItem)
const payload = new DecryptedPayload({
...PayloadTimestampDefaults(),
...contextualPayload,
})
void this.services.sync.sync()
const template = CreateDecryptedItemFromPayload(payload)
const item = await this.mutator.insertItem(template)
await this.mutator.changeItem(
item,
(mutator) => {
if (responseItem.clientData) {
const allComponentData = Copy(item.getDomainData(ComponentDataDomain) || {})
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
mutator.setDomainData(allComponentData, ComponentDataDomain)
}
},
MutationType.UpdateUserTimestamps,
PayloadEmitSource.ComponentCreated,
this.component.uuid,
)
processedItems.push(item)
}
void this.syncService.sync()
const reply =
message.action === ComponentAction.CreateItem
? { item: this.jsonForItem(processedItems[0]) }
: {
items: processedItems.map((item) => {
return this.jsonForItem(item)
}),
}
this.replyToMessage(message, reply)
})
const reply =
message.action === ComponentAction.CreateItem
? { item: this.jsonForItem(processedItems[0]) }
: {
items: processedItems.map((item) => {
return this.jsonForItem(item)
}),
}
this.replyToMessage(message, reply)
},
)
}
handleDeleteItemsMessage(message: ComponentMessage): void {
const data = message.data as DeleteItemsMessageData
const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type))
const requiredContentTypes = uniq(items.map((item) => item.content_type)).sort()
const requiredContentTypes = uniqueArray(items.map((item) => item.content_type)).sort()
const requiredPermissions: ComponentPermission[] = [
{
@@ -845,48 +897,60 @@ export class ComponentViewer implements ComponentViewerInterface {
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
const itemsData = items
const noun = itemsData.length === 1 ? 'item' : 'items'
let reply = null
const didConfirm = await this.alertService.confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`)
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
requiredPermissions,
async () => {
const itemsData = items
const noun = itemsData.length === 1 ? 'item' : 'items'
let reply = null
const didConfirm = await this.services.alerts.confirm(
`Are you sure you want to delete ${itemsData.length} ${noun}?`,
)
if (didConfirm) {
/* Filter for any components and deactivate before deleting */
for (const itemData of itemsData) {
const item = this.itemManager.findItem(itemData.uuid)
if (!item) {
void this.alertService.alert('The item you are trying to delete cannot be found.')
continue
if (didConfirm) {
/* Filter for any components and deactivate before deleting */
for (const itemData of itemsData) {
const item = this.services.items.findItem(itemData.uuid)
if (!item) {
void this.services.alerts.alert('The item you are trying to delete cannot be found.')
continue
}
await this.services.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
}
await this.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
void this.services.sync.sync()
reply = { deleted: true }
} else {
/* Rejected by user */
reply = { deleted: false }
}
void this.syncService.sync()
reply = { deleted: true }
} else {
/* Rejected by user */
reply = { deleted: false }
}
this.replyToMessage(message, reply)
})
this.replyToMessage(message, reply)
},
)
}
handleSetComponentDataMessage(message: ComponentMessage): void {
handleSetComponentPreferencesMessage(message: ComponentMessage): void {
const noPermissionsRequired: ComponentPermission[] = []
this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => {
await this.mutator.changeComponent(this.component, (mutator) => {
mutator.componentData = message.data.componentData || {}
})
this.config.componentManagerFunctions.runWithPermissions(
this.componentUniqueIdentifier,
noPermissionsRequired,
async () => {
const newPreferences = <ComponentPreferencesEntry | undefined>message.data.componentData
void this.syncService.sync()
})
if (!newPreferences) {
return
}
await this.config.componentManagerFunctions.setComponentPreferences(this.componentOrFeature, newPreferences)
},
)
}
handleSetSizeEvent(message: ComponentMessage): void {
if (this.component.area !== ComponentArea.EditorStack) {
if (this.componentOrFeature.area !== ComponentArea.EditorStack) {
return
}

View File

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

View File

@@ -1,8 +1,30 @@
import { ComponentArea, ComponentAction, FeatureIdentifier, LegacyFileSafeIdentifier } from '@standardnotes/features'
import {
ComponentArea,
ComponentAction,
FeatureIdentifier,
LegacyFileSafeIdentifier,
ComponentPermission,
} 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,
ComponentAction.CreateItems,
ComponentAction.DeleteItems,
ComponentAction.SetComponentData,
]
export type Writeable<T> = { -readonly [P in keyof T]: T[P] }
/**
* Extensions allowed to batch stream AllowedBatchContentTypes
*/

View File

@@ -0,0 +1,57 @@
import { ContentType } from '@standardnotes/domain-core'
import { ComponentAction, ComponentArea, ComponentPermission } from '@standardnotes/features'
import { ComponentInterface } from '@standardnotes/models'
import { uniqueArray } from '@standardnotes/utils'
export function permissionsStringForPermissions(
permissions: ComponentPermission[],
component: ComponentInterface,
): string {
if (permissions.length === 0) {
return '.'
}
let contentTypeStrings: string[] = []
let contextAreaStrings: string[] = []
permissions.forEach((permission) => {
switch (permission.name) {
case ComponentAction.StreamItems:
if (!permission.content_types) {
return
}
permission.content_types.forEach((contentTypeString: string) => {
const contentTypeOrError = ContentType.create(contentTypeString)
if (contentTypeOrError.isFailed()) {
return
}
const contentType = contentTypeOrError.getValue()
const desc = contentType.getDisplayName()
if (desc) {
contentTypeStrings.push(`${desc}s`)
} else {
contentTypeStrings.push(`items of type ${contentType.value}`)
}
})
break
case ComponentAction.StreamContextItem:
{
const componentAreaMapping = {
[ComponentArea.EditorStack]: 'working note',
[ComponentArea.Editor]: 'working note',
[ComponentArea.Themes]: 'Unknown',
}
contextAreaStrings.push(componentAreaMapping[component.area])
}
break
}
})
contentTypeStrings = uniqueArray(contentTypeStrings)
contextAreaStrings = uniqueArray(contextAreaStrings)
if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) {
return '.'
}
return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.'
}