refactor: native feature management (#2350)
This commit is contained in:
@@ -29,7 +29,6 @@ import * as Models from '@standardnotes/models'
|
||||
import * as Responses from '@standardnotes/responses'
|
||||
import * as InternalServices from '../Services'
|
||||
import * as Utils from '@standardnotes/utils'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
import { UuidString, ApplicationEventPayload } from '../Types'
|
||||
import { applicationEventForSyncEvent } from '@Lib/Application/Event'
|
||||
import {
|
||||
@@ -50,7 +49,7 @@ import {
|
||||
EncryptionServiceEvent,
|
||||
FilesBackupService,
|
||||
FileService,
|
||||
SubscriptionClientInterface,
|
||||
SubscriptionManagerInterface,
|
||||
SubscriptionManager,
|
||||
ChallengePrompt,
|
||||
Challenge,
|
||||
@@ -151,7 +150,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare userRequestServer: UserRequestServerInterface
|
||||
private declare subscriptionApiService: SubscriptionApiServiceInterface
|
||||
private declare subscriptionServer: SubscriptionServerInterface
|
||||
private declare subscriptionManager: SubscriptionClientInterface
|
||||
private declare subscriptionManager: SubscriptionManagerInterface
|
||||
private declare webSocketApiService: WebSocketApiServiceInterface
|
||||
private declare webSocketServer: WebSocketServerInterface
|
||||
|
||||
@@ -275,7 +274,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.defineInternalEventHandlers()
|
||||
}
|
||||
|
||||
get subscriptions(): ExternalServices.SubscriptionClientInterface {
|
||||
get subscriptions(): ExternalServices.SubscriptionManagerInterface {
|
||||
return this.subscriptionManager
|
||||
}
|
||||
|
||||
@@ -407,6 +406,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this.sharedVaultService
|
||||
}
|
||||
|
||||
public get preferences(): ExternalServices.PreferenceServiceInterface {
|
||||
return this.preferencesService
|
||||
}
|
||||
|
||||
public computePrivateUsername(username: string): Promise<string | undefined> {
|
||||
return ComputePrivateUsername(this.options.crypto, username)
|
||||
}
|
||||
@@ -633,6 +636,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
}
|
||||
}
|
||||
|
||||
this.internalEventBus.publish({
|
||||
type: event,
|
||||
payload: data,
|
||||
})
|
||||
|
||||
void this.migrationService.handleApplicationEvent(event)
|
||||
}
|
||||
|
||||
@@ -671,19 +679,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return Common.compareVersions(userVersion, Common.ProtocolVersion.V004) >= 0
|
||||
}
|
||||
|
||||
public async getUserSubscription(): Promise<Subscription | Responses.ClientDisplayableError | undefined> {
|
||||
return this.sessionManager.getSubscription()
|
||||
}
|
||||
|
||||
public async getAvailableSubscriptions(): Promise<
|
||||
Responses.AvailableSubscriptions | Responses.ClientDisplayableError
|
||||
> {
|
||||
if (this.isThirdPartyHostUsed()) {
|
||||
return ClientDisplayableError.FromString('Third party hosts do not support subscriptions.')
|
||||
}
|
||||
return this.sessionManager.getAvailableSubscriptions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin streaming items to display in the UI. The stream callback will be called
|
||||
* immediately with the present items that match the constraint, and over time whenever
|
||||
@@ -1268,9 +1263,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.createSubscriptionApiService()
|
||||
this.createWebSocketServer()
|
||||
this.createWebSocketApiService()
|
||||
this.createSubscriptionManager()
|
||||
this.createWebSocketsService()
|
||||
this.createSessionManager()
|
||||
this.createSubscriptionManager()
|
||||
this.createHistoryManager()
|
||||
this.createSyncManager()
|
||||
this.createProtectionService()
|
||||
@@ -1498,9 +1493,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private createFeaturesService() {
|
||||
this.featuresService = new InternalServices.SNFeaturesService(
|
||||
this.diskStorageService,
|
||||
this.apiService,
|
||||
this.itemManager,
|
||||
this.mutator,
|
||||
this.subscriptions,
|
||||
this.apiService,
|
||||
this.webSocketsService,
|
||||
this.settingsService,
|
||||
this.userService,
|
||||
@@ -1517,8 +1513,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
void this.notifyEvent(ApplicationEvent.UserRolesChanged)
|
||||
break
|
||||
}
|
||||
case ExternalServices.FeaturesEvent.FeaturesUpdated: {
|
||||
void this.notifyEvent(ApplicationEvent.FeaturesUpdated)
|
||||
case ExternalServices.FeaturesEvent.FeaturesAvailabilityChanged: {
|
||||
void this.notifyEvent(ApplicationEvent.FeaturesAvailabilityChanged)
|
||||
break
|
||||
}
|
||||
case ExternalServices.FeaturesEvent.DidPurchaseSubscription: {
|
||||
@@ -1561,6 +1557,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
internalEventBus: this.internalEventBus,
|
||||
legacySessionStorageMapper: this.legacySessionStorageMapper,
|
||||
backups: this.fileBackups,
|
||||
preferences: this.preferencesService,
|
||||
})
|
||||
this.services.push(this.migrationService)
|
||||
}
|
||||
@@ -1629,7 +1626,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
}
|
||||
|
||||
private createSubscriptionManager() {
|
||||
this.subscriptionManager = new SubscriptionManager(this.subscriptionApiService, this.internalEventBus)
|
||||
this.subscriptionManager = new SubscriptionManager(
|
||||
this.subscriptionApiService,
|
||||
this.sessions,
|
||||
this.storage,
|
||||
this.internalEventBus,
|
||||
)
|
||||
this.services.push(this.subscriptionManager)
|
||||
}
|
||||
|
||||
private createItemManager() {
|
||||
@@ -1647,8 +1650,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.alertService,
|
||||
this.environment,
|
||||
this.platform,
|
||||
this.internalEventBus,
|
||||
this.deviceInterface,
|
||||
this.internalEventBus,
|
||||
)
|
||||
this.services.push(this.componentManagerService)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
InternalEventBusInterface,
|
||||
EncryptionService,
|
||||
MutatorClientInterface,
|
||||
PreferenceServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { SNSessionManager } from '../Services/Session/SessionManager'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
@@ -23,6 +24,7 @@ export type MigrationServices = {
|
||||
mutator: MutatorClientInterface
|
||||
singletonManager: SNSingletonManager
|
||||
featuresService: SNFeaturesService
|
||||
preferences: PreferenceServiceInterface
|
||||
environment: Environment
|
||||
platform: Platform
|
||||
identifier: ApplicationIdentifier
|
||||
|
||||
98
packages/snjs/lib/Migrations/Versions/2_202_1.ts
Normal file
98
packages/snjs/lib/Migrations/Versions/2_202_1.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { AllComponentPreferences, ComponentInterface, PrefKey } from '@standardnotes/models'
|
||||
import { Copy, Uuids } from '@standardnotes/utils'
|
||||
import { FindNativeFeature } from '@standardnotes/features'
|
||||
|
||||
export class Migration2_202_1 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.202.1'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => {
|
||||
await this.migrateComponentDataToUserPreferences()
|
||||
await this.migrateActiveComponentsToUserPreferences()
|
||||
await this.deleteComponentsWhichAreNativeFeatures()
|
||||
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async migrateComponentDataToUserPreferences(): Promise<void> {
|
||||
const components = this.services.itemManager.getItems<ComponentInterface>(ContentType.TYPES.Component)
|
||||
|
||||
if (components.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const mutablePreferencesValue = Copy<AllComponentPreferences>(
|
||||
this.services.preferences.getValue(PrefKey.ComponentPreferences) ?? {},
|
||||
)
|
||||
|
||||
for (const component of components) {
|
||||
const componentData = component.legacyComponentData
|
||||
if (!componentData) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Object.keys(componentData).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const preferencesLookupKey = FindNativeFeature(component.identifier) ? component.identifier : component.uuid
|
||||
|
||||
const componentPreferences = mutablePreferencesValue[preferencesLookupKey] ?? {}
|
||||
for (const key of Object.keys(componentData)) {
|
||||
componentPreferences[key] = componentData[key]
|
||||
}
|
||||
|
||||
mutablePreferencesValue[preferencesLookupKey] = componentPreferences
|
||||
}
|
||||
|
||||
await this.services.preferences.setValueDetached(PrefKey.ComponentPreferences, mutablePreferencesValue)
|
||||
}
|
||||
|
||||
private async migrateActiveComponentsToUserPreferences(): Promise<void> {
|
||||
const allActiveitems = [
|
||||
...this.services.itemManager.getItems<ComponentInterface>(ContentType.TYPES.Component),
|
||||
...this.services.itemManager.getItems<ComponentInterface>(ContentType.TYPES.Theme),
|
||||
].filter((component) => component.legacyActive)
|
||||
|
||||
if (allActiveitems.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeThemes = allActiveitems.filter((component) => component.isTheme())
|
||||
const activeComponents = allActiveitems.filter((component) => !component.isTheme())
|
||||
|
||||
await this.services.preferences.setValueDetached(PrefKey.ActiveThemes, Uuids(activeThemes))
|
||||
await this.services.preferences.setValueDetached(PrefKey.ActiveComponents, Uuids(activeComponents))
|
||||
}
|
||||
|
||||
private async deleteComponentsWhichAreNativeFeatures(): Promise<void> {
|
||||
const componentsToDelete = [
|
||||
...this.services.itemManager.getItems<ComponentInterface>(ContentType.TYPES.Component),
|
||||
...this.services.itemManager.getItems<ComponentInterface>(ContentType.TYPES.Theme),
|
||||
].filter((candidate) => {
|
||||
const nativeFeature = FindNativeFeature(candidate.identifier)
|
||||
if (!nativeFeature) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isDeprecatedAndThusShouldNotDeleteComponentSinceUserHasItRetained = nativeFeature.deprecated
|
||||
if (isDeprecatedAndThusShouldNotDeleteComponentSinceUserHasItRetained) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (componentsToDelete.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.services.mutator.setItemsToBeDeleted(componentsToDelete)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { SNTheme } from '@standardnotes/models'
|
||||
import { ThemeInterface } from '@standardnotes/models'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
|
||||
const NoDistractionIdentifier = 'org.standardnotes.theme-no-distraction' as FeatureIdentifier
|
||||
@@ -19,7 +19,7 @@ export class Migration2_42_0 extends Migration {
|
||||
}
|
||||
|
||||
private async deleteNoDistraction(): Promise<void> {
|
||||
const themes = (this.services.itemManager.getItems(ContentType.TYPES.Theme) as SNTheme[]).filter((theme) => {
|
||||
const themes = this.services.itemManager.getItems<ThemeInterface>(ContentType.TYPES.Theme).filter((theme) => {
|
||||
return theme.identifier === NoDistractionIdentifier
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Migration2_36_0 } from './2_36_0'
|
||||
import { Migration2_42_0 } from './2_42_0'
|
||||
import { Migration2_167_6 } from './2_167_6'
|
||||
import { Migration2_168_6 } from './2_168_6'
|
||||
import { Migration2_202_1 } from './2_202_1'
|
||||
|
||||
export const MigrationClasses = [
|
||||
Migration2_0_15,
|
||||
@@ -14,6 +15,7 @@ export const MigrationClasses = [
|
||||
Migration2_42_0,
|
||||
Migration2_167_6,
|
||||
Migration2_168_6,
|
||||
Migration2_202_1,
|
||||
]
|
||||
|
||||
export {
|
||||
@@ -24,4 +26,5 @@ export {
|
||||
Migration2_42_0,
|
||||
Migration2_167_6,
|
||||
Migration2_168_6,
|
||||
Migration2_202_1,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FeatureDescription } from '@standardnotes/features'
|
||||
import { joinPaths } from '@standardnotes/utils'
|
||||
import {
|
||||
AbstractService,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
API_MESSAGE_FAILED_LISTED_REGISTRATION,
|
||||
API_MESSAGE_FAILED_OFFLINE_ACTIVATION,
|
||||
API_MESSAGE_FAILED_OFFLINE_FEATURES,
|
||||
API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
|
||||
API_MESSAGE_FAILED_UPDATE_SETTINGS,
|
||||
API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL,
|
||||
API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL,
|
||||
@@ -41,13 +39,10 @@ import {
|
||||
RawSyncResponse,
|
||||
SessionRenewalResponse,
|
||||
SessionListResponse,
|
||||
UserFeaturesResponse,
|
||||
ListSettingsResponse,
|
||||
UpdateSettingResponse,
|
||||
GetSettingResponse,
|
||||
DeleteSettingResponse,
|
||||
GetSubscriptionResponse,
|
||||
GetAvailableSubscriptionsResponse,
|
||||
PostSubscriptionTokensResponse,
|
||||
GetOfflineFeaturesResponse,
|
||||
ListedRegistrationResponse,
|
||||
@@ -84,9 +79,9 @@ import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
|
||||
import { Paths } from './Paths'
|
||||
import { DiskStorageService } from '../Storage/DiskStorageService'
|
||||
import { UuidString } from '../../Types/UuidString'
|
||||
import merge from 'lodash/merge'
|
||||
import { SettingsServerInterface } from '../Settings/SettingsServerInterface'
|
||||
import { Strings } from '@Lib/Strings'
|
||||
import { AnyFeatureDescription } from '@standardnotes/features'
|
||||
|
||||
/** Legacy api version field to be specified in params when calling v0 APIs. */
|
||||
const V0_API_VERSION = '20200115'
|
||||
@@ -199,9 +194,12 @@ export class SNApiService
|
||||
}
|
||||
|
||||
private params(inParams: Record<string | number | symbol, unknown>): HttpRequestParams {
|
||||
const params = merge(inParams, {
|
||||
[ApiEndpointParam.ApiVersion]: this.apiVersion,
|
||||
})
|
||||
const params = {
|
||||
...inParams,
|
||||
...{
|
||||
[ApiEndpointParam.ApiVersion]: this.apiVersion,
|
||||
},
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -508,19 +506,6 @@ export class SNApiService
|
||||
return response
|
||||
}
|
||||
|
||||
async getUserFeatures(userUuid: UuidString): Promise<HttpResponse<UserFeaturesResponse>> {
|
||||
const path = Paths.v1.userFeatures(userUuid)
|
||||
const response = await this.httpService.get<UserFeaturesResponse>(path, undefined, this.getSessionAccessToken())
|
||||
|
||||
if (isErrorResponse(response)) {
|
||||
this.preprocessAuthenticatedErrorResponse(response)
|
||||
return this.errorResponseWithFallbackMessage(response, API_MESSAGE_GENERIC_SYNC_FAIL)
|
||||
}
|
||||
|
||||
this.processSuccessResponseForMetaBody(response)
|
||||
return response
|
||||
}
|
||||
|
||||
private async tokenRefreshableRequest<T>(
|
||||
params: HttpRequest & { fallbackErrorMessage: string },
|
||||
): Promise<HttpResponse<T>> {
|
||||
@@ -605,25 +590,6 @@ export class SNApiService
|
||||
})
|
||||
}
|
||||
|
||||
public async getSubscription(userUuid: string): Promise<HttpResponse<GetSubscriptionResponse>> {
|
||||
const url = joinPaths(this.host, Paths.v1.subscription(userUuid))
|
||||
return this.tokenRefreshableRequest({
|
||||
verb: HttpVerb.Get,
|
||||
url,
|
||||
authentication: this.getSessionAccessToken(),
|
||||
fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
|
||||
})
|
||||
}
|
||||
|
||||
public async getAvailableSubscriptions(): Promise<HttpResponse<GetAvailableSubscriptionsResponse>> {
|
||||
const url = joinPaths(this.host, Paths.v2.subscriptions)
|
||||
return this.request({
|
||||
verb: HttpVerb.Get,
|
||||
url,
|
||||
fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
|
||||
})
|
||||
}
|
||||
|
||||
public async getNewSubscriptionToken(): Promise<string | undefined> {
|
||||
const url = joinPaths(this.host, Paths.v1.subscriptionTokens)
|
||||
const response = await this.request<PostSubscriptionTokensResponse>({
|
||||
@@ -642,7 +608,7 @@ export class SNApiService
|
||||
|
||||
public async downloadOfflineFeaturesFromRepo(
|
||||
repo: SNFeatureRepo,
|
||||
): Promise<{ features: FeatureDescription[]; roles: string[] } | ClientDisplayableError> {
|
||||
): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> {
|
||||
try {
|
||||
const featuresUrl = repo.offlineFeaturesUrl
|
||||
const extensionKey = repo.offlineKey
|
||||
|
||||
@@ -49,7 +49,6 @@ const SubscriptionPaths = {
|
||||
purchase: '/v1/purchase',
|
||||
subscription: (userUuid: string) => `/v1/users/${userUuid}/subscription`,
|
||||
subscriptionTokens: '/v1/subscription-tokens',
|
||||
userFeatures: (userUuid: string) => `/v1/users/${userUuid}/features`,
|
||||
}
|
||||
|
||||
const SubscriptionPathsV2 = {
|
||||
|
||||
3
packages/snjs/lib/Services/Api/WebSocketsServiceEvent.ts
Normal file
3
packages/snjs/lib/Services/Api/WebSocketsServiceEvent.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum WebSocketsServiceEvent {
|
||||
UserRoleMessageReceived = 'WebSocketMessageReceived',
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { isErrorResponse } from '@standardnotes/responses'
|
||||
import { UserRolesChangedEvent } from '@standardnotes/domain-events'
|
||||
import { AbstractService, InternalEventBusInterface, StorageKey } from '@standardnotes/services'
|
||||
import {
|
||||
AbstractService,
|
||||
InternalEventBusInterface,
|
||||
StorageKey,
|
||||
StorageServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { WebSocketApiServiceInterface } from '@standardnotes/api'
|
||||
|
||||
import { DiskStorageService } from '../Storage/DiskStorageService'
|
||||
|
||||
export enum WebSocketsServiceEvent {
|
||||
UserRoleMessageReceived = 'WebSocketMessageReceived',
|
||||
}
|
||||
import { WebSocketsServiceEvent } from './WebSocketsServiceEvent'
|
||||
|
||||
export class SNWebSocketsService extends AbstractService<WebSocketsServiceEvent, UserRolesChangedEvent> {
|
||||
private webSocket?: WebSocket
|
||||
|
||||
constructor(
|
||||
private storageService: DiskStorageService,
|
||||
private storageService: StorageServiceInterface,
|
||||
private webSocketUrl: string | undefined,
|
||||
private webSocketApiService: WebSocketApiServiceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
|
||||
@@ -7,74 +7,93 @@ import { createNote } from './../../Spec/SpecUtils'
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
FeatureDescription,
|
||||
FindNativeFeature,
|
||||
FeatureIdentifier,
|
||||
NoteType,
|
||||
UIFeatureDescriptionTypes,
|
||||
IframeComponentFeatureDescription,
|
||||
} from '@standardnotes/features'
|
||||
import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import {
|
||||
GenericItem,
|
||||
SNComponent,
|
||||
Environment,
|
||||
Platform,
|
||||
ComponentInterface,
|
||||
ComponentOrNativeFeature,
|
||||
ComponentContent,
|
||||
DecryptedPayload,
|
||||
PayloadTimestampDefaults,
|
||||
} from '@standardnotes/models'
|
||||
import {
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
AlertService,
|
||||
DeviceInterface,
|
||||
MutatorClientInterface,
|
||||
ItemManagerInterface,
|
||||
SyncServiceInterface,
|
||||
PreferenceServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
|
||||
import { SNComponentManager } from './ComponentManager'
|
||||
import { SNSyncService } from '../Sync/SyncService'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { ComponentPackageInfo } from '@standardnotes/models'
|
||||
|
||||
describe('featuresService', () => {
|
||||
let itemManager: ItemManager
|
||||
let items: ItemManagerInterface
|
||||
let mutator: MutatorClientInterface
|
||||
let featureService: SNFeaturesService
|
||||
let alertService: AlertService
|
||||
let syncService: SNSyncService
|
||||
let prefsService: SNPreferencesService
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
let features: SNFeaturesService
|
||||
let alerts: AlertService
|
||||
let sync: SyncServiceInterface
|
||||
let prefs: PreferenceServiceInterface
|
||||
let eventBus: InternalEventBusInterface
|
||||
let device: DeviceInterface
|
||||
|
||||
const nativeFeatureAsUIFeature = <F extends UIFeatureDescriptionTypes>(identifier: FeatureIdentifier) => {
|
||||
return new ComponentOrNativeFeature(FindNativeFeature<F>(identifier)!)
|
||||
}
|
||||
|
||||
const desktopExtHost = 'http://localhost:123'
|
||||
|
||||
const createManager = (environment: Environment, platform: Platform) => {
|
||||
const desktopManager: DesktopManagerInterface = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
syncComponentsInstallation() {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
registerUpdateObserver(_callback: (component: SNComponent) => void) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {}
|
||||
},
|
||||
getExtServerHost() {
|
||||
return desktopExtHost
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new SNComponentManager(
|
||||
itemManager,
|
||||
items,
|
||||
mutator,
|
||||
syncService,
|
||||
featureService,
|
||||
prefsService,
|
||||
alertService,
|
||||
sync,
|
||||
features,
|
||||
prefs,
|
||||
alerts,
|
||||
environment,
|
||||
platform,
|
||||
internalEventBus,
|
||||
device,
|
||||
eventBus,
|
||||
)
|
||||
manager.setDesktopManager(desktopManager)
|
||||
|
||||
if (environment === Environment.Desktop) {
|
||||
const desktopManager: DesktopManagerInterface = {
|
||||
syncComponentsInstallation() {},
|
||||
registerUpdateObserver(_callback: (component: ComponentInterface) => void) {
|
||||
return () => {}
|
||||
},
|
||||
getExtServerHost() {
|
||||
return desktopExtHost
|
||||
},
|
||||
}
|
||||
manager.setDesktopManager(desktopManager)
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = {} as jest.Mocked<SNSyncService>
|
||||
syncService.sync = jest.fn()
|
||||
sync = {} as jest.Mocked<SNSyncService>
|
||||
sync.sync = jest.fn()
|
||||
|
||||
itemManager = {} as jest.Mocked<ItemManager>
|
||||
itemManager.getItems = jest.fn().mockReturnValue([])
|
||||
itemManager.addObserver = jest.fn()
|
||||
items = {} as jest.Mocked<ItemManager>
|
||||
items.getItems = jest.fn().mockReturnValue([])
|
||||
items.addObserver = jest.fn()
|
||||
|
||||
mutator = {} as jest.Mocked<MutatorClientInterface>
|
||||
mutator.createItem = jest.fn()
|
||||
@@ -83,62 +102,40 @@ describe('featuresService', () => {
|
||||
mutator.changeItem = jest.fn()
|
||||
mutator.changeFeatureRepo = jest.fn()
|
||||
|
||||
featureService = {} as jest.Mocked<SNFeaturesService>
|
||||
features = {} as jest.Mocked<SNFeaturesService>
|
||||
|
||||
prefsService = {} as jest.Mocked<SNPreferencesService>
|
||||
prefs = {} as jest.Mocked<SNPreferencesService>
|
||||
prefs.addEventObserver = jest.fn()
|
||||
|
||||
alertService = {} as jest.Mocked<AlertService>
|
||||
alertService.confirm = jest.fn()
|
||||
alertService.alert = jest.fn()
|
||||
alerts = {} as jest.Mocked<AlertService>
|
||||
alerts.confirm = jest.fn()
|
||||
alerts.alert = jest.fn()
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publish = jest.fn()
|
||||
eventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
eventBus.publish = jest.fn()
|
||||
|
||||
device = {} as jest.Mocked<DeviceInterface>
|
||||
})
|
||||
|
||||
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {
|
||||
return new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
package_info: {
|
||||
const thirdPartyFeature = () => {
|
||||
const component = new SNComponent(
|
||||
new DecryptedPayload({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
...PayloadTimestampDefaults(),
|
||||
content: {
|
||||
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
|
||||
hosted_url: 'https://example.com/component',
|
||||
identifier: identifier || FeatureIdentifier.PlusEditor,
|
||||
file_type: file_type ?? 'html',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier' as FeatureIdentifier,
|
||||
expires_at: new Date().getTime(),
|
||||
availableInRoles: [],
|
||||
} as unknown as jest.Mocked<ComponentPackageInfo>,
|
||||
} as unknown as jest.Mocked<ComponentContent>,
|
||||
}),
|
||||
)
|
||||
|
||||
const deprecatedComponent = () => {
|
||||
return new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
package_info: {
|
||||
hosted_url: 'https://example.com/component',
|
||||
identifier: FeatureIdentifier.DeprecatedFileSafe,
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
const thirdPartyComponent = () => {
|
||||
return new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
|
||||
hosted_url: 'https://example.com/component',
|
||||
package_info: {
|
||||
identifier: 'non-native-identifier',
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
|
||||
}
|
||||
|
||||
describe('permissions', () => {
|
||||
@@ -152,7 +149,10 @@ describe('featuresService', () => {
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownProEditor), permissions),
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
@@ -165,7 +165,12 @@ describe('featuresService', () => {
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple tags', () => {
|
||||
@@ -177,7 +182,12 @@ describe('featuresService', () => {
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('no extension should be able to stream multiple notes or tags', () => {
|
||||
@@ -189,7 +199,12 @@ describe('featuresService', () => {
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
it('some valid and some invalid permissions should still return invalid permissions', () => {
|
||||
@@ -202,7 +217,10 @@ describe('featuresService', () => {
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(false)
|
||||
})
|
||||
|
||||
@@ -220,7 +238,10 @@ describe('featuresService', () => {
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
@@ -238,7 +259,10 @@ describe('featuresService', () => {
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedBoldEditor), permissions),
|
||||
manager.areRequestedPermissionsValid(
|
||||
nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor),
|
||||
permissions,
|
||||
),
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
@@ -255,9 +279,9 @@ describe('featuresService', () => {
|
||||
]
|
||||
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
manager.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions),
|
||||
).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,25 +289,31 @@ describe('featuresService', () => {
|
||||
describe('desktop', () => {
|
||||
it('returns native path for native component', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const component = nativeComponent()
|
||||
const url = manager.urlForComponent(component)
|
||||
const feature = FindNativeFeature(component.identifier)
|
||||
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns native path for deprecated native component', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const component = deprecatedComponent()
|
||||
const url = manager.urlForComponent(component)
|
||||
const feature = FindNativeFeature(component.identifier)
|
||||
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.DeprecatedBoldEditor,
|
||||
)!
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nonnative path for third party component', () => {
|
||||
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
|
||||
const component = thirdPartyComponent()
|
||||
const url = manager.urlForComponent(component)
|
||||
expect(url).toEqual(`${desktopExtHost}/Extensions/${component.identifier}/dist/index.html`)
|
||||
const feature = thirdPartyFeature()
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`)
|
||||
})
|
||||
|
||||
it('returns hosted url for third party component with no local_url', () => {
|
||||
@@ -299,7 +329,8 @@ describe('featuresService', () => {
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const url = manager.urlForComponent(component)
|
||||
const feature = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(component)
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual('https://example.com/component')
|
||||
})
|
||||
})
|
||||
@@ -307,29 +338,30 @@ describe('featuresService', () => {
|
||||
describe('web', () => {
|
||||
it('returns native path for native component', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeComponent()
|
||||
const url = manager.urlForComponent(component)
|
||||
const feature = FindNativeFeature(component.identifier) as FeatureDescription
|
||||
expect(url).toEqual(`http://localhost/components/assets/${component.identifier}/${feature.index_path}`)
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.MarkdownProEditor)
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(
|
||||
`http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns hosted path for third party component', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = thirdPartyComponent()
|
||||
const url = manager.urlForComponent(component)
|
||||
expect(url).toEqual(component.hosted_url)
|
||||
const feature = thirdPartyFeature()
|
||||
const url = manager.urlForComponent(feature)
|
||||
expect(url).toEqual(feature.asComponent.hosted_url)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('editors', () => {
|
||||
it('getEditorForNote should return undefined is note type is plain', () => {
|
||||
it('getEditorForNote should return plain notes is note type is plain', () => {
|
||||
const note = createNote({
|
||||
noteType: NoteType.Plain,
|
||||
})
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
|
||||
expect(manager.editorForNote(note)).toBe(undefined)
|
||||
expect(manager.editorForNote(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor)
|
||||
})
|
||||
|
||||
it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => {
|
||||
@@ -345,60 +377,74 @@ describe('featuresService', () => {
|
||||
describe('editor change alert', () => {
|
||||
it('should not require alert switching from plain editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeComponent()
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to plain editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const component = nativeComponent()
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from a markdown editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeComponent()
|
||||
const markdownEditor = nativeComponent(undefined, 'md')
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching to a markdown editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeComponent()
|
||||
const markdownEditor = nativeComponent(undefined, 'md')
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.MarkdownProEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require alert switching from & to a html editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeComponent()
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(false)
|
||||
})
|
||||
|
||||
it('should require alert switching from a html editor to custom editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeComponent()
|
||||
const customEditor = nativeComponent(undefined, 'json')
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to html editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const htmlEditor = nativeComponent()
|
||||
const customEditor = nativeComponent(undefined, 'json')
|
||||
const htmlEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(FeatureIdentifier.PlusEditor)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
it('should require alert switching from a custom editor to custom editor', () => {
|
||||
const manager = createManager(Environment.Web, Platform.MacWeb)
|
||||
const customEditor = nativeComponent(undefined, 'json')
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
FeatureIdentifier.TokenVaultEditor,
|
||||
)
|
||||
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
|
||||
expect(requiresAlert).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { AllowedBatchStreaming } from './Types'
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import {
|
||||
ActionObserver,
|
||||
SNNote,
|
||||
SNTheme,
|
||||
SNComponent,
|
||||
ComponentMutator,
|
||||
PayloadEmitSource,
|
||||
PermissionDialog,
|
||||
Environment,
|
||||
Platform,
|
||||
ComponentMessage,
|
||||
ComponentOrNativeFeature,
|
||||
ComponentInterface,
|
||||
PrefKey,
|
||||
ThemeInterface,
|
||||
ComponentPreferencesEntry,
|
||||
AllComponentPreferences,
|
||||
} from '@standardnotes/models'
|
||||
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
|
||||
import find from 'lodash/find'
|
||||
import uniq from 'lodash/uniq'
|
||||
import {
|
||||
ComponentArea,
|
||||
ComponentAction,
|
||||
@@ -24,9 +24,25 @@ import {
|
||||
FindNativeFeature,
|
||||
NoteType,
|
||||
FeatureIdentifier,
|
||||
EditorFeatureDescription,
|
||||
GetIframeAndNativeEditors,
|
||||
FindNativeTheme,
|
||||
UIFeatureDescriptionTypes,
|
||||
IframeComponentFeatureDescription,
|
||||
GetPlainNoteFeature,
|
||||
GetSuperNoteFeature,
|
||||
ComponentFeatureDescription,
|
||||
ThemeFeatureDescription,
|
||||
} from '@standardnotes/features'
|
||||
import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import {
|
||||
Copy,
|
||||
filterFromArray,
|
||||
removeFromArray,
|
||||
sleep,
|
||||
assert,
|
||||
uniqueArray,
|
||||
isNotUndefined,
|
||||
} from '@standardnotes/utils'
|
||||
import { AllowedBatchContentTypes } from '@Lib/Services/ComponentManager/Types'
|
||||
import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer'
|
||||
import {
|
||||
@@ -39,8 +55,14 @@ import {
|
||||
DeviceInterface,
|
||||
isMobileDevice,
|
||||
MutatorClientInterface,
|
||||
PreferenceServiceInterface,
|
||||
ComponentViewerItem,
|
||||
PreferencesServiceEvent,
|
||||
ItemManagerInterface,
|
||||
SyncServiceInterface,
|
||||
FeatureStatus,
|
||||
} from '@standardnotes/services'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { permissionsStringForPermissions } from './permissionsStringForPermissions'
|
||||
|
||||
const DESKTOP_URL_PREFIX = 'sn://'
|
||||
const LOCAL_HOST = 'localhost'
|
||||
@@ -78,22 +100,30 @@ export class SNComponentManager
|
||||
private permissionDialogs: PermissionDialog[] = []
|
||||
|
||||
constructor(
|
||||
private itemManager: ItemManager,
|
||||
private items: ItemManagerInterface,
|
||||
private mutator: MutatorClientInterface,
|
||||
private syncService: SNSyncService,
|
||||
private featuresService: SNFeaturesService,
|
||||
private preferencesSerivce: SNPreferencesService,
|
||||
protected alertService: AlertService,
|
||||
private sync: SyncServiceInterface,
|
||||
private features: SNFeaturesService,
|
||||
private preferences: PreferenceServiceInterface,
|
||||
protected alerts: AlertService,
|
||||
private environment: Environment,
|
||||
private platform: Platform,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
private device: DeviceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
this.loggingEnabled = false
|
||||
|
||||
this.addItemObserver()
|
||||
|
||||
this.eventDisposers.push(
|
||||
preferences.addEventObserver((event) => {
|
||||
if (event === PreferencesServiceEvent.PreferencesChanged) {
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
window.addEventListener
|
||||
? window.addEventListener('focus', this.detectFocusChange, true)
|
||||
: window.attachEvent('onfocusout', this.detectFocusChange)
|
||||
@@ -112,20 +142,16 @@ export class SNComponentManager
|
||||
return this.environment === Environment.Mobile
|
||||
}
|
||||
|
||||
get components(): SNComponent[] {
|
||||
return this.itemManager.getDisplayableComponents()
|
||||
get thirdPartyComponents(): ComponentInterface[] {
|
||||
return this.items.getDisplayableComponents()
|
||||
}
|
||||
|
||||
componentsForArea(area: ComponentArea): SNComponent[] {
|
||||
return this.components.filter((component) => {
|
||||
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] {
|
||||
return this.thirdPartyComponents.filter((component) => {
|
||||
return component.area === area
|
||||
})
|
||||
}
|
||||
|
||||
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined {
|
||||
return this.components.find((component) => component.identifier === identifier)
|
||||
}
|
||||
|
||||
override deinit(): void {
|
||||
super.deinit()
|
||||
|
||||
@@ -137,11 +163,11 @@ export class SNComponentManager
|
||||
this.permissionDialogs.length = 0
|
||||
|
||||
this.desktopManager = undefined
|
||||
;(this.itemManager as unknown) = undefined
|
||||
;(this.featuresService as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.alertService as unknown) = undefined
|
||||
;(this.preferencesSerivce as unknown) = undefined
|
||||
;(this.items as unknown) = undefined
|
||||
;(this.features as unknown) = undefined
|
||||
;(this.sync as unknown) = undefined
|
||||
;(this.alerts as unknown) = undefined
|
||||
;(this.preferences as unknown) = undefined
|
||||
|
||||
this.removeItemObserver?.()
|
||||
;(this.removeItemObserver as unknown) = undefined
|
||||
@@ -157,27 +183,35 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
public createComponentViewer(
|
||||
component: SNComponent,
|
||||
contextItem?: UuidString,
|
||||
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
|
||||
item: ComponentViewerItem,
|
||||
actionObserver?: ActionObserver,
|
||||
): ComponentViewerInterface {
|
||||
const viewer = new ComponentViewer(
|
||||
component,
|
||||
this.itemManager,
|
||||
this.mutator,
|
||||
this.syncService,
|
||||
this.alertService,
|
||||
this.preferencesSerivce,
|
||||
this.featuresService,
|
||||
this.environment,
|
||||
this.platform,
|
||||
{
|
||||
runWithPermissions: this.runWithPermissions.bind(this),
|
||||
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
|
||||
items: this.items,
|
||||
mutator: this.mutator,
|
||||
sync: this.sync,
|
||||
alerts: this.alerts,
|
||||
preferences: this.preferences,
|
||||
features: this.features,
|
||||
},
|
||||
{
|
||||
url: this.urlForComponent(component) ?? '',
|
||||
item,
|
||||
actionObserver,
|
||||
},
|
||||
{
|
||||
environment: this.environment,
|
||||
platform: this.platform,
|
||||
componentManagerFunctions: {
|
||||
runWithPermissions: this.runWithPermissions.bind(this),
|
||||
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
|
||||
setComponentPreferences: this.setComponentPreferences.bind(this),
|
||||
getComponentPreferences: this.getComponentPreferences.bind(this),
|
||||
},
|
||||
},
|
||||
this.urlForComponent(component),
|
||||
contextItem,
|
||||
actionObserver,
|
||||
)
|
||||
this.viewers.push(viewer)
|
||||
return viewer
|
||||
@@ -193,7 +227,7 @@ export class SNComponentManager
|
||||
this.configureForDesktop()
|
||||
}
|
||||
|
||||
handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void {
|
||||
private handleChangedComponents(components: ComponentInterface[], source: PayloadEmitSource): void {
|
||||
const acceptableSources = [
|
||||
PayloadEmitSource.LocalChanged,
|
||||
PayloadEmitSource.RemoteRetrieved,
|
||||
@@ -221,8 +255,8 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
addItemObserver(): void {
|
||||
this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
|
||||
private addItemObserver(): void {
|
||||
this.removeItemObserver = this.items.addObserver<ComponentInterface>(
|
||||
[ContentType.TYPES.Component, ContentType.TYPES.Theme],
|
||||
({ changed, inserted, removed, source }) => {
|
||||
const items = [...changed, ...inserted]
|
||||
@@ -231,7 +265,7 @@ export class SNComponentManager
|
||||
const device = this.device
|
||||
if (isMobileDevice(device) && 'addComponentUrl' in device) {
|
||||
inserted.forEach((component) => {
|
||||
const url = this.urlForComponent(component)
|
||||
const url = this.urlForComponent(new ComponentOrNativeFeature<ComponentFeatureDescription>(component))
|
||||
if (url) {
|
||||
device.addComponentUrl(component.uuid, url)
|
||||
}
|
||||
@@ -274,9 +308,11 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
configureForDesktop(): void {
|
||||
this.desktopManager?.registerUpdateObserver((component: SNComponent) => {
|
||||
this.desktopManager?.registerUpdateObserver((component: ComponentInterface) => {
|
||||
/* Reload theme if active */
|
||||
if (component.active && component.isTheme()) {
|
||||
const activeComponents = this.getActiveComponents()
|
||||
const isComponentActive = activeComponents.find((candidate) => candidate.uuid === component.uuid)
|
||||
if (isComponentActive && component.isTheme()) {
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
})
|
||||
@@ -288,53 +324,57 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
getActiveThemes(): SNTheme[] {
|
||||
return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
|
||||
return theme.active
|
||||
}) as SNTheme[]
|
||||
private urlForComponentOnDesktop(
|
||||
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
): string | undefined {
|
||||
assert(this.desktopManager)
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
return `${this.desktopManager.getExtServerHost()}/components/${uiFeature.featureIdentifier}/${
|
||||
uiFeature.asFeatureDescription.index_path
|
||||
}`
|
||||
} else {
|
||||
if (uiFeature.asComponent.local_url) {
|
||||
return uiFeature.asComponent.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
|
||||
}
|
||||
|
||||
return uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
|
||||
}
|
||||
}
|
||||
|
||||
urlForComponent(component: SNComponent): string | undefined {
|
||||
const platformSupportsOfflineOnly = this.isDesktop
|
||||
if (component.offlineOnly && !platformSupportsOfflineOnly) {
|
||||
private urlForNativeComponent(feature: ComponentFeatureDescription): string {
|
||||
if (this.isMobile) {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${feature.identifier}/${feature.index_path}`
|
||||
} else {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.origin
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${feature.identifier}/${feature.index_path}`
|
||||
}
|
||||
}
|
||||
|
||||
urlForComponent(uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>): string | undefined {
|
||||
if (this.desktopManager) {
|
||||
return this.urlForComponentOnDesktop(uiFeature)
|
||||
}
|
||||
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
return this.urlForNativeComponent(uiFeature.asFeatureDescription)
|
||||
}
|
||||
|
||||
if (uiFeature.asComponent.offlineOnly) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(component.identifier)
|
||||
|
||||
if (this.isDesktop) {
|
||||
assert(this.desktopManager)
|
||||
|
||||
if (nativeFeature) {
|
||||
return `${this.desktopManager.getExtServerHost()}/components/${component.identifier}/${
|
||||
nativeFeature.index_path
|
||||
}`
|
||||
} else if (component.local_url) {
|
||||
return component.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
|
||||
} else {
|
||||
return component.hosted_url || component.legacy_url
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = this.environment === Environment.Mobile
|
||||
if (nativeFeature) {
|
||||
if (isMobile) {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${component.identifier}/${nativeFeature.index_path}`
|
||||
} else {
|
||||
const baseUrlRequiredForThemesInsideEditors = window.location.origin
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}`
|
||||
}
|
||||
}
|
||||
|
||||
let url = component.hosted_url || component.legacy_url
|
||||
const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isMobile) {
|
||||
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
|
||||
url = url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
|
||||
return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -350,8 +390,24 @@ export class SNComponentManager
|
||||
return urls
|
||||
}
|
||||
|
||||
private findComponent(uuid: UuidString): SNComponent | undefined {
|
||||
return this.itemManager.findItem<SNComponent>(uuid)
|
||||
private findComponent(uuid: string): ComponentInterface | undefined {
|
||||
return this.items.findItem<ComponentInterface>(uuid)
|
||||
}
|
||||
|
||||
private findComponentOrNativeFeature(
|
||||
identifier: string,
|
||||
): ComponentOrNativeFeature<ComponentFeatureDescription> | undefined {
|
||||
const nativeFeature = FindNativeFeature<ComponentFeatureDescription>(identifier as FeatureIdentifier)
|
||||
if (nativeFeature) {
|
||||
return new ComponentOrNativeFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const componentItem = this.items.findItem<ComponentInterface>(identifier)
|
||||
if (componentItem) {
|
||||
return new ComponentOrNativeFeature<ComponentFeatureDescription>(componentItem)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
findComponentViewer(identifier: string): ComponentViewerInterface | undefined {
|
||||
@@ -362,10 +418,13 @@ export class SNComponentManager
|
||||
return this.viewers.find((viewer) => viewer.sessionKey === key)
|
||||
}
|
||||
|
||||
areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean {
|
||||
areRequestedPermissionsValid(
|
||||
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
permissions: ComponentPermission[],
|
||||
): boolean {
|
||||
for (const permission of permissions) {
|
||||
if (permission.name === ComponentAction.StreamItems) {
|
||||
if (!AllowedBatchStreaming.includes(component.identifier)) {
|
||||
if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) {
|
||||
return false
|
||||
}
|
||||
const hasNonAllowedBatchPermission = permission.content_types?.some(
|
||||
@@ -381,28 +440,32 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
runWithPermissions(
|
||||
componentUuid: UuidString,
|
||||
componentIdentifier: string,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
): void {
|
||||
const component = this.findComponent(componentUuid)
|
||||
const uiFeature = this.findComponentOrNativeFeature(componentIdentifier)
|
||||
|
||||
if (!component) {
|
||||
void this.alertService.alert(
|
||||
`Unable to find component with ID ${componentUuid}. Please restart the app and try again.`,
|
||||
if (!uiFeature) {
|
||||
void this.alerts.alert(
|
||||
`Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`,
|
||||
'An unexpected error occurred',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.areRequestedPermissionsValid(component, requiredPermissions)) {
|
||||
console.error('Component is requesting invalid permissions', componentUuid, requiredPermissions)
|
||||
if (uiFeature.isFeatureDescription) {
|
||||
runFunction()
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(component.identifier)
|
||||
const acquiredPermissions = nativeFeature?.component_permissions || component.permissions
|
||||
if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) {
|
||||
console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions)
|
||||
return
|
||||
}
|
||||
|
||||
const acquiredPermissions = uiFeature.acquiredPermissions
|
||||
|
||||
/* Make copy as not to mutate input values */
|
||||
requiredPermissions = Copy(requiredPermissions) as ComponentPermission[]
|
||||
@@ -420,7 +483,7 @@ export class SNComponentManager
|
||||
filterFromArray(requiredPermissions, required)
|
||||
continue
|
||||
}
|
||||
for (const acquiredContentType of respectiveAcquired.content_types!) {
|
||||
for (const acquiredContentType of respectiveAcquired.content_types as string[]) {
|
||||
removeFromArray(requiredContentTypes, acquiredContentType)
|
||||
}
|
||||
if (requiredContentTypes.length === 0) {
|
||||
@@ -429,8 +492,8 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
if (requiredPermissions.length > 0) {
|
||||
this.promptForPermissionsWithAngularAsyncRendering(
|
||||
component,
|
||||
this.promptForPermissionsWithDeferredRendering(
|
||||
uiFeature.asComponent,
|
||||
requiredPermissions,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (approved) => {
|
||||
@@ -444,8 +507,8 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
promptForPermissionsWithAngularAsyncRendering(
|
||||
component: SNComponent,
|
||||
promptForPermissionsWithDeferredRendering(
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
@@ -455,14 +518,14 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
promptForPermissions(
|
||||
component: SNComponent,
|
||||
component: ComponentInterface,
|
||||
permissions: ComponentPermission[],
|
||||
callback: (approved: boolean) => Promise<void>,
|
||||
): void {
|
||||
const params: PermissionDialog = {
|
||||
component: component,
|
||||
permissions: permissions,
|
||||
permissionsString: this.permissionsStringForPermissions(permissions, component),
|
||||
permissionsString: permissionsStringForPermissions(permissions, component),
|
||||
actionBlock: callback,
|
||||
callback: async (approved: boolean) => {
|
||||
const latestComponent = this.findComponent(component.uuid)
|
||||
@@ -481,7 +544,7 @@ export class SNComponentManager
|
||||
} else {
|
||||
/* Permission already exists, but content_types may have been expanded */
|
||||
const contentTypes = matchingPermission.content_types || []
|
||||
matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!))
|
||||
matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +553,7 @@ export class SNComponentManager
|
||||
mutator.permissions = componentPermissions
|
||||
})
|
||||
|
||||
void this.syncService.sync()
|
||||
void this.sync.sync()
|
||||
}
|
||||
|
||||
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
|
||||
@@ -528,9 +591,7 @@ export class SNComponentManager
|
||||
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
|
||||
* We only want to present one and trigger all callbacks based on one modal result
|
||||
*/
|
||||
const existingDialog = find(this.permissionDialogs, {
|
||||
component: component,
|
||||
})
|
||||
const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component)
|
||||
this.permissionDialogs.push(params)
|
||||
if (!existingDialog) {
|
||||
this.presentPermissionsDialog(params)
|
||||
@@ -544,56 +605,72 @@ export class SNComponentManager
|
||||
throw 'Must override SNComponentManager.presentPermissionsDialog'
|
||||
}
|
||||
|
||||
async toggleTheme(uuid: UuidString): Promise<void> {
|
||||
this.log('Toggling theme', uuid)
|
||||
async toggleTheme(uiFeature: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
this.log('Toggling theme', uiFeature.uniqueIdentifier)
|
||||
|
||||
const theme = this.findComponent(uuid) as SNTheme
|
||||
if (theme.active) {
|
||||
await this.mutator.changeComponent(theme, (mutator) => {
|
||||
mutator.active = false
|
||||
})
|
||||
} else {
|
||||
const activeThemes = this.getActiveThemes()
|
||||
|
||||
/* Activate current before deactivating others, so as not to flicker */
|
||||
await this.mutator.changeComponent(theme, (mutator) => {
|
||||
mutator.active = true
|
||||
})
|
||||
|
||||
/* Deactive currently active theme(s) if new theme is not layerable */
|
||||
if (!theme.isLayerable()) {
|
||||
await sleep(10)
|
||||
for (const candidate of activeThemes) {
|
||||
if (candidate && !candidate.isLayerable()) {
|
||||
await this.mutator.changeComponent(candidate, (mutator) => {
|
||||
mutator.active = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void this.syncService.sync()
|
||||
}
|
||||
|
||||
async toggleComponent(uuid: UuidString): Promise<void> {
|
||||
this.log('Toggling component', uuid)
|
||||
|
||||
const component = this.findComponent(uuid)
|
||||
|
||||
if (!component) {
|
||||
if (this.isThemeActive(uiFeature)) {
|
||||
await this.removeActiveTheme(uiFeature)
|
||||
return
|
||||
}
|
||||
|
||||
await this.mutator.changeComponent(component, (mutator) => {
|
||||
mutator.active = !(mutator.getItem() as SNComponent).active
|
||||
})
|
||||
const featureStatus = this.features.getFeatureStatus(uiFeature.featureIdentifier)
|
||||
if (featureStatus !== FeatureStatus.Entitled) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.syncService.sync()
|
||||
/* Activate current before deactivating others, so as not to flicker */
|
||||
await this.addActiveTheme(uiFeature)
|
||||
|
||||
/* Deactive currently active theme(s) if new theme is not layerable */
|
||||
if (!uiFeature.layerable) {
|
||||
await sleep(10)
|
||||
|
||||
const activeThemes = this.getActiveThemes()
|
||||
for (const candidate of activeThemes) {
|
||||
if (candidate.featureIdentifier === uiFeature.featureIdentifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!candidate.layerable) {
|
||||
await this.removeActiveTheme(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isComponentActive(component: SNComponent): boolean {
|
||||
return component.active
|
||||
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[] {
|
||||
const activeThemesIdentifiers = this.getActiveThemesIdentifiers()
|
||||
|
||||
const thirdPartyThemes = this.items.findItems<ThemeInterface>(activeThemesIdentifiers).map((item) => {
|
||||
return new ComponentOrNativeFeature<ThemeFeatureDescription>(item)
|
||||
})
|
||||
|
||||
const nativeThemes = activeThemesIdentifiers
|
||||
.map((identifier) => {
|
||||
return FindNativeTheme(identifier as FeatureIdentifier)
|
||||
})
|
||||
.filter(isNotUndefined)
|
||||
.map((theme) => new ComponentOrNativeFeature(theme))
|
||||
|
||||
const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => {
|
||||
return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled
|
||||
})
|
||||
|
||||
return entitledThemes
|
||||
}
|
||||
|
||||
getActiveThemesIdentifiers(): string[] {
|
||||
return this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
|
||||
}
|
||||
|
||||
async toggleComponent(component: ComponentInterface): Promise<void> {
|
||||
this.log('Toggling component', component.uuid)
|
||||
|
||||
if (this.isComponentActive(component)) {
|
||||
await this.removeActiveComponent(component)
|
||||
} else {
|
||||
await this.addActiveComponent(component)
|
||||
}
|
||||
}
|
||||
|
||||
allComponentIframes(): HTMLIFrameElement[] {
|
||||
@@ -604,23 +681,67 @@ export class SNComponentManager
|
||||
return viewer.getIframe()
|
||||
}
|
||||
|
||||
editorForNote(note: SNNote): SNComponent | undefined {
|
||||
if (note.noteType === NoteType.Plain || note.noteType === NoteType.Super) {
|
||||
return undefined
|
||||
componentOrNativeFeatureForIdentifier<F extends UIFeatureDescriptionTypes>(
|
||||
identifier: FeatureIdentifier | string,
|
||||
): ComponentOrNativeFeature<F> | undefined {
|
||||
const nativeFeature = FindNativeFeature<F>(identifier as FeatureIdentifier)
|
||||
if (nativeFeature) {
|
||||
return new ComponentOrNativeFeature(nativeFeature)
|
||||
}
|
||||
|
||||
const component = this.thirdPartyComponents.find((component) => {
|
||||
return component.identifier === identifier
|
||||
})
|
||||
if (component) {
|
||||
return new ComponentOrNativeFeature<F>(component)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
editorForNote(note: SNNote): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription> {
|
||||
if (note.noteType === NoteType.Plain) {
|
||||
return new ComponentOrNativeFeature(GetPlainNoteFeature())
|
||||
}
|
||||
|
||||
if (note.noteType === NoteType.Super) {
|
||||
return new ComponentOrNativeFeature(GetSuperNoteFeature())
|
||||
}
|
||||
|
||||
if (note.editorIdentifier) {
|
||||
return this.componentWithIdentifier(note.editorIdentifier)
|
||||
const result = this.componentOrNativeFeatureForIdentifier<
|
||||
EditorFeatureDescription | IframeComponentFeatureDescription
|
||||
>(note.editorIdentifier)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return this.legacyGetEditorForNote(note)
|
||||
if (note.noteType && note.noteType !== NoteType.Unknown) {
|
||||
const result = this.nativeEditorForNoteType(note.noteType)
|
||||
if (result) {
|
||||
return new ComponentOrNativeFeature(result)
|
||||
}
|
||||
}
|
||||
|
||||
const legacyResult = this.legacyGetEditorForNote(note)
|
||||
if (legacyResult) {
|
||||
return new ComponentOrNativeFeature<IframeComponentFeatureDescription>(legacyResult)
|
||||
}
|
||||
|
||||
return new ComponentOrNativeFeature(GetPlainNoteFeature())
|
||||
}
|
||||
|
||||
private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined {
|
||||
const nativeEditors = GetIframeAndNativeEditors()
|
||||
return nativeEditors.find((editor) => editor.note_type === noteType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly.
|
||||
*/
|
||||
private legacyGetEditorForNote(note: SNNote): SNComponent | undefined {
|
||||
const editors = this.componentsForArea(ComponentArea.Editor)
|
||||
private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
for (const editor of editors) {
|
||||
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
return editor
|
||||
@@ -635,67 +756,25 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
legacyGetDefaultEditor(): SNComponent | undefined {
|
||||
const editors = this.componentsForArea(ComponentArea.Editor)
|
||||
legacyGetDefaultEditor(): ComponentInterface | undefined {
|
||||
const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
|
||||
}
|
||||
|
||||
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string {
|
||||
if (permissions.length === 0) {
|
||||
return '.'
|
||||
doesEditorChangeRequireAlert(
|
||||
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
|
||||
): boolean {
|
||||
if (!from || !to) {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentTypeStrings: string[] = []
|
||||
let contextAreaStrings: string[] = []
|
||||
const fromFileType = from.fileType
|
||||
const toFileType = to.fileType
|
||||
const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md'
|
||||
const areBothHtml = fromFileType === 'html' && toFileType === 'html'
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
switch (permission.name) {
|
||||
case ComponentAction.StreamItems:
|
||||
if (!permission.content_types) {
|
||||
return
|
||||
}
|
||||
permission.content_types.forEach((contentTypeString: string) => {
|
||||
const contentTypeOrError = ContentType.create(contentTypeString)
|
||||
if (contentTypeOrError.isFailed()) {
|
||||
return
|
||||
}
|
||||
const contentType = contentTypeOrError.getValue()
|
||||
const desc = contentType.getDisplayName()
|
||||
if (desc) {
|
||||
contentTypeStrings.push(`${desc}s`)
|
||||
} else {
|
||||
contentTypeStrings.push(`items of type ${contentType.value}`)
|
||||
}
|
||||
})
|
||||
break
|
||||
case ComponentAction.StreamContextItem:
|
||||
{
|
||||
const componentAreaMapping = {
|
||||
[ComponentArea.EditorStack]: 'working note',
|
||||
[ComponentArea.Editor]: 'working note',
|
||||
[ComponentArea.Themes]: 'Unknown',
|
||||
}
|
||||
contextAreaStrings.push(componentAreaMapping[component.area])
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
contentTypeStrings = uniq(contentTypeStrings)
|
||||
contextAreaStrings = uniq(contextAreaStrings)
|
||||
|
||||
if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) {
|
||||
return '.'
|
||||
}
|
||||
return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.'
|
||||
}
|
||||
|
||||
doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean {
|
||||
const isEitherPlainEditor = !from || !to
|
||||
const isEitherMarkdown = from?.package_info.file_type === 'md' || to?.package_info.file_type === 'md'
|
||||
const areBothHtml = from?.package_info.file_type === 'html' && to?.package_info.file_type === 'html'
|
||||
|
||||
if (isEitherPlainEditor || isEitherMarkdown || areBothHtml) {
|
||||
if (isEitherMarkdown || areBothHtml) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
@@ -703,7 +782,7 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
async showEditorChangeAlert(): Promise<boolean> {
|
||||
const shouldChangeEditor = await this.alertService.confirm(
|
||||
const shouldChangeEditor = await this.alerts.confirm(
|
||||
'Doing so might result in minor formatting changes.',
|
||||
"Are you sure you want to change this note's type?",
|
||||
'Yes, change it',
|
||||
@@ -711,4 +790,91 @@ export class SNComponentManager
|
||||
|
||||
return shouldChangeEditor
|
||||
}
|
||||
|
||||
async setComponentPreferences(
|
||||
uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
preferences: ComponentPreferencesEntry,
|
||||
): Promise<void> {
|
||||
const mutablePreferencesValue = Copy<AllComponentPreferences>(
|
||||
this.preferences.getValue(PrefKey.ComponentPreferences, undefined) ?? {},
|
||||
)
|
||||
|
||||
const preferencesLookupKey = uiFeature.uniqueIdentifier
|
||||
|
||||
mutablePreferencesValue[preferencesLookupKey] = preferences
|
||||
|
||||
await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue)
|
||||
}
|
||||
|
||||
getComponentPreferences(
|
||||
component: ComponentOrNativeFeature<ComponentFeatureDescription>,
|
||||
): ComponentPreferencesEntry | undefined {
|
||||
const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined)
|
||||
|
||||
if (!preferences) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const preferencesLookupKey = component.uniqueIdentifier
|
||||
|
||||
return preferences[preferencesLookupKey]
|
||||
}
|
||||
|
||||
async addActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice()
|
||||
|
||||
activeThemes.push(theme.uniqueIdentifier)
|
||||
|
||||
await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes)
|
||||
}
|
||||
|
||||
async replaceActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier])
|
||||
}
|
||||
|
||||
async removeActiveTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void> {
|
||||
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
|
||||
|
||||
const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier)
|
||||
|
||||
await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes)
|
||||
}
|
||||
|
||||
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean {
|
||||
if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []
|
||||
|
||||
return activeThemes.includes(theme.uniqueIdentifier)
|
||||
}
|
||||
|
||||
async addActiveComponent(component: ComponentInterface): Promise<void> {
|
||||
const activeComponents = (this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []).slice()
|
||||
|
||||
activeComponents.push(component.uuid)
|
||||
|
||||
await this.preferences.setValue(PrefKey.ActiveComponents, activeComponents)
|
||||
}
|
||||
|
||||
async removeActiveComponent(component: ComponentInterface): Promise<void> {
|
||||
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
|
||||
|
||||
const filteredComponents = activeComponents.filter((activeComponent) => activeComponent !== component.uuid)
|
||||
|
||||
await this.preferences.setValue(PrefKey.ActiveComponents, filteredComponents)
|
||||
}
|
||||
|
||||
getActiveComponents(): ComponentInterface[] {
|
||||
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
|
||||
|
||||
return this.items.findItems(activeComponents)
|
||||
}
|
||||
|
||||
isComponentActive(component: ComponentInterface): boolean {
|
||||
const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []
|
||||
|
||||
return activeComponents.includes(component.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import {
|
||||
ComponentViewerInterface,
|
||||
ComponentViewerError,
|
||||
@@ -6,6 +5,11 @@ import {
|
||||
FeaturesEvent,
|
||||
AlertService,
|
||||
MutatorClientInterface,
|
||||
PreferenceServiceInterface,
|
||||
ComponentViewerItem,
|
||||
isComponentViewerItemReadonlyItem,
|
||||
ItemManagerInterface,
|
||||
SyncServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { SNFeaturesService } from '@Lib/Services'
|
||||
import {
|
||||
@@ -13,7 +17,6 @@ import {
|
||||
ComponentEventObserver,
|
||||
ComponentViewerEvent,
|
||||
ComponentMessage,
|
||||
SNComponent,
|
||||
PrefKey,
|
||||
NoteContent,
|
||||
MutationType,
|
||||
@@ -36,11 +39,10 @@ import {
|
||||
Environment,
|
||||
Platform,
|
||||
OutgoingItemMessagePayload,
|
||||
ComponentPreferencesEntry,
|
||||
ComponentOrNativeFeature,
|
||||
ComponentInterface,
|
||||
} from '@standardnotes/models'
|
||||
import find from 'lodash/find'
|
||||
import uniq from 'lodash/uniq'
|
||||
import remove from 'lodash/remove'
|
||||
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
|
||||
import { environmentToString, platformToString } from '@Lib/Application/Platforms'
|
||||
import {
|
||||
MessageReply,
|
||||
@@ -48,10 +50,15 @@ import {
|
||||
AllowedBatchContentTypes,
|
||||
DeleteItemsMessageData,
|
||||
MessageReplyData,
|
||||
ReadwriteActions,
|
||||
} from './Types'
|
||||
import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import { ComponentViewerRequiresComponentManagerFunctions } from './ComponentViewerRequiresComponentManagerFunctions'
|
||||
import {
|
||||
ComponentAction,
|
||||
ComponentPermission,
|
||||
ComponentArea,
|
||||
IframeComponentFeatureDescription,
|
||||
} from '@standardnotes/features'
|
||||
import {
|
||||
isString,
|
||||
extendArray,
|
||||
@@ -63,30 +70,10 @@ import {
|
||||
Uuids,
|
||||
sureSearchArray,
|
||||
isNotUndefined,
|
||||
uniqueArray,
|
||||
} from '@standardnotes/utils'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
|
||||
type RunWithPermissionsCallback = (
|
||||
componentUuid: UuidString,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
) => void
|
||||
|
||||
type ComponentManagerFunctions = {
|
||||
runWithPermissions: RunWithPermissionsCallback
|
||||
urlsForActiveThemes: () => string[]
|
||||
}
|
||||
|
||||
const ReadwriteActions = [
|
||||
ComponentAction.SaveItems,
|
||||
ComponentAction.CreateItem,
|
||||
ComponentAction.CreateItems,
|
||||
ComponentAction.DeleteItems,
|
||||
ComponentAction.SetComponentData,
|
||||
]
|
||||
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
|
||||
|
||||
export class ComponentViewer implements ComponentViewerInterface {
|
||||
private streamItems?: string[]
|
||||
private streamContextItemOriginalMessage?: ComponentMessage
|
||||
@@ -95,7 +82,6 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
private loggingEnabled = false
|
||||
public identifier = nonSecureRandomIdentifier()
|
||||
private actionObservers: ActionObserver[] = []
|
||||
public overrideContextItem?: DecryptedItemInterface
|
||||
private featureStatus: FeatureStatus
|
||||
private removeFeaturesObserver: () => void
|
||||
private eventObservers: ComponentEventObserver[] = []
|
||||
@@ -108,21 +94,31 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
public sessionKey?: string
|
||||
|
||||
constructor(
|
||||
public readonly component: SNComponent,
|
||||
private itemManager: ItemManager,
|
||||
private mutator: MutatorClientInterface,
|
||||
private syncService: SNSyncService,
|
||||
private alertService: AlertService,
|
||||
private preferencesSerivce: SNPreferencesService,
|
||||
featuresService: SNFeaturesService,
|
||||
private environment: Environment,
|
||||
private platform: Platform,
|
||||
private componentManagerFunctions: ComponentManagerFunctions,
|
||||
public readonly url?: string,
|
||||
private contextItemUuid?: UuidString,
|
||||
actionObserver?: ActionObserver,
|
||||
private componentOrFeature: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
|
||||
private services: {
|
||||
items: ItemManagerInterface
|
||||
mutator: MutatorClientInterface
|
||||
sync: SyncServiceInterface
|
||||
alerts: AlertService
|
||||
preferences: PreferenceServiceInterface
|
||||
features: SNFeaturesService
|
||||
},
|
||||
private options: {
|
||||
item: ComponentViewerItem
|
||||
url: string
|
||||
actionObserver?: ActionObserver
|
||||
},
|
||||
private config: {
|
||||
environment: Environment
|
||||
platform: Platform
|
||||
componentManagerFunctions: ComponentViewerRequiresComponentManagerFunctions
|
||||
},
|
||||
) {
|
||||
this.removeItemObserver = this.itemManager.addObserver(
|
||||
if (isComponentViewerItemReadonlyItem(options.item)) {
|
||||
this.setReadonly(true)
|
||||
this.lockReadonly = true
|
||||
}
|
||||
this.removeItemObserver = this.services.items.addObserver(
|
||||
ContentType.TYPES.Any,
|
||||
({ changed, inserted, removed, source, sourceKey }) => {
|
||||
if (this.dealloced) {
|
||||
@@ -132,21 +128,22 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
this.handleChangesInItems(items, source, sourceKey)
|
||||
},
|
||||
)
|
||||
if (actionObserver) {
|
||||
this.actionObservers.push(actionObserver)
|
||||
if (options.actionObserver) {
|
||||
this.actionObservers.push(options.actionObserver)
|
||||
}
|
||||
|
||||
this.featureStatus = featuresService.getFeatureStatus(component.identifier)
|
||||
this.featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier)
|
||||
|
||||
this.removeFeaturesObserver = featuresService.addEventObserver((event) => {
|
||||
this.removeFeaturesObserver = services.features.addEventObserver((event) => {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
if (event === FeaturesEvent.FeaturesUpdated) {
|
||||
const featureStatus = featuresService.getFeatureStatus(component.identifier)
|
||||
|
||||
if (event === FeaturesEvent.FeaturesAvailabilityChanged) {
|
||||
const featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier)
|
||||
if (featureStatus !== this.featureStatus) {
|
||||
this.featureStatus = featureStatus
|
||||
this.postActiveThemes()
|
||||
this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated)
|
||||
}
|
||||
}
|
||||
@@ -155,12 +152,20 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
this.log('Constructor', this)
|
||||
}
|
||||
|
||||
public getComponentOrFeatureItem(): ComponentOrNativeFeature<IframeComponentFeatureDescription> {
|
||||
return this.componentOrFeature
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.options.url
|
||||
}
|
||||
|
||||
get isDesktop(): boolean {
|
||||
return this.environment === Environment.Desktop
|
||||
return this.config.environment === Environment.Desktop
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.environment === Environment.Mobile
|
||||
return this.config.environment === Environment.Mobile
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
@@ -170,12 +175,10 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
|
||||
private deinit(): void {
|
||||
this.dealloced = true
|
||||
;(this.component as unknown) = undefined
|
||||
;(this.itemManager as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.alertService as unknown) = undefined
|
||||
;(this.preferencesSerivce as unknown) = undefined
|
||||
;(this.componentManagerFunctions as unknown) = undefined
|
||||
;(this.componentOrFeature as unknown) = undefined
|
||||
;(this.services as unknown) = undefined
|
||||
;(this.config as unknown) = undefined
|
||||
;(this.options as unknown) = undefined
|
||||
|
||||
this.eventObservers.length = 0
|
||||
this.actionObservers.length = 0
|
||||
@@ -218,8 +221,8 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
this.readonly = readonly
|
||||
}
|
||||
|
||||
get componentUuid(): string {
|
||||
return this.component.uuid
|
||||
get componentUniqueIdentifier(): string {
|
||||
return this.componentOrFeature.uniqueIdentifier
|
||||
}
|
||||
|
||||
public getFeatureStatus(): FeatureStatus {
|
||||
@@ -227,20 +230,17 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
private isOfflineRestricted(): boolean {
|
||||
return this.component.offlineOnly && !this.isDesktop
|
||||
}
|
||||
|
||||
private isNativeFeature(): boolean {
|
||||
return !!FindNativeFeature(this.component.identifier)
|
||||
return this.componentOrFeature.isComponent && this.componentOrFeature.asComponent.offlineOnly && !this.isDesktop
|
||||
}
|
||||
|
||||
private hasUrlError(): boolean {
|
||||
if (this.isNativeFeature()) {
|
||||
if (!this.componentOrFeature.isComponent) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.isDesktop
|
||||
? !this.component.local_url && !this.component.hasValidHostedUrl()
|
||||
: !this.component.hasValidHostedUrl()
|
||||
? !this.componentOrFeature.asComponent.local_url && !this.componentOrFeature.asComponent.hasValidHostedUrl
|
||||
: !this.componentOrFeature.asComponent.hasValidHostedUrl
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
@@ -251,6 +251,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
if (this.isOfflineRestricted()) {
|
||||
return ComponentViewerError.OfflineRestricted
|
||||
}
|
||||
|
||||
if (this.hasUrlError()) {
|
||||
return ComponentViewerError.MissingUrl
|
||||
}
|
||||
@@ -259,10 +260,18 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
private updateOurComponentRefFromChangedItems(items: DecryptedItemInterface[]): void {
|
||||
const updatedComponent = items.find((item) => item.uuid === this.component.uuid)
|
||||
if (updatedComponent && isDecryptedItem(updatedComponent)) {
|
||||
;(this.component as Writeable<SNComponent>) = updatedComponent as SNComponent
|
||||
if (!this.componentOrFeature.isComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedComponent = items.find((item) => item.uuid === this.componentUniqueIdentifier) as ComponentInterface
|
||||
if (!updatedComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = new ComponentOrNativeFeature<IframeComponentFeatureDescription>(updatedComponent)
|
||||
|
||||
this.componentOrFeature = item
|
||||
}
|
||||
|
||||
handleChangesInItems(
|
||||
@@ -275,7 +284,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
|
||||
this.updateOurComponentRefFromChangedItems(nondeletedItems)
|
||||
|
||||
const areWeOriginator = sourceKey && sourceKey === this.component.uuid
|
||||
const areWeOriginator = sourceKey && sourceKey === this.componentUniqueIdentifier
|
||||
if (areWeOriginator) {
|
||||
return
|
||||
}
|
||||
@@ -291,7 +300,12 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
if (this.streamContextItemOriginalMessage) {
|
||||
const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid })
|
||||
const optionsItem = this.options.item
|
||||
if (isComponentViewerItemReadonlyItem(optionsItem)) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchingItem = nondeletedItems.find((item) => item.uuid === optionsItem.uuid)
|
||||
if (matchingItem) {
|
||||
this.sendContextItemThroughBridge(matchingItem, source)
|
||||
}
|
||||
@@ -302,13 +316,17 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: this.streamItems!.sort(),
|
||||
content_types: this.streamItems?.sort(),
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
|
||||
this.sendItemsInReply(items, this.streamItemsOriginalMessage!)
|
||||
})
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
() => {
|
||||
this.sendItemsInReply(items, this.streamItemsOriginalMessage as ComponentMessage)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void {
|
||||
@@ -317,21 +335,25 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
name: ComponentAction.StreamContextItem,
|
||||
},
|
||||
] as ComponentPermission[]
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredContextPermissions, () => {
|
||||
this.log(
|
||||
'Send context item in reply',
|
||||
'component:',
|
||||
this.component,
|
||||
'item: ',
|
||||
item,
|
||||
'originalMessage: ',
|
||||
this.streamContextItemOriginalMessage,
|
||||
)
|
||||
const response: MessageReplyData = {
|
||||
item: this.jsonForItem(item, source),
|
||||
}
|
||||
this.replyToMessage(this.streamContextItemOriginalMessage!, response)
|
||||
})
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredContextPermissions,
|
||||
() => {
|
||||
this.log(
|
||||
'Send context item in reply',
|
||||
'component:',
|
||||
this.componentOrFeature,
|
||||
'item: ',
|
||||
item,
|
||||
'originalMessage: ',
|
||||
this.streamContextItemOriginalMessage,
|
||||
)
|
||||
const response: MessageReplyData = {
|
||||
item: this.jsonForItem(item, source),
|
||||
}
|
||||
this.replyToMessage(this.streamContextItemOriginalMessage as ComponentMessage, response)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private log(message: string, ...args: unknown[]): void {
|
||||
@@ -345,7 +367,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
message: ComponentMessage,
|
||||
source?: PayloadEmitSource,
|
||||
): void {
|
||||
this.log('Send items in reply', this.component, items, message)
|
||||
this.log('Send items in reply', this.componentOrFeature, items, message)
|
||||
|
||||
const responseData: MessageReplyData = {}
|
||||
|
||||
@@ -377,9 +399,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
|
||||
if (isDecryptedItem(item)) {
|
||||
params.content = this.contentForItem(item)
|
||||
const globalComponentData = item.getDomainData(ComponentDataDomain) || {}
|
||||
const thisComponentData = globalComponentData[this.component.getClientDataKey()] || {}
|
||||
params.clientData = thisComponentData as Record<string, unknown>
|
||||
params.clientData = this.getClientData(item)
|
||||
} else {
|
||||
params.deleted = true
|
||||
}
|
||||
@@ -387,13 +407,19 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
return this.responseItemsByRemovingPrivateProperties([params])[0]
|
||||
}
|
||||
|
||||
private getClientData(item: DecryptedItemInterface): Record<string, unknown> {
|
||||
const globalComponentData = item.getDomainData(ComponentDataDomain) || {}
|
||||
const thisComponentData = globalComponentData[this.componentUniqueIdentifier] || {}
|
||||
return thisComponentData as Record<string, unknown>
|
||||
}
|
||||
|
||||
contentForItem(item: DecryptedItemInterface): ItemContent | undefined {
|
||||
if (isNote(item)) {
|
||||
const content = item.content
|
||||
const spellcheck =
|
||||
item.spellcheck != undefined
|
||||
? item.spellcheck
|
||||
: this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true)
|
||||
: this.services.preferences.getValue(PrefKey.EditorSpellcheck, true)
|
||||
|
||||
return {
|
||||
...content,
|
||||
@@ -421,21 +447,21 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
const permissibleActionsWhileHidden = [ComponentAction.ComponentRegistered, ComponentAction.ActivateThemes]
|
||||
|
||||
if (this.hidden && !permissibleActionsWhileHidden.includes(message.action)) {
|
||||
this.log('Component disabled for current item, ignoring messages.', this.component.name)
|
||||
this.log('Component disabled for current item, ignoring messages.', this.componentOrFeature.displayName)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.window && message.action === ComponentAction.Reply) {
|
||||
this.log('Component has been deallocated in between message send and reply', this.component, message)
|
||||
this.log('Component has been deallocated in between message send and reply', this.componentOrFeature, message)
|
||||
return
|
||||
}
|
||||
this.log('Send message to component', this.component, 'message: ', message)
|
||||
this.log('Send message to component', this.componentOrFeature, 'message: ', message)
|
||||
|
||||
let origin = this.url
|
||||
let origin = this.options.url
|
||||
if (!origin || !this.window) {
|
||||
if (essential) {
|
||||
void this.alertService.alert(
|
||||
`Standard Notes is trying to communicate with ${this.component.name}, ` +
|
||||
void this.services.alerts.alert(
|
||||
`Standard Notes is trying to communicate with ${this.componentOrFeature.displayName}, ` +
|
||||
'but an error is occurring. Please restart this extension and try again.',
|
||||
)
|
||||
}
|
||||
@@ -498,20 +524,22 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
throw Error('Attempting to override component viewer window. Create a new component viewer instead.')
|
||||
}
|
||||
|
||||
this.log('setWindow', 'component: ', this.component, 'window: ', window)
|
||||
this.log('setWindow', 'component: ', this.componentOrFeature, 'window: ', window)
|
||||
|
||||
this.window = window
|
||||
this.sessionKey = UuidGenerator.GenerateUuid()
|
||||
|
||||
const componentData = this.config.componentManagerFunctions.getComponentPreferences(this.componentOrFeature) ?? {}
|
||||
|
||||
this.sendMessage({
|
||||
action: ComponentAction.ComponentRegistered,
|
||||
sessionKey: this.sessionKey,
|
||||
componentData: this.component.componentData,
|
||||
componentData: componentData,
|
||||
data: {
|
||||
uuid: this.component.uuid,
|
||||
environment: environmentToString(this.environment),
|
||||
platform: platformToString(this.platform),
|
||||
activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(),
|
||||
uuid: this.componentUniqueIdentifier,
|
||||
environment: environmentToString(this.config.environment),
|
||||
platform: platformToString(this.config.platform),
|
||||
activeThemeUrls: this.config.componentManagerFunctions.urlsForActiveThemes(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -521,7 +549,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
postActiveThemes(): void {
|
||||
const urls = this.componentManagerFunctions.urlsForActiveThemes()
|
||||
const urls = this.config.componentManagerFunctions.urlsForActiveThemes()
|
||||
const data: MessageData = {
|
||||
themes: urls,
|
||||
}
|
||||
@@ -547,24 +575,24 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
if (this.streamItems) {
|
||||
this.handleStreamItemsMessage(this.streamItemsOriginalMessage!)
|
||||
this.handleStreamItemsMessage(this.streamItemsOriginalMessage as ComponentMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message: ComponentMessage): void {
|
||||
this.log('Handle message', message, this)
|
||||
if (!this.component) {
|
||||
if (!this.componentOrFeature) {
|
||||
this.log('Component not defined for message, returning', message)
|
||||
void this.alertService.alert(
|
||||
void this.services.alerts.alert(
|
||||
'A component is trying to communicate with Standard Notes, ' +
|
||||
'but there is an error establishing a bridge. Please restart the app and try again.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (this.readonly && ReadwriteActions.includes(message.action)) {
|
||||
void this.alertService.alert(
|
||||
`${this.component.name} is trying to save, but it is in a locked state and cannot accept changes.`,
|
||||
void this.services.alerts.alert(
|
||||
`${this.componentOrFeature.displayName} is trying to save, but it is in a locked state and cannot accept changes.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -572,7 +600,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
const messageHandlers: Partial<Record<ComponentAction, (message: ComponentMessage) => void>> = {
|
||||
[ComponentAction.StreamItems]: this.handleStreamItemsMessage.bind(this),
|
||||
[ComponentAction.StreamContextItem]: this.handleStreamContextItemMessage.bind(this),
|
||||
[ComponentAction.SetComponentData]: this.handleSetComponentDataMessage.bind(this),
|
||||
[ComponentAction.SetComponentData]: this.handleSetComponentPreferencesMessage.bind(this),
|
||||
[ComponentAction.DeleteItems]: this.handleDeleteItemsMessage.bind(this),
|
||||
[ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this),
|
||||
[ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this),
|
||||
@@ -597,18 +625,22 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
content_types: types,
|
||||
},
|
||||
]
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
|
||||
if (!this.streamItems) {
|
||||
this.streamItems = types
|
||||
this.streamItemsOriginalMessage = message
|
||||
}
|
||||
/* Push immediately now */
|
||||
const items: DecryptedItemInterface[] = []
|
||||
for (const contentType of types) {
|
||||
extendArray(items, this.itemManager.getItems(contentType))
|
||||
}
|
||||
this.sendItemsInReply(items, message)
|
||||
})
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
() => {
|
||||
if (!this.streamItems) {
|
||||
this.streamItems = types
|
||||
this.streamItemsOriginalMessage = message
|
||||
}
|
||||
/* Push immediately now */
|
||||
const items: DecryptedItemInterface[] = []
|
||||
for (const contentType of types) {
|
||||
extendArray(items, this.services.items.getItems(contentType))
|
||||
}
|
||||
this.sendItemsInReply(items, message)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
handleStreamContextItemMessage(message: ComponentMessage): void {
|
||||
@@ -618,15 +650,21 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
|
||||
if (!this.streamContextItemOriginalMessage) {
|
||||
this.streamContextItemOriginalMessage = message
|
||||
}
|
||||
const matchingItem = this.overrideContextItem || this.itemManager.findItem(this.contextItemUuid!)
|
||||
if (matchingItem) {
|
||||
this.sendContextItemThroughBridge(matchingItem)
|
||||
}
|
||||
})
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
() => {
|
||||
if (!this.streamContextItemOriginalMessage) {
|
||||
this.streamContextItemOriginalMessage = message
|
||||
}
|
||||
const matchingItem = isComponentViewerItemReadonlyItem(this.options.item)
|
||||
? this.options.item.readonlyItem
|
||||
: this.services.items.findItem(this.options.item.uuid)
|
||||
if (matchingItem) {
|
||||
this.sendContextItemThroughBridge(matchingItem)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -640,8 +678,12 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
/* Pending as in needed to be accounted for in permissions. */
|
||||
const pendingResponseItems = responsePayloads.slice()
|
||||
|
||||
if (isComponentViewerItemReadonlyItem(this.options.item)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const responseItem of responsePayloads.slice()) {
|
||||
if (responseItem.uuid === this.contextItemUuid) {
|
||||
if (responseItem.uuid === this.options.item.uuid) {
|
||||
requiredPermissions.push({
|
||||
name: ComponentAction.StreamContextItem,
|
||||
})
|
||||
@@ -653,7 +695,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
|
||||
/* Check to see if additional privileges are required */
|
||||
if (pendingResponseItems.length > 0) {
|
||||
const requiredContentTypes = uniq(
|
||||
const requiredContentTypes = uniqueArray(
|
||||
pendingResponseItems.map((item) => {
|
||||
return item.content_type
|
||||
}),
|
||||
@@ -665,8 +707,8 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
} as ComponentPermission)
|
||||
}
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(
|
||||
this.component.uuid,
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
|
||||
async () => {
|
||||
@@ -674,7 +716,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
|
||||
/* Filter locked items */
|
||||
const uuids = Uuids(responsePayloads)
|
||||
const items = this.itemManager.findItemsIncludingBlanks(uuids)
|
||||
const items = this.services.items.findItemsIncludingBlanks(uuids)
|
||||
let lockedCount = 0
|
||||
let lockedNoteCount = 0
|
||||
|
||||
@@ -684,7 +726,9 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
if (item.locked) {
|
||||
remove(responsePayloads, { uuid: item.uuid })
|
||||
responsePayloads = responsePayloads.filter((responseItem) => {
|
||||
return responseItem.uuid !== item.uuid
|
||||
})
|
||||
lockedCount++
|
||||
if (item.content_type === ContentType.TYPES.Note) {
|
||||
lockedNoteCount++
|
||||
@@ -693,7 +737,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
|
||||
if (lockedNoteCount === 1) {
|
||||
void this.alertService.alert(
|
||||
void this.services.alerts.alert(
|
||||
'The note you are attempting to save has editing disabled',
|
||||
'Note has Editing Disabled',
|
||||
)
|
||||
@@ -701,7 +745,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
} else if (lockedCount > 0) {
|
||||
const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items'
|
||||
const auxVerb = lockedCount === 1 ? 'has' : 'have'
|
||||
void this.alertService.alert(
|
||||
void this.services.alerts.alert(
|
||||
`${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`,
|
||||
'Items have Editing Disabled',
|
||||
)
|
||||
@@ -714,14 +758,14 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
})
|
||||
|
||||
for (const contextualPayload of contextualPayloads) {
|
||||
const item = this.itemManager.findItem(contextualPayload.uuid)
|
||||
const item = this.services.items.findItem(contextualPayload.uuid)
|
||||
if (!item) {
|
||||
const payload = new DecryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
...contextualPayload,
|
||||
})
|
||||
const template = CreateDecryptedItemFromPayload(payload)
|
||||
await this.mutator.insertItem(template)
|
||||
await this.services.mutator.insertItem(template)
|
||||
} else {
|
||||
if (contextualPayload.content_type !== item.content_type) {
|
||||
throw Error('Extension is trying to modify content type of item.')
|
||||
@@ -729,7 +773,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
await this.mutator.changeItems(
|
||||
await this.services.mutator.changeItems(
|
||||
items.filter(isNotUndefined),
|
||||
(mutator) => {
|
||||
const contextualPayload = sureSearchArray(contextualPayloads, {
|
||||
@@ -743,17 +787,19 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
})
|
||||
|
||||
if (responseItem.clientData) {
|
||||
const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {})
|
||||
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
|
||||
const allComponentData = Copy<Record<string, unknown>>(
|
||||
mutator.getItem().getDomainData(ComponentDataDomain) || {},
|
||||
)
|
||||
allComponentData[this.componentUniqueIdentifier] = responseItem.clientData
|
||||
mutator.setDomainData(allComponentData, ComponentDataDomain)
|
||||
}
|
||||
},
|
||||
MutationType.UpdateUserTimestamps,
|
||||
PayloadEmitSource.ComponentRetrieved,
|
||||
this.component.uuid,
|
||||
this.componentUniqueIdentifier,
|
||||
)
|
||||
|
||||
this.syncService
|
||||
this.services.sync
|
||||
.sync({
|
||||
onPresyncSave: () => {
|
||||
this.replyToMessage(message, {})
|
||||
@@ -771,7 +817,7 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
handleCreateItemsMessage(message: ComponentMessage): void {
|
||||
let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[]
|
||||
|
||||
const uniqueContentTypes = uniq(
|
||||
const uniqueContentTypes = uniqueArray(
|
||||
responseItems.map((item) => {
|
||||
return item.content_type
|
||||
}),
|
||||
@@ -784,59 +830,65 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
|
||||
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
|
||||
const processedItems = []
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
async () => {
|
||||
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
|
||||
const processedItems = []
|
||||
|
||||
for (const responseItem of responseItems) {
|
||||
if (!responseItem.uuid) {
|
||||
responseItem.uuid = UuidGenerator.GenerateUuid()
|
||||
for (const responseItem of responseItems) {
|
||||
if (!responseItem.uuid) {
|
||||
responseItem.uuid = UuidGenerator.GenerateUuid()
|
||||
}
|
||||
|
||||
const contextualPayload = createComponentCreatedContextPayload(responseItem)
|
||||
const payload = new DecryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
...contextualPayload,
|
||||
})
|
||||
|
||||
const template = CreateDecryptedItemFromPayload(payload)
|
||||
const item = await this.services.mutator.insertItem(template)
|
||||
|
||||
await this.services.mutator.changeItem(
|
||||
item,
|
||||
(mutator) => {
|
||||
if (responseItem.clientData) {
|
||||
const allComponentClientData = Copy<Record<string, unknown>>(
|
||||
item.getDomainData(ComponentDataDomain) || {},
|
||||
)
|
||||
allComponentClientData[this.componentUniqueIdentifier] = responseItem.clientData
|
||||
mutator.setDomainData(allComponentClientData, ComponentDataDomain)
|
||||
}
|
||||
},
|
||||
MutationType.UpdateUserTimestamps,
|
||||
PayloadEmitSource.ComponentCreated,
|
||||
this.componentUniqueIdentifier,
|
||||
)
|
||||
processedItems.push(item)
|
||||
}
|
||||
|
||||
const contextualPayload = createComponentCreatedContextPayload(responseItem)
|
||||
const payload = new DecryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
...contextualPayload,
|
||||
})
|
||||
void this.services.sync.sync()
|
||||
|
||||
const template = CreateDecryptedItemFromPayload(payload)
|
||||
const item = await this.mutator.insertItem(template)
|
||||
|
||||
await this.mutator.changeItem(
|
||||
item,
|
||||
(mutator) => {
|
||||
if (responseItem.clientData) {
|
||||
const allComponentData = Copy(item.getDomainData(ComponentDataDomain) || {})
|
||||
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
|
||||
mutator.setDomainData(allComponentData, ComponentDataDomain)
|
||||
}
|
||||
},
|
||||
MutationType.UpdateUserTimestamps,
|
||||
PayloadEmitSource.ComponentCreated,
|
||||
this.component.uuid,
|
||||
)
|
||||
processedItems.push(item)
|
||||
}
|
||||
|
||||
void this.syncService.sync()
|
||||
|
||||
const reply =
|
||||
message.action === ComponentAction.CreateItem
|
||||
? { item: this.jsonForItem(processedItems[0]) }
|
||||
: {
|
||||
items: processedItems.map((item) => {
|
||||
return this.jsonForItem(item)
|
||||
}),
|
||||
}
|
||||
this.replyToMessage(message, reply)
|
||||
})
|
||||
const reply =
|
||||
message.action === ComponentAction.CreateItem
|
||||
? { item: this.jsonForItem(processedItems[0]) }
|
||||
: {
|
||||
items: processedItems.map((item) => {
|
||||
return this.jsonForItem(item)
|
||||
}),
|
||||
}
|
||||
this.replyToMessage(message, reply)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
handleDeleteItemsMessage(message: ComponentMessage): void {
|
||||
const data = message.data as DeleteItemsMessageData
|
||||
const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type))
|
||||
|
||||
const requiredContentTypes = uniq(items.map((item) => item.content_type)).sort()
|
||||
const requiredContentTypes = uniqueArray(items.map((item) => item.content_type)).sort()
|
||||
|
||||
const requiredPermissions: ComponentPermission[] = [
|
||||
{
|
||||
@@ -845,48 +897,60 @@ export class ComponentViewer implements ComponentViewerInterface {
|
||||
},
|
||||
]
|
||||
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
|
||||
const itemsData = items
|
||||
const noun = itemsData.length === 1 ? 'item' : 'items'
|
||||
let reply = null
|
||||
const didConfirm = await this.alertService.confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`)
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
requiredPermissions,
|
||||
async () => {
|
||||
const itemsData = items
|
||||
const noun = itemsData.length === 1 ? 'item' : 'items'
|
||||
let reply = null
|
||||
const didConfirm = await this.services.alerts.confirm(
|
||||
`Are you sure you want to delete ${itemsData.length} ${noun}?`,
|
||||
)
|
||||
|
||||
if (didConfirm) {
|
||||
/* Filter for any components and deactivate before deleting */
|
||||
for (const itemData of itemsData) {
|
||||
const item = this.itemManager.findItem(itemData.uuid)
|
||||
if (!item) {
|
||||
void this.alertService.alert('The item you are trying to delete cannot be found.')
|
||||
continue
|
||||
if (didConfirm) {
|
||||
/* Filter for any components and deactivate before deleting */
|
||||
for (const itemData of itemsData) {
|
||||
const item = this.services.items.findItem(itemData.uuid)
|
||||
if (!item) {
|
||||
void this.services.alerts.alert('The item you are trying to delete cannot be found.')
|
||||
continue
|
||||
}
|
||||
await this.services.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
|
||||
}
|
||||
await this.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
|
||||
|
||||
void this.services.sync.sync()
|
||||
|
||||
reply = { deleted: true }
|
||||
} else {
|
||||
/* Rejected by user */
|
||||
reply = { deleted: false }
|
||||
}
|
||||
|
||||
void this.syncService.sync()
|
||||
|
||||
reply = { deleted: true }
|
||||
} else {
|
||||
/* Rejected by user */
|
||||
reply = { deleted: false }
|
||||
}
|
||||
|
||||
this.replyToMessage(message, reply)
|
||||
})
|
||||
this.replyToMessage(message, reply)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
handleSetComponentDataMessage(message: ComponentMessage): void {
|
||||
handleSetComponentPreferencesMessage(message: ComponentMessage): void {
|
||||
const noPermissionsRequired: ComponentPermission[] = []
|
||||
this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => {
|
||||
await this.mutator.changeComponent(this.component, (mutator) => {
|
||||
mutator.componentData = message.data.componentData || {}
|
||||
})
|
||||
this.config.componentManagerFunctions.runWithPermissions(
|
||||
this.componentUniqueIdentifier,
|
||||
noPermissionsRequired,
|
||||
async () => {
|
||||
const newPreferences = <ComponentPreferencesEntry | undefined>message.data.componentData
|
||||
|
||||
void this.syncService.sync()
|
||||
})
|
||||
if (!newPreferences) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.config.componentManagerFunctions.setComponentPreferences(this.componentOrFeature, newPreferences)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
handleSetSizeEvent(message: ComponentMessage): void {
|
||||
if (this.component.area !== ComponentArea.EditorStack) {
|
||||
if (this.componentOrFeature.area !== ComponentArea.EditorStack) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentOrNativeFeature, ComponentPreferencesEntry } from '@standardnotes/models'
|
||||
import { RunWithPermissionsCallback } from './Types'
|
||||
import { IframeComponentFeatureDescription } from '@standardnotes/features'
|
||||
|
||||
export interface ComponentViewerRequiresComponentManagerFunctions {
|
||||
runWithPermissions: RunWithPermissionsCallback
|
||||
urlsForActiveThemes: () => string[]
|
||||
setComponentPreferences(
|
||||
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
|
||||
preferences: ComponentPreferencesEntry,
|
||||
): Promise<void>
|
||||
getComponentPreferences(
|
||||
component: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
|
||||
): ComponentPreferencesEntry | undefined
|
||||
}
|
||||
@@ -1,8 +1,30 @@
|
||||
import { ComponentArea, ComponentAction, FeatureIdentifier, LegacyFileSafeIdentifier } from '@standardnotes/features'
|
||||
import {
|
||||
ComponentArea,
|
||||
ComponentAction,
|
||||
FeatureIdentifier,
|
||||
LegacyFileSafeIdentifier,
|
||||
ComponentPermission,
|
||||
} from '@standardnotes/features'
|
||||
import { ComponentMessage, MessageData, OutgoingItemMessagePayload } from '@standardnotes/models'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
|
||||
export type RunWithPermissionsCallback = (
|
||||
componentUuid: UuidString,
|
||||
requiredPermissions: ComponentPermission[],
|
||||
runFunction: () => void,
|
||||
) => void
|
||||
|
||||
export const ReadwriteActions = [
|
||||
ComponentAction.SaveItems,
|
||||
ComponentAction.CreateItem,
|
||||
ComponentAction.CreateItems,
|
||||
ComponentAction.DeleteItems,
|
||||
ComponentAction.SetComponentData,
|
||||
]
|
||||
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] }
|
||||
|
||||
/**
|
||||
* Extensions allowed to batch stream AllowedBatchContentTypes
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { ComponentAction, ComponentArea, ComponentPermission } from '@standardnotes/features'
|
||||
import { ComponentInterface } from '@standardnotes/models'
|
||||
import { uniqueArray } from '@standardnotes/utils'
|
||||
|
||||
export function permissionsStringForPermissions(
|
||||
permissions: ComponentPermission[],
|
||||
component: ComponentInterface,
|
||||
): string {
|
||||
if (permissions.length === 0) {
|
||||
return '.'
|
||||
}
|
||||
|
||||
let contentTypeStrings: string[] = []
|
||||
let contextAreaStrings: string[] = []
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
switch (permission.name) {
|
||||
case ComponentAction.StreamItems:
|
||||
if (!permission.content_types) {
|
||||
return
|
||||
}
|
||||
permission.content_types.forEach((contentTypeString: string) => {
|
||||
const contentTypeOrError = ContentType.create(contentTypeString)
|
||||
if (contentTypeOrError.isFailed()) {
|
||||
return
|
||||
}
|
||||
const contentType = contentTypeOrError.getValue()
|
||||
const desc = contentType.getDisplayName()
|
||||
if (desc) {
|
||||
contentTypeStrings.push(`${desc}s`)
|
||||
} else {
|
||||
contentTypeStrings.push(`items of type ${contentType.value}`)
|
||||
}
|
||||
})
|
||||
break
|
||||
case ComponentAction.StreamContextItem:
|
||||
{
|
||||
const componentAreaMapping = {
|
||||
[ComponentArea.EditorStack]: 'working note',
|
||||
[ComponentArea.Editor]: 'working note',
|
||||
[ComponentArea.Themes]: 'Unknown',
|
||||
}
|
||||
contextAreaStrings.push(componentAreaMapping[component.area])
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
contentTypeStrings = uniqueArray(contentTypeStrings)
|
||||
contextAreaStrings = uniqueArray(contextAreaStrings)
|
||||
|
||||
if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) {
|
||||
return '.'
|
||||
}
|
||||
return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.'
|
||||
}
|
||||
@@ -1,53 +1,57 @@
|
||||
import { ItemInterface, SNComponent, SNFeatureRepo } from '@standardnotes/models'
|
||||
import { ItemInterface, SNFeatureRepo } from '@standardnotes/models'
|
||||
import { SNSyncService } from '../Sync/SyncService'
|
||||
import { SettingName } from '@standardnotes/settings'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features'
|
||||
import { ContentType, RoleName } from '@standardnotes/domain-core'
|
||||
import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features'
|
||||
import { RoleName, ContentType } from '@standardnotes/domain-core'
|
||||
import { FeatureIdentifier, GetFeatures } from '@standardnotes/features'
|
||||
import { SNWebSocketsService } from '../Api/WebsocketsService'
|
||||
import { SNSettingsService } from '../Settings'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { convertTimestampToMilliseconds } from '@standardnotes/utils'
|
||||
import {
|
||||
AlertService,
|
||||
ApiServiceInterface,
|
||||
FeaturesEvent,
|
||||
FeatureStatus,
|
||||
InternalEventBusInterface,
|
||||
ItemManagerInterface,
|
||||
MutatorClientInterface,
|
||||
SessionsClientInterface,
|
||||
StorageKey,
|
||||
StorageServiceInterface,
|
||||
SubscriptionManagerInterface,
|
||||
SyncServiceInterface,
|
||||
UserClientInterface,
|
||||
UserService,
|
||||
} from '@standardnotes/services'
|
||||
import { SNApiService, SNSessionManager } from '../Api'
|
||||
import { ItemManager } from '../Items'
|
||||
import { DiskStorageService } from '../Storage/DiskStorageService'
|
||||
import { SettingsClientInterface } from '../Settings/SettingsClientInterface'
|
||||
|
||||
describe('featuresService', () => {
|
||||
let storageService: DiskStorageService
|
||||
let apiService: SNApiService
|
||||
let itemManager: ItemManager
|
||||
describe('FeaturesService', () => {
|
||||
let storageService: StorageServiceInterface
|
||||
let itemManager: ItemManagerInterface
|
||||
let mutator: MutatorClientInterface
|
||||
let subscriptions: SubscriptionManagerInterface
|
||||
let apiService: ApiServiceInterface
|
||||
let webSocketsService: SNWebSocketsService
|
||||
let settingsService: SNSettingsService
|
||||
let userService: UserService
|
||||
let syncService: SNSyncService
|
||||
let settingsService: SettingsClientInterface
|
||||
let userService: UserClientInterface
|
||||
let syncService: SyncServiceInterface
|
||||
let alertService: AlertService
|
||||
let sessionManager: SNSessionManager
|
||||
let sessionManager: SessionsClientInterface
|
||||
let crypto: PureCryptoInterface
|
||||
let roles: string[]
|
||||
let features: FeatureDescription[]
|
||||
let items: ItemInterface[]
|
||||
let now: Date
|
||||
let tomorrow_server: number
|
||||
let tomorrow_client: number
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
const expiredDate = new Date(new Date().getTime() - 1000).getTime()
|
||||
|
||||
const createService = () => {
|
||||
return new SNFeaturesService(
|
||||
storageService,
|
||||
apiService,
|
||||
itemManager,
|
||||
mutator,
|
||||
subscriptions,
|
||||
apiService,
|
||||
webSocketsService,
|
||||
settingsService,
|
||||
userService,
|
||||
@@ -62,21 +66,6 @@ describe('featuresService', () => {
|
||||
beforeEach(() => {
|
||||
roles = [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser]
|
||||
|
||||
now = new Date()
|
||||
tomorrow_client = now.setDate(now.getDate() + 1)
|
||||
tomorrow_server = convertTimestampToMilliseconds(tomorrow_client * 1_000)
|
||||
|
||||
features = [
|
||||
{
|
||||
...GetFeatures().find((f) => f.identifier === FeatureIdentifier.MidnightTheme),
|
||||
expires_at: tomorrow_server,
|
||||
},
|
||||
{
|
||||
...GetFeatures().find((f) => f.identifier === FeatureIdentifier.PlusEditor),
|
||||
expires_at: tomorrow_server,
|
||||
},
|
||||
] as jest.Mocked<FeatureDescription[]>
|
||||
|
||||
items = [] as jest.Mocked<ItemInterface[]>
|
||||
|
||||
storageService = {} as jest.Mocked<DiskStorageService>
|
||||
@@ -85,14 +74,6 @@ describe('featuresService', () => {
|
||||
|
||||
apiService = {} as jest.Mocked<SNApiService>
|
||||
apiService.addEventObserver = jest.fn()
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
apiService.downloadOfflineFeaturesFromRepo = jest.fn().mockReturnValue({
|
||||
features,
|
||||
})
|
||||
apiService.isThirdPartyHostUsed = jest.fn().mockReturnValue(false)
|
||||
|
||||
itemManager = {} as jest.Mocked<ItemManager>
|
||||
@@ -107,6 +88,10 @@ describe('featuresService', () => {
|
||||
mutator.changeItem = jest.fn()
|
||||
mutator.changeFeatureRepo = jest.fn()
|
||||
|
||||
subscriptions = {} as jest.Mocked<SubscriptionManagerInterface>
|
||||
subscriptions.getOnlineSubscription = jest.fn()
|
||||
subscriptions.addEventObserver = jest.fn()
|
||||
|
||||
webSocketsService = {} as jest.Mocked<SNWebSocketsService>
|
||||
webSocketsService.addEventObserver = jest.fn()
|
||||
|
||||
@@ -132,6 +117,7 @@ describe('featuresService', () => {
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publish = jest.fn()
|
||||
internalEventBus.addEventHandler = jest.fn()
|
||||
})
|
||||
|
||||
describe('experimental features', () => {
|
||||
@@ -150,65 +136,12 @@ describe('featuresService', () => {
|
||||
|
||||
expect(featuresService.isExperimentalFeatureEnabled(FeatureIdentifier.PlusEditor)).toEqual(false)
|
||||
})
|
||||
|
||||
it('does not create a component for not enabled experimental feature', async () => {
|
||||
const features = [
|
||||
{
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
expires_at: tomorrow_server,
|
||||
content_type: ContentType.TYPES.Component,
|
||||
},
|
||||
]
|
||||
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
|
||||
const featuresService = createService()
|
||||
featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
|
||||
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does create a component for enabled experimental feature', async () => {
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features: GetFeatures(),
|
||||
},
|
||||
})
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
|
||||
const featuresService = createService()
|
||||
featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
|
||||
|
||||
featuresService.getEnabledExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
|
||||
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadUserRoles()', () => {
|
||||
it('retrieves user roles and features from storage', async () => {
|
||||
createService().initializeFromDisk()
|
||||
expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserRoles, undefined, [])
|
||||
expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserFeatures, undefined, [])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -221,7 +154,7 @@ describe('featuresService', () => {
|
||||
const mock = (featuresService['notifyEvent'] = jest.fn())
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
await featuresService.setOnlineRoles(newRoles)
|
||||
featuresService.setOnlineRoles(newRoles)
|
||||
|
||||
expect(mock.mock.calls[0][0]).toEqual(FeaturesEvent.UserRolesChanged)
|
||||
})
|
||||
@@ -234,8 +167,7 @@ describe('featuresService', () => {
|
||||
const spy = jest.spyOn(featuresService, 'notifyEvent' as never)
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.ProUser]
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues(newRoles)
|
||||
|
||||
expect(spy.mock.calls[1][0]).toEqual(FeaturesEvent.DidPurchaseSubscription)
|
||||
})
|
||||
@@ -249,351 +181,52 @@ describe('featuresService', () => {
|
||||
const spy = jest.spyOn(featuresService, 'notifyEvent' as never)
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.ProUser]
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues(newRoles)
|
||||
|
||||
const triggeredEvents = spy.mock.calls.map((call) => call[0])
|
||||
expect(triggeredEvents).not.toContain(FeaturesEvent.DidPurchaseSubscription)
|
||||
})
|
||||
|
||||
it('saves new roles to storage and fetches features if a role has been added', async () => {
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
it('saves new roles to storage if a role has been added', async () => {
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
const newRoles = [...roles, RoleName.NAMES.ProUser]
|
||||
await featuresService.updateOnlineRolesWithNewValues(newRoles)
|
||||
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
|
||||
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
expect(apiService.getUserFeatures).toHaveBeenCalledWith('123')
|
||||
})
|
||||
|
||||
it('saves new roles to storage and fetches features if a role has been removed', async () => {
|
||||
it('saves new roles to storage if a role has been removed', async () => {
|
||||
const newRoles = [RoleName.NAMES.CoreUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues(newRoles)
|
||||
|
||||
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
|
||||
expect(apiService.getUserFeatures).toHaveBeenCalledWith('123')
|
||||
})
|
||||
|
||||
it('saves features to storage when roles change', async () => {
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserFeatures, features)
|
||||
})
|
||||
|
||||
it('creates items for non-expired features with content type if they do not exist', async () => {
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).toHaveBeenCalledTimes(2)
|
||||
expect(mutator.createItem).toHaveBeenCalledWith(
|
||||
ContentType.TYPES.Theme,
|
||||
expect.objectContaining({
|
||||
package_info: expect.objectContaining({
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
expires_at: tomorrow_client,
|
||||
identifier: FeatureIdentifier.MidnightTheme,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
expect(mutator.createItem).toHaveBeenCalledWith(
|
||||
ContentType.TYPES.Component,
|
||||
expect.objectContaining({
|
||||
package_info: expect.objectContaining({
|
||||
content_type: ContentType.TYPES.Component,
|
||||
expires_at: tomorrow_client,
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('if item for a feature exists updates its content', async () => {
|
||||
const existingItem = new SNComponent({
|
||||
uuid: '789',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
package_info: {
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
itemManager.getItems = jest.fn().mockReturnValue([existingItem])
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function))
|
||||
})
|
||||
|
||||
it('creates items for expired components if they do not exist', async () => {
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
const now = new Date()
|
||||
const yesterday_client = now.setDate(now.getDate() - 1)
|
||||
const yesterday_server = yesterday_client * 1_000
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
...features[1],
|
||||
expires_at: yesterday_server,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).toHaveBeenCalledWith(
|
||||
ContentType.TYPES.Component,
|
||||
expect.objectContaining({
|
||||
package_info: expect.objectContaining({
|
||||
content_type: ContentType.TYPES.Component,
|
||||
expires_at: yesterday_client,
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes items for expired themes', async () => {
|
||||
const existingItem = new SNComponent({
|
||||
uuid: '456',
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
content: {
|
||||
package_info: {
|
||||
identifier: FeatureIdentifier.MidnightTheme,
|
||||
valid_until: new Date(),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
const now = new Date()
|
||||
const yesterday = now.setDate(now.getDate() - 1)
|
||||
|
||||
mutator.changeComponent = jest.fn().mockReturnValue(existingItem)
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
itemManager.getItems = jest.fn().mockReturnValue([existingItem])
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
...features[0],
|
||||
expires_at: yesterday,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem])
|
||||
})
|
||||
|
||||
it('does not create an item for a feature without content type', async () => {
|
||||
const features = [
|
||||
{
|
||||
identifier: FeatureIdentifier.TagNesting,
|
||||
expires_at: tomorrow_server,
|
||||
},
|
||||
]
|
||||
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create an item for deprecated features', async () => {
|
||||
const features = [
|
||||
{
|
||||
identifier: FeatureIdentifier.DeprecatedBoldEditor,
|
||||
expires_at: tomorrow_server,
|
||||
},
|
||||
]
|
||||
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(mutator.createItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing after initial update if roles have not changed', async () => {
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles: didChangeRoles1 } = await featuresService.updateOnlineRoles(roles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles1)
|
||||
const { didChangeRoles: didChangeRoles2 } = await featuresService.updateOnlineRoles(roles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles2)
|
||||
const { didChangeRoles: didChangeRoles3 } = await featuresService.updateOnlineRoles(roles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles3)
|
||||
const { didChangeRoles: didChangeRoles4 } = await featuresService.updateOnlineRoles(roles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles4)
|
||||
expect(storageService.setValue).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('remote native features should be swapped with compiled version', async () => {
|
||||
const remoteFeature = {
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
content_type: ContentType.TYPES.Component,
|
||||
expires_at: tomorrow_server,
|
||||
} as FeatureDescription
|
||||
|
||||
const newRoles = [...roles, RoleName.NAMES.PlusUser]
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features: [remoteFeature],
|
||||
},
|
||||
})
|
||||
|
||||
const featuresService = createService()
|
||||
const nativeFeature = featuresService['mapRemoteNativeFeatureToStaticFeature'](remoteFeature)
|
||||
featuresService['mapRemoteNativeFeatureToItem'] = jest.fn()
|
||||
featuresService.initializeFromDisk()
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles)
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(featuresService['mapRemoteNativeFeatureToItem']).toHaveBeenCalledWith(
|
||||
nativeFeature,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('mapRemoteNativeFeatureToItem should throw if called with client controlled feature', async () => {
|
||||
const clientFeature = {
|
||||
identifier: FeatureIdentifier.DarkTheme,
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
clientControlled: true,
|
||||
} as FeatureDescription
|
||||
|
||||
storageService.getValue = jest.fn().mockReturnValue(roles)
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features: [clientFeature],
|
||||
},
|
||||
})
|
||||
|
||||
const featuresService = createService()
|
||||
featuresService.initializeFromDisk()
|
||||
await expect(() => featuresService['mapRemoteNativeFeatureToItem'](clientFeature, [], [])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('role-based feature status', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
features = [] as jest.Mocked<FeatureDescription[]>
|
||||
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.CoreUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.SuperEditor)).toBe(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('feature status with no paid role but features listings', async () => {
|
||||
it('feature status with no paid role', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
features = [
|
||||
{
|
||||
identifier: FeatureIdentifier.MidnightTheme,
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
expires_at: tomorrow_server,
|
||||
role_name: RoleName.NAMES.PlusUser,
|
||||
},
|
||||
{
|
||||
identifier: FeatureIdentifier.PlusEditor,
|
||||
content_type: ContentType.TYPES.Component,
|
||||
expires_at: expiredDate,
|
||||
role_name: RoleName.NAMES.ProUser,
|
||||
},
|
||||
] as jest.Mocked<FeatureDescription[]>
|
||||
|
||||
apiService.getUserFeatures = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
features,
|
||||
},
|
||||
})
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(false)
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NoUserSubscription)
|
||||
@@ -605,61 +238,24 @@ describe('featuresService', () => {
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.ProUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser])
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.SuperEditor)).toBe(FeatureStatus.NotInCurrentPlan)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.SuperEditor)).toBe(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
|
||||
it('third party feature status', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const themeFeature = {
|
||||
identifier: 'third-party-theme' as FeatureIdentifier,
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
expires_at: tomorrow_server,
|
||||
role_name: RoleName.NAMES.CoreUser,
|
||||
}
|
||||
itemManager.getDisplayableComponents = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ identifier: 'third-party-theme' }, { identifier: 'third-party-editor', isExpired: true }])
|
||||
|
||||
const editorFeature = {
|
||||
identifier: 'third-party-editor' as FeatureIdentifier,
|
||||
content_type: ContentType.TYPES.Component,
|
||||
expires_at: expiredDate,
|
||||
role_name: RoleName.NAMES.PlusUser,
|
||||
}
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
|
||||
features = [themeFeature, editorFeature] as jest.Mocked<FeatureDescription[]>
|
||||
|
||||
featuresService['features'] = features
|
||||
|
||||
itemManager.getDisplayableComponents = jest.fn().mockReturnValue([
|
||||
new SNComponent({
|
||||
uuid: '123',
|
||||
content_type: ContentType.TYPES.Theme,
|
||||
content: {
|
||||
valid_until: themeFeature.expires_at,
|
||||
package_info: {
|
||||
...themeFeature,
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
new SNComponent({
|
||||
uuid: '456',
|
||||
content_type: ContentType.TYPES.Component,
|
||||
content: {
|
||||
valid_until: new Date(editorFeature.expires_at),
|
||||
package_info: {
|
||||
...editorFeature,
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
])
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
expect(featuresService.getFeatureStatus(themeFeature.identifier)).toBe(FeatureStatus.Entitled)
|
||||
expect(featuresService.getFeatureStatus(editorFeature.identifier)).toBe(FeatureStatus.InCurrentPlanButExpired)
|
||||
expect(featuresService.getFeatureStatus('third-party-theme' as FeatureIdentifier)).toBe(FeatureStatus.Entitled)
|
||||
expect(featuresService.getFeatureStatus('third-party-editor' as FeatureIdentifier)).toBe(
|
||||
FeatureStatus.InCurrentPlanButExpired,
|
||||
)
|
||||
expect(featuresService.getFeatureStatus('missing-feature-identifier' as FeatureIdentifier)).toBe(
|
||||
FeatureStatus.NoUserSubscription,
|
||||
)
|
||||
@@ -668,69 +264,42 @@ describe('featuresService', () => {
|
||||
it('feature status should be not entitled if no account or offline repo', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
|
||||
|
||||
featuresService['completedSuccessfulFeaturesRetrieval'] = false
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(
|
||||
FeatureStatus.NoUserSubscription,
|
||||
)
|
||||
})
|
||||
|
||||
it('didDownloadFeatures should filter out client controlled features', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
featuresService['mapRemoteNativeFeaturesToItems'] = jest.fn()
|
||||
|
||||
await featuresService.didDownloadFeatures(GetFeatures().filter((f) => f.clientControlled))
|
||||
|
||||
expect(featuresService['mapRemoteNativeFeaturesToItems']).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('feature status for offline subscription', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.CoreUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
|
||||
featuresService.onlineRolesIncludePaidSubscription = jest.fn().mockReturnValue(false)
|
||||
featuresService['completedSuccessfulFeaturesRetrieval'] = true
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(
|
||||
FeatureStatus.NoUserSubscription,
|
||||
)
|
||||
|
||||
featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true)
|
||||
featuresService.hasFirstPartySubscription = jest.fn().mockReturnValue(true)
|
||||
await featuresService.setOfflineRoles([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
|
||||
featuresService.hasFirstPartyOfflineSubscription = jest.fn().mockReturnValue(true)
|
||||
featuresService.setOfflineRoles([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('feature status for deprecated feature', async () => {
|
||||
it('feature status for deprecated feature and no subscription', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(false)
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe(
|
||||
FeatureStatus.NoUserSubscription,
|
||||
)
|
||||
})
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.CoreUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
it('feature status for deprecated feature with subscription', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
|
||||
|
||||
expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe(
|
||||
FeatureStatus.Entitled,
|
||||
@@ -740,17 +309,13 @@ describe('featuresService', () => {
|
||||
it('has paid subscription', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const { didChangeRoles: didChangeRoles1 } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles1)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
|
||||
expect(featuresService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toBeFalsy
|
||||
|
||||
const { didChangeRoles: didChangeRoles2 } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.CoreUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles2)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
|
||||
|
||||
expect(featuresService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
|
||||
})
|
||||
@@ -758,8 +323,7 @@ describe('featuresService', () => {
|
||||
it('has paid subscription should be true if offline repo and signed into third party server', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
|
||||
featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true)
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
|
||||
@@ -789,7 +353,7 @@ describe('featuresService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadExternalFeature', () => {
|
||||
describe('downloadRemoteThirdPartyFeature', () => {
|
||||
it('should not allow if identifier matches native identifier', async () => {
|
||||
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
@@ -806,7 +370,7 @@ describe('featuresService', () => {
|
||||
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
|
||||
|
||||
const featuresService = createService()
|
||||
const result = await featuresService.downloadExternalFeature(installUrl)
|
||||
const result = await featuresService.downloadRemoteThirdPartyFeature(installUrl)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -826,7 +390,7 @@ describe('featuresService', () => {
|
||||
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
|
||||
|
||||
const featuresService = createService()
|
||||
const result = await featuresService.downloadExternalFeature(installUrl)
|
||||
const result = await featuresService.downloadRemoteThirdPartyFeature(installUrl)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -849,8 +413,7 @@ describe('featuresService', () => {
|
||||
it('should be false if core user checks for plus role', async () => {
|
||||
const featuresService = createService()
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([RoleName.NAMES.CoreUser])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
|
||||
|
||||
const hasPlusUserRole = featuresService.hasMinimumRole(RoleName.NAMES.PlusUser)
|
||||
|
||||
@@ -861,12 +424,9 @@ describe('featuresService', () => {
|
||||
const featuresService = createService()
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.PlusUser,
|
||||
RoleName.NAMES.CoreUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.PlusUser, RoleName.NAMES.CoreUser])
|
||||
|
||||
const hasProUserRole = featuresService.hasMinimumRole(RoleName.NAMES.ProUser)
|
||||
|
||||
@@ -877,12 +437,9 @@ describe('featuresService', () => {
|
||||
const featuresService = createService()
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.ProUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser])
|
||||
|
||||
const hasCoreUserRole = featuresService.hasMinimumRole(RoleName.NAMES.CoreUser)
|
||||
|
||||
@@ -893,12 +450,9 @@ describe('featuresService', () => {
|
||||
const featuresService = createService()
|
||||
|
||||
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
|
||||
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
|
||||
|
||||
const { didChangeRoles } = await featuresService.updateOnlineRoles([
|
||||
RoleName.NAMES.ProUser,
|
||||
RoleName.NAMES.PlusUser,
|
||||
])
|
||||
await featuresService.fetchFeatures('123', didChangeRoles)
|
||||
await featuresService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser])
|
||||
|
||||
const hasProUserRole = featuresService.hasMinimumRole(RoleName.NAMES.ProUser)
|
||||
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
import { SNApiService } from '../Api/ApiService'
|
||||
import {
|
||||
arraysEqual,
|
||||
convertTimestampToMilliseconds,
|
||||
removeFromArray,
|
||||
Copy,
|
||||
lastElement,
|
||||
isString,
|
||||
} from '@standardnotes/utils'
|
||||
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
|
||||
import { ContentType, RoleName } from '@standardnotes/domain-core'
|
||||
import { FillItemContent, PayloadEmitSource } from '@standardnotes/models'
|
||||
import { ItemManager } from '../Items/ItemManager'
|
||||
import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '../../Hosts'
|
||||
import { SettingName } from '@standardnotes/settings'
|
||||
import { MigrateFeatureRepoToUserSettingUseCase } from './UseCase/MigrateFeatureRepoToUserSetting'
|
||||
import { arraysEqual, removeFromArray, lastElement } from '@standardnotes/utils'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { RoleName, ContentType } from '@standardnotes/domain-core'
|
||||
import { PROD_OFFLINE_FEATURES_URL } from '../../Hosts'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { SNSessionManager } from '@Lib/Services/Session/SessionManager'
|
||||
import { SNSettingsService } from '../Settings'
|
||||
import { DiskStorageService } from '../Storage/DiskStorageService'
|
||||
import { SNSyncService } from '../Sync/SyncService'
|
||||
import { SNWebSocketsService, WebSocketsServiceEvent } from '../Api/WebsocketsService'
|
||||
import { SNWebSocketsService } from '../Api/WebsocketsService'
|
||||
import { WebSocketsServiceEvent } from '../Api/WebSocketsServiceEvent'
|
||||
import { TRUSTED_CUSTOM_EXTENSIONS_HOSTS, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
|
||||
import { UserRolesChangedEvent } from '@standardnotes/domain-events'
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
import * as FeaturesImports from '@standardnotes/features'
|
||||
import * as Models from '@standardnotes/models'
|
||||
import { ExperimentalFeatures, FindNativeFeature, FeatureIdentifier } from '@standardnotes/features'
|
||||
import {
|
||||
SNFeatureRepo,
|
||||
FeatureRepoContent,
|
||||
FillItemContent,
|
||||
PayloadEmitSource,
|
||||
ComponentInterface,
|
||||
ThemeInterface,
|
||||
} from '@standardnotes/models'
|
||||
import {
|
||||
AbstractService,
|
||||
AccountEvent,
|
||||
AlertService,
|
||||
ApiServiceEvent,
|
||||
API_MESSAGE_FAILED_DOWNLOADING_EXTENSION,
|
||||
API_MESSAGE_FAILED_OFFLINE_ACTIVATION,
|
||||
API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING,
|
||||
ApplicationStage,
|
||||
ButtonType,
|
||||
DiagnosticInfo,
|
||||
FeaturesClientInterface,
|
||||
FeaturesEvent,
|
||||
FeatureStatus,
|
||||
@@ -46,10 +36,22 @@ import {
|
||||
OfflineSubscriptionEntitlements,
|
||||
SetOfflineFeaturesFunctionResponse,
|
||||
StorageKey,
|
||||
UserService,
|
||||
MutatorClientInterface,
|
||||
StorageServiceInterface,
|
||||
ApiServiceInterface,
|
||||
ItemManagerInterface,
|
||||
SyncServiceInterface,
|
||||
SessionsClientInterface,
|
||||
UserClientInterface,
|
||||
SubscriptionManagerInterface,
|
||||
AccountEvent,
|
||||
SubscriptionManagerEvent,
|
||||
} from '@standardnotes/services'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
|
||||
import { DownloadRemoteThirdPartyFeatureUseCase } from './UseCase/DownloadRemoteThirdPartyFeature'
|
||||
import { MigrateFeatureRepoToOfflineEntitlementsUseCase } from './UseCase/MigrateFeatureRepoToOfflineEntitlements'
|
||||
import { GetFeatureStatusUseCase } from './UseCase/GetFeatureStatus'
|
||||
import { SettingsClientInterface } from '../Settings/SettingsClientInterface'
|
||||
|
||||
type GetOfflineSubscriptionDetailsResponse = OfflineSubscriptionEntitlements | ClientDisplayableError
|
||||
|
||||
@@ -57,46 +59,48 @@ export class SNFeaturesService
|
||||
extends AbstractService<FeaturesEvent>
|
||||
implements FeaturesClientInterface, InternalEventHandlerInterface
|
||||
{
|
||||
private deinited = false
|
||||
private onlineRoles: string[] = []
|
||||
private offlineRoles: string[] = []
|
||||
private features: FeaturesImports.FeatureDescription[] = []
|
||||
private enabledExperimentalFeatures: FeaturesImports.FeatureIdentifier[] = []
|
||||
private removeWebSocketsServiceObserver: () => void
|
||||
private removefeatureReposObserver: () => void
|
||||
private removeSignInObserver: () => void
|
||||
private needsInitialFeaturesUpdate = true
|
||||
private completedSuccessfulFeaturesRetrieval = false
|
||||
private enabledExperimentalFeatures: FeatureIdentifier[] = []
|
||||
|
||||
private getFeatureStatusUseCase = new GetFeatureStatusUseCase(this.items)
|
||||
|
||||
constructor(
|
||||
private storageService: DiskStorageService,
|
||||
private apiService: SNApiService,
|
||||
private itemManager: ItemManager,
|
||||
private storage: StorageServiceInterface,
|
||||
private items: ItemManagerInterface,
|
||||
private mutator: MutatorClientInterface,
|
||||
webSocketsService: SNWebSocketsService,
|
||||
private settingsService: SNSettingsService,
|
||||
private userService: UserService,
|
||||
private syncService: SNSyncService,
|
||||
private alertService: AlertService,
|
||||
private sessionManager: SNSessionManager,
|
||||
private subscriptions: SubscriptionManagerInterface,
|
||||
private api: ApiServiceInterface,
|
||||
sockets: SNWebSocketsService,
|
||||
private settings: SettingsClientInterface,
|
||||
private user: UserClientInterface,
|
||||
private sync: SyncServiceInterface,
|
||||
private alerts: AlertService,
|
||||
private sessions: SessionsClientInterface,
|
||||
private crypto: PureCryptoInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
|
||||
this.removeWebSocketsServiceObserver = webSocketsService.addEventObserver(async (eventName, data) => {
|
||||
if (eventName === WebSocketsServiceEvent.UserRoleMessageReceived) {
|
||||
const {
|
||||
payload: { userUuid, currentRoles },
|
||||
} = data as UserRolesChangedEvent
|
||||
const { didChangeRoles } = await this.updateOnlineRoles(currentRoles)
|
||||
await this.fetchFeatures(userUuid, didChangeRoles)
|
||||
}
|
||||
})
|
||||
this.eventDisposers.push(
|
||||
sockets.addEventObserver(async (eventName, data) => {
|
||||
if (eventName === WebSocketsServiceEvent.UserRoleMessageReceived) {
|
||||
const currentRoles = (data as UserRolesChangedEvent).payload.currentRoles
|
||||
void this.updateOnlineRolesWithNewValues(currentRoles)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
this.removefeatureReposObserver = this.itemManager.addObserver(
|
||||
ContentType.TYPES.ExtensionRepo,
|
||||
async ({ changed, inserted, source }) => {
|
||||
this.eventDisposers.push(
|
||||
subscriptions.addEventObserver((event) => {
|
||||
if (event === SubscriptionManagerEvent.DidFetchSubscription) {
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesAvailabilityChanged)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
this.eventDisposers.push(
|
||||
this.items.addObserver(ContentType.TYPES.ExtensionRepo, async ({ changed, inserted, source }) => {
|
||||
const sources = [
|
||||
PayloadEmitSource.InitialObserverRegistrationPush,
|
||||
PayloadEmitSource.LocalInserted,
|
||||
@@ -106,141 +110,90 @@ export class SNFeaturesService
|
||||
]
|
||||
|
||||
if (sources.includes(source)) {
|
||||
const items = [...changed, ...inserted] as Models.SNFeatureRepo[]
|
||||
if (this.sessionManager.isSignedIntoFirstPartyServer()) {
|
||||
await this.migrateFeatureRepoToUserSetting(items)
|
||||
const items = [...changed, ...inserted] as SNFeatureRepo[]
|
||||
if (this.sessions.isSignedIntoFirstPartyServer()) {
|
||||
void this.migrateFeatureRepoToUserSetting(items)
|
||||
} else {
|
||||
await this.migrateFeatureRepoToOfflineEntitlements(items)
|
||||
void this.migrateFeatureRepoToOfflineEntitlements(items)
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
this.removeSignInObserver = this.userService.addEventObserver((eventName: AccountEvent) => {
|
||||
if (eventName === AccountEvent.SignedInOrRegistered) {
|
||||
const featureRepos = this.itemManager.getItems(ContentType.TYPES.ExtensionRepo) as Models.SNFeatureRepo[]
|
||||
this.eventDisposers.push(
|
||||
this.user.addEventObserver((eventName: AccountEvent) => {
|
||||
if (eventName === AccountEvent.SignedInOrRegistered) {
|
||||
const featureRepos = this.items.getItems(ContentType.TYPES.ExtensionRepo) as SNFeatureRepo[]
|
||||
|
||||
if (!this.apiService.isThirdPartyHostUsed()) {
|
||||
void this.migrateFeatureRepoToUserSetting(featureRepos)
|
||||
if (!this.api.isThirdPartyHostUsed()) {
|
||||
void this.migrateFeatureRepoToUserSetting(featureRepos)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
public initializeFromDisk(): void {
|
||||
this.onlineRoles = this.storageService.getValue<string[]>(StorageKey.UserRoles, undefined, [])
|
||||
this.onlineRoles = this.storage.getValue<string[]>(StorageKey.UserRoles, undefined, [])
|
||||
|
||||
this.offlineRoles = this.storageService.getValue<string[]>(StorageKey.OfflineUserRoles, undefined, [])
|
||||
this.offlineRoles = this.storage.getValue<string[]>(StorageKey.OfflineUserRoles, undefined, [])
|
||||
|
||||
this.features = this.storageService.getValue(StorageKey.UserFeatures, undefined, [])
|
||||
|
||||
this.enabledExperimentalFeatures = this.storageService.getValue(StorageKey.ExperimentalFeatures, undefined, [])
|
||||
this.enabledExperimentalFeatures = this.storage.getValue(StorageKey.ExperimentalFeatures, undefined, [])
|
||||
}
|
||||
|
||||
async handleEvent(event: InternalEventInterface): Promise<void> {
|
||||
if (event.type === ApiServiceEvent.MetaReceived) {
|
||||
if (!this.syncService) {
|
||||
this.log('[Features Service] Handling events interrupted. Sync service is not yet initialized.', event)
|
||||
|
||||
if (!this.sync) {
|
||||
this.log('Handling events interrupted. Sync service is not yet initialized.', event)
|
||||
return
|
||||
}
|
||||
|
||||
const { userUuid, userRoles } = event.payload as MetaReceivedData
|
||||
const { didChangeRoles } = await this.updateOnlineRoles(userRoles.map((role) => role.name))
|
||||
|
||||
/**
|
||||
* All user data must be downloaded before we map features. Otherwise, feature mapping
|
||||
* may think a component doesn't exist and create a new one, when in reality the component
|
||||
* already exists but hasn't been downloaded yet.
|
||||
*/
|
||||
if (!this.syncService.completedOnlineDownloadFirstSync) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.fetchFeatures(userUuid, didChangeRoles)
|
||||
const { userRoles } = event.payload as MetaReceivedData
|
||||
void this.updateOnlineRolesWithNewValues(userRoles.map((role) => role.name))
|
||||
}
|
||||
}
|
||||
|
||||
override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
|
||||
await super.handleApplicationStage(stage)
|
||||
|
||||
if (stage === ApplicationStage.FullSyncCompleted_13) {
|
||||
void this.mapClientControlledFeaturesToItems()
|
||||
|
||||
if (!this.hasFirstPartyOnlineSubscription()) {
|
||||
const offlineRepo = this.getOfflineRepo()
|
||||
|
||||
if (offlineRepo) {
|
||||
void this.downloadOfflineFeatures(offlineRepo)
|
||||
void this.downloadOfflineRoles(offlineRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.handleApplicationStage(stage)
|
||||
}
|
||||
|
||||
private async mapClientControlledFeaturesToItems() {
|
||||
const clientFeatures = FeaturesImports.GetFeatures().filter((feature) => feature.clientControlled)
|
||||
const currentItems = this.itemManager.getItems<Models.SNComponent>([
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
])
|
||||
|
||||
for (const feature of clientFeatures) {
|
||||
if (!feature.content_type) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existingItem = currentItems.find((item) => item.identifier === feature.identifier)
|
||||
if (existingItem) {
|
||||
const hasChange = JSON.stringify(feature) !== JSON.stringify(existingItem.package_info)
|
||||
if (hasChange) {
|
||||
await this.mutator.changeComponent(existingItem, (mutator) => {
|
||||
mutator.package_info = feature
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
await this.mutator.createItem(
|
||||
feature.content_type,
|
||||
this.componentContentForNativeFeatureDescription(feature),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
|
||||
const feature = this.getFeatureThatOriginallyCameFromServer(identifier)
|
||||
|
||||
public enableExperimentalFeature(identifier: FeatureIdentifier): void {
|
||||
this.enabledExperimentalFeatures.push(identifier)
|
||||
|
||||
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
|
||||
void this.storage.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
|
||||
|
||||
if (feature) {
|
||||
void this.mapRemoteNativeFeaturesToItems([feature])
|
||||
}
|
||||
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesAvailabilityChanged)
|
||||
}
|
||||
|
||||
public disableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
|
||||
public disableExperimentalFeature(identifier: FeatureIdentifier): void {
|
||||
removeFromArray(this.enabledExperimentalFeatures, identifier)
|
||||
|
||||
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
|
||||
void this.storage.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
|
||||
|
||||
const component = this.itemManager
|
||||
.getItems<Models.SNComponent | Models.SNTheme>([ContentType.TYPES.Component, ContentType.TYPES.Theme])
|
||||
const component = this.items
|
||||
.getItems<ComponentInterface | ThemeInterface>([ContentType.TYPES.Component, ContentType.TYPES.Theme])
|
||||
.find((component) => component.identifier === identifier)
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.mutator.setItemToBeDeleted(component).then(() => {
|
||||
void this.syncService.sync()
|
||||
void this.sync.sync()
|
||||
})
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesAvailabilityChanged)
|
||||
}
|
||||
|
||||
public toggleExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
|
||||
public toggleExperimentalFeature(identifier: FeatureIdentifier): void {
|
||||
if (this.isExperimentalFeatureEnabled(identifier)) {
|
||||
this.disableExperimentalFeature(identifier)
|
||||
} else {
|
||||
@@ -248,19 +201,19 @@ export class SNFeaturesService
|
||||
}
|
||||
}
|
||||
|
||||
public getExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] {
|
||||
return FeaturesImports.ExperimentalFeatures
|
||||
public getExperimentalFeatures(): FeatureIdentifier[] {
|
||||
return ExperimentalFeatures
|
||||
}
|
||||
|
||||
public isExperimentalFeature(featureId: FeaturesImports.FeatureIdentifier): boolean {
|
||||
public isExperimentalFeature(featureId: FeatureIdentifier): boolean {
|
||||
return this.getExperimentalFeatures().includes(featureId)
|
||||
}
|
||||
|
||||
public getEnabledExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] {
|
||||
public getEnabledExperimentalFeatures(): FeatureIdentifier[] {
|
||||
return this.enabledExperimentalFeatures
|
||||
}
|
||||
|
||||
public isExperimentalFeatureEnabled(featureId: FeaturesImports.FeatureIdentifier): boolean {
|
||||
public isExperimentalFeatureEnabled(featureId: FeatureIdentifier): boolean {
|
||||
return this.enabledExperimentalFeatures.includes(featureId)
|
||||
}
|
||||
|
||||
@@ -280,18 +233,20 @@ export class SNFeaturesService
|
||||
offlineFeaturesUrl: result.featuresUrl,
|
||||
offlineKey: result.extensionKey,
|
||||
migratedToOfflineEntitlements: true,
|
||||
} as Models.FeatureRepoContent),
|
||||
} as FeatureRepoContent),
|
||||
true,
|
||||
)) as Models.SNFeatureRepo
|
||||
void this.syncService.sync()
|
||||
return this.downloadOfflineFeatures(offlineRepo)
|
||||
)) as SNFeatureRepo
|
||||
|
||||
void this.sync.sync()
|
||||
|
||||
return this.downloadOfflineRoles(offlineRepo)
|
||||
} catch (err) {
|
||||
return new ClientDisplayableError(`${API_MESSAGE_FAILED_OFFLINE_ACTIVATION}, ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getOfflineRepo(): Models.SNFeatureRepo | undefined {
|
||||
const repos = this.itemManager.getItems(ContentType.TYPES.ExtensionRepo) as Models.SNFeatureRepo[]
|
||||
private getOfflineRepo(): SNFeatureRepo | undefined {
|
||||
const repos = this.items.getItems(ContentType.TYPES.ExtensionRepo) as SNFeatureRepo[]
|
||||
return repos.filter((repo) => repo.migratedToOfflineEntitlements)[0]
|
||||
}
|
||||
|
||||
@@ -301,11 +256,11 @@ export class SNFeaturesService
|
||||
|
||||
public async deleteOfflineFeatureRepo(): Promise<void> {
|
||||
const repo = this.getOfflineRepo()
|
||||
|
||||
if (repo) {
|
||||
await this.mutator.setItemToBeDeleted(repo)
|
||||
void this.syncService.sync()
|
||||
void this.sync.sync()
|
||||
}
|
||||
await this.storageService.removeValue(StorageKey.UserFeatures)
|
||||
}
|
||||
|
||||
private parseOfflineEntitlementsCode(code: string): GetOfflineSubscriptionDetailsResponse | ClientDisplayableError {
|
||||
@@ -320,81 +275,39 @@ export class SNFeaturesService
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadOfflineFeatures(
|
||||
repo: Models.SNFeatureRepo,
|
||||
): Promise<SetOfflineFeaturesFunctionResponse | ClientDisplayableError> {
|
||||
const result = await this.apiService.downloadOfflineFeaturesFromRepo(repo)
|
||||
private async downloadOfflineRoles(repo: SNFeatureRepo): Promise<SetOfflineFeaturesFunctionResponse> {
|
||||
const result = await this.api.downloadOfflineFeaturesFromRepo(repo)
|
||||
|
||||
if (result instanceof ClientDisplayableError) {
|
||||
return result
|
||||
}
|
||||
|
||||
await this.didDownloadFeatures(result.features)
|
||||
await this.setOfflineRoles(result.roles)
|
||||
|
||||
return undefined
|
||||
this.setOfflineRoles(result.roles)
|
||||
}
|
||||
|
||||
public async migrateFeatureRepoToUserSetting(featureRepos: Models.SNFeatureRepo[] = []): Promise<void> {
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToUserSetting) {
|
||||
continue
|
||||
}
|
||||
if (item.onlineUrl) {
|
||||
const repoUrl: string = item.onlineUrl
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
await this.settingsService.updateSetting(
|
||||
SettingName.create(SettingName.NAMES.ExtensionKey).getValue(),
|
||||
userKey,
|
||||
true,
|
||||
)
|
||||
await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.migratedToUserSetting = true
|
||||
})
|
||||
}
|
||||
}
|
||||
public async migrateFeatureRepoToUserSetting(featureRepos: SNFeatureRepo[] = []): Promise<void> {
|
||||
const usecase = new MigrateFeatureRepoToUserSettingUseCase(this.mutator, this.settings)
|
||||
await usecase.execute(featureRepos)
|
||||
}
|
||||
|
||||
public async migrateFeatureRepoToOfflineEntitlements(featureRepos: SNFeatureRepo[] = []): Promise<void> {
|
||||
const usecase = new MigrateFeatureRepoToOfflineEntitlementsUseCase(this.mutator)
|
||||
const updatedRepos = await usecase.execute(featureRepos)
|
||||
|
||||
if (updatedRepos.length > 0) {
|
||||
await this.downloadOfflineRoles(updatedRepos[0])
|
||||
}
|
||||
}
|
||||
|
||||
public async migrateFeatureRepoToOfflineEntitlements(featureRepos: Models.SNFeatureRepo[] = []): Promise<void> {
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToOfflineEntitlements) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.onlineUrl) {
|
||||
const repoUrl = item.onlineUrl
|
||||
const { origin } = new URL(repoUrl)
|
||||
|
||||
if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL
|
||||
m.offlineKey = userKey
|
||||
m.migratedToOfflineEntitlements = true
|
||||
})
|
||||
await this.downloadOfflineFeatures(updatedRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription(): boolean {
|
||||
return this.onlineRolesIncludePaidSubscription() || this.hasOfflineRepo()
|
||||
}
|
||||
|
||||
hasFirstPartyOnlineSubscription(): boolean {
|
||||
return this.sessionManager.isSignedIntoFirstPartyServer() && this.onlineRolesIncludePaidSubscription()
|
||||
private hasFirstPartyOnlineSubscription(): boolean {
|
||||
return this.sessions.isSignedIntoFirstPartyServer() && this.subscriptions.hasOnlineSubscription()
|
||||
}
|
||||
|
||||
hasFirstPartySubscription(): boolean {
|
||||
if (this.hasFirstPartyOnlineSubscription()) {
|
||||
return true
|
||||
}
|
||||
|
||||
public hasFirstPartyOfflineSubscription(): boolean {
|
||||
const offlineRepo = this.getOfflineRepo()
|
||||
if (!offlineRepo || !offlineRepo.content.offlineFeaturesUrl) {
|
||||
return false
|
||||
@@ -404,55 +317,27 @@ export class SNFeaturesService
|
||||
return hasFirstPartyOfflineSubscription || new URL(offlineRepo.content.offlineFeaturesUrl).hostname === 'localhost'
|
||||
}
|
||||
|
||||
async updateOnlineRoles(roles: string[]): Promise<{
|
||||
didChangeRoles: boolean
|
||||
}> {
|
||||
async updateOnlineRolesWithNewValues(roles: string[]): Promise<void> {
|
||||
const previousRoles = this.onlineRoles
|
||||
|
||||
const userRolesChanged =
|
||||
roles.some((role) => !this.onlineRoles.includes(role)) || this.onlineRoles.some((role) => !roles.includes(role))
|
||||
|
||||
const isInitialLoadRolesChange = previousRoles.length === 0 && userRolesChanged
|
||||
|
||||
if (!userRolesChanged && !this.needsInitialFeaturesUpdate) {
|
||||
return {
|
||||
didChangeRoles: false,
|
||||
}
|
||||
if (!userRolesChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.setOnlineRoles(roles)
|
||||
this.setOnlineRoles(roles)
|
||||
|
||||
if (userRolesChanged && !isInitialLoadRolesChange) {
|
||||
const isInitialLoadRolesChange = previousRoles.length === 0
|
||||
if (!isInitialLoadRolesChange) {
|
||||
if (this.onlineRolesIncludePaidSubscription()) {
|
||||
await this.notifyEvent(FeaturesEvent.DidPurchaseSubscription)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
didChangeRoles: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFeatures(userUuid: UuidString, didChangeRoles: boolean): Promise<void> {
|
||||
if (!didChangeRoles && !this.needsInitialFeaturesUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
this.needsInitialFeaturesUpdate = false
|
||||
|
||||
const shouldDownloadRoleBasedFeatures = !this.hasOfflineRepo()
|
||||
|
||||
if (shouldDownloadRoleBasedFeatures) {
|
||||
const featuresResponse = await this.apiService.getUserFeatures(userUuid)
|
||||
|
||||
if (!isErrorResponse(featuresResponse) && !this.deinited) {
|
||||
const features = featuresResponse.data.features
|
||||
await this.didDownloadFeatures(features)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setOnlineRoles(roles: string[]): Promise<void> {
|
||||
setOnlineRoles(roles: string[]): void {
|
||||
const rolesChanged = !arraysEqual(this.onlineRoles, roles)
|
||||
|
||||
this.onlineRoles = roles
|
||||
@@ -461,10 +346,10 @@ export class SNFeaturesService
|
||||
void this.notifyEvent(FeaturesEvent.UserRolesChanged)
|
||||
}
|
||||
|
||||
this.storageService.setValue(StorageKey.UserRoles, this.onlineRoles)
|
||||
this.storage.setValue(StorageKey.UserRoles, this.onlineRoles)
|
||||
}
|
||||
|
||||
async setOfflineRoles(roles: string[]): Promise<void> {
|
||||
setOfflineRoles(roles: string[]): void {
|
||||
const rolesChanged = !arraysEqual(this.offlineRoles, roles)
|
||||
|
||||
this.offlineRoles = roles
|
||||
@@ -473,73 +358,19 @@ export class SNFeaturesService
|
||||
void this.notifyEvent(FeaturesEvent.UserRolesChanged)
|
||||
}
|
||||
|
||||
this.storageService.setValue(StorageKey.OfflineUserRoles, this.offlineRoles)
|
||||
}
|
||||
|
||||
public async didDownloadFeatures(features: FeaturesImports.FeatureDescription[]): Promise<void> {
|
||||
features = features
|
||||
.filter((feature) => {
|
||||
const nativeFeature = FeaturesImports.FindNativeFeature(feature.identifier)
|
||||
return nativeFeature != undefined && !nativeFeature.clientControlled
|
||||
})
|
||||
.map((feature) => this.mapRemoteNativeFeatureToStaticFeature(feature))
|
||||
|
||||
this.features = features
|
||||
this.completedSuccessfulFeaturesRetrieval = true
|
||||
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
|
||||
void this.storageService.setValue(StorageKey.UserFeatures, this.features)
|
||||
|
||||
await this.mapRemoteNativeFeaturesToItems(features)
|
||||
this.storage.setValue(StorageKey.OfflineUserRoles, this.offlineRoles)
|
||||
}
|
||||
|
||||
public isThirdPartyFeature(identifier: string): boolean {
|
||||
const isNativeFeature = !!FeaturesImports.FindNativeFeature(identifier as FeaturesImports.FeatureIdentifier)
|
||||
const isNativeFeature = !!FindNativeFeature(identifier as FeatureIdentifier)
|
||||
return !isNativeFeature
|
||||
}
|
||||
|
||||
private mapRemoteNativeFeatureToStaticFeature(
|
||||
remoteFeature: FeaturesImports.FeatureDescription,
|
||||
): FeaturesImports.FeatureDescription {
|
||||
const remoteFields: (keyof FeaturesImports.FeatureDescription)[] = [
|
||||
'expires_at',
|
||||
'role_name',
|
||||
'no_expire',
|
||||
'permission_name',
|
||||
]
|
||||
|
||||
const nativeFeature = FeaturesImports.FindNativeFeature(remoteFeature.identifier)
|
||||
if (!nativeFeature) {
|
||||
throw Error(`Attempting to map remote native to unfound static feature ${remoteFeature.identifier}`)
|
||||
}
|
||||
|
||||
const nativeFeatureCopy = Copy(nativeFeature) as FeaturesImports.FeatureDescription
|
||||
|
||||
for (const field of remoteFields) {
|
||||
nativeFeatureCopy[field] = remoteFeature[field] as never
|
||||
}
|
||||
|
||||
if (nativeFeatureCopy.expires_at) {
|
||||
nativeFeatureCopy.expires_at = convertTimestampToMilliseconds(nativeFeatureCopy.expires_at)
|
||||
}
|
||||
|
||||
return nativeFeatureCopy
|
||||
}
|
||||
|
||||
public getFeatureThatOriginallyCameFromServer(
|
||||
featureId: FeaturesImports.FeatureIdentifier,
|
||||
): FeaturesImports.FeatureDescription | undefined {
|
||||
return this.features.find((feature) => feature.identifier === featureId)
|
||||
}
|
||||
|
||||
onlineRolesIncludePaidSubscription(): boolean {
|
||||
const unpaidRoles = [RoleName.NAMES.CoreUser]
|
||||
return this.onlineRoles.some((role) => !unpaidRoles.includes(role))
|
||||
}
|
||||
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription(): boolean {
|
||||
return this.onlineRolesIncludePaidSubscription() || this.hasOfflineRepo()
|
||||
}
|
||||
|
||||
public rolesBySorting(roles: string[]): string[] {
|
||||
return Object.values(RoleName.NAMES).filter((role) => roles.includes(role))
|
||||
}
|
||||
@@ -547,7 +378,9 @@ export class SNFeaturesService
|
||||
public hasMinimumRole(role: string): boolean {
|
||||
const sortedAllRoles = Object.values(RoleName.NAMES)
|
||||
|
||||
const sortedUserRoles = this.rolesBySorting(this.rolesToUseForFeatureCheck())
|
||||
const sortedUserRoles = this.rolesBySorting(
|
||||
this.hasFirstPartyOnlineSubscription() ? this.onlineRoles : this.offlineRoles,
|
||||
)
|
||||
|
||||
const highestUserRoleIndex = sortedAllRoles.indexOf(lastElement(sortedUserRoles) as string)
|
||||
|
||||
@@ -556,201 +389,37 @@ export class SNFeaturesService
|
||||
return indexOfRoleToCheck <= highestUserRoleIndex
|
||||
}
|
||||
|
||||
public isFeatureDeprecated(featureId: FeaturesImports.FeatureIdentifier): boolean {
|
||||
return FeaturesImports.FindNativeFeature(featureId)?.deprecated === true
|
||||
}
|
||||
|
||||
public isFreeFeature(featureId: FeaturesImports.FeatureIdentifier) {
|
||||
return [FeatureIdentifier.DarkTheme].includes(featureId)
|
||||
}
|
||||
|
||||
public getFeatureStatus(featureId: FeaturesImports.FeatureIdentifier): FeatureStatus {
|
||||
if (this.isFreeFeature(featureId)) {
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
const nativeFeature = FeaturesImports.FindNativeFeature(featureId)
|
||||
|
||||
const isDeprecated = this.isFeatureDeprecated(featureId)
|
||||
if (isDeprecated) {
|
||||
if (this.hasPaidAnyPartyOnlineOrOfflineSubscription()) {
|
||||
return FeatureStatus.Entitled
|
||||
} else {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
}
|
||||
|
||||
const isThirdParty = nativeFeature == undefined
|
||||
if (isThirdParty) {
|
||||
const component = this.itemManager
|
||||
.getDisplayableComponents()
|
||||
.find((candidate) => candidate.identifier === featureId)
|
||||
if (!component) {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
if (component.isExpired) {
|
||||
return FeatureStatus.InCurrentPlanButExpired
|
||||
}
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
if (this.hasPaidAnyPartyOnlineOrOfflineSubscription()) {
|
||||
if (!this.completedSuccessfulFeaturesRetrieval) {
|
||||
const hasCachedFeatures = this.features.length > 0
|
||||
const temporarilyAllowUntilServerUpdates = !hasCachedFeatures
|
||||
if (temporarilyAllowUntilServerUpdates) {
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
|
||||
if (nativeFeature) {
|
||||
if (!this.hasFirstPartySubscription()) {
|
||||
return FeatureStatus.NotInCurrentPlan
|
||||
}
|
||||
|
||||
const roles = this.rolesToUseForFeatureCheck()
|
||||
if (nativeFeature.availableInRoles) {
|
||||
const hasRole = roles.some((role) => {
|
||||
return nativeFeature.availableInRoles?.includes(role)
|
||||
})
|
||||
if (!hasRole) {
|
||||
return FeatureStatus.NotInCurrentPlan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private rolesToUseForFeatureCheck(): string[] {
|
||||
return this.hasFirstPartyOnlineSubscription() ? this.onlineRoles : this.offlineRoles
|
||||
}
|
||||
|
||||
private componentContentForNativeFeatureDescription(feature: FeaturesImports.FeatureDescription): Models.ItemContent {
|
||||
const componentContent: Partial<Models.ComponentContent> = {
|
||||
area: feature.area,
|
||||
name: feature.name,
|
||||
package_info: feature,
|
||||
valid_until: new Date(feature.expires_at || 0),
|
||||
}
|
||||
return FillItemContent(componentContent)
|
||||
}
|
||||
|
||||
private async mapRemoteNativeFeaturesToItems(features: FeaturesImports.FeatureDescription[]): Promise<void> {
|
||||
const currentItems = this.itemManager.getItems<Models.SNComponent>([
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
])
|
||||
const itemsToDelete: Models.SNComponent[] = []
|
||||
let hasChanges = false
|
||||
|
||||
for (const feature of features) {
|
||||
const didChange = await this.mapRemoteNativeFeatureToItem(feature, currentItems, itemsToDelete)
|
||||
if (didChange) {
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
await this.mutator.setItemsToBeDeleted(itemsToDelete)
|
||||
|
||||
if (hasChanges) {
|
||||
void this.syncService.sync()
|
||||
}
|
||||
}
|
||||
|
||||
private async mapRemoteNativeFeatureToItem(
|
||||
feature: FeaturesImports.FeatureDescription,
|
||||
currentItems: Models.SNComponent[],
|
||||
itemsToDelete: Models.SNComponent[],
|
||||
): Promise<boolean> {
|
||||
if (feature.clientControlled) {
|
||||
throw new Error('Attempted to map client controlled feature as remote item')
|
||||
}
|
||||
|
||||
if (!feature.content_type) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isDisabledExperimentalFeature =
|
||||
this.isExperimentalFeature(feature.identifier) && !this.isExperimentalFeatureEnabled(feature.identifier)
|
||||
|
||||
if (isDisabledExperimentalFeature) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasChanges = false
|
||||
|
||||
const now = new Date()
|
||||
const expired = this.isFreeFeature(feature.identifier)
|
||||
? false
|
||||
: new Date(feature.expires_at || 0).getTime() < now.getTime()
|
||||
|
||||
const existingItem = currentItems.find((item) => {
|
||||
if (item.content.package_info) {
|
||||
const itemIdentifier = item.content.package_info.identifier
|
||||
return itemIdentifier === feature.identifier
|
||||
}
|
||||
|
||||
return false
|
||||
public getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus {
|
||||
return this.getFeatureStatusUseCase.execute({
|
||||
featureId,
|
||||
firstPartyRoles: this.hasFirstPartyOnlineSubscription()
|
||||
? { online: this.onlineRoles }
|
||||
: this.hasFirstPartyOfflineSubscription()
|
||||
? { offline: this.offlineRoles }
|
||||
: undefined,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: this.hasPaidAnyPartyOnlineOrOfflineSubscription(),
|
||||
firstPartyOnlineSubscription: this.hasFirstPartyOnlineSubscription()
|
||||
? this.subscriptions.getOnlineSubscription()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (feature.deprecated && !existingItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
let resultingItem: Models.SNComponent | undefined = existingItem
|
||||
|
||||
if (existingItem) {
|
||||
const featureExpiresAt = new Date(feature.expires_at || 0)
|
||||
const hasChangeInPackageInfo = JSON.stringify(feature) !== JSON.stringify(existingItem.package_info)
|
||||
const hasChangeInExpiration = featureExpiresAt.getTime() !== existingItem.valid_until.getTime()
|
||||
|
||||
const hasChange = hasChangeInPackageInfo || hasChangeInExpiration
|
||||
|
||||
if (hasChange) {
|
||||
resultingItem = await this.mutator.changeComponent(existingItem, (mutator) => {
|
||||
mutator.package_info = feature
|
||||
mutator.valid_until = featureExpiresAt
|
||||
})
|
||||
|
||||
hasChanges = true
|
||||
} else {
|
||||
resultingItem = existingItem
|
||||
}
|
||||
} else if (!expired || feature.content_type === ContentType.TYPES.Component) {
|
||||
resultingItem = (await this.mutator.createItem(
|
||||
feature.content_type,
|
||||
this.componentContentForNativeFeatureDescription(feature),
|
||||
true,
|
||||
)) as Models.SNComponent
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if (expired && resultingItem) {
|
||||
if (feature.content_type !== ContentType.TYPES.Component) {
|
||||
itemsToDelete.push(resultingItem)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges
|
||||
}
|
||||
|
||||
public async downloadExternalFeature(urlOrCode: string): Promise<Models.SNComponent | undefined> {
|
||||
public async downloadRemoteThirdPartyFeature(urlOrCode: string): Promise<ComponentInterface | undefined> {
|
||||
let url = urlOrCode
|
||||
try {
|
||||
url = this.crypto.base64Decode(urlOrCode)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
void err
|
||||
}
|
||||
|
||||
try {
|
||||
const trustedCustomExtensionsUrls = [...TRUSTED_FEATURE_HOSTS, ...TRUSTED_CUSTOM_EXTENSIONS_HOSTS]
|
||||
const { host } = new URL(url)
|
||||
|
||||
const usecase = new DownloadRemoteThirdPartyFeatureUseCase(this.api, this.items, this.alerts)
|
||||
|
||||
if (!trustedCustomExtensionsUrls.includes(host)) {
|
||||
const didConfirm = await this.alertService.confirm(
|
||||
const didConfirm = await this.alerts.confirm(
|
||||
API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING,
|
||||
'Install extension from an untrusted source?',
|
||||
'Proceed to install',
|
||||
@@ -758,109 +427,32 @@ export class SNFeaturesService
|
||||
'Cancel',
|
||||
)
|
||||
if (didConfirm) {
|
||||
return this.performDownloadExternalFeature(url)
|
||||
return usecase.execute(url)
|
||||
}
|
||||
} else {
|
||||
return this.performDownloadExternalFeature(url)
|
||||
return usecase.execute(url)
|
||||
}
|
||||
} catch (err) {
|
||||
void this.alertService.alert(INVALID_EXTENSION_URL)
|
||||
void this.alerts.alert(INVALID_EXTENSION_URL)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async performDownloadExternalFeature(url: string): Promise<Models.SNComponent | undefined> {
|
||||
const response = await this.apiService.downloadFeatureUrl(url)
|
||||
if (response.data?.error) {
|
||||
await this.alertService.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let rawFeature = response.data as FeaturesImports.ThirdPartyFeatureDescription
|
||||
|
||||
if (isString(rawFeature)) {
|
||||
try {
|
||||
rawFeature = JSON.parse(rawFeature)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!rawFeature.content_type) {
|
||||
return
|
||||
}
|
||||
|
||||
const isValidContentType = [
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
ContentType.TYPES.ActionsExtension,
|
||||
ContentType.TYPES.ExtensionRepo,
|
||||
].includes(rawFeature.content_type)
|
||||
|
||||
if (!isValidContentType) {
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FeaturesImports.FindNativeFeature(rawFeature.identifier)
|
||||
if (nativeFeature) {
|
||||
await this.alertService.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
|
||||
if (rawFeature.url) {
|
||||
for (const nativeFeature of FeaturesImports.GetFeatures()) {
|
||||
if (rawFeature.url.includes(nativeFeature.identifier)) {
|
||||
await this.alertService.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = FillItemContent({
|
||||
area: rawFeature.area,
|
||||
name: rawFeature.name,
|
||||
package_info: rawFeature,
|
||||
valid_until: new Date(rawFeature.expires_at || 0),
|
||||
hosted_url: rawFeature.url,
|
||||
} as Partial<Models.ComponentContent>)
|
||||
|
||||
const component = this.itemManager.createTemplateItem(rawFeature.content_type, content) as Models.SNComponent
|
||||
|
||||
return component
|
||||
}
|
||||
|
||||
override deinit(): void {
|
||||
super.deinit()
|
||||
this.removeSignInObserver()
|
||||
;(this.removeSignInObserver as unknown) = undefined
|
||||
this.removeWebSocketsServiceObserver()
|
||||
;(this.removeWebSocketsServiceObserver as unknown) = undefined
|
||||
this.removefeatureReposObserver()
|
||||
;(this.removefeatureReposObserver as unknown) = undefined
|
||||
;(this.onlineRoles as unknown) = undefined
|
||||
;(this.offlineRoles as unknown) = undefined
|
||||
;(this.storageService as unknown) = undefined
|
||||
;(this.apiService as unknown) = undefined
|
||||
;(this.itemManager as unknown) = undefined
|
||||
;(this.storage as unknown) = undefined
|
||||
;(this.items as unknown) = undefined
|
||||
;(this.mutator as unknown) = undefined
|
||||
;(this.settingsService as unknown) = undefined
|
||||
;(this.userService as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.alertService as unknown) = undefined
|
||||
;(this.sessionManager as unknown) = undefined
|
||||
;(this.api as unknown) = undefined
|
||||
;(this.subscriptions as unknown) = undefined
|
||||
;(this.settings as unknown) = undefined
|
||||
;(this.user as unknown) = undefined
|
||||
;(this.sync as unknown) = undefined
|
||||
;(this.alerts as unknown) = undefined
|
||||
;(this.sessions as unknown) = undefined
|
||||
;(this.crypto as unknown) = undefined
|
||||
this.deinited = true
|
||||
}
|
||||
|
||||
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
|
||||
return Promise.resolve({
|
||||
features: {
|
||||
roles: this.onlineRoles,
|
||||
features: this.features,
|
||||
enabledExperimentalFeatures: this.enabledExperimentalFeatures,
|
||||
needsInitialFeaturesUpdate: this.needsInitialFeaturesUpdate,
|
||||
completedSuccessfulFeaturesRetrieval: this.completedSuccessfulFeaturesRetrieval,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { FindNativeFeature, GetFeatures, ThirdPartyFeatureDescription } from '@standardnotes/features'
|
||||
import {
|
||||
ComponentContent,
|
||||
ComponentContentSpecialized,
|
||||
ComponentInterface,
|
||||
FillItemContentSpecialized,
|
||||
} from '@standardnotes/models'
|
||||
import {
|
||||
AlertService,
|
||||
API_MESSAGE_FAILED_DOWNLOADING_EXTENSION,
|
||||
ApiServiceInterface,
|
||||
ItemManagerInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { isString } from '@standardnotes/utils'
|
||||
|
||||
export class DownloadRemoteThirdPartyFeatureUseCase {
|
||||
constructor(private api: ApiServiceInterface, private items: ItemManagerInterface, private alerts: AlertService) {}
|
||||
|
||||
async execute(url: string): Promise<ComponentInterface | undefined> {
|
||||
const response = await this.api.downloadFeatureUrl(url)
|
||||
if (response.data?.error) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let rawFeature = response.data as ThirdPartyFeatureDescription
|
||||
|
||||
if (isString(rawFeature)) {
|
||||
try {
|
||||
rawFeature = JSON.parse(rawFeature)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!rawFeature.content_type) {
|
||||
return
|
||||
}
|
||||
|
||||
const isValidContentType = [
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
ContentType.TYPES.ActionsExtension,
|
||||
ContentType.TYPES.ExtensionRepo,
|
||||
].includes(rawFeature.content_type)
|
||||
|
||||
if (!isValidContentType) {
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(rawFeature.identifier)
|
||||
if (nativeFeature) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
|
||||
if (rawFeature.url) {
|
||||
for (const nativeFeature of GetFeatures()) {
|
||||
if (rawFeature.url.includes(nativeFeature.identifier)) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = FillItemContentSpecialized<ComponentContentSpecialized, ComponentContent>({
|
||||
area: rawFeature.area,
|
||||
name: rawFeature.name ?? '',
|
||||
package_info: rawFeature,
|
||||
valid_until: new Date(rawFeature.expires_at || 0),
|
||||
hosted_url: rawFeature.url,
|
||||
})
|
||||
|
||||
const component = this.items.createTemplateItem<ComponentContent, ComponentInterface>(
|
||||
rawFeature.content_type,
|
||||
content,
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services'
|
||||
import { GetFeatureStatusUseCase } from './GetFeatureStatus'
|
||||
import { ComponentInterface } from '@standardnotes/models'
|
||||
|
||||
jest.mock('@standardnotes/features', () => ({
|
||||
FeatureIdentifier: {
|
||||
DarkTheme: 'darkTheme',
|
||||
},
|
||||
FindNativeFeature: jest.fn(),
|
||||
}))
|
||||
|
||||
import { FindNativeFeature } from '@standardnotes/features'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
|
||||
describe('GetFeatureStatusUseCase', () => {
|
||||
let items: jest.Mocked<ItemManagerInterface>
|
||||
let usecase: GetFeatureStatusUseCase
|
||||
|
||||
beforeEach(() => {
|
||||
items = {
|
||||
getDisplayableComponents: jest.fn(),
|
||||
} as unknown as jest.Mocked<ItemManagerInterface>
|
||||
usecase = new GetFeatureStatusUseCase(items)
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('free features', () => {
|
||||
it('should return entitled for free features', () => {
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: FeatureIdentifier.DarkTheme,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deprecated features', () => {
|
||||
it('should return entitled for deprecated paid features if any subscription is active', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: true })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'deprecatedFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: true,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return NoUserSubscription for deprecated paid features if no subscription is active', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: true })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'deprecatedFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native features', () => {
|
||||
it('should return NoUserSubscription for native features without subscription and roles', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: false })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
|
||||
it('should return NotInCurrentPlan for native features with roles not in available roles', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['notInRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NotInCurrentPlan)
|
||||
})
|
||||
|
||||
it('should return Entitled for native features with roles in available roles and active subscription', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['inRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: {
|
||||
endsAt: new Date(Date.now() + 10000).getTime(),
|
||||
} as jest.Mocked<Subscription>,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return InCurrentPlanButExpired for native features with roles in available roles and expired subscription', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['inRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: {
|
||||
endsAt: new Date(Date.now() - 10000).getTime(),
|
||||
} as jest.Mocked<Subscription>,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.InCurrentPlanButExpired)
|
||||
})
|
||||
})
|
||||
|
||||
describe('third party features', () => {
|
||||
it('should return Entitled for third-party features', () => {
|
||||
const mockComponent = {
|
||||
identifier: 'thirdPartyFeature',
|
||||
isExpired: false,
|
||||
} as unknown as jest.Mocked<ComponentInterface>
|
||||
|
||||
items.getDisplayableComponents.mockReturnValue([mockComponent])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'thirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return NoUserSubscription for non-existing third-party features', () => {
|
||||
;(items.getDisplayableComponents as jest.Mock).mockReturnValue([])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nonExistingThirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
|
||||
it('should return InCurrentPlanButExpired for expired third-party features', () => {
|
||||
const mockComponent = {
|
||||
identifier: 'thirdPartyFeature',
|
||||
isExpired: true,
|
||||
} as unknown as jest.Mocked<ComponentInterface>
|
||||
|
||||
items.getDisplayableComponents.mockReturnValue([mockComponent])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'thirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.InCurrentPlanButExpired)
|
||||
})
|
||||
})
|
||||
})
|
||||
104
packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts
Normal file
104
packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { AnyFeatureDescription, FeatureIdentifier, FindNativeFeature } from '@standardnotes/features'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services'
|
||||
import { convertTimestampToMilliseconds } from '@standardnotes/utils'
|
||||
|
||||
export class GetFeatureStatusUseCase {
|
||||
constructor(private items: ItemManagerInterface) {}
|
||||
|
||||
execute(dto: {
|
||||
featureId: FeatureIdentifier | string
|
||||
firstPartyOnlineSubscription: Subscription | undefined
|
||||
firstPartyRoles: { online: string[] } | { offline: string[] } | undefined
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: boolean
|
||||
}): FeatureStatus {
|
||||
if (this.isFreeFeature(dto.featureId as FeatureIdentifier)) {
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(dto.featureId as FeatureIdentifier)
|
||||
|
||||
if (!nativeFeature) {
|
||||
return this.getThirdPartyFeatureStatus(dto.featureId as string)
|
||||
}
|
||||
|
||||
if (nativeFeature.deprecated) {
|
||||
return this.getDeprecatedNativeFeatureStatus({
|
||||
nativeFeature,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: dto.hasPaidAnyPartyOnlineOrOfflineSubscription,
|
||||
})
|
||||
}
|
||||
|
||||
return this.getNativeFeatureFeatureStatus({
|
||||
nativeFeature,
|
||||
firstPartyOnlineSubscription: dto.firstPartyOnlineSubscription,
|
||||
firstPartyRoles: dto.firstPartyRoles,
|
||||
})
|
||||
}
|
||||
|
||||
private getDeprecatedNativeFeatureStatus(dto: {
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: boolean
|
||||
nativeFeature: AnyFeatureDescription
|
||||
}): FeatureStatus {
|
||||
if (dto.hasPaidAnyPartyOnlineOrOfflineSubscription) {
|
||||
return FeatureStatus.Entitled
|
||||
} else {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
}
|
||||
|
||||
private getNativeFeatureFeatureStatus(dto: {
|
||||
nativeFeature: AnyFeatureDescription
|
||||
firstPartyOnlineSubscription: Subscription | undefined
|
||||
firstPartyRoles: { online: string[] } | { offline: string[] } | undefined
|
||||
}): FeatureStatus {
|
||||
if (!dto.firstPartyOnlineSubscription && !dto.firstPartyRoles) {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
|
||||
const roles = !dto.firstPartyRoles
|
||||
? undefined
|
||||
: 'online' in dto.firstPartyRoles
|
||||
? dto.firstPartyRoles.online
|
||||
: dto.firstPartyRoles.offline
|
||||
|
||||
if (dto.nativeFeature.availableInRoles && roles) {
|
||||
const hasRole = roles.some((role) => {
|
||||
return dto.nativeFeature.availableInRoles?.includes(role)
|
||||
})
|
||||
|
||||
if (!hasRole) {
|
||||
return FeatureStatus.NotInCurrentPlan
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.firstPartyOnlineSubscription) {
|
||||
const isSubscriptionExpired =
|
||||
new Date(convertTimestampToMilliseconds(dto.firstPartyOnlineSubscription.endsAt)) < new Date()
|
||||
|
||||
if (isSubscriptionExpired) {
|
||||
return FeatureStatus.InCurrentPlanButExpired
|
||||
}
|
||||
}
|
||||
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private getThirdPartyFeatureStatus(featureId: string): FeatureStatus {
|
||||
const component = this.items.getDisplayableComponents().find((candidate) => candidate.identifier === featureId)
|
||||
|
||||
if (!component) {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
|
||||
if (component.isExpired) {
|
||||
return FeatureStatus.InCurrentPlanButExpired
|
||||
}
|
||||
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private isFreeFeature(featureId: FeatureIdentifier) {
|
||||
return [FeatureIdentifier.DarkTheme, FeatureIdentifier.PlainEditor].includes(featureId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '@Lib/Hosts'
|
||||
import { SNFeatureRepo } from '@standardnotes/models'
|
||||
import { MutatorClientInterface } from '@standardnotes/services'
|
||||
|
||||
export class MigrateFeatureRepoToOfflineEntitlementsUseCase {
|
||||
constructor(private mutator: MutatorClientInterface) {}
|
||||
|
||||
async execute(featureRepos: SNFeatureRepo[] = []): Promise<SNFeatureRepo[]> {
|
||||
const updatedRepos: SNFeatureRepo[] = []
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToOfflineEntitlements) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.onlineUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const repoUrl = item.onlineUrl
|
||||
const { origin } = new URL(repoUrl)
|
||||
|
||||
if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
|
||||
const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL
|
||||
m.offlineKey = userKey
|
||||
m.migratedToOfflineEntitlements = true
|
||||
})
|
||||
|
||||
updatedRepos.push(updatedRepo)
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRepos
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface'
|
||||
import { SNFeatureRepo } from '@standardnotes/models'
|
||||
import { MutatorClientInterface } from '@standardnotes/services'
|
||||
import { SettingName } from '@standardnotes/settings'
|
||||
|
||||
export class MigrateFeatureRepoToUserSettingUseCase {
|
||||
constructor(private mutator: MutatorClientInterface, private settings: SettingsClientInterface) {}
|
||||
|
||||
async execute(featureRepos: SNFeatureRepo[] = []): Promise<void> {
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToUserSetting) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.onlineUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const repoUrl: string = item.onlineUrl
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
await this.settings.updateSetting(SettingName.create(SettingName.NAMES.ExtensionKey).getValue(), userKey, true)
|
||||
|
||||
await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.migratedToUserSetting = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export class ItemManager extends Services.AbstractService implements Services.It
|
||||
>
|
||||
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsDisplayOptions>
|
||||
private itemsKeyDisplayController!: Models.ItemDisplayController<SNItemsKey>
|
||||
private componentDisplayController!: Models.ItemDisplayController<Models.SNComponent>
|
||||
private themeDisplayController!: Models.ItemDisplayController<Models.SNTheme>
|
||||
private componentDisplayController!: Models.ItemDisplayController<Models.ComponentInterface>
|
||||
private themeDisplayController!: Models.ItemDisplayController<Models.ThemeInterface>
|
||||
private fileDisplayController!: Models.ItemDisplayController<Models.FileItem>
|
||||
private smartViewDisplayController!: Models.ItemDisplayController<Models.SmartView>
|
||||
|
||||
@@ -120,7 +120,7 @@ export class ItemManager extends Services.AbstractService implements Services.It
|
||||
return this.invalidItems.filter((item) => !item.key_system_identifier)
|
||||
}
|
||||
|
||||
public createItemFromPayload(payload: Models.DecryptedPayloadInterface): Models.DecryptedItemInterface {
|
||||
public createItemFromPayload<T extends Models.DecryptedItemInterface>(payload: Models.DecryptedPayloadInterface): T {
|
||||
return Models.CreateDecryptedItemFromPayload(payload)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ export class ItemManager extends Services.AbstractService implements Services.It
|
||||
return this.itemsKeyDisplayController.items()
|
||||
}
|
||||
|
||||
public getDisplayableComponents(): (Models.SNComponent | Models.SNTheme)[] {
|
||||
public getDisplayableComponents(): (Models.ComponentInterface | Models.ThemeInterface)[] {
|
||||
return [...this.componentDisplayController.items(), ...this.themeDisplayController.items()]
|
||||
}
|
||||
|
||||
@@ -275,10 +275,6 @@ export class ItemManager extends Services.AbstractService implements Services.It
|
||||
return this.collection.findAllDecrypted(uuids) as T[]
|
||||
}
|
||||
|
||||
/**
|
||||
* If item is not found, an `undefined` element
|
||||
* will be inserted into the array.
|
||||
*/
|
||||
findItemsIncludingBlanks<T extends Models.DecryptedItemInterface>(uuids: UuidString[]): (T | undefined)[] {
|
||||
return this.collection.findAllDecryptedWithBlanks(uuids) as (T | undefined)[]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PayloadManager } from '../Payloads/PayloadManager'
|
||||
import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders'
|
||||
import {
|
||||
ActionsExtensionMutator,
|
||||
ComponentInterface,
|
||||
ComponentMutator,
|
||||
CreateDecryptedMutatorForItem,
|
||||
DecryptedItemInterface,
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
SmartViewContent,
|
||||
SmartViewDefaultIconName,
|
||||
SNActionsExtension,
|
||||
SNComponent,
|
||||
SNFeatureRepo,
|
||||
SNNote,
|
||||
SNTag,
|
||||
@@ -205,19 +205,19 @@ export class MutatorService extends AbstractService implements MutatorClientInte
|
||||
}
|
||||
|
||||
async changeComponent(
|
||||
itemToLookupUuidFor: SNComponent,
|
||||
itemToLookupUuidFor: ComponentInterface,
|
||||
mutate: (mutator: ComponentMutator) => void,
|
||||
mutationType: MutationType = MutationType.UpdateUserTimestamps,
|
||||
emitSource = PayloadEmitSource.LocalChanged,
|
||||
payloadSourceKey?: string,
|
||||
): Promise<SNComponent> {
|
||||
const component = this.itemManager.findItem<SNComponent>(itemToLookupUuidFor.uuid)
|
||||
): Promise<ComponentInterface> {
|
||||
const component = this.itemManager.findItem<ComponentInterface>(itemToLookupUuidFor.uuid)
|
||||
if (!component) {
|
||||
throw Error('Attempting to change non-existant component')
|
||||
}
|
||||
const mutator = new ComponentMutator(component, mutationType)
|
||||
await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey)
|
||||
return this.itemManager.findSureItem<SNComponent>(itemToLookupUuidFor.uuid)
|
||||
return this.itemManager.findSureItem<ComponentInterface>(itemToLookupUuidFor.uuid)
|
||||
}
|
||||
|
||||
async changeFeatureRepo(
|
||||
|
||||
@@ -24,19 +24,19 @@ export class SNPreferencesService
|
||||
private removeSyncObserver?: () => void
|
||||
|
||||
constructor(
|
||||
private singletonManager: SNSingletonManager,
|
||||
itemManager: ItemManager,
|
||||
private singletons: SNSingletonManager,
|
||||
items: ItemManager,
|
||||
private mutator: MutatorClientInterface,
|
||||
private syncService: SNSyncService,
|
||||
private sync: SNSyncService,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
|
||||
this.removeItemObserver = itemManager.addObserver(ContentType.TYPES.UserPrefs, () => {
|
||||
this.removeItemObserver = items.addObserver(ContentType.TYPES.UserPrefs, () => {
|
||||
this.shouldReload = true
|
||||
})
|
||||
|
||||
this.removeSyncObserver = syncService.addEventObserver((event) => {
|
||||
this.removeSyncObserver = sync.addEventObserver((event) => {
|
||||
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded || event === SyncEvent.LocalDataIncrementalLoad) {
|
||||
void this.reload()
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export class SNPreferencesService
|
||||
override deinit(): void {
|
||||
this.removeItemObserver?.()
|
||||
this.removeSyncObserver?.()
|
||||
;(this.singletonManager as unknown) = undefined
|
||||
;(this.singletons as unknown) = undefined
|
||||
;(this.mutator as unknown) = undefined
|
||||
|
||||
super.deinit()
|
||||
@@ -57,7 +57,7 @@ export class SNPreferencesService
|
||||
|
||||
if (stage === ApplicationStage.LoadedDatabase_12) {
|
||||
/** Try to read preferences singleton from storage */
|
||||
this.preferences = this.singletonManager.findSingleton<SNUserPrefs>(
|
||||
this.preferences = this.singletons.findSingleton<SNUserPrefs>(
|
||||
ContentType.TYPES.UserPrefs,
|
||||
SNUserPrefs.singletonPredicate,
|
||||
)
|
||||
@@ -75,6 +75,14 @@ export class SNPreferencesService
|
||||
}
|
||||
|
||||
async setValue<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void> {
|
||||
await this.setValueDetached(key, value)
|
||||
|
||||
void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged)
|
||||
|
||||
void this.sync.sync({ sourceDescription: 'PreferencesService.setValue' })
|
||||
}
|
||||
|
||||
async setValueDetached<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void> {
|
||||
if (!this.preferences) {
|
||||
return
|
||||
}
|
||||
@@ -82,10 +90,6 @@ export class SNPreferencesService
|
||||
this.preferences = (await this.mutator.changeItem<UserPrefsMutator>(this.preferences, (m) => {
|
||||
m.setPref(key, value)
|
||||
})) as SNUserPrefs
|
||||
|
||||
void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged)
|
||||
|
||||
void this.syncService.sync({ sourceDescription: 'PreferencesService.setValue' })
|
||||
}
|
||||
|
||||
private async reload() {
|
||||
@@ -98,7 +102,7 @@ export class SNPreferencesService
|
||||
try {
|
||||
const previousRef = this.preferences
|
||||
|
||||
this.preferences = await this.singletonManager.findOrCreateContentTypeSingleton<ItemContent, SNUserPrefs>(
|
||||
this.preferences = await this.singletons.findOrCreateContentTypeSingleton<ItemContent, SNUserPrefs>(
|
||||
ContentType.TYPES.UserPrefs,
|
||||
FillItemContent({}),
|
||||
)
|
||||
|
||||
@@ -32,14 +32,12 @@ import {
|
||||
} from '@standardnotes/services'
|
||||
import { Base64String, PkcKeyPair } from '@standardnotes/sncrypto-common'
|
||||
import {
|
||||
ClientDisplayableError,
|
||||
SessionBody,
|
||||
ErrorTag,
|
||||
HttpResponse,
|
||||
isErrorResponse,
|
||||
SessionListEntry,
|
||||
User,
|
||||
AvailableSubscriptions,
|
||||
KeyParamsResponse,
|
||||
SignInResponse,
|
||||
ChangeCredentialsResponse,
|
||||
@@ -50,7 +48,6 @@ import {
|
||||
import { CopyPayloadWithContentOverride, RootKeyWithKeyPairsInterface } from '@standardnotes/models'
|
||||
import { LegacySession, MapperInterface, Result, Session, SessionToken } from '@standardnotes/domain-core'
|
||||
import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey } from '@standardnotes/encryption'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
import * as Common from '@standardnotes/common'
|
||||
|
||||
import { RawStorageValue } from './Sessions/Types'
|
||||
@@ -313,28 +310,6 @@ export class SNSessionManager
|
||||
})
|
||||
}
|
||||
|
||||
public async getSubscription(): Promise<ClientDisplayableError | Subscription | undefined> {
|
||||
const result = await this.apiService.getSubscription(this.getSureUser().uuid)
|
||||
|
||||
if (isErrorResponse(result)) {
|
||||
return ClientDisplayableError.FromNetworkError(result)
|
||||
}
|
||||
|
||||
const subscription = result.data.subscription
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
public async getAvailableSubscriptions(): Promise<AvailableSubscriptions | ClientDisplayableError> {
|
||||
const response = await this.apiService.getAvailableSubscriptions()
|
||||
|
||||
if (isErrorResponse(response)) {
|
||||
return ClientDisplayableError.FromNetworkError(response)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
private async promptForU2FVerification(username: string): Promise<Record<string, unknown> | undefined> {
|
||||
const challenge = new Challenge(
|
||||
[
|
||||
|
||||
@@ -224,7 +224,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv
|
||||
* either as a plain object, or an encrypted item.
|
||||
*/
|
||||
private async generatePersistableValues() {
|
||||
const rawContent = Copy(this.values) as Partial<Services.StorageValuesObject>
|
||||
const rawContent = <Partial<Services.StorageValuesObject>>Copy(this.values)
|
||||
|
||||
const valuesToWrap = rawContent[Services.ValueModesKeys.Unwrapped]
|
||||
rawContent[Services.ValueModesKeys.Unwrapped] = undefined
|
||||
|
||||
Reference in New Issue
Block a user