From 1c7a2155195f2a845031574a3cd426d7dbb8c87a Mon Sep 17 00:00:00 2001 From: Mo Date: Fri, 14 Jul 2023 11:32:28 -0500 Subject: [PATCH] chore: feature status in context of item (#2359) --- .../Domain/Feature/FeaturesClientInterface.ts | 4 +- .../ComponentManager/ComponentManager.ts | 69 ++++++---------- .../ComponentManager/ComponentViewer.ts | 82 +++++++------------ .../lib/Services/Features/FeaturesService.ts | 7 +- .../Features/UseCase/GetFeatureStatus.spec.ts | 30 ++++++- .../Features/UseCase/GetFeatureStatus.ts | 11 +++ .../Components/SuperEditor/SuperEditor.tsx | 4 +- 7 files changed, 106 insertions(+), 101 deletions(-) diff --git a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts index 3335258ff..2d0563e38 100644 --- a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts +++ b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts @@ -1,11 +1,11 @@ import { FeatureIdentifier } from '@standardnotes/features' -import { ComponentInterface } from '@standardnotes/models' +import { ComponentInterface, DecryptedItemInterface } from '@standardnotes/models' import { FeatureStatus } from './FeatureStatus' import { SetOfflineFeaturesFunctionResponse } from './SetOfflineFeaturesFunctionResponse' export interface FeaturesClientInterface { - getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus + getFeatureStatus(featureId: FeatureIdentifier, options?: { inContextOfItem?: DecryptedItemInterface }): FeatureStatus hasMinimumRole(role: string): boolean hasFirstPartyOfflineSubscription(): boolean diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts index ff0c311c6..402f116ee 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts @@ -126,24 +126,6 @@ export class SNComponentManager window.addEventListener('message', this.onWindowMessage, true) } - get isDesktop(): boolean { - return this.environment === Environment.Desktop - } - - get isMobile(): boolean { - return this.environment === Environment.Mobile - } - - get thirdPartyComponents(): ComponentInterface[] { - return this.items.getDisplayableComponents() - } - - thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] { - return this.thirdPartyComponents.filter((component) => { - return component.area === area - }) - } - override deinit(): void { super.deinit() @@ -172,11 +154,17 @@ export class SNComponentManager ;(this.onWindowMessage as unknown) = undefined } - setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void { + public setPermissionDialogUIHandler(handler: (dialog: PermissionDialog) => void): void { this.permissionDialogUIHandler = handler this.runWithPermissionsUseCase.setPermissionDialogUIHandler(handler) } + public thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] { + return this.items.getDisplayableComponents().filter((component) => { + return component.area === area + }) + } + public createComponentViewer( component: UIFeature, item: ComponentViewerItem, @@ -217,7 +205,7 @@ export class SNComponentManager removeFromArray(this.viewers, viewer) } - setDesktopManager(desktopManager: DesktopManagerInterface): void { + public setDesktopManager(desktopManager: DesktopManagerInterface): void { this.desktopManager = desktopManager this.configureForDesktop() } @@ -234,13 +222,14 @@ export class SNComponentManager return } - if (this.isDesktop) { + if (this.desktopManager) { const thirdPartyComponents = components.filter((component) => { const nativeFeature = FindNativeFeature(component.identifier) return nativeFeature ? false : true }) + if (thirdPartyComponents.length > 0) { - this.desktopManager?.syncComponentsInstallation(thirdPartyComponents) + this.desktopManager.syncComponentsInstallation(thirdPartyComponents) } } @@ -304,7 +293,8 @@ export class SNComponentManager } detectFocusChange = (): void => { - const activeIframes = this.allComponentIframes() + const activeIframes = Array.from(document.getElementsByTagName('iframe')) + for (const iframe of activeIframes) { if (document.activeElement === iframe) { setTimeout(() => { @@ -322,18 +312,19 @@ export class SNComponentManager } onWindowMessage = (event: MessageEvent): void => { - /** Make sure this message is for us */ const data = event.data as ComponentMessage - if (data.sessionKey) { this.log('Component manager received message', data) this.componentViewerForSessionKey(data.sessionKey)?.handleMessage(data) } } - configureForDesktop(): void { - this.desktopManager?.registerUpdateObserver((component: ComponentInterface) => { - /* Reload theme if active */ + private configureForDesktop(): void { + if (!this.desktopManager) { + throw new Error('Desktop manager is not defined') + } + + this.desktopManager.registerUpdateObserver((component: ComponentInterface) => { const activeComponents = this.getActiveComponents() const isComponentActive = activeComponents.find((candidate) => candidate.uuid === component.uuid) if (isComponentActive && component.isTheme()) { @@ -342,18 +333,18 @@ export class SNComponentManager }) } - postActiveThemesToAllViewers(): void { + private postActiveThemesToAllViewers(): void { for (const viewer of this.viewers) { viewer.postActiveThemes() } } - urlForFeature(uiFeature: UIFeature): string | undefined { + public urlForFeature(uiFeature: UIFeature): string | undefined { const usecase = new GetFeatureUrl(this.desktopManager, this.environment, this.platform) return usecase.execute(uiFeature) } - urlsForActiveThemes(): string[] { + public urlsForActiveThemes(): string[] { const themes = this.getActiveThemes() const urls = [] for (const theme of themes) { @@ -373,7 +364,7 @@ export class SNComponentManager return this.viewers.find((viewer) => viewer.sessionKey === key) } - async toggleTheme(uiFeature: UIFeature): Promise { + public async toggleTheme(uiFeature: UIFeature): Promise { this.log('Toggling theme', uiFeature.uniqueIdentifier) if (this.isThemeActive(uiFeature)) { @@ -406,7 +397,7 @@ export class SNComponentManager } } - getActiveThemes(): UIFeature[] { + public getActiveThemes(): UIFeature[] { const activeThemesIdentifiers = this.getActiveThemesIdentifiers() const thirdPartyThemes = this.items.findItems(activeThemesIdentifiers).map((item) => { @@ -427,11 +418,11 @@ export class SNComponentManager return entitledThemes } - getActiveThemesIdentifiers(): string[] { + public getActiveThemesIdentifiers(): string[] { return this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] } - async toggleComponent(component: ComponentInterface): Promise { + public async toggleComponent(component: ComponentInterface): Promise { this.log('Toggling component', component.uuid) if (this.isComponentActive(component)) { @@ -441,14 +432,6 @@ export class SNComponentManager } } - allComponentIframes(): HTMLIFrameElement[] { - return Array.from(document.getElementsByTagName('iframe')) - } - - iframeForComponentViewer(viewer: ComponentViewer): HTMLIFrameElement | undefined { - return viewer.getIframe() - } - editorForNote(note: SNNote): UIFeature { const usecase = new EditorForNoteUseCase(this.items) return usecase.execute(note) diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts index 46e3721f5..139d88b09 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts @@ -82,13 +82,12 @@ export class ComponentViewer implements ComponentViewerInterface { private loggingEnabled = false public identifier = nonSecureRandomIdentifier() private actionObservers: ActionObserver[] = [] - private featureStatus: FeatureStatus + private removeFeaturesObserver: () => void private eventObservers: ComponentEventObserver[] = [] private dealloced = false private window?: Window - private hidden = false private readonly = false public lockReadonly = false public sessionKey?: string @@ -132,20 +131,14 @@ export class ComponentViewer implements ComponentViewerInterface { this.actionObservers.push(options.actionObserver) } - this.featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier) - this.removeFeaturesObserver = services.features.addEventObserver((event) => { if (this.dealloced) { return } if (event === FeaturesEvent.FeaturesAvailabilityChanged) { - const featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier) - if (featureStatus !== this.featureStatus) { - this.featureStatus = featureStatus - this.postActiveThemes() - this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated) - } + this.postActiveThemes() + this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated) } }) @@ -226,7 +219,19 @@ export class ComponentViewer implements ComponentViewerInterface { } public getFeatureStatus(): FeatureStatus { - return this.featureStatus + return this.services.features.getFeatureStatus(this.componentOrFeature.featureIdentifier, { + inContextOfItem: this.getContextItem(), + }) + } + + private getContextItem(): DecryptedItemInterface | undefined { + if (isComponentViewerItemReadonlyItem(this.options.item)) { + return this.options.item.readonlyItem + } + + const matchingItem = this.services.items.findItem(this.options.item.uuid) + + return matchingItem } private isOfflineRestricted(): boolean { @@ -274,7 +279,7 @@ export class ComponentViewer implements ComponentViewerInterface { this.componentOrFeature = item } - handleChangesInItems( + private handleChangesInItems( items: (DecryptedItemInterface | DeletedItemInterface | EncryptedItemInterface)[], source: PayloadEmitSource, sourceKey?: string, @@ -312,7 +317,7 @@ export class ComponentViewer implements ComponentViewerInterface { } } - sendManyItemsThroughBridge(items: (DecryptedItemInterface | DeletedItemInterface)[]): void { + private sendManyItemsThroughBridge(items: (DecryptedItemInterface | DeletedItemInterface)[]): void { const requiredPermissions: ComponentPermission[] = [ { name: ComponentAction.StreamItems, @@ -329,7 +334,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void { + private sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void { const requiredContextPermissions = [ { name: ComponentAction.StreamContextItem, @@ -443,14 +448,7 @@ export class ComponentViewer implements ComponentViewerInterface { * @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.componentOrFeature.displayName) - return - } - + private sendMessage(message: ComponentMessage | MessageReply, essential = true): void { if (!this.window && message.action === ComponentAction.Reply) { this.log('Component has been deallocated in between message send and reply', this.componentOrFeature, message) return @@ -514,10 +512,6 @@ export class ComponentViewer implements ComponentViewerInterface { }) } - public getWindow(): Window | undefined { - return this.window - } - /** Called by client when the iframe is ready */ public setWindow(window: Window): void { if (this.window) { @@ -548,7 +542,7 @@ export class ComponentViewer implements ComponentViewerInterface { this.postActiveThemes() } - postActiveThemes(): void { + public postActiveThemes(): void { const urls = this.config.componentManagerFunctions.urlsForActiveThemes() const data: MessageData = { themes: urls, @@ -562,24 +556,6 @@ export class ComponentViewer implements ComponentViewerInterface { 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 as ComponentMessage) - } - } - } - handleMessage(message: ComponentMessage): void { this.log('Handle message', message, this) if (!this.componentOrFeature) { @@ -616,7 +592,7 @@ export class ComponentViewer implements ComponentViewerInterface { } } - handleStreamItemsMessage(message: ComponentMessage): void { + private handleStreamItemsMessage(message: ComponentMessage): void { const data = message.data as StreamItemsMessageData const types = data.content_types.filter((type) => AllowedBatchContentTypes.includes(type)).sort() const requiredPermissions = [ @@ -643,7 +619,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - handleStreamContextItemMessage(message: ComponentMessage): void { + private handleStreamContextItemMessage(message: ComponentMessage): void { const requiredPermissions: ComponentPermission[] = [ { name: ComponentAction.StreamContextItem, @@ -671,7 +647,7 @@ export class ComponentViewer implements ComponentViewerInterface { * Save items is capable of saving existing items, and also creating new ones * if they don't exist. */ - handleSaveItemsMessage(message: ComponentMessage): void { + private handleSaveItemsMessage(message: ComponentMessage): void { let responsePayloads = message.data.items as IncomingComponentItemPayload[] const requiredPermissions = [] @@ -814,7 +790,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - handleCreateItemsMessage(message: ComponentMessage): void { + private handleCreateItemsMessage(message: ComponentMessage): void { let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[] const uniqueContentTypes = uniqueArray( @@ -884,7 +860,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - handleDeleteItemsMessage(message: ComponentMessage): void { + private handleDeleteItemsMessage(message: ComponentMessage): void { const data = message.data as DeleteItemsMessageData const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type)) @@ -932,7 +908,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - handleSetComponentPreferencesMessage(message: ComponentMessage): void { + private handleSetComponentPreferencesMessage(message: ComponentMessage): void { const noPermissionsRequired: ComponentPermission[] = [] this.config.componentManagerFunctions.runWithPermissionsUseCase.execute( this.componentUniqueIdentifier, @@ -949,7 +925,7 @@ export class ComponentViewer implements ComponentViewerInterface { ) } - handleSetSizeEvent(message: ComponentMessage): void { + private handleSetSizeEvent(message: ComponentMessage): void { if (this.componentOrFeature.area !== ComponentArea.EditorStack) { return } @@ -967,7 +943,7 @@ export class ComponentViewer implements ComponentViewerInterface { } } - getIframe(): HTMLIFrameElement | undefined { + private getIframe(): HTMLIFrameElement | undefined { return Array.from(document.getElementsByTagName('iframe')).find( (iframe) => iframe.dataset.componentViewerId === this.identifier, ) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index bf3357bce..b02e40054 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -16,6 +16,7 @@ import { PayloadEmitSource, ComponentInterface, ThemeInterface, + DecryptedItemInterface, } from '@standardnotes/models' import { AbstractService, @@ -389,7 +390,10 @@ export class SNFeaturesService return indexOfRoleToCheck <= highestUserRoleIndex } - public getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus { + public getFeatureStatus( + featureId: FeatureIdentifier, + options: { inContextOfItem?: DecryptedItemInterface } = {}, + ): FeatureStatus { return this.getFeatureStatusUseCase.execute({ featureId, firstPartyRoles: this.hasFirstPartyOnlineSubscription() @@ -401,6 +405,7 @@ export class SNFeaturesService firstPartyOnlineSubscription: this.hasFirstPartyOnlineSubscription() ? this.subscriptions.getOnlineSubscription() : undefined, + inContextOfItem: options.inContextOfItem, }) } diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts index e51b389a9..c9e455f83 100644 --- a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts @@ -1,7 +1,7 @@ import { FeatureIdentifier } from '@standardnotes/features' import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services' import { GetFeatureStatusUseCase } from './GetFeatureStatus' -import { ComponentInterface } from '@standardnotes/models' +import { ComponentInterface, DecryptedItemInterface } from '@standardnotes/models' jest.mock('@standardnotes/features', () => ({ FeatureIdentifier: { @@ -71,6 +71,34 @@ describe('GetFeatureStatusUseCase', () => { }) describe('native features', () => { + it('should return Entitled if the context item belongs to a shared vault and user does not have subscription', () => { + ;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: false }) + + expect( + usecase.execute({ + featureId: 'nativeFeature', + firstPartyOnlineSubscription: undefined, + firstPartyRoles: undefined, + hasPaidAnyPartyOnlineOrOfflineSubscription: false, + inContextOfItem: { shared_vault_uuid: 'sharedVaultUuid' } as jest.Mocked, + }), + ).toEqual(FeatureStatus.Entitled) + }) + + it('should return NoUserSubscription if the context item does not belong to a shared vault and user does not have subscription', () => { + ;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: false }) + + expect( + usecase.execute({ + featureId: 'nativeFeature', + firstPartyOnlineSubscription: undefined, + firstPartyRoles: undefined, + hasPaidAnyPartyOnlineOrOfflineSubscription: false, + inContextOfItem: { shared_vault_uuid: undefined } as jest.Mocked, + }), + ).toEqual(FeatureStatus.NoUserSubscription) + }) + it('should return NoUserSubscription for native features without subscription and roles', () => { ;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: false }) diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts index 1c45bc4ae..896cffbd7 100644 --- a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts @@ -1,4 +1,5 @@ import { AnyFeatureDescription, FeatureIdentifier, FindNativeFeature } from '@standardnotes/features' +import { DecryptedItemInterface } from '@standardnotes/models' import { Subscription } from '@standardnotes/security' import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services' import { convertTimestampToMilliseconds } from '@standardnotes/utils' @@ -11,6 +12,7 @@ export class GetFeatureStatusUseCase { firstPartyOnlineSubscription: Subscription | undefined firstPartyRoles: { online: string[] } | { offline: string[] } | undefined hasPaidAnyPartyOnlineOrOfflineSubscription: boolean + inContextOfItem?: DecryptedItemInterface }): FeatureStatus { if (this.isFreeFeature(dto.featureId as FeatureIdentifier)) { return FeatureStatus.Entitled @@ -33,6 +35,7 @@ export class GetFeatureStatusUseCase { nativeFeature, firstPartyOnlineSubscription: dto.firstPartyOnlineSubscription, firstPartyRoles: dto.firstPartyRoles, + inContextOfItem: dto.inContextOfItem, }) } @@ -51,7 +54,15 @@ export class GetFeatureStatusUseCase { nativeFeature: AnyFeatureDescription firstPartyOnlineSubscription: Subscription | undefined firstPartyRoles: { online: string[] } | { offline: string[] } | undefined + inContextOfItem?: DecryptedItemInterface }): FeatureStatus { + if (dto.inContextOfItem) { + const isSharedVaultItem = dto.inContextOfItem.shared_vault_uuid !== undefined + if (isSharedVaultItem) { + return FeatureStatus.Entitled + } + } + if (!dto.firstPartyOnlineSubscription && !dto.firstPartyRoles) { return FeatureStatus.NoUserSubscription } diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx index c36daa1c2..fc22f3cb8 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx @@ -75,7 +75,9 @@ export const SuperEditor: FunctionComponent = ({ const [featureStatus, setFeatureStatus] = useState(FeatureStatus.Entitled) useEffect(() => { - setFeatureStatus(application.features.getFeatureStatus(FeatureIdentifier.SuperEditor)) + setFeatureStatus( + application.features.getFeatureStatus(FeatureIdentifier.SuperEditor, { inContextOfItem: note.current }), + ) }, [application.features]) const commandService = useCommandService()