refactor: native feature management (#2350)

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

View File

@@ -0,0 +1,6 @@
/* istanbul ignore file */
export enum ApiServiceEvent {
MetaReceived = 'MetaReceived',
SessionRefreshed = 'SessionRefreshed',
}

View File

@@ -0,0 +1,5 @@
import { Either } from '@standardnotes/common'
import { SessionRefreshedData } from './SessionRefreshedData'
import { MetaReceivedData } from './MetaReceivedData'
export type ApiServiceEventData = Either<MetaReceivedData, SessionRefreshedData>

View File

@@ -1,26 +1,17 @@
import { Either } from '@standardnotes/common'
import { FilesApiInterface } from '@standardnotes/files'
import { Session } from '@standardnotes/domain-core'
import { Role } from '@standardnotes/security'
import { AbstractService } from '../Service/AbstractService'
import { ApiServiceEvent } from './ApiServiceEvent'
import { ApiServiceEventData } from './ApiServiceEventData'
import { SNFeatureRepo } from '@standardnotes/models'
import { ClientDisplayableError, HttpResponse } from '@standardnotes/responses'
import { AnyFeatureDescription } from '@standardnotes/features'
/* istanbul ignore file */
export interface ApiServiceInterface extends AbstractService<ApiServiceEvent, ApiServiceEventData>, FilesApiInterface {
isThirdPartyHostUsed(): boolean
export enum ApiServiceEvent {
MetaReceived = 'MetaReceived',
SessionRefreshed = 'SessionRefreshed',
downloadOfflineFeaturesFromRepo(
repo: SNFeatureRepo,
): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError>
downloadFeatureUrl(url: string): Promise<HttpResponse>
}
export type MetaReceivedData = {
userUuid: string
userRoles: Role[]
}
export type SessionRefreshedData = {
session: Session
}
export type ApiServiceEventData = Either<MetaReceivedData, SessionRefreshedData>
export interface ApiServiceInterface extends AbstractService<ApiServiceEvent, ApiServiceEventData>, FilesApiInterface {}

View File

@@ -0,0 +1,6 @@
import { Role } from '@standardnotes/security'
export type MetaReceivedData = {
userUuid: string
userRoles: Role[]
}

View File

@@ -0,0 +1,5 @@
import { Session } from '@standardnotes/domain-core'
export type SessionRefreshedData = {
session: Session
}

View File

@@ -1,3 +1,4 @@
import { PreferenceServiceInterface } from './../Preferences/PreferenceServiceInterface'
import { AsymmetricMessageServiceInterface } from './../AsymmetricMessage/AsymmetricMessageServiceInterface'
import { SyncOptions } from './../Sync/SyncOptions'
import { ImportDataReturnType } from './../Mutator/ImportDataUseCase'
@@ -21,7 +22,7 @@ import { ComponentManagerInterface } from '../Component/ComponentManagerInterfac
import { ApplicationEvent } from '../Event/ApplicationEvent'
import { ApplicationEventCallback } from '../Event/ApplicationEventCallback'
import { FeaturesClientInterface } from '../Feature/FeaturesClientInterface'
import { SubscriptionClientInterface } from '../Subscription/SubscriptionClientInterface'
import { SubscriptionManagerInterface } from '../Subscription/SubscriptionManagerInterface'
import { DeviceInterface } from '../Device/DeviceInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { MutatorClientInterface } from '../Mutator/MutatorClientInterface'
@@ -66,6 +67,7 @@ export interface ApplicationInterface {
setCustomHost(host: string): Promise<void>
isThirdPartyHostUsed(): boolean
isUsingHomeServer(): Promise<boolean>
getNewSubscriptionToken(): Promise<string | undefined>
importData(data: BackupFile, awaitSync?: boolean): Promise<ImportDataReturnType>
/**
@@ -96,7 +98,7 @@ export interface ApplicationInterface {
get mutator(): MutatorClientInterface
get user(): UserClientInterface
get files(): FilesClientInterface
get subscriptions(): SubscriptionClientInterface
get subscriptions(): SubscriptionManagerInterface
get fileBackups(): BackupServiceInterface | undefined
get sessions(): SessionsClientInterface
get homeServer(): HomeServerServiceInterface | undefined
@@ -104,6 +106,7 @@ export interface ApplicationInterface {
get challenges(): ChallengeServiceInterface
get alerts(): AlertService
get asymmetric(): AsymmetricMessageServiceInterface
get preferences(): PreferenceServiceInterface
readonly identifier: ApplicationIdentifier
readonly platform: Platform

View File

@@ -1,26 +1,47 @@
import { ComponentArea, FeatureIdentifier } from '@standardnotes/features'
import { ActionObserver, PermissionDialog, SNComponent, SNNote } from '@standardnotes/models'
import { ComponentViewerItem } from './ComponentViewerItem'
import {
ComponentArea,
ComponentFeatureDescription,
EditorFeatureDescription,
IframeComponentFeatureDescription,
ThemeFeatureDescription,
} from '@standardnotes/features'
import {
ActionObserver,
ComponentInterface,
ComponentOrNativeFeature,
PermissionDialog,
SNNote,
} from '@standardnotes/models'
import { DesktopManagerInterface } from '../Device/DesktopManagerInterface'
import { ComponentViewerInterface } from './ComponentViewerInterface'
export interface ComponentManagerInterface {
urlForComponent(component: SNComponent): string | undefined
urlForComponent(uiFeature: ComponentOrNativeFeature<ComponentFeatureDescription>): string | undefined
setDesktopManager(desktopManager: DesktopManagerInterface): void
componentsForArea(area: ComponentArea): SNComponent[]
editorForNote(note: SNNote): SNComponent | undefined
doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean
thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[]
editorForNote(note: SNNote): ComponentOrNativeFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
doesEditorChangeRequireAlert(
from: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
to: ComponentOrNativeFeature<IframeComponentFeatureDescription | EditorFeatureDescription> | undefined,
): boolean
showEditorChangeAlert(): Promise<boolean>
destroyComponentViewer(viewer: ComponentViewerInterface): void
createComponentViewer(
component: SNComponent,
contextItem?: string,
uiFeature: ComponentOrNativeFeature<IframeComponentFeatureDescription>,
item: ComponentViewerItem,
actionObserver?: ActionObserver,
urlOverride?: string,
): ComponentViewerInterface
presentPermissionsDialog(_dialog: PermissionDialog): void
legacyGetDefaultEditor(): SNComponent | undefined
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined
toggleTheme(uuid: string): Promise<void>
toggleComponent(uuid: string): Promise<void>
legacyGetDefaultEditor(): ComponentInterface | undefined
isThemeActive(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): boolean
toggleTheme(theme: ComponentOrNativeFeature<ThemeFeatureDescription>): Promise<void>
getActiveThemes(): ComponentOrNativeFeature<ThemeFeatureDescription>[]
getActiveThemesIdentifiers(): string[]
isComponentActive(component: ComponentInterface): boolean
toggleComponent(component: ComponentInterface): Promise<void>
}

View File

@@ -2,20 +2,22 @@ import {
ActionObserver,
ComponentEventObserver,
ComponentMessage,
DecryptedItemInterface,
SNComponent,
ComponentOrNativeFeature,
} from '@standardnotes/models'
import { FeatureStatus } from '../Feature/FeatureStatus'
import { ComponentViewerError } from './ComponentViewerError'
import { IframeComponentFeatureDescription } from '@standardnotes/features'
export interface ComponentViewerInterface {
readonly component: SNComponent
readonly url?: string
identifier: string
lockReadonly: boolean
sessionKey?: string
overrideContextItem?: DecryptedItemInterface
get componentUuid(): string
readonly identifier: string
readonly lockReadonly: boolean
readonly sessionKey?: string
get url(): string
get componentUniqueIdentifier(): string
getComponentOrFeatureItem(): ComponentOrNativeFeature<IframeComponentFeatureDescription>
destroy(): void
setReadonly(readonly: boolean): void
getFeatureStatus(): FeatureStatus

View File

@@ -0,0 +1,9 @@
import { DecryptedItemInterface } from '@standardnotes/models'
export type ComponentViewerItem = { uuid: string } | { readonlyItem: DecryptedItemInterface }
export function isComponentViewerItemReadonlyItem(
item: ComponentViewerItem,
): item is { readonlyItem: DecryptedItemInterface } {
return 'readonlyItem' in item
}

View File

@@ -1,7 +1,7 @@
import { SNComponent } from '@standardnotes/models'
import { ComponentInterface } from '@standardnotes/models'
export interface DesktopManagerInterface {
syncComponentsInstallation(components: SNComponent[]): void
registerUpdateObserver(callback: (component: SNComponent) => void): () => void
syncComponentsInstallation(components: ComponentInterface[]): void
registerUpdateObserver(callback: (component: ComponentInterface) => void): () => void
getExtServerHost(): string
}

View File

@@ -15,6 +15,7 @@ export interface MobileDeviceInterface extends DeviceInterface {
authenticateWithBiometrics(): Promise<boolean>
hideMobileInterfaceFromScreenshots(): void
stopHidingMobileInterfaceFromScreenshots(): void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
consoleLog(...args: any[]): void
handleThemeSchemeChange(isDark: boolean, bgColor: string): void
shareBase64AsFile(base64: string, filename: string): Promise<void>

View File

@@ -1,79 +1,61 @@
import { ApplicationStage } from './../Application/ApplicationStage'
export enum ApplicationEvent {
SignedIn = 'signed-in',
SignedOut = 'signed-out',
SignedIn = 'Application:SignedIn',
SignedOut = 'Application:SignedOut',
/** When a full, potentially multi-page sync completes */
CompletedFullSync = 'completed-full-sync',
FailedSync = 'failed-sync',
HighLatencySync = 'high-latency-sync',
EnteredOutOfSync = 'entered-out-of-sync',
ExitedOutOfSync = 'exited-out-of-sync',
ApplicationStageChanged = 'application-stage-changed',
CompletedFullSync = 'Application:CompletedFullSync',
FailedSync = 'Application:FailedSync',
HighLatencySync = 'Application:HighLatencySync',
EnteredOutOfSync = 'Application:EnteredOutOfSync',
ExitedOutOfSync = 'Application:ExitedOutOfSync',
ApplicationStageChanged = 'Application:ApplicationStageChanged',
/**
* The application has finished its prepareForLaunch state and is now ready for unlock
* Called when the application has initialized and is ready for launch, but before
* the application has been unlocked, if applicable. Use this to do pre-launch
* configuration, but do not attempt to access user data like notes or tags.
*/
Started = 'started',
Started = 'Application:Started',
/**
* The applicaiton is fully unlocked and ready for i/o
* Called when the application has been fully decrypted and unlocked. Use this to
* to begin streaming data like notes and tags.
*/
Launched = 'launched',
LocalDataLoaded = 'local-data-loaded',
Launched = 'Application:Launched',
LocalDataLoaded = 'Application:LocalDataLoaded',
/**
* When the root key or root key wrapper changes. Includes events like account state
* changes (registering, signing in, changing pw, logging out) and passcode state
* changes (adding, removing, changing).
*/
KeyStatusChanged = 'key-status-changed',
MajorDataChange = 'major-data-change',
CompletedRestart = 'completed-restart',
LocalDataIncrementalLoad = 'local-data-incremental-load',
SyncStatusChanged = 'sync-status-changed',
WillSync = 'will-sync',
InvalidSyncSession = 'invalid-sync-session',
LocalDatabaseReadError = 'local-database-read-error',
LocalDatabaseWriteError = 'local-database-write-error',
KeyStatusChanged = 'Application:KeyStatusChanged',
MajorDataChange = 'Application:MajorDataChange',
CompletedRestart = 'Application:CompletedRestart',
LocalDataIncrementalLoad = 'Application:LocalDataIncrementalLoad',
SyncStatusChanged = 'Application:SyncStatusChanged',
WillSync = 'Application:WillSync',
InvalidSyncSession = 'Application:InvalidSyncSession',
LocalDatabaseReadError = 'Application:LocalDatabaseReadError',
LocalDatabaseWriteError = 'Application:LocalDatabaseWriteError',
/**
* When a single roundtrip completes with sync, in a potentially multi-page sync request.
* If just a single roundtrip, this event will be triggered, along with CompletedFullSync
*/
CompletedIncrementalSync = 'completed-incremental-sync',
CompletedIncrementalSync = 'Application:CompletedIncrementalSync',
/**
* The application has loaded all pending migrations (but not run any, except for the base one),
* and consumers may now call hasPendingMigrations
*/
MigrationsLoaded = 'migrations-loaded',
MigrationsLoaded = 'Application:MigrationsLoaded',
/** When StorageService is ready (but NOT yet decrypted) to start servicing read/write requests */
StorageReady = 'storage-ready',
PreferencesChanged = 'preferences-changed',
UnprotectedSessionBegan = 'unprotected-session-began',
UserRolesChanged = 'user-roles-changed',
FeaturesUpdated = 'features-updated',
UnprotectedSessionExpired = 'unprotected-session-expired',
StorageReady = 'Application:StorageReady',
PreferencesChanged = 'Application:PreferencesChanged',
UnprotectedSessionBegan = 'Application:UnprotectedSessionBegan',
UserRolesChanged = 'Application:UserRolesChanged',
FeaturesAvailabilityChanged = 'Application:FeaturesAvailabilityChanged',
UnprotectedSessionExpired = 'Application:UnprotectedSessionExpired',
/** Called when the app first launches and after first sync request made after sign in */
CompletedInitialSync = 'completed-initial-sync',
BiometricsSoftLockEngaged = 'biometrics-soft-lock-engaged',
BiometricsSoftLockDisengaged = 'biometrics-soft-lock-disengaged',
DidPurchaseSubscription = 'did-purchase-subscription',
}
export type ApplicationStageChangedEventPayload = {
stage: ApplicationStage
CompletedInitialSync = 'Application:CompletedInitialSync',
BiometricsSoftLockEngaged = 'Application:BiometricsSoftLockEngaged',
BiometricsSoftLockDisengaged = 'Application:BiometricsSoftLockDisengaged',
DidPurchaseSubscription = 'Application:DidPurchaseSubscription',
}

View File

@@ -0,0 +1,5 @@
import { ApplicationStage } from './../Application/ApplicationStage'
export type ApplicationStageChangedEventPayload = {
stage: ApplicationStage
}

View File

@@ -1,37 +1,27 @@
import { FeatureIdentifier } from '@standardnotes/features'
import { SNComponent } from '@standardnotes/models'
import { ComponentInterface } from '@standardnotes/models'
import { FeatureStatus } from './FeatureStatus'
import { SetOfflineFeaturesFunctionResponse } from './SetOfflineFeaturesFunctionResponse'
export interface FeaturesClientInterface {
downloadExternalFeature(urlOrCode: string): Promise<SNComponent | undefined>
getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus
hasFirstPartySubscription(): boolean
hasMinimumRole(role: string): boolean
hasFirstPartyOfflineSubscription(): boolean
setOfflineFeaturesCode(code: string): Promise<SetOfflineFeaturesFunctionResponse>
hasOfflineRepo(): boolean
deleteOfflineFeatureRepo(): Promise<void>
isThirdPartyFeature(identifier: string): boolean
toggleExperimentalFeature(identifier: FeatureIdentifier): void
getExperimentalFeatures(): FeatureIdentifier[]
getEnabledExperimentalFeatures(): FeatureIdentifier[]
enableExperimentalFeature(identifier: FeatureIdentifier): void
disableExperimentalFeature(identifier: FeatureIdentifier): void
isExperimentalFeatureEnabled(identifier: FeatureIdentifier): boolean
isExperimentalFeature(identifier: FeatureIdentifier): boolean
downloadRemoteThirdPartyFeature(urlOrCode: string): Promise<ComponentInterface | undefined>
}

View File

@@ -1,5 +1,5 @@
export enum FeaturesEvent {
UserRolesChanged = 'UserRolesChanged',
FeaturesUpdated = 'FeaturesUpdated',
FeaturesAvailabilityChanged = 'Features:FeaturesAvailabilityChanged',
DidPurchaseSubscription = 'DidPurchaseSubscription',
}

View File

@@ -1,3 +1,3 @@
import { ClientDisplayableError } from '@standardnotes/responses'
export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | undefined
export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | void

View File

@@ -15,13 +15,13 @@ import {
SNNote,
SmartView,
TagItemCountChangeObserver,
SNComponent,
SNTheme,
DecryptedPayloadInterface,
DecryptedTransferPayload,
FileItem,
VaultDisplayOptions,
NotesAndFilesDisplayControllerOptions,
ThemeInterface,
ComponentInterface,
} from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
@@ -92,9 +92,15 @@ export interface ItemManagerInterface extends AbstractService {
itemToLookupUuidFor: DecryptedItemInterface,
contentType?: string,
): I[]
findItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T | undefined
findItems<T extends DecryptedItemInterface>(uuids: string[]): T[]
findSureItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T
/**
* If item is not found, an `undefined` element will be inserted into the array.
*/
findItemsIncludingBlanks<T extends DecryptedItemInterface>(uuids: string[]): (T | undefined)[]
get trashedItems(): SNNote[]
itemsBelongingToKeySystem(systemIdentifier: KeySystemIdentifier): DecryptedItemInterface[]
hasTagsNeedingFoldersMigration(): boolean
@@ -111,8 +117,8 @@ export interface ItemManagerInterface extends AbstractService {
getTagParent(itemToLookupUuidFor: SNTag): SNTag | undefined
isValidTagParent(parentTagToLookUpUuidFor: SNTag, childToLookUpUuidFor: SNTag): boolean
isSmartViewTitle(title: string): boolean
getDisplayableComponents(): (SNComponent | SNTheme)[]
createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface
getDisplayableComponents(): (ComponentInterface | ThemeInterface)[]
createItemFromPayload<T extends DecryptedItemInterface>(payload: DecryptedPayloadInterface): T
createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface
getDisplayableFiles(): FileItem[]
setVaultDisplayOptions(options: VaultDisplayOptions): void

View File

@@ -1,4 +1,5 @@
import {
ComponentInterface,
ComponentMutator,
DecryptedItemInterface,
DecryptedItemMutator,
@@ -13,7 +14,6 @@ import {
PayloadEmitSource,
PredicateInterface,
SmartView,
SNComponent,
SNFeatureRepo,
SNNote,
SNTag,
@@ -72,12 +72,12 @@ export interface MutatorClientInterface {
): Promise<ItemsKeyInterface>
changeComponent(
itemToLookupUuidFor: SNComponent,
itemToLookupUuidFor: ComponentInterface,
mutate: (mutator: ComponentMutator) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<SNComponent>
): Promise<ComponentInterface>
changeFeatureRepo(
itemToLookupUuidFor: SNFeatureRepo,

View File

@@ -1,8 +1,6 @@
import { PrefKey, PrefValue } from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
/* istanbul ignore file */
export enum PreferencesServiceEvent {
PreferencesChanged = 'PreferencesChanged',
}
@@ -11,5 +9,8 @@ export interface PreferenceServiceInterface extends AbstractService<PreferencesS
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K]): PrefValue[K]
getValue<K extends PrefKey>(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined
setValue<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void>
/** Set value without triggering sync or event notifications */
setValueDetached<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void>
}

View File

@@ -15,6 +15,7 @@ export interface SessionsClientInterface {
isSignedIn(): boolean
get userUuid(): string
getSureUser(): User
isSignedIntoFirstPartyServer(): boolean
isCurrentSessionReadOnly(): boolean | undefined
register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody>

View File

@@ -36,7 +36,6 @@ export enum StorageKey {
WebSocketUrl = 'webSocket_url',
UserRoles = 'user_roles',
OfflineUserRoles = 'offline_user_roles',
UserFeatures = 'user_features',
ExperimentalFeatures = 'experimental_features',
DeinitMode = 'deinit_mode',
CodeVerifier = 'code_verifier',
@@ -50,6 +49,7 @@ export enum StorageKey {
FileBackupsEnabled = 'file_backups_enabled',
FileBackupsLocation = 'file_backups_location',
VaultSelectionOptions = 'vault_selection_options',
Subscription = 'subscription',
}
export enum NonwrappedStorageKey {

View File

@@ -1,13 +0,0 @@
import { Invitation } from '@standardnotes/models'
import { AppleIAPReceipt } from './AppleIAPReceipt'
export interface SubscriptionClientInterface {
listSubscriptionInvitations(): Promise<Invitation[]>
inviteToSubscription(inviteeEmail: string): Promise<boolean>
cancelInvitation(inviteUuid: string): Promise<boolean>
acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }>
confirmAppleIAP(
receipt: AppleIAPReceipt,
subscriptionToken: string,
): Promise<{ success: true } | { success: false; message: string }>
}

View File

@@ -1,3 +1,5 @@
import { StorageServiceInterface } from './../Storage/StorageServiceInterface'
import { SessionsClientInterface } from './../Session/SessionsClientInterface'
import { SubscriptionApiServiceInterface } from '@standardnotes/api'
import { Invitation } from '@standardnotes/models'
import { InternalEventBusInterface } from '..'
@@ -6,8 +8,10 @@ import { SubscriptionManager } from './SubscriptionManager'
describe('SubscriptionManager', () => {
let subscriptionApiService: SubscriptionApiServiceInterface
let internalEventBus: InternalEventBusInterface
let sessions: SessionsClientInterface
let storage: StorageServiceInterface
const createManager = () => new SubscriptionManager(subscriptionApiService, internalEventBus)
const createManager = () => new SubscriptionManager(subscriptionApiService, sessions, storage, internalEventBus)
beforeEach(() => {
subscriptionApiService = {} as jest.Mocked<SubscriptionApiServiceInterface>
@@ -16,7 +20,12 @@ describe('SubscriptionManager', () => {
subscriptionApiService.invite = jest.fn()
subscriptionApiService.listInvites = jest.fn()
sessions = {} as jest.Mocked<SessionsClientInterface>
storage = {} as jest.Mocked<StorageServiceInterface>
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.addEventHandler = jest.fn()
})
it('should invite user by email to a shared subscription', async () => {

View File

@@ -1,17 +1,117 @@
import { StorageKey } from './../Storage/StorageKeys'
import { ApplicationStage } from './../Application/ApplicationStage'
import { StorageServiceInterface } from './../Storage/StorageServiceInterface'
import { InternalEventInterface } from './../Internal/InternalEventInterface'
import { SessionsClientInterface } from './../Session/SessionsClientInterface'
import { SubscriptionName } from '@standardnotes/common'
import { convertTimestampToMilliseconds } from '@standardnotes/utils'
import { ApplicationEvent } from './../Event/ApplicationEvent'
import { InternalEventHandlerInterface } from './../Internal/InternalEventHandlerInterface'
import { Invitation } from '@standardnotes/models'
import { SubscriptionApiServiceInterface } from '@standardnotes/api'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService'
import { SubscriptionClientInterface } from './SubscriptionClientInterface'
import { SubscriptionManagerInterface } from './SubscriptionManagerInterface'
import { AppleIAPReceipt } from './AppleIAPReceipt'
import { getErrorFromErrorResponse, isErrorResponse } from '@standardnotes/responses'
import { AvailableSubscriptions, getErrorFromErrorResponse, isErrorResponse } from '@standardnotes/responses'
import { Subscription } from '@standardnotes/security'
import { SubscriptionManagerEvent } from './SubscriptionManagerEvent'
export class SubscriptionManager
extends AbstractService<SubscriptionManagerEvent>
implements SubscriptionManagerInterface, InternalEventHandlerInterface
{
private onlineSubscription?: Subscription
private availableSubscriptions?: AvailableSubscriptions | undefined
export class SubscriptionManager extends AbstractService implements SubscriptionClientInterface {
constructor(
private subscriptionApiService: SubscriptionApiServiceInterface,
private sessions: SessionsClientInterface,
private storage: StorageServiceInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
internalEventBus.addEventHandler(this, ApplicationEvent.UserRolesChanged)
internalEventBus.addEventHandler(this, ApplicationEvent.Launched)
internalEventBus.addEventHandler(this, ApplicationEvent.SignedIn)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
switch (event.type) {
case ApplicationEvent.Launched: {
void this.fetchOnlineSubscription()
void this.fetchAvailableSubscriptions()
break
}
case ApplicationEvent.UserRolesChanged:
case ApplicationEvent.SignedIn:
void this.fetchOnlineSubscription()
break
}
}
public override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.StorageDecrypted_09) {
this.onlineSubscription = this.storage.getValue(StorageKey.Subscription)
void this.notifyEvent(SubscriptionManagerEvent.DidFetchSubscription)
}
}
hasOnlineSubscription(): boolean {
return this.onlineSubscription != undefined
}
getOnlineSubscription(): Subscription | undefined {
return this.onlineSubscription
}
getAvailableSubscriptions(): AvailableSubscriptions | undefined {
return this.availableSubscriptions
}
get userSubscriptionName(): string {
if (!this.onlineSubscription) {
throw new Error('Attempting to get subscription name without a subscription.')
}
if (
this.availableSubscriptions &&
this.availableSubscriptions[this.onlineSubscription.planName as SubscriptionName]
) {
return this.availableSubscriptions[this.onlineSubscription.planName as SubscriptionName].name
}
return ''
}
get userSubscriptionExpirationDate(): Date | undefined {
if (!this.onlineSubscription) {
return undefined
}
return new Date(convertTimestampToMilliseconds(this.onlineSubscription.endsAt))
}
get isUserSubscriptionExpired(): boolean {
if (!this.onlineSubscription) {
throw new Error('Attempting to check subscription expiration without a subscription.')
}
if (!this.userSubscriptionExpirationDate) {
return false
}
return this.userSubscriptionExpirationDate.getTime() < new Date().getTime()
}
get isUserSubscriptionCanceled(): boolean {
if (!this.onlineSubscription) {
throw new Error('Attempting to check subscription expiration without a subscription.')
}
return this.onlineSubscription.cancelled
}
async acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }> {
@@ -70,6 +170,48 @@ export class SubscriptionManager extends AbstractService implements Subscription
}
}
private async fetchOnlineSubscription(): Promise<void> {
if (!this.sessions.isSignedIn()) {
return
}
try {
const result = await this.subscriptionApiService.getUserSubscription({ userUuid: this.sessions.userUuid })
if (isErrorResponse(result)) {
return
}
const subscription = result.data.subscription
this.handleReceivedOnlineSubscriptionFromServer(subscription)
} catch (error) {
void error
}
}
private handleReceivedOnlineSubscriptionFromServer(subscription: Subscription | undefined): void {
this.onlineSubscription = subscription
this.storage.setValue(StorageKey.Subscription, subscription)
void this.notifyEvent(SubscriptionManagerEvent.DidFetchSubscription)
}
private async fetchAvailableSubscriptions(): Promise<void> {
try {
const response = await this.subscriptionApiService.getAvailableSubscriptions()
if (isErrorResponse(response)) {
return
}
this.availableSubscriptions = response.data
} catch (error) {
void error
}
}
async confirmAppleIAP(
params: AppleIAPReceipt,
subscriptionToken: string,

View File

@@ -0,0 +1,3 @@
export enum SubscriptionManagerEvent {
DidFetchSubscription = 'Subscription:DidFetchSubscription',
}

View File

@@ -0,0 +1,26 @@
import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface'
import { Invitation } from '@standardnotes/models'
import { AppleIAPReceipt } from './AppleIAPReceipt'
import { AvailableSubscriptions } from '@standardnotes/responses'
import { Subscription } from '@standardnotes/security'
import { SubscriptionManagerEvent } from './SubscriptionManagerEvent'
export interface SubscriptionManagerInterface extends ApplicationServiceInterface<SubscriptionManagerEvent, unknown> {
getOnlineSubscription(): Subscription | undefined
getAvailableSubscriptions(): AvailableSubscriptions | undefined
hasOnlineSubscription(): boolean
get userSubscriptionName(): string
get userSubscriptionExpirationDate(): Date | undefined
get isUserSubscriptionExpired(): boolean
get isUserSubscriptionCanceled(): boolean
listSubscriptionInvitations(): Promise<Invitation[]>
inviteToSubscription(inviteeEmail: string): Promise<boolean>
cancelInvitation(inviteUuid: string): Promise<boolean>
acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }>
confirmAppleIAP(
receipt: AppleIAPReceipt,
subscriptionToken: string,
): Promise<{ success: true } | { success: false; message: string }>
}

View File

@@ -0,0 +1,4 @@
export enum AccountEvent {
SignedInOrRegistered = 'SignedInOrRegistered',
SignedOut = 'SignedOut',
}

View File

@@ -0,0 +1,7 @@
import { Either } from '@standardnotes/common'
import { SignedInOrRegisteredEventPayload } from './SignedInOrRegisteredEventPayload'
import { SignedOutEventPayload } from './SignedOutEventPayload'
export interface AccountEventData {
payload: Either<SignedInOrRegisteredEventPayload, SignedOutEventPayload>
}

View File

@@ -0,0 +1,3 @@
import { HttpError } from '@standardnotes/responses'
export type CredentialsChangeFunctionResponse = { error?: HttpError }

View File

@@ -0,0 +1,6 @@
export interface SignedInOrRegisteredEventPayload {
ephemeral: boolean
mergeLocal: boolean
awaitSync: boolean
checkIntegrity: boolean
}

View File

@@ -0,0 +1,5 @@
import { DeinitSource } from '../Application/DeinitSource'
export interface SignedOutEventPayload {
source: DeinitSource
}

View File

@@ -1,33 +1,14 @@
import { Base64String } from '@standardnotes/sncrypto-common'
import { Either, UserRequestType } from '@standardnotes/common'
import { UserRequestType } from '@standardnotes/common'
import { DeinitSource } from '../Application/DeinitSource'
import { UserRegistrationResponseBody } from '@standardnotes/api'
import { HttpError, HttpResponse, SignInResponse } from '@standardnotes/responses'
import { HttpResponse, SignInResponse } from '@standardnotes/responses'
import { AbstractService } from '../Service/AbstractService'
export type CredentialsChangeFunctionResponse = { error?: HttpError }
export enum AccountEvent {
SignedInOrRegistered = 'SignedInOrRegistered',
SignedOut = 'SignedOut',
}
export interface SignedInOrRegisteredEventPayload {
ephemeral: boolean
mergeLocal: boolean
awaitSync: boolean
checkIntegrity: boolean
}
export interface SignedOutEventPayload {
source: DeinitSource
}
export interface AccountEventData {
payload: Either<SignedInOrRegisteredEventPayload, SignedOutEventPayload>
}
import { AccountEventData } from './AccountEventData'
import { AccountEvent } from './AccountEvent'
export interface UserClientInterface extends AbstractService<AccountEvent, AccountEventData> {
getUserUuid(): string
isSignedIn(): boolean
register(
email: string,

View File

@@ -10,13 +10,6 @@ import {
import { KeyParamsOrigination, UserRequestType } from '@standardnotes/common'
import { UuidGenerator } from '@standardnotes/utils'
import { UserApiServiceInterface, UserRegistrationResponseBody } from '@standardnotes/api'
import {
AccountEventData,
AccountEvent,
SignedInOrRegisteredEventPayload,
CredentialsChangeFunctionResponse,
} from '@standardnotes/services'
import * as Messages from '../Strings/Messages'
import { InfoStrings } from '../Strings/InfoStrings'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
@@ -39,6 +32,10 @@ import { SessionsClientInterface } from '../Session/SessionsClientInterface'
import { ProtectionsClientInterface } from '../Protection/ProtectionClientInterface'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
import { AccountEventData } from './AccountEventData'
import { AccountEvent } from './AccountEvent'
import { SignedInOrRegisteredEventPayload } from './SignedInOrRegisteredEventPayload'
import { CredentialsChangeFunctionResponse } from './CredentialsChangeFunctionResponse'
export class UserService
extends AbstractService<AccountEvent, AccountEventData>
@@ -115,6 +112,10 @@ export class UserService
;(this.userApiService as unknown) = undefined
}
getUserUuid(): string {
return this.sessionManager.userUuid
}
isSignedIn(): boolean {
return this.sessionManager.isSignedIn()
}

View File

@@ -1,5 +1,10 @@
export * from './Alert/AlertService'
export * from './Api/ApiServiceInterface'
export * from './Api/ApiServiceEventData'
export * from './Api/ApiServiceEvent'
export * from './Api/MetaReceivedData'
export * from './Api/SessionRefreshedData'
export * from './Application/AppGroupManagedApplication'
export * from './Application/ApplicationInterface'
@@ -24,6 +29,7 @@ export * from './Challenge'
export * from './Component/ComponentManagerInterface'
export * from './Component/ComponentViewerError'
export * from './Component/ComponentViewerInterface'
export * from './Component/ComponentViewerItem'
export * from './Contacts/ContactServiceInterface'
export * from './Contacts/ContactService'
@@ -69,6 +75,7 @@ export * from './Event/EventObserver'
export * from './Event/SyncEvent'
export * from './Event/SyncEventReceiver'
export * from './Event/WebAppEvent'
export * from './Event/ApplicationStageChangedEventPayload'
export * from './Feature/FeaturesClientInterface'
export * from './Feature/FeaturesEvent'
@@ -140,8 +147,9 @@ export * from './Strings/Messages'
export * from './Subscription/AppleIAPProductId'
export * from './Subscription/AppleIAPReceipt'
export * from './Subscription/SubscriptionClientInterface'
export * from './Subscription/SubscriptionManagerInterface'
export * from './Subscription/SubscriptionManager'
export * from './Subscription/SubscriptionManagerEvent'
export * from './Sync/SyncMode'
export * from './Sync/SyncOptions'
@@ -152,6 +160,11 @@ export * from './Sync/SyncSource'
export * from './User/UserClientInterface'
export * from './User/UserClientInterface'
export * from './User/UserService'
export * from './User/AccountEvent'
export * from './User/AccountEventData'
export * from './User/CredentialsChangeFunctionResponse'
export * from './User/SignedInOrRegisteredEventPayload'
export * from './User/SignedOutEventPayload'
export * from './UserEvent/UserEventService'
export * from './UserEvent/UserEventServiceEvent'