refactor: native feature management (#2350)
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user