feat: add snjs package
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
FeatureDescription,
|
||||
FindNativeFeature,
|
||||
FeatureIdentifier,
|
||||
} from '@standardnotes/features'
|
||||
import { DesktopManagerInterface } from '@Lib/Services/ComponentManager/Types'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { GenericItem, SNComponent } from '@standardnotes/models'
|
||||
import { InternalEventBusInterface, Environment, Platform, AlertService } 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'
|
||||
|
||||
describe('featuresService', () => {
|
||||
let itemManager: ItemManager
|
||||
let featureService: SNFeaturesService
|
||||
let alertService: AlertService
|
||||
let syncService: SNSyncService
|
||||
let prefsService: SNPreferencesService
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
|
||||
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() {},
|
||||
getExtServerHost() {
|
||||
return desktopExtHost
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new SNComponentManager(
|
||||
itemManager,
|
||||
syncService,
|
||||
featureService,
|
||||
prefsService,
|
||||
alertService,
|
||||
environment,
|
||||
platform,
|
||||
internalEventBus,
|
||||
)
|
||||
manager.setDesktopManager(desktopManager)
|
||||
return manager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = {} as jest.Mocked<SNSyncService>
|
||||
syncService.sync = jest.fn()
|
||||
|
||||
itemManager = {} as jest.Mocked<ItemManager>
|
||||
itemManager.getItems = jest.fn().mockReturnValue([])
|
||||
itemManager.createItem = jest.fn()
|
||||
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<GenericItem>)
|
||||
itemManager.setItemsToBeDeleted = jest.fn()
|
||||
itemManager.addObserver = jest.fn()
|
||||
itemManager.changeItem = jest.fn()
|
||||
itemManager.changeFeatureRepo = jest.fn()
|
||||
|
||||
featureService = {} as jest.Mocked<SNFeaturesService>
|
||||
|
||||
prefsService = {} as jest.Mocked<SNPreferencesService>
|
||||
|
||||
alertService = {} as jest.Mocked<AlertService>
|
||||
alertService.confirm = jest.fn()
|
||||
alertService.alert = jest.fn()
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publish = jest.fn()
|
||||
})
|
||||
|
||||
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {
|
||||
return new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.Component,
|
||||
content: {
|
||||
package_info: {
|
||||
hosted_url: 'https://example.com/component',
|
||||
identifier: identifier || FeatureIdentifier.PlusEditor,
|
||||
file_type: file_type ?? 'html',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
const deprecatedComponent = () => {
|
||||
return new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.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.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)
|
||||
}
|
||||
|
||||
describe('permissions', () => {
|
||||
it('editor should be able to to stream single note', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamContextItem,
|
||||
content_types: [ContentType.Note],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownVisualEditor), permissions),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple notes', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.Note],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple tags', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.Tag],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple notes or tags', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.Tag, ContentType.Note],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
})
|
||||
|
||||
it('some valid and some invalid permissions should still return invalid permissions', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [ContentType.Tag, ContentType.FilesafeFileMetadata],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('filesafe should be able to stream its files', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.FilesafeFileMetadata,
|
||||
ContentType.FilesafeCredentials,
|
||||
ContentType.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
it('bold editor should be able to stream filesafe files', () => {
|
||||
const permissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.FilesafeFileMetadata,
|
||||
ContentType.FilesafeCredentials,
|
||||
ContentType.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(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.FilesafeFileMetadata,
|
||||
ContentType.FilesafeCredentials,
|
||||
ContentType.FilesafeIntegration,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlForComponent', () => {
|
||||
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}`)
|
||||
})
|
||||
|
||||
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}`)
|
||||
})
|
||||
|
||||
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`)
|
||||
})
|
||||
|
||||
it('returns hosted url for third party component with no local_url', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const component = new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.Component,
|
||||
content: {
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const url = manager.urlForComponent(component)
|
||||
expect(url).toEqual('https://example.com/component')
|
||||
})
|
||||
})
|
||||
|
||||
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}`)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor change alert', () => {
|
||||
it('should not require alert switching from plain editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeComponent()
|
||||
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 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 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 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 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 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 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 requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
673
packages/snjs/lib/Services/ComponentManager/ComponentManager.ts
Normal file
673
packages/snjs/lib/Services/ComponentManager/ComponentManager.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
import { AllowedBatchStreaming } from './Types'
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
|
||||
import { ContentType, DisplayStringForContentType } from '@standardnotes/common'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { SNNote, SNTheme, SNComponent, ComponentMutator, PayloadEmitSource } from '@standardnotes/models'
|
||||
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
|
||||
import find from 'lodash/find'
|
||||
import uniq from 'lodash/uniq'
|
||||
import { ComponentArea, ComponentAction, ComponentPermission, FindNativeFeature } from '@standardnotes/features'
|
||||
import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import {
|
||||
PermissionDialog,
|
||||
DesktopManagerInterface,
|
||||
AllowedBatchContentTypes,
|
||||
} from '@Lib/Services/ComponentManager/Types'
|
||||
import { ActionObserver, ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer'
|
||||
import {
|
||||
AbstractService,
|
||||
InternalEventBusInterface,
|
||||
Environment,
|
||||
Platform,
|
||||
AlertService,
|
||||
} 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'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** IE Handlers */
|
||||
attachEvent(event: string, listener: EventListener): boolean
|
||||
detachEvent(event: string, listener: EventListener): void
|
||||
}
|
||||
}
|
||||
|
||||
export enum ComponentManagerEvent {
|
||||
ViewerDidFocus = 'ViewerDidFocus',
|
||||
}
|
||||
|
||||
export type EventData = {
|
||||
componentViewer?: ComponentViewer
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for orchestrating component functionality, including editors, themes,
|
||||
* and other components. The component manager primarily deals with iframes, and orchestrates
|
||||
* sending and receiving messages to and from frames via the postMessage API.
|
||||
*/
|
||||
export class SNComponentManager extends AbstractService<ComponentManagerEvent, EventData> {
|
||||
private desktopManager?: DesktopManagerInterface
|
||||
private viewers: ComponentViewer[] = []
|
||||
private removeItemObserver!: () => void
|
||||
private permissionDialogs: PermissionDialog[] = []
|
||||
|
||||
constructor(
|
||||
private itemManager: ItemManager,
|
||||
private syncService: SNSyncService,
|
||||
private featuresService: SNFeaturesService,
|
||||
private preferencesSerivce: SNPreferencesService,
|
||||
protected alertService: AlertService,
|
||||
private environment: Environment,
|
||||
private platform: Platform,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
this.loggingEnabled = false
|
||||
|
||||
this.addItemObserver()
|
||||
|
||||
/* On mobile, events listeners are handled by a respective component */
|
||||
if (environment !== Environment.Mobile) {
|
||||
window.addEventListener
|
||||
? window.addEventListener('focus', this.detectFocusChange, true)
|
||||
: window.attachEvent('onfocusout', this.detectFocusChange)
|
||||
window.addEventListener
|
||||
? window.addEventListener('blur', this.detectFocusChange, true)
|
||||
: window.attachEvent('onblur', this.detectFocusChange)
|
||||
|
||||
window.addEventListener('message', this.onWindowMessage, true)
|
||||
}
|
||||
}
|
||||
|
||||
get isDesktop(): boolean {
|
||||
return this.environment === Environment.Desktop
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.environment === Environment.Mobile
|
||||
}
|
||||
|
||||
get components(): SNComponent[] {
|
||||
return this.itemManager.getDisplayableComponents()
|
||||
}
|
||||
|
||||
componentsForArea(area: ComponentArea): SNComponent[] {
|
||||
return this.components.filter((component) => {
|
||||
return component.area === area
|
||||
})
|
||||
}
|
||||
|
||||
override deinit(): void {
|
||||
super.deinit()
|
||||
|
||||
for (const viewer of this.viewers) {
|
||||
viewer.destroy()
|
||||
}
|
||||
|
||||
this.viewers.length = 0
|
||||
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.removeItemObserver?.()
|
||||
;(this.removeItemObserver as unknown) = undefined
|
||||
|
||||
if (window && !this.isMobile) {
|
||||
window.removeEventListener('focus', this.detectFocusChange, true)
|
||||
window.removeEventListener('blur', this.detectFocusChange, true)
|
||||
window.removeEventListener('message', this.onWindowMessage, true)
|
||||
}
|
||||
|
||||
;(this.detectFocusChange as unknown) = undefined
|
||||
;(this.onWindowMessage as unknown) = undefined
|
||||
}
|
||||
|
||||
public createComponentViewer(
|
||||
component: SNComponent,
|
||||
contextItem?: UuidString,
|
||||
actionObserver?: ActionObserver,
|
||||
urlOverride?: string,
|
||||
): ComponentViewer {
|
||||
const viewer = new ComponentViewer(
|
||||
component,
|
||||
this.itemManager,
|
||||
this.syncService,
|
||||
this.alertService,
|
||||
this.preferencesSerivce,
|
||||
this.featuresService,
|
||||
this.environment,
|
||||
this.platform,
|
||||
{
|
||||
runWithPermissions: this.runWithPermissions.bind(this),
|
||||
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
|
||||
},
|
||||
urlOverride || this.urlForComponent(component),
|
||||
contextItem,
|
||||
actionObserver,
|
||||
)
|
||||
this.viewers.push(viewer)
|
||||
return viewer
|
||||
}
|
||||
|
||||
public destroyComponentViewer(viewer: ComponentViewer): void {
|
||||
viewer.destroy()
|
||||
removeFromArray(this.viewers, viewer)
|
||||
}
|
||||
|
||||
setDesktopManager(desktopManager: DesktopManagerInterface): void {
|
||||
this.desktopManager = desktopManager
|
||||
this.configureForDesktop()
|
||||
}
|
||||
|
||||
handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void {
|
||||
const acceptableSources = [
|
||||
PayloadEmitSource.LocalChanged,
|
||||
PayloadEmitSource.RemoteRetrieved,
|
||||
PayloadEmitSource.LocalDatabaseLoaded,
|
||||
PayloadEmitSource.LocalInserted,
|
||||
]
|
||||
|
||||
if (components.length === 0 || !acceptableSources.includes(source)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isDesktop) {
|
||||
const thirdPartyComponents = components.filter((component) => {
|
||||
const nativeFeature = FindNativeFeature(component.identifier)
|
||||
return nativeFeature ? false : true
|
||||
})
|
||||
if (thirdPartyComponents.length > 0) {
|
||||
this.desktopManager?.syncComponentsInstallation(thirdPartyComponents)
|
||||
}
|
||||
}
|
||||
|
||||
const themes = components.filter((c) => c.isTheme())
|
||||
if (themes.length > 0) {
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
}
|
||||
|
||||
addItemObserver(): void {
|
||||
this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
|
||||
[ContentType.Component, ContentType.Theme],
|
||||
({ changed, inserted, source }) => {
|
||||
const items = [...changed, ...inserted]
|
||||
this.handleChangedComponents(items, source)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
detectFocusChange = (): void => {
|
||||
const activeIframes = this.allComponentIframes()
|
||||
for (const iframe of activeIframes) {
|
||||
if (document.activeElement === iframe) {
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const viewer = this.findComponentViewer(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
iframe.dataset.componentViewerId!,
|
||||
)!
|
||||
void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, {
|
||||
componentViewer: viewer,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onWindowMessage = (event: MessageEvent): void => {
|
||||
/** Make sure this message is for us */
|
||||
if (event.data.sessionKey) {
|
||||
this.log('Component manager received message', event.data)
|
||||
this.componentViewerForSessionKey(event.data.sessionKey)?.handleMessage(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
configureForDesktop(): void {
|
||||
this.desktopManager?.registerUpdateObserver((component: SNComponent) => {
|
||||
/* Reload theme if active */
|
||||
if (component.active && component.isTheme()) {
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
postActiveThemesToAllViewers(): void {
|
||||
for (const viewer of this.viewers) {
|
||||
viewer.postActiveThemes()
|
||||
}
|
||||
}
|
||||
|
||||
getActiveThemes(): SNTheme[] {
|
||||
if (this.environment === Environment.Mobile) {
|
||||
throw Error('getActiveThemes must be handled separately by mobile')
|
||||
}
|
||||
return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
|
||||
return theme.active
|
||||
}) as SNTheme[]
|
||||
}
|
||||
|
||||
urlForComponent(component: SNComponent): string | undefined {
|
||||
const platformSupportsOfflineOnly = this.isDesktop
|
||||
if (component.offlineOnly && !platformSupportsOfflineOnly) {
|
||||
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 isWeb = this.environment === Environment.Web
|
||||
if (nativeFeature) {
|
||||
if (!isWeb) {
|
||||
throw Error('Mobile must override urlForComponent to handle native paths')
|
||||
}
|
||||
return `${window.location.origin}/components/assets/${component.identifier}/${nativeFeature.index_path}`
|
||||
}
|
||||
|
||||
let url = component.hosted_url || component.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
|
||||
}
|
||||
|
||||
urlsForActiveThemes(): string[] {
|
||||
const themes = this.getActiveThemes()
|
||||
const urls = []
|
||||
for (const theme of themes) {
|
||||
const url = this.urlForComponent(theme)
|
||||
if (url) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
private findComponent(uuid: UuidString): SNComponent | undefined {
|
||||
return this.itemManager.findItem<SNComponent>(uuid)
|
||||
}
|
||||
|
||||
findComponentViewer(identifier: string): ComponentViewer | undefined {
|
||||
return this.viewers.find((viewer) => viewer.identifier === identifier)
|
||||
}
|
||||
|
||||
componentViewerForSessionKey(key: string): ComponentViewer | undefined {
|
||||
return this.viewers.find((viewer) => viewer.sessionKey === key)
|
||||
}
|
||||
|
||||
areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean {
|
||||
for (const permission of permissions) {
|
||||
if (permission.name === ComponentAction.StreamItems) {
|
||||
if (!AllowedBatchStreaming.includes(component.identifier)) {
|
||||
return false
|
||||
}
|
||||
const hasNonAllowedBatchPermission = permission.content_types?.some(
|
||||
(type) => !AllowedBatchContentTypes.includes(type),
|
||||
)
|
||||
if (hasNonAllowedBatchPermission) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
runWithPermissions(
|
||||
componentUuid: UuidString,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
): void {
|
||||
const component = this.findComponent(componentUuid)
|
||||
|
||||
if (!component) {
|
||||
void this.alertService.alert(
|
||||
`Unable to find component with ID ${componentUuid}. 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)
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(component.identifier)
|
||||
const acquiredPermissions = nativeFeature?.component_permissions || component.permissions
|
||||
|
||||
/* Make copy as not to mutate input values */
|
||||
requiredPermissions = Copy(requiredPermissions) as ComponentPermission[]
|
||||
for (const required of requiredPermissions.slice()) {
|
||||
/* Remove anything we already have */
|
||||
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
|
||||
if (!respectiveAcquired) {
|
||||
continue
|
||||
}
|
||||
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
|
||||
const requiredContentTypes = required.content_types
|
||||
if (!requiredContentTypes) {
|
||||
/* If this permission does not require any content types (i.e stream-context-item)
|
||||
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
|
||||
filterFromArray(requiredPermissions, required)
|
||||
continue
|
||||
}
|
||||
for (const acquiredContentType of respectiveAcquired.content_types!) {
|
||||
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.promptForPermissionsWithAngularAsyncRendering(
|
||||
component,
|
||||
requiredPermissions,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (approved) => {
|
||||
if (approved) {
|
||||
runFunction()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
runFunction()
|
||||
}
|
||||
}
|
||||
|
||||
promptForPermissionsWithAngularAsyncRendering(
|
||||
component: SNComponent,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
setTimeout(() => {
|
||||
this.promptForPermissions(component, permissions, callback)
|
||||
})
|
||||
}
|
||||
|
||||
promptForPermissions(
|
||||
component: SNComponent,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
const params: PermissionDialog = {
|
||||
component: component,
|
||||
permissions: permissions,
|
||||
permissionsString: this.permissionsStringForPermissions(permissions, component),
|
||||
actionBlock: callback,
|
||||
callback: async (approved: boolean) => {
|
||||
const latestComponent = this.findComponent(component.uuid)
|
||||
|
||||
if (!latestComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
this.log('Changing component to expand permissions', component)
|
||||
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
|
||||
for (const permission of permissions) {
|
||||
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
|
||||
if (!matchingPermission) {
|
||||
componentPermissions.push(permission)
|
||||
} else {
|
||||
/* Permission already exists, but content_types may have been expanded */
|
||||
const contentTypes = matchingPermission.content_types || []
|
||||
matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!))
|
||||
}
|
||||
}
|
||||
|
||||
await this.itemManager.changeItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.permissions = componentPermissions
|
||||
})
|
||||
|
||||
void this.syncService.sync()
|
||||
}
|
||||
|
||||
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
|
||||
/* Remove self */
|
||||
if (pendingDialog === params) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
return false
|
||||
}
|
||||
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
|
||||
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
|
||||
}
|
||||
if (pendingDialog.component === component) {
|
||||
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
|
||||
if (
|
||||
pendingDialog.permissions === permissions ||
|
||||
containsObjectSubset(permissions, pendingDialog.permissions)
|
||||
) {
|
||||
/* If approved, run the action block. Otherwise, if canceled, cancel any
|
||||
pending ones as well, since the user was explicit in their intentions */
|
||||
if (approved) {
|
||||
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (this.permissionDialogs.length > 0) {
|
||||
this.presentPermissionsDialog(this.permissionDialogs[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
/**
|
||||
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
|
||||
* We only want to present one and trigger all callbacks based on one modal result
|
||||
*/
|
||||
const existingDialog = find(this.permissionDialogs, {
|
||||
component: component,
|
||||
})
|
||||
this.permissionDialogs.push(params)
|
||||
if (!existingDialog) {
|
||||
this.presentPermissionsDialog(params)
|
||||
} else {
|
||||
this.log('Existing dialog, not presenting.')
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
presentPermissionsDialog(_dialog: PermissionDialog): void {
|
||||
throw 'Must override SNComponentManager.presentPermissionsDialog'
|
||||
}
|
||||
|
||||
async toggleTheme(uuid: UuidString): Promise<void> {
|
||||
this.log('Toggling theme', uuid)
|
||||
|
||||
const theme = this.findComponent(uuid) as SNTheme
|
||||
if (theme.active) {
|
||||
await this.itemManager.changeComponent(theme, (mutator) => {
|
||||
mutator.active = false
|
||||
})
|
||||
} else {
|
||||
const activeThemes = this.getActiveThemes()
|
||||
|
||||
/* Activate current before deactivating others, so as not to flicker */
|
||||
await this.itemManager.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.itemManager.changeComponent(candidate, (mutator) => {
|
||||
mutator.active = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async toggleComponent(uuid: UuidString): Promise<void> {
|
||||
this.log('Toggling component', uuid)
|
||||
|
||||
const component = this.findComponent(uuid)
|
||||
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.itemManager.changeComponent(component, (mutator) => {
|
||||
mutator.active = !(mutator.getItem() as SNComponent).active
|
||||
})
|
||||
}
|
||||
|
||||
isComponentActive(component: SNComponent): boolean {
|
||||
return component.active
|
||||
}
|
||||
|
||||
allComponentIframes(): HTMLIFrameElement[] {
|
||||
if (this.isMobile) {
|
||||
/**
|
||||
* Retrieving all iframes is typically related to lifecycle management of
|
||||
* non-editor components. So this function is not useful to mobile.
|
||||
*/
|
||||
return []
|
||||
}
|
||||
return Array.from(document.getElementsByTagName('iframe'))
|
||||
}
|
||||
|
||||
iframeForComponentViewer(viewer: ComponentViewer): HTMLIFrameElement | undefined {
|
||||
return viewer.getIframe()
|
||||
}
|
||||
|
||||
editorForNote(note: SNNote): SNComponent | undefined {
|
||||
const editors = this.componentsForArea(ComponentArea.Editor)
|
||||
for (const editor of editors) {
|
||||
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
return editor
|
||||
}
|
||||
}
|
||||
let defaultEditor
|
||||
/* No editor found for note. Use default editor, if note does not prefer system editor */
|
||||
if (this.isMobile) {
|
||||
if (!note.mobilePrefersPlainEditor) {
|
||||
defaultEditor = this.getDefaultEditor()
|
||||
}
|
||||
} else {
|
||||
if (!note.prefersPlainEditor) {
|
||||
defaultEditor = this.getDefaultEditor()
|
||||
}
|
||||
}
|
||||
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
|
||||
return defaultEditor
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultEditor(): SNComponent {
|
||||
const editors = this.componentsForArea(ComponentArea.Editor)
|
||||
if (this.isMobile) {
|
||||
return editors.filter((e) => {
|
||||
return e.isMobileDefault
|
||||
})[0]
|
||||
} else {
|
||||
return editors.filter((e) => e.isDefaultEditor())[0]
|
||||
}
|
||||
}
|
||||
|
||||
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): 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((contentType) => {
|
||||
const desc = DisplayStringForContentType(contentType)
|
||||
if (desc) {
|
||||
contentTypeStrings.push(`${desc}s`)
|
||||
} else {
|
||||
contentTypeStrings.push(`items of type ${contentType}`)
|
||||
}
|
||||
})
|
||||
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) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async showEditorChangeAlert(): Promise<boolean> {
|
||||
const shouldChangeEditor = await this.alertService.confirm(
|
||||
'Doing so might result in minor formatting changes.',
|
||||
"Are you sure you want to change this note's type?",
|
||||
'Yes, change it',
|
||||
)
|
||||
|
||||
return shouldChangeEditor
|
||||
}
|
||||
}
|
||||
913
packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts
Normal file
913
packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts
Normal file
@@ -0,0 +1,913 @@
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import { FeatureStatus, FeaturesEvent } from '@Lib/Services/Features'
|
||||
import { Environment, Platform, AlertService } from '@standardnotes/services'
|
||||
import { SNFeaturesService } from '@Lib/Services'
|
||||
import {
|
||||
SNComponent,
|
||||
PrefKey,
|
||||
NoteContent,
|
||||
MutationType,
|
||||
CreateDecryptedItemFromPayload,
|
||||
DecryptedItemInterface,
|
||||
DeletedItemInterface,
|
||||
EncryptedItemInterface,
|
||||
isDecryptedItem,
|
||||
isNotEncryptedItem,
|
||||
isNote,
|
||||
CreateComponentRetrievedContextPayload,
|
||||
createComponentCreatedContextPayload,
|
||||
DecryptedPayload,
|
||||
ItemContent,
|
||||
ComponentDataDomain,
|
||||
PayloadEmitSource,
|
||||
PayloadTimestampDefaults,
|
||||
} 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 {
|
||||
ComponentMessage,
|
||||
OutgoingItemMessagePayload,
|
||||
MessageReply,
|
||||
StreamItemsMessageData,
|
||||
AllowedBatchContentTypes,
|
||||
IncomingComponentItemPayload,
|
||||
DeleteItemsMessageData,
|
||||
MessageReplyData,
|
||||
} from './Types'
|
||||
import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import {
|
||||
isString,
|
||||
extendArray,
|
||||
Copy,
|
||||
removeFromArray,
|
||||
log,
|
||||
nonSecureRandomIdentifier,
|
||||
UuidGenerator,
|
||||
Uuids,
|
||||
sureSearchArray,
|
||||
isNotUndefined,
|
||||
} from '@standardnotes/utils'
|
||||
import { MessageData } from '..'
|
||||
|
||||
type RunWithPermissionsCallback = (
|
||||
componentUuid: UuidString,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
) => void
|
||||
|
||||
type ComponentManagerFunctions = {
|
||||
runWithPermissions: RunWithPermissionsCallback
|
||||
urlsForActiveThemes: () => string[]
|
||||
}
|
||||
|
||||
const ReadwriteActions = [
|
||||
ComponentAction.SaveItems,
|
||||
ComponentAction.AssociateItem,
|
||||
ComponentAction.DeassociateItem,
|
||||
ComponentAction.CreateItem,
|
||||
ComponentAction.CreateItems,
|
||||
ComponentAction.DeleteItems,
|
||||
ComponentAction.SetComponentData,
|
||||
]
|
||||
|
||||
export type ActionObserver = (action: ComponentAction, messageData: MessageData) => void
|
||||
|
||||
export enum ComponentViewerEvent {
|
||||
FeatureStatusUpdated = 'FeatureStatusUpdated',
|
||||
}
|
||||
type EventObserver = (event: ComponentViewerEvent) => void
|
||||
|
||||
export enum ComponentViewerError {
|
||||
OfflineRestricted = 'OfflineRestricted',
|
||||
MissingUrl = 'MissingUrl',
|
||||
}
|
||||
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
|
||||
|
||||
export class ComponentViewer {
|
||||
private streamItems?: ContentType[]
|
||||
private streamContextItemOriginalMessage?: ComponentMessage
|
||||
private streamItemsOriginalMessage?: ComponentMessage
|
||||
private removeItemObserver: () => void
|
||||
private loggingEnabled = false
|
||||
public identifier = nonSecureRandomIdentifier()
|
||||
private actionObservers: ActionObserver[] = []
|
||||
public overrideContextItem?: DecryptedItemInterface
|
||||
private featureStatus: FeatureStatus
|
||||
private removeFeaturesObserver: () => void
|
||||
private eventObservers: EventObserver[] = []
|
||||
private dealloced = false
|
||||
|
||||
private window?: Window
|
||||
private hidden = false
|
||||
private readonly = false
|
||||
public lockReadonly = false
|
||||
public sessionKey?: string
|
||||
|
||||
constructor(
|
||||
public readonly component: SNComponent,
|
||||
private itemManager: ItemManager,
|
||||
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,
|
||||
) {
|
||||
this.removeItemObserver = this.itemManager.addObserver(
|
||||
ContentType.Any,
|
||||
({ changed, inserted, removed, source, sourceKey }) => {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
const items = [...changed, ...inserted, ...removed]
|
||||
this.handleChangesInItems(items, source, sourceKey)
|
||||
},
|
||||
)
|
||||
if (actionObserver) {
|
||||
this.actionObservers.push(actionObserver)
|
||||
}
|
||||
|
||||
this.featureStatus = featuresService.getFeatureStatus(component.identifier)
|
||||
|
||||
this.removeFeaturesObserver = featuresService.addEventObserver((event) => {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
if (event === FeaturesEvent.FeaturesUpdated) {
|
||||
const featureStatus = featuresService.getFeatureStatus(component.identifier)
|
||||
|
||||
if (featureStatus !== this.featureStatus) {
|
||||
this.featureStatus = featureStatus
|
||||
this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.log('Constructor', this)
|
||||
}
|
||||
|
||||
get isDesktop(): boolean {
|
||||
return this.environment === Environment.Desktop
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.environment === Environment.Mobile
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.log('Destroying', this)
|
||||
this.deinit()
|
||||
}
|
||||
|
||||
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.eventObservers.length = 0
|
||||
this.actionObservers.length = 0
|
||||
|
||||
this.removeFeaturesObserver()
|
||||
;(this.removeFeaturesObserver as unknown) = undefined
|
||||
|
||||
this.removeItemObserver()
|
||||
;(this.removeItemObserver as unknown) = undefined
|
||||
}
|
||||
|
||||
public addEventObserver(observer: EventObserver): () => void {
|
||||
this.eventObservers.push(observer)
|
||||
|
||||
const thislessChangeObservers = this.eventObservers
|
||||
return () => {
|
||||
removeFromArray(thislessChangeObservers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyEventObservers(event: ComponentViewerEvent): void {
|
||||
for (const observer of this.eventObservers) {
|
||||
observer(event)
|
||||
}
|
||||
}
|
||||
|
||||
public addActionObserver(observer: ActionObserver): () => void {
|
||||
this.actionObservers.push(observer)
|
||||
|
||||
const thislessChangeObservers = this.actionObservers
|
||||
return () => {
|
||||
removeFromArray(thislessChangeObservers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
public setReadonly(readonly: boolean): void {
|
||||
if (this.lockReadonly) {
|
||||
throw Error('Attempting to set readonly on lockedReadonly component viewer')
|
||||
}
|
||||
this.readonly = readonly
|
||||
}
|
||||
|
||||
get componentUuid(): string {
|
||||
return this.component.uuid
|
||||
}
|
||||
|
||||
public getFeatureStatus(): FeatureStatus {
|
||||
return this.featureStatus
|
||||
}
|
||||
|
||||
private isOfflineRestricted(): boolean {
|
||||
return this.component.offlineOnly && !this.isDesktop
|
||||
}
|
||||
|
||||
private isNativeFeature(): boolean {
|
||||
return !!FindNativeFeature(this.component.identifier)
|
||||
}
|
||||
|
||||
private hasUrlError(): boolean {
|
||||
if (this.isNativeFeature()) {
|
||||
return false
|
||||
}
|
||||
return this.isDesktop
|
||||
? !this.component.local_url && !this.component.hasValidHostedUrl()
|
||||
: !this.component.hasValidHostedUrl()
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
return this.getError() == undefined
|
||||
}
|
||||
|
||||
public getError(): ComponentViewerError | undefined {
|
||||
if (this.isOfflineRestricted()) {
|
||||
return ComponentViewerError.OfflineRestricted
|
||||
}
|
||||
if (this.hasUrlError()) {
|
||||
return ComponentViewerError.MissingUrl
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
handleChangesInItems(
|
||||
items: (DecryptedItemInterface | DeletedItemInterface | EncryptedItemInterface)[],
|
||||
source: PayloadEmitSource,
|
||||
sourceKey?: string,
|
||||
): void {
|
||||
const nonencryptedItems = items.filter(isNotEncryptedItem)
|
||||
const nondeletedItems = nonencryptedItems.filter(isDecryptedItem)
|
||||
|
||||
this.updateOurComponentRefFromChangedItems(nondeletedItems)
|
||||
|
||||
const areWeOriginator = sourceKey && sourceKey === this.component.uuid
|
||||
if (areWeOriginator) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.streamItems) {
|
||||
const relevantItems = nonencryptedItems.filter((item) => {
|
||||
return this.streamItems?.includes(item.content_type)
|
||||
})
|
||||
|
||||
if (relevantItems.length > 0) {
|
||||
this.sendManyItemsThroughBridge(relevantItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.streamContextItemOriginalMessage) {
|
||||
const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid })
|
||||
if (matchingItem) {
|
||||
this.sendContextItemThroughBridge(matchingItem, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendManyItemsThroughBridge(items: (DecryptedItemInterface | DeletedItemInterface)[]): void {
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: this.streamItems!.sort(),
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
|
||||
this.sendItemsInReply(items, this.streamItemsOriginalMessage!)
|
||||
})
|
||||
}
|
||||
|
||||
sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void {
|
||||
const requiredContextPermissions = [
|
||||
{
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
private log(message: string, ...args: unknown[]): void {
|
||||
if (this.loggingEnabled) {
|
||||
log('ComponentViewer', message, args)
|
||||
}
|
||||
}
|
||||
|
||||
private sendItemsInReply(
|
||||
items: (DecryptedItemInterface | DeletedItemInterface)[],
|
||||
message: ComponentMessage,
|
||||
source?: PayloadEmitSource,
|
||||
): void {
|
||||
this.log('Send items in reply', this.component, items, message)
|
||||
|
||||
const responseData: MessageReplyData = {}
|
||||
|
||||
const mapped = items.map((item) => {
|
||||
return this.jsonForItem(item, source)
|
||||
})
|
||||
|
||||
responseData.items = mapped
|
||||
|
||||
this.replyToMessage(message, responseData)
|
||||
}
|
||||
|
||||
private jsonForItem(
|
||||
item: DecryptedItemInterface | DeletedItemInterface,
|
||||
source?: PayloadEmitSource,
|
||||
): OutgoingItemMessagePayload {
|
||||
const isMetadatUpdate =
|
||||
source === PayloadEmitSource.RemoteSaved ||
|
||||
source === PayloadEmitSource.OfflineSyncSaved ||
|
||||
source === PayloadEmitSource.PreSyncSave
|
||||
|
||||
const params: OutgoingItemMessagePayload = {
|
||||
uuid: item.uuid,
|
||||
content_type: item.content_type,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.serverUpdatedAt,
|
||||
isMetadataUpdate: isMetadatUpdate,
|
||||
}
|
||||
|
||||
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>
|
||||
} else {
|
||||
params.deleted = true
|
||||
}
|
||||
|
||||
return this.responseItemsByRemovingPrivateProperties([params])[0]
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
...content,
|
||||
spellcheck,
|
||||
} as NoteContent
|
||||
}
|
||||
|
||||
return item.content
|
||||
}
|
||||
|
||||
private replyToMessage(originalMessage: ComponentMessage, replyData: MessageReplyData): void {
|
||||
const reply: MessageReply = {
|
||||
action: ComponentAction.Reply,
|
||||
original: originalMessage,
|
||||
data: replyData,
|
||||
}
|
||||
this.sendMessage(reply)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param essential If the message is non-essential, no alert will be shown
|
||||
* if we can no longer find the window.
|
||||
*/
|
||||
sendMessage(message: ComponentMessage | MessageReply, essential = true): void {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.window && message.action === ComponentAction.Reply) {
|
||||
this.log('Component has been deallocated in between message send and reply', this.component, message)
|
||||
return
|
||||
}
|
||||
this.log('Send message to component', this.component, 'message: ', message)
|
||||
|
||||
let origin = this.url
|
||||
if (!origin || !this.window) {
|
||||
if (essential) {
|
||||
void this.alertService.alert(
|
||||
`Standard Notes is trying to communicate with ${this.component.name}, ` +
|
||||
'but an error is occurring. Please restart this extension and try again.',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!origin.startsWith('http') && !origin.startsWith('file')) {
|
||||
/* Native extension running in web, prefix current host */
|
||||
origin = window.location.href + origin
|
||||
}
|
||||
|
||||
/* Mobile messaging requires json */
|
||||
this.window.postMessage(this.isMobile ? JSON.stringify(message) : message, origin)
|
||||
}
|
||||
|
||||
private responseItemsByRemovingPrivateProperties<T extends OutgoingItemMessagePayload | IncomingComponentItemPayload>(
|
||||
responseItems: T[],
|
||||
removeUrls = false,
|
||||
): T[] {
|
||||
/* Don't allow component to overwrite these properties. */
|
||||
let privateContentProperties = ['autoupdateDisabled', 'permissions', 'active']
|
||||
if (removeUrls) {
|
||||
privateContentProperties = privateContentProperties.concat(['hosted_url', 'local_url'])
|
||||
}
|
||||
|
||||
return responseItems.map((responseItem) => {
|
||||
const privateProperties = privateContentProperties.slice()
|
||||
/** Server extensions are allowed to modify url property */
|
||||
if (removeUrls) {
|
||||
privateProperties.push('url')
|
||||
}
|
||||
if (!responseItem.content || isString(responseItem.content)) {
|
||||
return responseItem
|
||||
}
|
||||
|
||||
let content: Partial<ItemContent> = {}
|
||||
for (const [key, value] of Object.entries(responseItem.content)) {
|
||||
if (!privateProperties.includes(key)) {
|
||||
content = {
|
||||
...content,
|
||||
[key]: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...responseItem,
|
||||
content: content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getWindow(): Window | undefined {
|
||||
return this.window
|
||||
}
|
||||
|
||||
/** Called by client when the iframe is ready */
|
||||
public setWindow(window: Window): void {
|
||||
if (this.window) {
|
||||
throw Error('Attempting to override component viewer window. Create a new component viewer instead.')
|
||||
}
|
||||
|
||||
this.log('setWindow', 'component: ', this.component, 'window: ', window)
|
||||
|
||||
this.window = window
|
||||
this.sessionKey = UuidGenerator.GenerateUuid()
|
||||
|
||||
this.sendMessage({
|
||||
action: ComponentAction.ComponentRegistered,
|
||||
sessionKey: this.sessionKey,
|
||||
componentData: this.component.componentData,
|
||||
data: {
|
||||
uuid: this.component.uuid,
|
||||
environment: environmentToString(this.environment),
|
||||
platform: platformToString(this.platform),
|
||||
activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(),
|
||||
},
|
||||
})
|
||||
|
||||
this.log('setWindow got new sessionKey', this.sessionKey)
|
||||
|
||||
this.postActiveThemes()
|
||||
}
|
||||
|
||||
postActiveThemes(): void {
|
||||
const urls = this.componentManagerFunctions.urlsForActiveThemes()
|
||||
const data: MessageData = {
|
||||
themes: urls,
|
||||
}
|
||||
|
||||
const message: ComponentMessage = {
|
||||
action: ComponentAction.ActivateThemes,
|
||||
data: data,
|
||||
}
|
||||
|
||||
this.sendMessage(message, false)
|
||||
}
|
||||
|
||||
/* A hidden component will not receive messages. However, when a component is unhidden,
|
||||
* we need to send it any items it may have registered streaming for. */
|
||||
public setHidden(hidden: boolean): void {
|
||||
if (hidden) {
|
||||
this.hidden = true
|
||||
} else if (this.hidden) {
|
||||
this.hidden = false
|
||||
|
||||
if (this.streamContextItemOriginalMessage) {
|
||||
this.handleStreamContextItemMessage(this.streamContextItemOriginalMessage)
|
||||
}
|
||||
|
||||
if (this.streamItems) {
|
||||
this.handleStreamItemsMessage(this.streamItemsOriginalMessage!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message: ComponentMessage): void {
|
||||
this.log('Handle message', message, this)
|
||||
if (!this.component) {
|
||||
this.log('Component not defined for message, returning', message)
|
||||
void this.alertService.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.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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.DeleteItems]: this.handleDeleteItemsMessage.bind(this),
|
||||
[ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this),
|
||||
[ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this),
|
||||
[ComponentAction.SaveItems]: this.handleSaveItemsMessage.bind(this),
|
||||
[ComponentAction.SetSize]: this.handleSetSizeEvent.bind(this),
|
||||
}
|
||||
|
||||
const handler = messageHandlers[message.action]
|
||||
handler?.(message)
|
||||
|
||||
for (const observer of this.actionObservers) {
|
||||
observer(message.action, message.data)
|
||||
}
|
||||
}
|
||||
|
||||
handleStreamItemsMessage(message: ComponentMessage): void {
|
||||
const data = message.data as StreamItemsMessageData
|
||||
const types = data.content_types.filter((type) => AllowedBatchContentTypes.includes(type)).sort()
|
||||
const requiredPermissions = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
handleStreamContextItemMessage(message: ComponentMessage): void {
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamContextItem,
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save items is capable of saving existing items, and also creating new ones
|
||||
* if they don't exist.
|
||||
*/
|
||||
handleSaveItemsMessage(message: ComponentMessage): void {
|
||||
let responsePayloads = message.data.items as IncomingComponentItemPayload[]
|
||||
const requiredPermissions = []
|
||||
|
||||
/* Pending as in needed to be accounted for in permissions. */
|
||||
const pendingResponseItems = responsePayloads.slice()
|
||||
|
||||
for (const responseItem of responsePayloads.slice()) {
|
||||
if (responseItem.uuid === this.contextItemUuid) {
|
||||
requiredPermissions.push({
|
||||
name: ComponentAction.StreamContextItem,
|
||||
})
|
||||
removeFromArray(pendingResponseItems, responseItem)
|
||||
/* We break because there can only be one context item */
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* Check to see if additional privileges are required */
|
||||
if (pendingResponseItems.length > 0) {
|
||||
const requiredContentTypes = uniq(
|
||||
pendingResponseItems.map((item) => {
|
||||
return item.content_type
|
||||
}),
|
||||
).sort()
|
||||
|
||||
requiredPermissions.push({
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: requiredContentTypes,
|
||||
} as ComponentPermission)
|
||||
}
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(
|
||||
this.component.uuid,
|
||||
requiredPermissions,
|
||||
|
||||
async () => {
|
||||
responsePayloads = this.responseItemsByRemovingPrivateProperties(responsePayloads, true)
|
||||
|
||||
/* Filter locked items */
|
||||
const uuids = Uuids(responsePayloads)
|
||||
const items = this.itemManager.findItemsIncludingBlanks(uuids)
|
||||
let lockedCount = 0
|
||||
let lockedNoteCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.locked) {
|
||||
remove(responsePayloads, { uuid: item.uuid })
|
||||
lockedCount++
|
||||
if (item.content_type === ContentType.Note) {
|
||||
lockedNoteCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedNoteCount === 1) {
|
||||
void this.alertService.alert(
|
||||
'The note you are attempting to save has editing disabled',
|
||||
'Note has Editing Disabled',
|
||||
)
|
||||
return
|
||||
} else if (lockedCount > 0) {
|
||||
const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items'
|
||||
const auxVerb = lockedCount === 1 ? 'has' : 'have'
|
||||
void this.alertService.alert(
|
||||
`${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`,
|
||||
'Items have Editing Disabled',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const contextualPayloads = responsePayloads.map((responseItem) => {
|
||||
return CreateComponentRetrievedContextPayload(responseItem)
|
||||
})
|
||||
|
||||
for (const contextualPayload of contextualPayloads) {
|
||||
const item = this.itemManager.findItem(contextualPayload.uuid)
|
||||
if (!item) {
|
||||
const payload = new DecryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
...contextualPayload,
|
||||
})
|
||||
const template = CreateDecryptedItemFromPayload(payload)
|
||||
await this.itemManager.insertItem(template)
|
||||
} else {
|
||||
if (contextualPayload.content_type !== item.content_type) {
|
||||
throw Error('Extension is trying to modify content type of item.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.itemManager.changeItems(
|
||||
items.filter(isNotUndefined),
|
||||
(mutator) => {
|
||||
const contextualPayload = sureSearchArray(contextualPayloads, {
|
||||
uuid: mutator.getUuid(),
|
||||
})
|
||||
|
||||
mutator.setCustomContent(contextualPayload.content)
|
||||
|
||||
const responseItem = sureSearchArray(responsePayloads, {
|
||||
uuid: mutator.getUuid(),
|
||||
})
|
||||
|
||||
if (responseItem.clientData) {
|
||||
const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {})
|
||||
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
|
||||
mutator.setDomainData(allComponentData, ComponentDataDomain)
|
||||
}
|
||||
},
|
||||
MutationType.UpdateUserTimestamps,
|
||||
PayloadEmitSource.ComponentRetrieved,
|
||||
this.component.uuid,
|
||||
)
|
||||
|
||||
this.syncService
|
||||
.sync({
|
||||
onPresyncSave: () => {
|
||||
this.replyToMessage(message, {})
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
this.replyToMessage(message, {
|
||||
error: 'save-error',
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
handleCreateItemsMessage(message: ComponentMessage): void {
|
||||
let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[]
|
||||
|
||||
const uniqueContentTypes = uniq(
|
||||
responseItems.map((item) => {
|
||||
return item.content_type
|
||||
}),
|
||||
)
|
||||
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: uniqueContentTypes,
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
|
||||
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
|
||||
const processedItems = []
|
||||
|
||||
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.itemManager.insertItem(template)
|
||||
|
||||
await this.itemManager.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)
|
||||
})
|
||||
}
|
||||
|
||||
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() as ContentType[]
|
||||
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: requiredContentTypes,
|
||||
},
|
||||
]
|
||||
|
||||
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}?`)
|
||||
|
||||
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
|
||||
}
|
||||
await this.itemManager.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
|
||||
}
|
||||
|
||||
void this.syncService.sync()
|
||||
|
||||
reply = { deleted: true }
|
||||
} else {
|
||||
/* Rejected by user */
|
||||
reply = { deleted: false }
|
||||
}
|
||||
|
||||
this.replyToMessage(message, reply)
|
||||
})
|
||||
}
|
||||
|
||||
handleSetComponentDataMessage(message: ComponentMessage): void {
|
||||
const noPermissionsRequired: ComponentPermission[] = []
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => {
|
||||
await this.itemManager.changeComponent(this.component, (mutator) => {
|
||||
mutator.componentData = message.data.componentData || {}
|
||||
})
|
||||
|
||||
void this.syncService.sync()
|
||||
})
|
||||
}
|
||||
|
||||
handleSetSizeEvent(message: ComponentMessage): void {
|
||||
if (this.component.area !== ComponentArea.EditorStack) {
|
||||
return
|
||||
}
|
||||
|
||||
const parent = this.getIframe()?.parentElement
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = message.data
|
||||
const widthString = isString(data.width) ? data.width : `${data.width}px`
|
||||
const heightString = isString(data.height) ? data.height : `${data.height}px`
|
||||
if (parent) {
|
||||
parent.setAttribute('style', `width:${widthString}; height:${heightString};`)
|
||||
}
|
||||
}
|
||||
|
||||
getIframe(): HTMLIFrameElement | undefined {
|
||||
return Array.from(document.getElementsByTagName('iframe')).find(
|
||||
(iframe) => iframe.dataset.componentViewerId === this.identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
134
packages/snjs/lib/Services/ComponentManager/Types.ts
Normal file
134
packages/snjs/lib/Services/ComponentManager/Types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ComponentArea,
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
FeatureIdentifier,
|
||||
LegacyFileSafeIdentifier,
|
||||
} from '@standardnotes/features'
|
||||
import { ItemContent, SNComponent, DecryptedTransferPayload } from '@standardnotes/models'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
|
||||
export interface DesktopManagerInterface {
|
||||
syncComponentsInstallation(components: SNComponent[]): void
|
||||
registerUpdateObserver(callback: (component: SNComponent) => void): void
|
||||
getExtServerHost(): string
|
||||
}
|
||||
|
||||
export type IncomingComponentItemPayload = DecryptedTransferPayload & {
|
||||
clientData: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type OutgoingItemMessagePayload = {
|
||||
uuid: string
|
||||
content_type: ContentType
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
deleted?: boolean
|
||||
content?: ItemContent
|
||||
clientData?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* isMetadataUpdate implies that the extension should make reference of updated
|
||||
* metadata, but not update content values as they may be stale relative to what the
|
||||
* extension currently has.
|
||||
*/
|
||||
isMetadataUpdate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions allowed to batch stream AllowedBatchContentTypes
|
||||
*/
|
||||
export const AllowedBatchStreaming = Object.freeze([
|
||||
LegacyFileSafeIdentifier,
|
||||
FeatureIdentifier.DeprecatedFileSafe,
|
||||
FeatureIdentifier.DeprecatedBoldEditor,
|
||||
])
|
||||
|
||||
/**
|
||||
* Content types which are allowed to be managed/streamed in bulk by a component.
|
||||
*/
|
||||
export const AllowedBatchContentTypes = Object.freeze([
|
||||
ContentType.FilesafeCredentials,
|
||||
ContentType.FilesafeFileMetadata,
|
||||
ContentType.FilesafeIntegration,
|
||||
])
|
||||
|
||||
export type StreamObserver = {
|
||||
identifier: string
|
||||
componentUuid: UuidString
|
||||
area: ComponentArea
|
||||
originalMessage: ComponentMessage
|
||||
/** contentTypes is optional in the case of a context stream observer */
|
||||
contentTypes?: ContentType[]
|
||||
}
|
||||
|
||||
export type PermissionDialog = {
|
||||
component: SNComponent
|
||||
permissions: ComponentPermission[]
|
||||
permissionsString: string
|
||||
actionBlock: (approved: boolean) => void
|
||||
callback: (approved: boolean) => void
|
||||
}
|
||||
|
||||
export enum KeyboardModifier {
|
||||
Shift = 'Shift',
|
||||
Ctrl = 'Control',
|
||||
Meta = 'Meta',
|
||||
}
|
||||
|
||||
export type MessageData = Partial<{
|
||||
/** Related to the stream-item-context action */
|
||||
item?: IncomingComponentItemPayload
|
||||
/** Related to the stream-items action */
|
||||
content_types?: ContentType[]
|
||||
items?: IncomingComponentItemPayload[]
|
||||
/** Related to the request-permission action */
|
||||
permissions?: ComponentPermission[]
|
||||
/** Related to the component-registered action */
|
||||
componentData?: Record<string, unknown>
|
||||
uuid?: UuidString
|
||||
environment?: string
|
||||
platform?: string
|
||||
activeThemeUrls?: string[]
|
||||
/** Related to set-size action */
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
type?: string
|
||||
/** Related to themes action */
|
||||
themes?: string[]
|
||||
/** Related to clear-selection action */
|
||||
content_type?: ContentType
|
||||
/** Related to key-pressed action */
|
||||
keyboardModifier?: KeyboardModifier
|
||||
}>
|
||||
|
||||
export type MessageReplyData = {
|
||||
approved?: boolean
|
||||
deleted?: boolean
|
||||
error?: string
|
||||
item?: OutgoingItemMessagePayload
|
||||
items?: OutgoingItemMessagePayload[]
|
||||
themes?: string[]
|
||||
}
|
||||
|
||||
export type StreamItemsMessageData = MessageData & {
|
||||
content_types: ContentType[]
|
||||
}
|
||||
|
||||
export type DeleteItemsMessageData = MessageData & {
|
||||
items: OutgoingItemMessagePayload[]
|
||||
}
|
||||
|
||||
export type ComponentMessage = {
|
||||
action: ComponentAction
|
||||
sessionKey?: string
|
||||
componentData?: Record<string, unknown>
|
||||
data: MessageData
|
||||
}
|
||||
|
||||
export type MessageReply = {
|
||||
action: ComponentAction
|
||||
original: ComponentMessage
|
||||
data: MessageReplyData
|
||||
}
|
||||
3
packages/snjs/lib/Services/ComponentManager/index.ts
Normal file
3
packages/snjs/lib/Services/ComponentManager/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ComponentManager'
|
||||
export * from './ComponentViewer'
|
||||
export * from './Types'
|
||||
Reference in New Issue
Block a user