diff --git a/packages/api/package.json b/packages/api/package.json index a8546d87e..e8711225c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -27,7 +27,6 @@ }, "devDependencies": { "@types/jest": "^29.2.3", - "@types/lodash": "^4.14.189", "@typescript-eslint/eslint-plugin": "*", "eslint": "^8.27.0", "eslint-plugin-prettier": "*", diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts index 4495f5595..7de275a4f 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts @@ -4,4 +4,6 @@ export enum SubscriptionApiOperations { ListingInvites, AcceptingInvite, ConfirmAppleIAP, + GetSubscription, + GetAvailableSubscriptions, } diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts index a1d529490..27bb1ccac 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts @@ -8,11 +8,17 @@ import { SubscriptionInviteAcceptResponseBody } from '../../Response/Subscriptio import { SubscriptionInviteCancelResponseBody } from '../../Response/Subscription/SubscriptionInviteCancelResponseBody' import { SubscriptionInviteListResponseBody } from '../../Response/Subscription/SubscriptionInviteListResponseBody' import { SubscriptionInviteResponseBody } from '../../Response/Subscription/SubscriptionInviteResponseBody' -import { HttpResponse, ApiEndpointParam } from '@standardnotes/responses' +import { + HttpResponse, + ApiEndpointParam, + GetSubscriptionResponse, + GetAvailableSubscriptionsResponse, +} from '@standardnotes/responses' import { SubscriptionApiServiceInterface } from './SubscriptionApiServiceInterface' import { SubscriptionApiOperations } from './SubscriptionApiOperations' import { AppleIAPConfirmRequestParams } from '../../Request' +import { GetUserSubscriptionRequestParams } from '../../Request/Subscription/GetUserSubscriptionRequestParams' export class SubscriptionApiService implements SubscriptionApiServiceInterface { private operationsInProgress: Map @@ -118,4 +124,36 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface { this.operationsInProgress.set(SubscriptionApiOperations.ConfirmAppleIAP, false) } } + + async getUserSubscription(params: GetUserSubscriptionRequestParams): Promise> { + if (this.operationsInProgress.get(SubscriptionApiOperations.GetSubscription)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(SubscriptionApiOperations.GetSubscription, true) + + try { + const response = await this.subscriptionServer.getUserSubscription(params) + + return response + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.GetSubscription, false) + } + } + + async getAvailableSubscriptions(): Promise> { + if (this.operationsInProgress.get(SubscriptionApiOperations.GetAvailableSubscriptions)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(SubscriptionApiOperations.GetAvailableSubscriptions, true) + + try { + const response = await this.subscriptionServer.getAvailableSubscriptions() + + return response + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.GetAvailableSubscriptions, false) + } + } } diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts index 5f4279f41..8f82f3ec7 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts @@ -4,7 +4,8 @@ import { SubscriptionInviteAcceptResponseBody } from '../../Response/Subscriptio import { SubscriptionInviteCancelResponseBody } from '../../Response/Subscription/SubscriptionInviteCancelResponseBody' import { SubscriptionInviteListResponseBody } from '../../Response/Subscription/SubscriptionInviteListResponseBody' import { SubscriptionInviteResponseBody } from '../../Response/Subscription/SubscriptionInviteResponseBody' -import { HttpResponse } from '@standardnotes/responses' +import { GetAvailableSubscriptionsResponse, GetSubscriptionResponse, HttpResponse } from '@standardnotes/responses' +import { GetUserSubscriptionRequestParams } from '../../Request/Subscription/GetUserSubscriptionRequestParams' export interface SubscriptionApiServiceInterface { invite(inviteeEmail: string): Promise> @@ -12,4 +13,6 @@ export interface SubscriptionApiServiceInterface { cancelInvite(inviteUuid: string): Promise> acceptInvite(inviteUuid: string): Promise> confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise> + getUserSubscription(params: GetUserSubscriptionRequestParams): Promise> + getAvailableSubscriptions(): Promise> } diff --git a/packages/api/src/Domain/Request/Subscription/GetUserSubscriptionRequestParams.ts b/packages/api/src/Domain/Request/Subscription/GetUserSubscriptionRequestParams.ts new file mode 100644 index 000000000..2e84c51b6 --- /dev/null +++ b/packages/api/src/Domain/Request/Subscription/GetUserSubscriptionRequestParams.ts @@ -0,0 +1,3 @@ +export type GetUserSubscriptionRequestParams = { + userUuid: string +} diff --git a/packages/api/src/Domain/Server/Subscription/Paths.ts b/packages/api/src/Domain/Server/Subscription/Paths.ts index 60783541e..d987da165 100644 --- a/packages/api/src/Domain/Server/Subscription/Paths.ts +++ b/packages/api/src/Domain/Server/Subscription/Paths.ts @@ -6,13 +6,23 @@ const SharingPaths = { listInvites: '/v1/subscription-invites', } +const UserSubscriptionPaths = { + subscription: (userUuid: string) => `/v1/users/${userUuid}/subscription`, +} + const ApplePaths = { confirmAppleIAP: '/v1/subscriptions/apple_iap_confirm', } +const UnauthenticatedSubscriptionsPaths = { + availableSubscriptions: '/v2/subscriptions', +} + export const Paths = { v1: { ...SharingPaths, ...ApplePaths, + ...UserSubscriptionPaths, + ...UnauthenticatedSubscriptionsPaths, }, } diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts index 9d4de393a..c2b8017f3 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts @@ -11,10 +11,11 @@ import { SubscriptionInviteCancelResponseBody } from '../../Response/Subscriptio import { SubscriptionInviteDeclineResponseBody } from '../../Response/Subscription/SubscriptionInviteDeclineResponseBody' import { SubscriptionInviteListResponseBody } from '../../Response/Subscription/SubscriptionInviteListResponseBody' import { SubscriptionInviteResponseBody } from '../../Response/Subscription/SubscriptionInviteResponseBody' -import { HttpResponse } from '@standardnotes/responses' +import { GetAvailableSubscriptionsResponse, GetSubscriptionResponse, HttpResponse } from '@standardnotes/responses' import { Paths } from './Paths' import { SubscriptionServerInterface } from './SubscriptionServerInterface' +import { GetUserSubscriptionRequestParams } from '../../Request/Subscription/GetUserSubscriptionRequestParams' export class SubscriptionServer implements SubscriptionServerInterface { constructor(private httpService: HttpServiceInterface) {} @@ -50,4 +51,12 @@ export class SubscriptionServer implements SubscriptionServerInterface { async confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise> { return this.httpService.post(Paths.v1.confirmAppleIAP, params) } + + async getUserSubscription(params: GetUserSubscriptionRequestParams): Promise> { + return this.httpService.get(Paths.v1.subscription(params.userUuid), params) + } + + async getAvailableSubscriptions(): Promise> { + return this.httpService.get(Paths.v1.availableSubscriptions) + } } diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts index 9f1dd0799..b348c8ed4 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts @@ -11,7 +11,8 @@ import { SubscriptionInviteCancelResponseBody } from '../../Response/Subscriptio import { SubscriptionInviteDeclineResponseBody } from '../../Response/Subscription/SubscriptionInviteDeclineResponseBody' import { SubscriptionInviteListResponseBody } from '../../Response/Subscription/SubscriptionInviteListResponseBody' import { SubscriptionInviteResponseBody } from '../../Response/Subscription/SubscriptionInviteResponseBody' -import { HttpResponse } from '@standardnotes/responses' +import { GetAvailableSubscriptionsResponse, GetSubscriptionResponse, HttpResponse } from '@standardnotes/responses' +import { GetUserSubscriptionRequestParams } from '../../Request/Subscription/GetUserSubscriptionRequestParams' export interface SubscriptionServerInterface { invite(params: SubscriptionInviteRequestParams): Promise> @@ -25,5 +26,9 @@ export interface SubscriptionServerInterface { params: SubscriptionInviteCancelRequestParams, ): Promise> listInvites(params: SubscriptionInviteListRequestParams): Promise> + confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise> + + getUserSubscription(params: GetUserSubscriptionRequestParams): Promise> + getAvailableSubscriptions(): Promise> } diff --git a/packages/desktop/app/javascripts/Main/ExtensionsServer.ts b/packages/desktop/app/javascripts/Main/ExtensionsServer.ts index 13b931213..b11542f3b 100644 --- a/packages/desktop/app/javascripts/Main/ExtensionsServer.ts +++ b/packages/desktop/app/javascripts/Main/ExtensionsServer.ts @@ -10,11 +10,14 @@ import { FileErrorCodes } from './File/FileErrorCodes' const Protocol = 'http' +// eslint-disable-next-line @typescript-eslint/no-explicit-any function logError(...message: any) { console.error('extServer:', ...message) } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function log(...message: any) { + // eslint-disable-next-line no-console console.log('extServer:', ...message) } @@ -71,8 +74,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) response.writeHead(200) response.end(data) - } catch (error: any) { - onRequestError(error, response) + } catch (error) { + onRequestError(error as Error, response) } } diff --git a/packages/features/src/Domain/Component/NoteType.ts b/packages/features/src/Domain/Component/NoteType.ts index 269a66fdd..24d8355b3 100644 --- a/packages/features/src/Domain/Component/NoteType.ts +++ b/packages/features/src/Domain/Component/NoteType.ts @@ -1,4 +1,6 @@ +import { EditorFeatureDescription } from '../Feature/EditorFeatureDescription' import { FindNativeFeature } from '../Feature/Features' +import { IframeComponentFeatureDescription } from '../Feature/IframeComponentFeatureDescription' import { FeatureIdentifier } from './../Feature/FeatureIdentifier' import { EditorIdentifier } from './EditorIdentifier' @@ -15,13 +17,9 @@ export enum NoteType { } export function noteTypeForEditorIdentifier(identifier: EditorIdentifier): NoteType { - if (identifier === FeatureIdentifier.PlainEditor) { - return NoteType.Plain - } else if (identifier === FeatureIdentifier.SuperEditor) { - return NoteType.Super - } - - const feature = FindNativeFeature(identifier as FeatureIdentifier) + const feature = FindNativeFeature( + identifier as FeatureIdentifier, + ) if (feature && feature.note_type) { return feature.note_type } diff --git a/packages/features/src/Domain/Feature/AnyFeatureDescription.ts b/packages/features/src/Domain/Feature/AnyFeatureDescription.ts new file mode 100644 index 000000000..c198035b0 --- /dev/null +++ b/packages/features/src/Domain/Feature/AnyFeatureDescription.ts @@ -0,0 +1,14 @@ +import { EditorFeatureDescription } from './EditorFeatureDescription' +import { ThemeFeatureDescription } from './ThemeFeatureDescription' +import { ComponentFeatureDescription } from './ComponentFeatureDescription' +import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription' +import { ClientFeatureDescription } from './ClientFeatureDescription' +import { ServerFeatureDescription } from './ServerFeatureDescription' + +export type AnyFeatureDescription = + | ComponentFeatureDescription + | EditorFeatureDescription + | ThemeFeatureDescription + | IframeComponentFeatureDescription + | ClientFeatureDescription + | ServerFeatureDescription diff --git a/packages/features/src/Domain/Feature/BaseFeatureDescription.ts b/packages/features/src/Domain/Feature/BaseFeatureDescription.ts new file mode 100644 index 000000000..df8bd98f4 --- /dev/null +++ b/packages/features/src/Domain/Feature/BaseFeatureDescription.ts @@ -0,0 +1,24 @@ +import { PermissionName } from '../Permission/PermissionName' +import { FeatureIdentifier } from './FeatureIdentifier' +import { ComponentFlag } from '../Component/ComponentFlag' +import { RoleFields } from './RoleFields' + +export type BaseFeatureDescription = RoleFields & { + deletion_warning?: string + deprecated?: boolean + deprecation_message?: string + description?: string + expires_at?: number + + /** Whether the client controls availability of this feature (such as the dark theme) */ + clientControlled?: boolean + + flags?: ComponentFlag[] + identifier: FeatureIdentifier + marketing_url?: string + name: string + no_expire?: boolean + no_mobile?: boolean + thumbnail_url?: string + permission_name: PermissionName +} diff --git a/packages/features/src/Domain/Feature/ClientFeatureDescription.ts b/packages/features/src/Domain/Feature/ClientFeatureDescription.ts new file mode 100644 index 000000000..ccd0acfe5 --- /dev/null +++ b/packages/features/src/Domain/Feature/ClientFeatureDescription.ts @@ -0,0 +1,11 @@ +import { PermissionName } from '../Permission/PermissionName' +import { FeatureIdentifier } from './FeatureIdentifier' +import { RoleFields } from './RoleFields' + +export type ClientFeatureDescription = RoleFields & { + identifier: FeatureIdentifier + permission_name: PermissionName + description: string + name: string + deprecated?: boolean +} diff --git a/packages/features/src/Domain/Feature/ComponentFeatureDescription.ts b/packages/features/src/Domain/Feature/ComponentFeatureDescription.ts new file mode 100644 index 000000000..16080acc2 --- /dev/null +++ b/packages/features/src/Domain/Feature/ComponentFeatureDescription.ts @@ -0,0 +1,9 @@ +import { ComponentArea } from '../Component/ComponentArea' +import { BaseFeatureDescription } from './BaseFeatureDescription' + +export type ComponentFeatureDescription = BaseFeatureDescription & { + /** The relative path of the index.html file or the main css file if theme, within the component folder itself */ + index_path: string + content_type: string + area: ComponentArea +} diff --git a/packages/features/src/Domain/Feature/EditorFeatureDescription.ts b/packages/features/src/Domain/Feature/EditorFeatureDescription.ts new file mode 100644 index 000000000..65acc0dcc --- /dev/null +++ b/packages/features/src/Domain/Feature/EditorFeatureDescription.ts @@ -0,0 +1,10 @@ +import { NoteType } from '../Component/NoteType' +import { BaseFeatureDescription } from './BaseFeatureDescription' + +export type EditorFeatureDescription = BaseFeatureDescription & { + file_type: 'txt' | 'html' | 'md' | 'json' + /** Whether an editor is interchangable with another editor that has the same file_type */ + interchangeable: boolean + note_type: NoteType + spellcheckControl: boolean +} diff --git a/packages/features/src/Domain/Feature/FeatureDescription.ts b/packages/features/src/Domain/Feature/FeatureDescription.ts deleted file mode 100644 index 7b87eafc1..000000000 --- a/packages/features/src/Domain/Feature/FeatureDescription.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ComponentPermission } from '../Component/ComponentPermission' -import { ComponentArea } from '../Component/ComponentArea' -import { PermissionName } from '../Permission/PermissionName' -import { FeatureIdentifier } from './FeatureIdentifier' -import { ComponentFlag } from '../Component/ComponentFlag' -import { NoteType } from '../Component/NoteType' -import { ThemeDockIcon } from '../Component/ThemeDockIcon' - -type RoleFields = { - /** Server populated */ - role_name?: string - - /** Statically populated. Non-influencing; used as a reference by other static consumers (such as email service) */ - availableInRoles: string[] -} - -export type BaseFeatureDescription = RoleFields & { - deletion_warning?: string - deprecated?: boolean - deprecation_message?: string - description?: string - expires_at?: number - - /** Whether the client controls availability of this feature (such as the dark theme) */ - clientControlled?: boolean - - flags?: ComponentFlag[] - identifier: FeatureIdentifier - marketing_url?: string - name?: string - no_expire?: boolean - no_mobile?: boolean - thumbnail_url?: string - permission_name: PermissionName -} - -export type ServerFeatureDescription = RoleFields & { - name?: string - identifier: FeatureIdentifier - permission_name: PermissionName -} - -export type ClientFeatureDescription = RoleFields & { - identifier: FeatureIdentifier - permission_name: PermissionName - description: string - name: string -} - -export type ComponentFeatureDescription = BaseFeatureDescription & { - /** The relative path of the index.html file or the main css file if theme, within the component folder itself */ - index_path: string - content_type: string - area: ComponentArea -} - -export type ThirdPartyFeatureDescription = ComponentFeatureDescription & { - url: string -} - -export type IframeComponentFeatureDescription = ComponentFeatureDescription & { - component_permissions: ComponentPermission[] -} - -export type EditorFeatureDescription = IframeComponentFeatureDescription & { - file_type: 'txt' | 'html' | 'md' | 'json' - /** Whether an editor is interchangable with another editor that has the same file_type */ - interchangeable: boolean - note_type: NoteType - spellcheckControl?: boolean -} - -export type ThemeFeatureDescription = ComponentFeatureDescription & { - /** Some themes can be layered on top of other themes */ - layerable?: boolean - dock_icon?: ThemeDockIcon - isDark?: boolean -} - -export type FeatureDescription = BaseFeatureDescription & - Partial diff --git a/packages/features/src/Domain/Feature/Features.ts b/packages/features/src/Domain/Feature/Features.ts index fce3835a5..a4bd050a6 100644 --- a/packages/features/src/Domain/Feature/Features.ts +++ b/packages/features/src/Domain/Feature/Features.ts @@ -1,14 +1,51 @@ -import { FeatureDescription } from './FeatureDescription' +import { AnyFeatureDescription } from './AnyFeatureDescription' +import { ThemeFeatureDescription } from './ThemeFeatureDescription' +import { EditorFeatureDescription } from './EditorFeatureDescription' import { FeatureIdentifier } from './FeatureIdentifier' import { serverFeatures } from '../Lists/ServerFeatures' import { clientFeatures } from '../Lists/ClientFeatures' import { GetDeprecatedFeatures } from '../Lists/DeprecatedFeatures' import { experimentalFeatures } from '../Lists/ExperimentalFeatures' +import { IframeEditors } from '../Lists/IframeEditors' +import { themes } from '../Lists/Themes' +import { nativeEditors } from '../Lists/NativeEditors' -export function GetFeatures(): FeatureDescription[] { - return [...serverFeatures(), ...clientFeatures(), ...experimentalFeatures(), ...GetDeprecatedFeatures()] +export function GetFeatures(): AnyFeatureDescription[] { + return [ + ...serverFeatures(), + ...clientFeatures(), + ...themes(), + ...nativeEditors(), + ...IframeEditors(), + ...experimentalFeatures(), + ...GetDeprecatedFeatures(), + ] } -export function FindNativeFeature(identifier: FeatureIdentifier): FeatureDescription | undefined { - return GetFeatures().find((f) => f.identifier === identifier) +export function FindNativeFeature(identifier: FeatureIdentifier): T | undefined { + return GetFeatures().find((f) => f.identifier === identifier) as T +} + +export function FindNativeTheme(identifier: FeatureIdentifier): ThemeFeatureDescription | undefined { + return themes().find((t) => t.identifier === identifier) +} + +export function GetIframeAndNativeEditors(): EditorFeatureDescription[] { + return [...IframeEditors(), ...nativeEditors()] +} + +export function GetSuperNoteFeature(): EditorFeatureDescription { + return FindNativeFeature(FeatureIdentifier.SuperEditor) as EditorFeatureDescription +} + +export function GetPlainNoteFeature(): EditorFeatureDescription { + return FindNativeFeature(FeatureIdentifier.PlainEditor) as EditorFeatureDescription +} + +export function GetNativeThemes(): ThemeFeatureDescription[] { + return themes() +} + +export function GetDarkThemeFeature(): ThemeFeatureDescription { + return themes().find((t) => t.identifier === FeatureIdentifier.DarkTheme) as ThemeFeatureDescription } diff --git a/packages/features/src/Domain/Feature/IframeComponentFeatureDescription.ts b/packages/features/src/Domain/Feature/IframeComponentFeatureDescription.ts new file mode 100644 index 000000000..4197ea6a8 --- /dev/null +++ b/packages/features/src/Domain/Feature/IframeComponentFeatureDescription.ts @@ -0,0 +1,7 @@ +import { ComponentPermission } from '../Component/ComponentPermission' +import { ComponentFeatureDescription } from './ComponentFeatureDescription' +import { EditorFeatureDescription } from './EditorFeatureDescription' + +export type IframeComponentFeatureDescription = (EditorFeatureDescription & ComponentFeatureDescription) & { + component_permissions: ComponentPermission[] +} diff --git a/packages/features/src/Domain/Feature/RoleFields.ts b/packages/features/src/Domain/Feature/RoleFields.ts new file mode 100644 index 000000000..2b4ca7bfb --- /dev/null +++ b/packages/features/src/Domain/Feature/RoleFields.ts @@ -0,0 +1,7 @@ +export type RoleFields = { + /** Server populated */ + role_name?: string + + /** Statically populated. Non-influencing; used as a reference by other static consumers (such as email service) */ + availableInRoles: string[] +} diff --git a/packages/features/src/Domain/Feature/ServerFeatureDescription.ts b/packages/features/src/Domain/Feature/ServerFeatureDescription.ts new file mode 100644 index 000000000..7b2485faf --- /dev/null +++ b/packages/features/src/Domain/Feature/ServerFeatureDescription.ts @@ -0,0 +1,11 @@ +import { PermissionName } from '../Permission/PermissionName' +import { FeatureIdentifier } from './FeatureIdentifier' +import { RoleFields } from './RoleFields' + +export type ServerFeatureDescription = RoleFields & { + name: string + description?: string + identifier: FeatureIdentifier + permission_name: PermissionName + deprecated?: boolean +} diff --git a/packages/features/src/Domain/Feature/ThemeFeatureDescription.ts b/packages/features/src/Domain/Feature/ThemeFeatureDescription.ts new file mode 100644 index 000000000..001bfb4eb --- /dev/null +++ b/packages/features/src/Domain/Feature/ThemeFeatureDescription.ts @@ -0,0 +1,9 @@ +import { ThemeDockIcon } from '../Component/ThemeDockIcon' +import { ComponentFeatureDescription } from './ComponentFeatureDescription' + +export type ThemeFeatureDescription = ComponentFeatureDescription & { + /** Some themes can be layered on top of other themes */ + layerable?: boolean + dock_icon?: ThemeDockIcon + isDark?: boolean +} diff --git a/packages/features/src/Domain/Feature/ThirdPartyFeatureDescription.ts b/packages/features/src/Domain/Feature/ThirdPartyFeatureDescription.ts new file mode 100644 index 000000000..8f81cdbc1 --- /dev/null +++ b/packages/features/src/Domain/Feature/ThirdPartyFeatureDescription.ts @@ -0,0 +1,5 @@ +import { ComponentFeatureDescription } from './ComponentFeatureDescription' + +export type ThirdPartyFeatureDescription = ComponentFeatureDescription & { + url: string +} diff --git a/packages/features/src/Domain/Feature/TypeGuards.ts b/packages/features/src/Domain/Feature/TypeGuards.ts new file mode 100644 index 000000000..949f43be4 --- /dev/null +++ b/packages/features/src/Domain/Feature/TypeGuards.ts @@ -0,0 +1,28 @@ +import { ContentType } from '@standardnotes/domain-core' +import { AnyFeatureDescription } from './AnyFeatureDescription' +import { ThemeFeatureDescription } from './ThemeFeatureDescription' +import { EditorFeatureDescription } from './EditorFeatureDescription' +import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription' +import { ComponentFeatureDescription } from './ComponentFeatureDescription' +import { ComponentArea } from '../Component/ComponentArea' + +export function isThemeFeatureDescription(feature: AnyFeatureDescription): feature is ThemeFeatureDescription { + return 'content_type' in feature && feature.content_type === ContentType.TYPES.Theme +} + +export function isIframeComponentFeatureDescription( + feature: AnyFeatureDescription, +): feature is IframeComponentFeatureDescription { + return ( + 'content_type' in feature && + feature.content_type === ContentType.TYPES.Component && + [ComponentArea.Editor, ComponentArea.EditorStack].includes(feature.area) + ) +} + +export function isEditorFeatureDescription(feature: AnyFeatureDescription): feature is EditorFeatureDescription { + return ( + (feature as EditorFeatureDescription).note_type != undefined || + (feature as ComponentFeatureDescription).area === ComponentArea.Editor + ) +} diff --git a/packages/features/src/Domain/Feature/UIFeatureDescription.ts b/packages/features/src/Domain/Feature/UIFeatureDescription.ts new file mode 100644 index 000000000..b8c709646 --- /dev/null +++ b/packages/features/src/Domain/Feature/UIFeatureDescription.ts @@ -0,0 +1,10 @@ +import { ComponentFeatureDescription } from './ComponentFeatureDescription' +import { EditorFeatureDescription } from './EditorFeatureDescription' +import { IframeComponentFeatureDescription } from './IframeComponentFeatureDescription' +import { ThemeFeatureDescription } from './ThemeFeatureDescription' + +export type UIFeatureDescriptionTypes = + | IframeComponentFeatureDescription + | ThemeFeatureDescription + | EditorFeatureDescription + | ComponentFeatureDescription diff --git a/packages/features/src/Domain/Lists/ClientFeatures.ts b/packages/features/src/Domain/Lists/ClientFeatures.ts index fb626c4a9..fa629aece 100644 --- a/packages/features/src/Domain/Lists/ClientFeatures.ts +++ b/packages/features/src/Domain/Lists/ClientFeatures.ts @@ -1,14 +1,10 @@ -import { FeatureDescription } from '../Feature/FeatureDescription' import { PermissionName } from '../Permission/PermissionName' import { FeatureIdentifier } from '../Feature/FeatureIdentifier' import { RoleName } from '@standardnotes/domain-core' -import { themes } from './Themes' -import { editors } from './Editors' +import { ClientFeatureDescription } from '../Feature/ClientFeatureDescription' -export function clientFeatures(): FeatureDescription[] { +export function clientFeatures(): ClientFeatureDescription[] { return [ - ...themes(), - ...editors(), { name: 'Tag Nesting', availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], @@ -16,14 +12,7 @@ export function clientFeatures(): FeatureDescription[] { permission_name: PermissionName.TagNesting, description: 'Organize your tags into folders.', }, - { - name: 'Super Notes', - identifier: FeatureIdentifier.SuperEditor, - availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], - permission_name: PermissionName.SuperEditor, - description: - 'A new way to edit notes. Type / to bring up the block selection menu, or @ to embed images or link other tags and notes. Type - then space to start a list, or [] then space to start a checklist. Drag and drop an image or file to embed it in your note. Cmd/Ctrl + F to bring up search and replace.', - }, + { name: 'Smart Filters', availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], diff --git a/packages/features/src/Domain/Lists/DeprecatedFeatures.ts b/packages/features/src/Domain/Lists/DeprecatedFeatures.ts index 8af3498f3..c491338be 100644 --- a/packages/features/src/Domain/Lists/DeprecatedFeatures.ts +++ b/packages/features/src/Domain/Lists/DeprecatedFeatures.ts @@ -1,18 +1,16 @@ +import { AnyFeatureDescription } from '../Feature/AnyFeatureDescription' +import { EditorFeatureDescription } from '../Feature/EditorFeatureDescription' +import { IframeComponentFeatureDescription } from '../Feature/IframeComponentFeatureDescription' import { ContentType, RoleName } from '@standardnotes/domain-core' -import { - EditorFeatureDescription, - IframeComponentFeatureDescription, - FeatureDescription, -} from '../Feature/FeatureDescription' import { PermissionName } from '../Permission/PermissionName' import { FeatureIdentifier } from '../Feature/FeatureIdentifier' import { NoteType } from '../Component/NoteType' -import { FillEditorComponentDefaults } from './Utilities/FillEditorComponentDefaults' +import { FillIframeEditorDefaults } from './Utilities/FillEditorComponentDefaults' import { ComponentAction } from '../Component/ComponentAction' import { ComponentArea } from '../Component/ComponentArea' -export function GetDeprecatedFeatures(): FeatureDescription[] { - const bold: EditorFeatureDescription = FillEditorComponentDefaults({ +export function GetDeprecatedFeatures(): AnyFeatureDescription[] { + const bold: EditorFeatureDescription = FillIframeEditorDefaults({ name: 'Alternative Rich Text', identifier: FeatureIdentifier.DeprecatedBoldEditor, note_type: NoteType.RichText, @@ -39,7 +37,7 @@ export function GetDeprecatedFeatures(): FeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const markdownBasic: EditorFeatureDescription = FillEditorComponentDefaults({ + const markdownBasic: EditorFeatureDescription = FillIframeEditorDefaults({ name: 'Basic Markdown', identifier: FeatureIdentifier.DeprecatedMarkdownBasicEditor, note_type: NoteType.Markdown, @@ -52,7 +50,7 @@ export function GetDeprecatedFeatures(): FeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const markdownAlt: EditorFeatureDescription = FillEditorComponentDefaults({ + const markdownAlt: EditorFeatureDescription = FillIframeEditorDefaults({ name: 'Markdown Alternative', identifier: FeatureIdentifier.DeprecatedMarkdownVisualEditor, note_type: NoteType.Markdown, @@ -66,7 +64,7 @@ export function GetDeprecatedFeatures(): FeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const markdownMinimist: EditorFeatureDescription = FillEditorComponentDefaults({ + const markdownMinimist: EditorFeatureDescription = FillIframeEditorDefaults({ name: 'Minimal Markdown', identifier: FeatureIdentifier.DeprecatedMarkdownMinimistEditor, note_type: NoteType.Markdown, @@ -80,7 +78,7 @@ export function GetDeprecatedFeatures(): FeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const markdownMath: EditorFeatureDescription = FillEditorComponentDefaults({ + const markdownMath: EditorFeatureDescription = FillIframeEditorDefaults({ name: 'Markdown with Math', identifier: FeatureIdentifier.DeprecatedMarkdownMathEditor, spellcheckControl: true, @@ -94,7 +92,7 @@ export function GetDeprecatedFeatures(): FeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const filesafe: IframeComponentFeatureDescription = FillEditorComponentDefaults({ + const filesafe: IframeComponentFeatureDescription = FillIframeEditorDefaults({ name: 'FileSafe', identifier: FeatureIdentifier.DeprecatedFileSafe, component_permissions: [ diff --git a/packages/features/src/Domain/Lists/ExperimentalFeatures.ts b/packages/features/src/Domain/Lists/ExperimentalFeatures.ts index 30c3ebf5a..549c970aa 100644 --- a/packages/features/src/Domain/Lists/ExperimentalFeatures.ts +++ b/packages/features/src/Domain/Lists/ExperimentalFeatures.ts @@ -1,5 +1,5 @@ -import { FeatureDescription } from '../Feature/FeatureDescription' +import { AnyFeatureDescription } from '../Feature/AnyFeatureDescription' -export function experimentalFeatures(): FeatureDescription[] { +export function experimentalFeatures(): AnyFeatureDescription[] { return [] } diff --git a/packages/features/src/Domain/Lists/Editors.ts b/packages/features/src/Domain/Lists/IframeEditors.ts similarity index 84% rename from packages/features/src/Domain/Lists/Editors.ts rename to packages/features/src/Domain/Lists/IframeEditors.ts index 075400c4a..e1be1180a 100644 --- a/packages/features/src/Domain/Lists/Editors.ts +++ b/packages/features/src/Domain/Lists/IframeEditors.ts @@ -1,12 +1,12 @@ -import { EditorFeatureDescription } from '../Feature/FeatureDescription' import { PermissionName } from '../Permission/PermissionName' import { FeatureIdentifier } from '../Feature/FeatureIdentifier' import { NoteType } from '../Component/NoteType' -import { FillEditorComponentDefaults } from './Utilities/FillEditorComponentDefaults' +import { FillIframeEditorDefaults } from './Utilities/FillEditorComponentDefaults' import { RoleName } from '@standardnotes/domain-core' +import { IframeComponentFeatureDescription } from '../Feature/IframeComponentFeatureDescription' -export function editors(): EditorFeatureDescription[] { - const code: EditorFeatureDescription = FillEditorComponentDefaults({ +export function IframeEditors(): IframeComponentFeatureDescription[] { + const code = FillIframeEditorDefaults({ name: 'Code', spellcheckControl: true, identifier: FeatureIdentifier.CodeEditor, @@ -22,7 +22,7 @@ export function editors(): EditorFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const plus: EditorFeatureDescription = FillEditorComponentDefaults({ + const plus = FillIframeEditorDefaults({ name: 'Rich Text', note_type: NoteType.RichText, file_type: 'html', @@ -35,7 +35,7 @@ export function editors(): EditorFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const markdown: EditorFeatureDescription = FillEditorComponentDefaults({ + const markdown = FillIframeEditorDefaults({ name: 'Markdown', identifier: FeatureIdentifier.MarkdownProEditor, note_type: NoteType.Markdown, @@ -48,7 +48,7 @@ export function editors(): EditorFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const task: EditorFeatureDescription = FillEditorComponentDefaults({ + const task = FillIframeEditorDefaults({ name: 'Checklist', identifier: FeatureIdentifier.TaskEditor, note_type: NoteType.Task, @@ -62,7 +62,7 @@ export function editors(): EditorFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const tokenvault: EditorFeatureDescription = FillEditorComponentDefaults({ + const tokenvault = FillIframeEditorDefaults({ name: 'Authenticator', note_type: NoteType.Authentication, file_type: 'json', @@ -75,7 +75,7 @@ export function editors(): EditorFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }) - const spreadsheets: EditorFeatureDescription = FillEditorComponentDefaults({ + const spreadsheets = FillIframeEditorDefaults({ name: 'Spreadsheet', identifier: FeatureIdentifier.SheetsEditor, note_type: NoteType.Spreadsheet, diff --git a/packages/features/src/Domain/Lists/NativeEditors.ts b/packages/features/src/Domain/Lists/NativeEditors.ts new file mode 100644 index 000000000..f05cc6083 --- /dev/null +++ b/packages/features/src/Domain/Lists/NativeEditors.ts @@ -0,0 +1,32 @@ +import { RoleName } from '@standardnotes/domain-core' +import { NoteType } from '../Component/NoteType' +import { EditorFeatureDescription } from '../Feature/EditorFeatureDescription' +import { FeatureIdentifier } from '../Feature/FeatureIdentifier' +import { PermissionName } from '../Permission/PermissionName' + +export function nativeEditors(): EditorFeatureDescription[] { + return [ + { + name: 'Super', + note_type: NoteType.Super, + identifier: FeatureIdentifier.SuperEditor, + spellcheckControl: true, + file_type: 'json', + interchangeable: false, + availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], + permission_name: PermissionName.SuperEditor, + description: + 'The best way to edit notes. Type / to bring up the block selection menu, or @ to embed images or link other tags and notes. Type - then space to start a list, or [] then space to start a checklist. Drag and drop an image or file to embed it in your note. Cmd/Ctrl + F to bring up search and replace.', + }, + { + name: 'Plain Text', + note_type: NoteType.Plain, + spellcheckControl: true, + file_type: 'txt', + interchangeable: true, + identifier: FeatureIdentifier.PlainEditor, + availableInRoles: [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], + permission_name: PermissionName.PlainEditor, + }, + ] +} diff --git a/packages/features/src/Domain/Lists/ServerFeatures.ts b/packages/features/src/Domain/Lists/ServerFeatures.ts index 8da009c2e..f54be0b5a 100644 --- a/packages/features/src/Domain/Lists/ServerFeatures.ts +++ b/packages/features/src/Domain/Lists/ServerFeatures.ts @@ -1,4 +1,4 @@ -import { ServerFeatureDescription } from '../Feature/FeatureDescription' +import { ServerFeatureDescription } from '../Feature/ServerFeatureDescription' import { PermissionName } from '../Permission/PermissionName' import { FeatureIdentifier } from '../Feature/FeatureIdentifier' import { RoleName } from '@standardnotes/domain-core' @@ -42,16 +42,19 @@ export function serverFeatures(): ServerFeatureDescription[] { availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser], }, { + name: 'Files maximum storage tier', identifier: FeatureIdentifier.FilesMaximumStorageTier, permission_name: PermissionName.FilesMaximumStorageTier, availableInRoles: [RoleName.NAMES.ProUser], }, { + name: 'Files low storage tier', identifier: FeatureIdentifier.FilesLowStorageTier, permission_name: PermissionName.FilesLowStorageTier, availableInRoles: [RoleName.NAMES.PlusUser], }, { + name: 'Files medium storage tier', identifier: FeatureIdentifier.SubscriptionSharing, permission_name: PermissionName.SubscriptionSharing, availableInRoles: [RoleName.NAMES.ProUser], diff --git a/packages/features/src/Domain/Lists/Themes.ts b/packages/features/src/Domain/Lists/Themes.ts index 4b636f73b..9876242d6 100644 --- a/packages/features/src/Domain/Lists/Themes.ts +++ b/packages/features/src/Domain/Lists/Themes.ts @@ -1,4 +1,4 @@ -import { ThemeFeatureDescription } from '../Feature/FeatureDescription' +import { ThemeFeatureDescription } from '../Feature/ThemeFeatureDescription' import { PermissionName } from '../Permission/PermissionName' import { FeatureIdentifier } from '../Feature/FeatureIdentifier' import { FillThemeComponentDefaults } from './Utilities/FillThemeComponentDefaults' diff --git a/packages/features/src/Domain/Lists/Utilities/FillEditorComponentDefaults.ts b/packages/features/src/Domain/Lists/Utilities/FillEditorComponentDefaults.ts index 7dd7d9a8b..0708eac00 100644 --- a/packages/features/src/Domain/Lists/Utilities/FillEditorComponentDefaults.ts +++ b/packages/features/src/Domain/Lists/Utilities/FillEditorComponentDefaults.ts @@ -1,14 +1,15 @@ import { ContentType } from '@standardnotes/domain-core' import { ComponentAction } from '../../Component/ComponentAction' -import { EditorFeatureDescription } from '../../Feature/FeatureDescription' +import { EditorFeatureDescription } from '../../Feature/EditorFeatureDescription' +import { IframeComponentFeatureDescription } from '../../Feature/IframeComponentFeatureDescription' import { ComponentArea } from '../../Component/ComponentArea' export type RequiredEditorFields = Pick -export function FillEditorComponentDefaults( - component: Partial & RequiredEditorFields, -): EditorFeatureDescription { +export function FillIframeEditorDefaults( + component: Partial & RequiredEditorFields, +): IframeComponentFeatureDescription { if (!component.index_path) { component.index_path = 'dist/index.html' } @@ -31,5 +32,5 @@ export function FillEditorComponentDefaults( component.interchangeable = true } - return component as EditorFeatureDescription + return component as IframeComponentFeatureDescription } diff --git a/packages/features/src/Domain/Lists/Utilities/FillThemeComponentDefaults.ts b/packages/features/src/Domain/Lists/Utilities/FillThemeComponentDefaults.ts index d9ee9e82e..519bab6e3 100644 --- a/packages/features/src/Domain/Lists/Utilities/FillThemeComponentDefaults.ts +++ b/packages/features/src/Domain/Lists/Utilities/FillThemeComponentDefaults.ts @@ -1,6 +1,5 @@ import { ContentType } from '@standardnotes/domain-core' - -import { ThemeFeatureDescription } from '../../Feature/FeatureDescription' +import { ThemeFeatureDescription } from '../../Feature/ThemeFeatureDescription' import { ComponentArea } from '../../Component/ComponentArea' type RequiredThemeFields = Pick diff --git a/packages/features/src/Domain/Permission/PermissionName.ts b/packages/features/src/Domain/Permission/PermissionName.ts index b268d9d05..f87773493 100644 --- a/packages/features/src/Domain/Permission/PermissionName.ts +++ b/packages/features/src/Domain/Permission/PermissionName.ts @@ -22,6 +22,7 @@ export enum PermissionName { NoteHistory30Days = 'server:note-history-30-days', NoteHistory365Days = 'server:note-history-365-days', NoteHistoryUnlimited = 'server:note-history-unlimited', + PlainEditor = 'editor:plain', PlusEditor = 'editor:plus', SheetsEditor = 'editor:sheets', SignInAlerts = 'server:sign-in-alerts', diff --git a/packages/features/src/Domain/index.ts b/packages/features/src/Domain/index.ts index cc98d5888..8d3ff6092 100644 --- a/packages/features/src/Domain/index.ts +++ b/packages/features/src/Domain/index.ts @@ -1,6 +1,17 @@ -export * from './Feature/FeatureDescription' +export * from './Feature/AnyFeatureDescription' export * from './Feature/FeatureIdentifier' export * from './Feature/Features' +export * from './Feature/TypeGuards' + +export * from './Feature/ThirdPartyFeatureDescription' +export * from './Feature/ClientFeatureDescription' +export * from './Feature/ServerFeatureDescription' +export * from './Feature/IframeComponentFeatureDescription' +export * from './Feature/ComponentFeatureDescription' +export * from './Feature/BaseFeatureDescription' +export * from './Feature/EditorFeatureDescription' +export * from './Feature/ThemeFeatureDescription' +export * from './Feature/UIFeatureDescription' export * from './Permission/Permission' export * from './Permission/PermissionName' diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index ac3cd08b2..a3217a32a 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -637,7 +637,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: 60195509584153283780abdac5569feffb8f08cc @@ -658,7 +658,7 @@ SPEC CHECKSUMS: MMKV: 9c4663aa7ca255d478ff10f2f5cb7d17c1651ccd MMKVCore: 89f5c8a66bba2dcd551779dea4d412eeec8ff5bb OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: bec48f07daf7bcdc2655a0cde84e07d24d2a9e2a RCTTypeSafety: 171394eebacf71e1cfad79dbfae7ee8fc16ca80a React: d7433ccb6a8c36e4cbed59a73c0700fc83c3e98a diff --git a/packages/models/src/Domain/Abstract/Component/PermissionDialog.ts b/packages/models/src/Domain/Abstract/Component/PermissionDialog.ts index e9bedea0e..e2ecd84a6 100644 --- a/packages/models/src/Domain/Abstract/Component/PermissionDialog.ts +++ b/packages/models/src/Domain/Abstract/Component/PermissionDialog.ts @@ -1,8 +1,8 @@ import { ComponentPermission } from '@standardnotes/features' -import { SNComponent } from '../../Syncable/Component' +import { ComponentInterface } from '../../Syncable/Component' export type PermissionDialog = { - component: SNComponent + component: ComponentInterface permissions: ComponentPermission[] permissionsString: string actionBlock: (approved: boolean) => void diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts index e2d3cec27..97f957003 100644 --- a/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts @@ -5,7 +5,7 @@ import { DecryptedItemInterface } from './DecryptedItem' import { isDecryptedPayload, isDeletedPayload, isEncryptedPayload } from '../../Payload/Interfaces/TypeCheck' export function isDecryptedItem(item: ItemInterface): item is DecryptedItemInterface { - return isDecryptedPayload(item.payload) + return 'payload' in item && isDecryptedPayload(item.payload) } export function isEncryptedItem(item: ItemInterface): item is EncryptedItemInterface { diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts index d0732847e..492a78cf5 100644 --- a/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts +++ b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts @@ -19,7 +19,7 @@ export class DecryptedItemMutator< constructor(item: I, type: MutationType) { super(item, type) - const mutableCopy = Copy(this.immutablePayload.content) + const mutableCopy = Copy(this.immutablePayload.content) this.mutableContent = mutableCopy } diff --git a/packages/models/src/Domain/Syncable/Component/Component.spec.ts b/packages/models/src/Domain/Syncable/Component/Component.spec.ts index aff8997fd..a262779a1 100644 --- a/packages/models/src/Domain/Syncable/Component/Component.spec.ts +++ b/packages/models/src/Domain/Syncable/Component/Component.spec.ts @@ -58,7 +58,7 @@ describe('component model', () => { package_info: { note_type: NoteType.Authentication, }, - } as ComponentContent), + } as unknown as ComponentContent), ...PayloadTimestampDefaults(), }, PayloadSource.Constructor, diff --git a/packages/models/src/Domain/Syncable/Component/Component.ts b/packages/models/src/Domain/Syncable/Component/Component.ts index cf2dcc4b1..b1967c601 100644 --- a/packages/models/src/Domain/Syncable/Component/Component.ts +++ b/packages/models/src/Domain/Syncable/Component/Component.ts @@ -7,9 +7,11 @@ import { ComponentPermission, FindNativeFeature, NoteType, + isEditorFeatureDescription, } from '@standardnotes/features' import { AppDataField } from '../../Abstract/Item/Types/AppDataField' -import { ComponentContent, ComponentInterface } from './ComponentContent' +import { ComponentContent } from './ComponentContent' +import { ComponentInterface } from './ComponentInterface' import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy' import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' @@ -19,12 +21,24 @@ import { Predicate } from '../../Runtime/Predicate/Predicate' import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem' import { ComponentPackageInfo } from './PackageInfo' +import { isDecryptedItem } from '../../Abstract/Item' import { ContentType } from '@standardnotes/domain-core' -export const isComponent = (x: ItemInterface): x is SNComponent => x.content_type === ContentType.TYPES.Component +export function isComponent(x: ItemInterface): x is ComponentInterface { + if (!isDecryptedItem(x as DecryptedItemInterface)) { + return false + } -export const isComponentOrTheme = (x: ItemInterface): x is SNComponent => - x.content_type === ContentType.TYPES.Component || x.content_type === ContentType.TYPES.Theme + return x.content_type === ContentType.TYPES.Component +} + +export function isComponentOrTheme(x: ItemInterface): x is ComponentInterface { + if (!isDecryptedItem(x as DecryptedItemInterface)) { + return false + } + + return x.content_type === ContentType.TYPES.Component || x.content_type === ContentType.TYPES.Theme +} /** * Components are mostly iframe based extensions that communicate with the SN parent @@ -32,7 +46,7 @@ export const isComponentOrTheme = (x: ItemInterface): x is SNComponent => * only by its url. */ export class SNComponent extends DecryptedItem implements ComponentInterface { - public readonly componentData: Record + public readonly legacyComponentData: Record /** Items that have requested a component to be disabled in its context */ public readonly disassociatedItemIds: string[] /** Items that have requested a component to be enabled in its context */ @@ -46,14 +60,12 @@ export class SNComponent extends DecryptedItem implements Comp public readonly area: ComponentArea public readonly permissions: ComponentPermission[] = [] public readonly valid_until: Date - public readonly active: boolean + public readonly legacyActive: boolean public readonly legacy_url?: string public readonly isMobileDefault: boolean constructor(payload: DecryptedPayloadInterface) { super(payload) - /** Custom data that a component can store in itself */ - this.componentData = this.payload.content.componentData || {} if (payload.content.hosted_url && isValidUrl(payload.content.hosted_url)) { this.hosted_url = payload.content.hosted_url @@ -65,16 +77,16 @@ export class SNComponent extends DecryptedItem implements Comp this.local_url = payload.content.local_url this.valid_until = new Date(payload.content.valid_until || 0) - this.offlineOnly = payload.content.offlineOnly + this.offlineOnly = payload.content.offlineOnly ?? false this.name = payload.content.name this.area = payload.content.area this.package_info = payload.content.package_info || {} this.permissions = payload.content.permissions || [] - this.active = payload.content.active - this.autoupdateDisabled = payload.content.autoupdateDisabled + this.autoupdateDisabled = payload.content.autoupdateDisabled ?? false this.disassociatedItemIds = payload.content.disassociatedItemIds || [] this.associatedItemIds = payload.content.associatedItemIds || [] - this.isMobileDefault = payload.content.isMobileDefault + this.isMobileDefault = payload.content.isMobileDefault ?? false + /** * @legacy * We don't want to set this.url directly, as we'd like to phase it out. @@ -83,6 +95,10 @@ export class SNComponent extends DecryptedItem implements Comp * hosted_url is the url replacement. */ this.legacy_url = !payload.content.hosted_url ? payload.content.url : undefined + + this.legacyComponentData = this.payload.content.componentData || {} + + this.legacyActive = payload.content.active ?? false } /** Do not duplicate components under most circumstances. Always keep original */ @@ -123,18 +139,6 @@ export class SNComponent extends DecryptedItem implements Comp return this.getAppDomainValue(AppDataField.LastSize) } - /** - * The key used to look up data that this component may have saved to an item. - * This data will be stored on the item using this key. - */ - public getClientDataKey(): string { - if (this.legacy_url) { - return this.legacy_url - } else { - return this.uuid - } - } - public hasValidHostedUrl(): boolean { return (this.hosted_url || this.legacy_url) != undefined } @@ -180,7 +184,11 @@ export class SNComponent extends DecryptedItem implements Comp } public get noteType(): NoteType { - return this.package_info.note_type || NoteType.Unknown + if (isEditorFeatureDescription(this.package_info)) { + return this.package_info.note_type ?? NoteType.Unknown + } + + return NoteType.Unknown } public get isDeprecated(): boolean { diff --git a/packages/models/src/Domain/Syncable/Component/ComponentContent.ts b/packages/models/src/Domain/Syncable/Component/ComponentContent.ts index 5d7ee4bba..d6fbddcf0 100644 --- a/packages/models/src/Domain/Syncable/Component/ComponentContent.ts +++ b/packages/models/src/Domain/Syncable/Component/ComponentContent.ts @@ -1,35 +1,40 @@ import { ComponentArea, ComponentPermission } from '@standardnotes/features' -import { ItemContent } from '../../Abstract/Content/ItemContent' import { ComponentPackageInfo } from './PackageInfo' +import { ItemContent } from '../../Abstract/Content/ItemContent' -/* eslint-disable @typescript-eslint/no-explicit-any */ -export interface ComponentInterface { - componentData: Record - +export type ComponentContentSpecialized = { /** Items that have requested a component to be disabled in its context */ - disassociatedItemIds: string[] + disassociatedItemIds?: string[] /** Items that have requested a component to be enabled in its context */ - associatedItemIds: string[] + associatedItemIds?: string[] local_url?: string hosted_url?: string + offlineOnly?: boolean + name: string + autoupdateDisabled?: boolean + package_info: ComponentPackageInfo + area: ComponentArea + permissions?: ComponentPermission[] + valid_until: Date | number + + legacy_url?: string + isMobileDefault?: boolean + isDeprecated?: boolean + + /** @deprecated */ + active?: boolean + /** @deprecated */ url?: string - offlineOnly: boolean - name: string - autoupdateDisabled: boolean - package_info: ComponentPackageInfo - area: ComponentArea - permissions: ComponentPermission[] - valid_until: Date | number - active: boolean - legacy_url?: string - isMobileDefault: boolean - isDeprecated: boolean - isExplicitlyEnabledForItem(uuid: string): boolean + /** + * @deprecated + * Replaced with per-note component data stored in the note's ComponentDataDomain. + */ + componentData?: Record } -export type ComponentContent = ComponentInterface & ItemContent +export type ComponentContent = ItemContent & ComponentContentSpecialized diff --git a/packages/models/src/Domain/Syncable/Component/ComponentInterface.ts b/packages/models/src/Domain/Syncable/Component/ComponentInterface.ts new file mode 100644 index 000000000..4b2c73375 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/ComponentInterface.ts @@ -0,0 +1,63 @@ +import { + ComponentArea, + ComponentPermission, + FeatureIdentifier, + NoteType, + ThirdPartyFeatureDescription, +} from '@standardnotes/features' +import { ComponentPackageInfo } from './PackageInfo' +import { DecryptedItemInterface } from '../../Abstract/Item' +import { ComponentContent } from './ComponentContent' + +export interface ComponentInterface extends DecryptedItemInterface { + /** Items that have requested a component to be disabled in its context */ + disassociatedItemIds: string[] + + /** Items that have requested a component to be enabled in its context */ + associatedItemIds: string[] + + local_url?: string + hosted_url?: string + + offlineOnly: boolean + name: string + autoupdateDisabled: boolean + package_info: ComponentPackageInfo + area: ComponentArea + permissions: ComponentPermission[] + valid_until: Date + isMobileDefault: boolean + isDeprecated: boolean + + isExplicitlyEnabledForItem(uuid: string): boolean + hasValidHostedUrl(): boolean + isTheme(): boolean + isExplicitlyDisabledForItem(uuid: string): boolean + legacyIsDefaultEditor(): boolean + + get identifier(): FeatureIdentifier + get noteType(): NoteType + get displayName(): string + get deprecationMessage(): string | undefined + get thirdPartyPackageInfo(): ThirdPartyFeatureDescription + get isExpired(): boolean + + /** + * @deprecated + * Replaced with active preferences managed by preferences service. + */ + legacyActive: boolean + + /** @deprecated */ + legacy_url?: string + + /** @deprecated */ + url?: string + + /** + * @deprecated + * Replaced with per-note component data stored in the note's ComponentDataDomain. + */ + /* eslint-disable @typescript-eslint/no-explicit-any */ + legacyComponentData: Record +} diff --git a/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts b/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts index f4a5a34b6..76504eb62 100644 --- a/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts +++ b/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts @@ -1,23 +1,15 @@ import { addIfUnique, removeFromArray } from '@standardnotes/utils' -import { ComponentPermission, FeatureDescription } from '@standardnotes/features' +import { ComponentFeatureDescription, ComponentPermission } from '@standardnotes/features' import { AppDataField } from '../../Abstract/Item/Types/AppDataField' import { ComponentContent } from './ComponentContent' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' export class ComponentMutator extends DecryptedItemMutator { - set active(active: boolean) { - this.mutableContent.active = active - } - set isMobileDefault(isMobileDefault: boolean) { this.mutableContent.isMobileDefault = isMobileDefault } - set componentData(componentData: Record) { - this.mutableContent.componentData = componentData - } - - set package_info(package_info: FeatureDescription) { + set package_info(package_info: ComponentFeatureDescription) { this.mutableContent.package_info = package_info } diff --git a/packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts b/packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts new file mode 100644 index 000000000..a86a9ae15 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/ComponentOrNativeFeature.ts @@ -0,0 +1,172 @@ +import { + AnyFeatureDescription, + ComponentArea, + ComponentPermission, + EditorFeatureDescription, + FeatureIdentifier, + IframeComponentFeatureDescription, + NoteType, + ThemeDockIcon, + UIFeatureDescriptionTypes, + isEditorFeatureDescription, + isIframeComponentFeatureDescription, + isThemeFeatureDescription, +} from '@standardnotes/features' +import { ComponentInterface } from './ComponentInterface' +import { isTheme } from '../Theme' + +function isComponent(x: ComponentInterface | UIFeatureDescriptionTypes): x is ComponentInterface { + return 'uuid' in x +} + +function isFeatureDescription(x: ComponentInterface | AnyFeatureDescription): x is AnyFeatureDescription { + return !('uuid' in x) +} + +export function isIframeUIFeature( + x: ComponentOrNativeFeature, +): x is ComponentOrNativeFeature { + return isIframeComponentFeatureDescription(x.featureDescription) +} + +export class ComponentOrNativeFeature { + constructor(public readonly item: ComponentInterface | F) {} + + get isComponent(): boolean { + return isComponent(this.item) + } + + get isFeatureDescription(): boolean { + return isFeatureDescription(this.item) + } + + get isThemeComponent(): boolean { + return isComponent(this.item) && isTheme(this.item) + } + + get asComponent(): ComponentInterface { + if (isComponent(this.item)) { + return this.item + } + + throw new Error('Cannot cast item to component') + } + + get asFeatureDescription(): F { + if (isFeatureDescription(this.item)) { + return this.item + } + + throw new Error('Cannot cast item to feature description') + } + + get uniqueIdentifier(): string { + if (isFeatureDescription(this.item)) { + return this.item.identifier + } else { + return this.item.uuid + } + } + + get featureIdentifier(): FeatureIdentifier { + return this.item.identifier + } + + get noteType(): NoteType { + if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { + return this.item.note_type ?? NoteType.Unknown + } else if (isComponent(this.item)) { + return this.item.noteType + } + + throw new Error('Invalid component or feature description') + } + + get fileType(): EditorFeatureDescription['file_type'] { + if (isFeatureDescription(this.item) && isEditorFeatureDescription(this.item)) { + return this.item.file_type + } else if (isComponent(this.item) && isEditorFeatureDescription(this.item.package_info)) { + return this.item.package_info?.file_type ?? 'txt' + } + + throw new Error('Invalid component or feature description') + } + + get displayName(): string { + if (isFeatureDescription(this.item)) { + return this.item.name ?? '' + } else { + return this.item.displayName + } + } + + get description(): string { + if (isFeatureDescription(this.item)) { + return this.item.description ?? '' + } else { + return this.item.package_info.description ?? '' + } + } + + get deprecationMessage(): string | undefined { + if (isFeatureDescription(this.item)) { + return this.item.deprecation_message + } else { + return this.item.deprecationMessage + } + } + + get expirationDate(): Date | undefined { + if (isFeatureDescription(this.item)) { + return this.item.expires_at ? new Date(this.item.expires_at) : undefined + } else { + return this.item.valid_until + } + } + + get featureDescription(): F { + if (isFeatureDescription(this.item)) { + return this.item + } else { + return this.item.package_info as F + } + } + + get acquiredPermissions(): ComponentPermission[] { + if (isFeatureDescription(this.item) && isIframeComponentFeatureDescription(this.item)) { + return this.item.component_permissions ?? [] + } else if (isComponent(this.item)) { + return this.item.permissions + } + + throw new Error('Invalid component or feature description') + } + + get area(): ComponentArea { + if ('area' in this.item) { + return this.item.area + } + + return ComponentArea.Editor + } + + get layerable(): boolean { + if (isComponent(this.item) && isTheme(this.item)) { + return this.item.layerable + } else if (isThemeFeatureDescription(this.asFeatureDescription)) { + return this.asFeatureDescription.layerable ?? false + } + + return false + } + + get dockIcon(): ThemeDockIcon | undefined { + if (isComponent(this.item) && isTheme(this.item)) { + return this.item.package_info.dock_icon + } else if (isThemeFeatureDescription(this.asFeatureDescription)) { + return this.asFeatureDescription.dock_icon + } + + return undefined + } +} diff --git a/packages/models/src/Domain/Syncable/Component/PackageInfo.ts b/packages/models/src/Domain/Syncable/Component/PackageInfo.ts index 79f7aa0de..2ae97a840 100644 --- a/packages/models/src/Domain/Syncable/Component/PackageInfo.ts +++ b/packages/models/src/Domain/Syncable/Component/PackageInfo.ts @@ -1,9 +1,9 @@ -import { FeatureDescription, ThemeFeatureDescription } from '@standardnotes/features' +import { ComponentFeatureDescription, ThemeFeatureDescription } from '@standardnotes/features' type ThirdPartyPackageInfo = { version: string download_url?: string } -export type ComponentPackageInfo = FeatureDescription & Partial -export type ThemePackageInfo = FeatureDescription & Partial & ThemeFeatureDescription +export type ComponentPackageInfo = ComponentFeatureDescription & Partial +export type ThemePackageInfo = ThemeFeatureDescription & Partial & ThemeFeatureDescription diff --git a/packages/models/src/Domain/Syncable/Component/index.ts b/packages/models/src/Domain/Syncable/Component/index.ts index 6cbb1cc10..23ec98dd1 100644 --- a/packages/models/src/Domain/Syncable/Component/index.ts +++ b/packages/models/src/Domain/Syncable/Component/index.ts @@ -1,3 +1,6 @@ export * from './Component' export * from './ComponentMutator' export * from './ComponentContent' +export * from './ComponentInterface' +export * from './ComponentOrNativeFeature' +export * from './PackageInfo' diff --git a/packages/models/src/Domain/Syncable/Theme/Theme.ts b/packages/models/src/Domain/Syncable/Theme/Theme.ts index 80a3cc12d..1eda0b099 100644 --- a/packages/models/src/Domain/Syncable/Theme/Theme.ts +++ b/packages/models/src/Domain/Syncable/Theme/Theme.ts @@ -1,50 +1,18 @@ import { ComponentArea } from '@standardnotes/features' import { SNComponent } from '../Component/Component' -import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy' -import { AppDataField } from '../../Abstract/Item/Types/AppDataField' -import { HistoryEntryInterface } from '../../Runtime/History' -import { DecryptedItemInterface, ItemInterface } from '../../Abstract/Item' +import { ItemInterface } from '../../Abstract/Item' +import { ContentType } from '@standardnotes/domain-core' import { useBoolean } from '@standardnotes/utils' import { ThemePackageInfo } from '../Component/PackageInfo' -import { ContentType } from '@standardnotes/domain-core' +import { ThemeInterface } from './ThemeInterface' -export const isTheme = (x: ItemInterface): x is SNTheme => x.content_type === ContentType.TYPES.Theme +export const isTheme = (x: ItemInterface): x is ThemeInterface => x.content_type === ContentType.TYPES.Theme -export class SNTheme extends SNComponent { +export class SNTheme extends SNComponent implements ThemeInterface { public override area: ComponentArea = ComponentArea.Themes public declare readonly package_info: ThemePackageInfo - isLayerable(): boolean { + get layerable(): boolean { return useBoolean(this.package_info && this.package_info.layerable, false) } - - /** Do not duplicate under most circumstances. Always keep original */ - override strategyWhenConflictingWithItem( - _item: DecryptedItemInterface, - _previousRevision?: HistoryEntryInterface, - ): ConflictStrategy { - return ConflictStrategy.KeepBase - } - - getMobileRules() { - return ( - this.getAppDomainValue(AppDataField.MobileRules) || { - constants: {}, - rules: {}, - } - ) - } - - /** Same as getMobileRules but without default value. */ - hasMobileRules() { - return this.getAppDomainValue(AppDataField.MobileRules) - } - - getNotAvailOnMobile() { - return this.getAppDomainValue(AppDataField.NotAvailableOnMobile) - } - - isMobileActive() { - return this.getAppDomainValue(AppDataField.MobileActive) - } } diff --git a/packages/models/src/Domain/Syncable/Theme/ThemeInterface.ts b/packages/models/src/Domain/Syncable/Theme/ThemeInterface.ts new file mode 100644 index 000000000..a0d415f06 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Theme/ThemeInterface.ts @@ -0,0 +1,7 @@ +import { ComponentInterface } from '../Component' +import { ThemePackageInfo } from '../Component/PackageInfo' + +export interface ThemeInterface extends ComponentInterface { + get layerable(): boolean + readonly package_info: ThemePackageInfo +} diff --git a/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts b/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts index d102c8f11..e69cba77e 100644 --- a/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts +++ b/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts @@ -1,25 +1,4 @@ -import { AppDataField } from '../../Abstract/Item/Types/AppDataField' import { ComponentContent } from '../Component/ComponentContent' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' -export class ThemeMutator extends DecryptedItemMutator { - setMobileRules(rules: unknown) { - this.setAppDataItem(AppDataField.MobileRules, rules) - } - - setNotAvailOnMobile(notAvailable: boolean) { - this.setAppDataItem(AppDataField.NotAvailableOnMobile, notAvailable) - } - - set local_url(local_url: string) { - this.mutableContent.local_url = local_url - } - - /** - * We must not use .active because if you set that to true, it will also - * activate that theme on desktop/web - */ - setMobileActive(active: boolean) { - this.setAppDataItem(AppDataField.MobileActive, active) - } -} +export class ThemeMutator extends DecryptedItemMutator {} diff --git a/packages/models/src/Domain/Syncable/Theme/index.ts b/packages/models/src/Domain/Syncable/Theme/index.ts index 97b0c8f3e..2270bf706 100644 --- a/packages/models/src/Domain/Syncable/Theme/index.ts +++ b/packages/models/src/Domain/Syncable/Theme/index.ts @@ -1,2 +1,3 @@ export * from './Theme' export * from './ThemeMutator' +export * from './ThemeInterface' diff --git a/packages/models/src/Domain/Syncable/UserPrefs/ComponentPreferences.ts b/packages/models/src/Domain/Syncable/UserPrefs/ComponentPreferences.ts new file mode 100644 index 000000000..0b13b6f58 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/ComponentPreferences.ts @@ -0,0 +1,7 @@ +import { FeatureIdentifier } from '@standardnotes/features' + +type UuidString = string + +export type AllComponentPreferences = Record + +export type ComponentPreferencesEntry = Record diff --git a/packages/models/src/Domain/Syncable/UserPrefs/EditorFontSize.ts b/packages/models/src/Domain/Syncable/UserPrefs/EditorFontSize.ts new file mode 100644 index 000000000..edb9da80d --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/EditorFontSize.ts @@ -0,0 +1,7 @@ +export enum EditorFontSize { + ExtraSmall = 'ExtraSmall', + Small = 'Small', + Normal = 'Normal', + Medium = 'Medium', + Large = 'Large', +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/EditorLineHeight.ts b/packages/models/src/Domain/Syncable/UserPrefs/EditorLineHeight.ts new file mode 100644 index 000000000..9c3f4e118 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/EditorLineHeight.ts @@ -0,0 +1,8 @@ +export enum EditorLineHeight { + None = 'None', + Tight = 'Tight', + Snug = 'Snug', + Normal = 'Normal', + Relaxed = 'Relaxed', + Loose = 'Loose', +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/EditorLineWidth.ts b/packages/models/src/Domain/Syncable/UserPrefs/EditorLineWidth.ts new file mode 100644 index 000000000..592b9d7d3 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/EditorLineWidth.ts @@ -0,0 +1,6 @@ +export enum EditorLineWidth { + Narrow = 'Narrow', + Wide = 'Wide', + Dynamic = 'Dynamic', + FullWidth = 'FullWidth', +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/NewNoteTitleFormat.ts b/packages/models/src/Domain/Syncable/UserPrefs/NewNoteTitleFormat.ts new file mode 100644 index 000000000..3be43a94e --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/NewNoteTitleFormat.ts @@ -0,0 +1,6 @@ +export enum NewNoteTitleFormat { + CurrentDateAndTime = 'CurrentDateAndTime', + CurrentNoteCount = 'CurrentNoteCount', + CustomFormat = 'CustomFormat', + Empty = 'Empty', +} diff --git a/packages/web/src/javascripts/Constants/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts similarity index 76% rename from packages/web/src/javascripts/Constants/PrefDefaults.ts rename to packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 40ed7ddd0..b1e27095c 100644 --- a/packages/web/src/javascripts/Constants/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -1,13 +1,10 @@ -import { - PrefKey, - CollectionSort, - NewNoteTitleFormat, - EditorLineHeight, - EditorFontSize, - EditorLineWidth, - PrefValue, -} from '@standardnotes/models' -import { FeatureIdentifier } from '@standardnotes/snjs' +import { FeatureIdentifier } from '@standardnotes/features' +import { CollectionSort } from '../../Runtime/Collection/CollectionSort' +import { EditorFontSize } from './EditorFontSize' +import { EditorLineHeight } from './EditorLineHeight' +import { EditorLineWidth } from './EditorLineWidth' +import { PrefKey, PrefValue } from './PrefKey' +import { NewNoteTitleFormat } from './NewNoteTitleFormat' export const PrefDefaults = { [PrefKey.TagsPanelWidth]: 220, @@ -44,6 +41,9 @@ export const PrefDefaults = { [PrefKey.SuperNoteExportFormat]: 'json', [PrefKey.SystemViewPreferences]: {}, [PrefKey.AuthenticatorNames]: '', + [PrefKey.ComponentPreferences]: {}, + [PrefKey.ActiveThemes]: [], + [PrefKey.ActiveComponents]: [], } satisfies { [key in PrefKey]: PrefValue[key] } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index aa6e9c0bb..4f151edad 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -2,6 +2,11 @@ import { CollectionSortProperty } from '../../Runtime/Collection/CollectionSort' import { EditorIdentifier, FeatureIdentifier } from '@standardnotes/features' import { SystemViewId } from '../SmartView' import { TagPreferences } from '../Tag' +import { NewNoteTitleFormat } from './NewNoteTitleFormat' +import { EditorLineHeight } from './EditorLineHeight' +import { EditorLineWidth } from './EditorLineWidth' +import { EditorFontSize } from './EditorFontSize' +import { AllComponentPreferences } from './ComponentPreferences' export enum PrefKey { TagsPanelWidth = 'tagsPanelWidth', @@ -38,37 +43,9 @@ export enum PrefKey { SuperNoteExportFormat = 'superNoteExportFormat', AuthenticatorNames = 'authenticatorNames', PaneGesturesEnabled = 'paneGesturesEnabled', -} - -export enum NewNoteTitleFormat { - CurrentDateAndTime = 'CurrentDateAndTime', - CurrentNoteCount = 'CurrentNoteCount', - CustomFormat = 'CustomFormat', - Empty = 'Empty', -} - -export enum EditorLineHeight { - None = 'None', - Tight = 'Tight', - Snug = 'Snug', - Normal = 'Normal', - Relaxed = 'Relaxed', - Loose = 'Loose', -} - -export enum EditorLineWidth { - Narrow = 'Narrow', - Wide = 'Wide', - Dynamic = 'Dynamic', - FullWidth = 'FullWidth', -} - -export enum EditorFontSize { - ExtraSmall = 'ExtraSmall', - Small = 'Small', - Normal = 'Normal', - Medium = 'Medium', - Large = 'Large', + ComponentPreferences = 'componentPreferences', + ActiveThemes = 'activeThemes', + ActiveComponents = 'activeComponents', } export type PrefValue = { @@ -106,4 +83,7 @@ export type PrefValue = { [PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html' [PrefKey.AuthenticatorNames]: string [PrefKey.PaneGesturesEnabled]: boolean + [PrefKey.ComponentPreferences]: AllComponentPreferences + [PrefKey.ActiveThemes]: string[] + [PrefKey.ActiveComponents]: string[] } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/index.ts b/packages/models/src/Domain/Syncable/UserPrefs/index.ts index 3953a3ae1..859f7e4de 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/index.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/index.ts @@ -1,3 +1,9 @@ export * from './UserPrefs' export * from './UserPrefsMutator' export * from './PrefKey' +export * from './EditorLineHeight' +export * from './EditorFontSize' +export * from './EditorLineWidth' +export * from './NewNoteTitleFormat' +export * from './ComponentPreferences' +export * from './PrefDefaults' diff --git a/packages/responses/src/Domain/User/AvailableSubscriptions.ts b/packages/responses/src/Domain/User/AvailableSubscriptions.ts index 1595ac063..ace6de03a 100644 --- a/packages/responses/src/Domain/User/AvailableSubscriptions.ts +++ b/packages/responses/src/Domain/User/AvailableSubscriptions.ts @@ -1,4 +1,4 @@ -import { FeatureDescription } from '@standardnotes/features' +import { AnyFeatureDescription } from '@standardnotes/features' import { SubscriptionName } from '@standardnotes/common' export type AvailableSubscriptions = { @@ -8,6 +8,6 @@ export type AvailableSubscriptions = { price: number period: string }[] - features: FeatureDescription[] + features: AnyFeatureDescription[] } } diff --git a/packages/responses/src/Domain/User/GetOfflineFeaturesResponse.ts b/packages/responses/src/Domain/User/GetOfflineFeaturesResponse.ts index ed94ddab5..2f5abe8da 100644 --- a/packages/responses/src/Domain/User/GetOfflineFeaturesResponse.ts +++ b/packages/responses/src/Domain/User/GetOfflineFeaturesResponse.ts @@ -1,6 +1,6 @@ -import { FeatureDescription } from '@standardnotes/features' +import { AnyFeatureDescription } from '@standardnotes/features' export type GetOfflineFeaturesResponse = { - features: FeatureDescription[] + features: AnyFeatureDescription[] roles: string[] } diff --git a/packages/responses/src/Domain/User/UserFeaturesData.ts b/packages/responses/src/Domain/User/UserFeaturesData.ts deleted file mode 100644 index f12558061..000000000 --- a/packages/responses/src/Domain/User/UserFeaturesData.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FeatureDescription } from '@standardnotes/features' - -export type UserFeaturesData = { - features: FeatureDescription[] -} diff --git a/packages/responses/src/Domain/User/UserFeaturesResponse.ts b/packages/responses/src/Domain/User/UserFeaturesResponse.ts deleted file mode 100644 index d45fbc87b..000000000 --- a/packages/responses/src/Domain/User/UserFeaturesResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { UserFeaturesData } from './UserFeaturesData' - -export type UserFeaturesResponse = UserFeaturesData diff --git a/packages/responses/src/Domain/index.ts b/packages/responses/src/Domain/index.ts index 4b863fb64..94a340279 100644 --- a/packages/responses/src/Domain/index.ts +++ b/packages/responses/src/Domain/index.ts @@ -61,8 +61,6 @@ export * from './User/ListSettingsResponse' export * from './User/PostSubscriptionTokensResponse' export * from './User/SettingData' export * from './User/UpdateSettingResponse' -export * from './User/UserFeaturesData' -export * from './User/UserFeaturesResponse' export * from './UserEvent/UserEventServerHash' export * from './UserEvent/UserEventType' diff --git a/packages/services/src/Domain/Api/ApiServiceEvent.ts b/packages/services/src/Domain/Api/ApiServiceEvent.ts new file mode 100644 index 000000000..54033bb14 --- /dev/null +++ b/packages/services/src/Domain/Api/ApiServiceEvent.ts @@ -0,0 +1,6 @@ +/* istanbul ignore file */ + +export enum ApiServiceEvent { + MetaReceived = 'MetaReceived', + SessionRefreshed = 'SessionRefreshed', +} diff --git a/packages/services/src/Domain/Api/ApiServiceEventData.ts b/packages/services/src/Domain/Api/ApiServiceEventData.ts new file mode 100644 index 000000000..246b3eb15 --- /dev/null +++ b/packages/services/src/Domain/Api/ApiServiceEventData.ts @@ -0,0 +1,5 @@ +import { Either } from '@standardnotes/common' +import { SessionRefreshedData } from './SessionRefreshedData' +import { MetaReceivedData } from './MetaReceivedData' + +export type ApiServiceEventData = Either diff --git a/packages/services/src/Domain/Api/ApiServiceInterface.ts b/packages/services/src/Domain/Api/ApiServiceInterface.ts index 33e8d4914..4211628a1 100644 --- a/packages/services/src/Domain/Api/ApiServiceInterface.ts +++ b/packages/services/src/Domain/Api/ApiServiceInterface.ts @@ -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, FilesApiInterface { + isThirdPartyHostUsed(): boolean -export enum ApiServiceEvent { - MetaReceived = 'MetaReceived', - SessionRefreshed = 'SessionRefreshed', + downloadOfflineFeaturesFromRepo( + repo: SNFeatureRepo, + ): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> + + downloadFeatureUrl(url: string): Promise } - -export type MetaReceivedData = { - userUuid: string - userRoles: Role[] -} - -export type SessionRefreshedData = { - session: Session -} - -export type ApiServiceEventData = Either - -export interface ApiServiceInterface extends AbstractService, FilesApiInterface {} diff --git a/packages/services/src/Domain/Api/MetaReceivedData.ts b/packages/services/src/Domain/Api/MetaReceivedData.ts new file mode 100644 index 000000000..101beecfb --- /dev/null +++ b/packages/services/src/Domain/Api/MetaReceivedData.ts @@ -0,0 +1,6 @@ +import { Role } from '@standardnotes/security' + +export type MetaReceivedData = { + userUuid: string + userRoles: Role[] +} diff --git a/packages/services/src/Domain/Api/SessionRefreshedData.ts b/packages/services/src/Domain/Api/SessionRefreshedData.ts new file mode 100644 index 000000000..b63d46a7e --- /dev/null +++ b/packages/services/src/Domain/Api/SessionRefreshedData.ts @@ -0,0 +1,5 @@ +import { Session } from '@standardnotes/domain-core' + +export type SessionRefreshedData = { + session: Session +} diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index ae114a45a..fe3cc784d 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -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 isThirdPartyHostUsed(): boolean isUsingHomeServer(): Promise + getNewSubscriptionToken(): Promise importData(data: BackupFile, awaitSync?: boolean): Promise /** @@ -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 diff --git a/packages/services/src/Domain/Component/ComponentManagerInterface.ts b/packages/services/src/Domain/Component/ComponentManagerInterface.ts index 8ae47a091..a06a5c921 100644 --- a/packages/services/src/Domain/Component/ComponentManagerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentManagerInterface.ts @@ -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): 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 + doesEditorChangeRequireAlert( + from: ComponentOrNativeFeature | undefined, + to: ComponentOrNativeFeature | undefined, + ): boolean showEditorChangeAlert(): Promise destroyComponentViewer(viewer: ComponentViewerInterface): void createComponentViewer( - component: SNComponent, - contextItem?: string, + uiFeature: ComponentOrNativeFeature, + item: ComponentViewerItem, actionObserver?: ActionObserver, urlOverride?: string, ): ComponentViewerInterface presentPermissionsDialog(_dialog: PermissionDialog): void - legacyGetDefaultEditor(): SNComponent | undefined - componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined - toggleTheme(uuid: string): Promise - toggleComponent(uuid: string): Promise + legacyGetDefaultEditor(): ComponentInterface | undefined + + isThemeActive(theme: ComponentOrNativeFeature): boolean + toggleTheme(theme: ComponentOrNativeFeature): Promise + getActiveThemes(): ComponentOrNativeFeature[] + getActiveThemesIdentifiers(): string[] + + isComponentActive(component: ComponentInterface): boolean + toggleComponent(component: ComponentInterface): Promise } diff --git a/packages/services/src/Domain/Component/ComponentViewerInterface.ts b/packages/services/src/Domain/Component/ComponentViewerInterface.ts index acaee599e..78b55f4ae 100644 --- a/packages/services/src/Domain/Component/ComponentViewerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentViewerInterface.ts @@ -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 + destroy(): void setReadonly(readonly: boolean): void getFeatureStatus(): FeatureStatus diff --git a/packages/services/src/Domain/Component/ComponentViewerItem.ts b/packages/services/src/Domain/Component/ComponentViewerItem.ts new file mode 100644 index 000000000..d3a7db84f --- /dev/null +++ b/packages/services/src/Domain/Component/ComponentViewerItem.ts @@ -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 +} diff --git a/packages/services/src/Domain/Device/DesktopManagerInterface.ts b/packages/services/src/Domain/Device/DesktopManagerInterface.ts index aa3c81ad8..68affc8d0 100644 --- a/packages/services/src/Domain/Device/DesktopManagerInterface.ts +++ b/packages/services/src/Domain/Device/DesktopManagerInterface.ts @@ -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 } diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index e96254b54..6013a90f5 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -15,6 +15,7 @@ export interface MobileDeviceInterface extends DeviceInterface { authenticateWithBiometrics(): Promise 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 diff --git a/packages/services/src/Domain/Event/ApplicationEvent.ts b/packages/services/src/Domain/Event/ApplicationEvent.ts index b30f3dd43..05cc78c77 100644 --- a/packages/services/src/Domain/Event/ApplicationEvent.ts +++ b/packages/services/src/Domain/Event/ApplicationEvent.ts @@ -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', } diff --git a/packages/services/src/Domain/Event/ApplicationStageChangedEventPayload.ts b/packages/services/src/Domain/Event/ApplicationStageChangedEventPayload.ts new file mode 100644 index 000000000..ae3a55d24 --- /dev/null +++ b/packages/services/src/Domain/Event/ApplicationStageChangedEventPayload.ts @@ -0,0 +1,5 @@ +import { ApplicationStage } from './../Application/ApplicationStage' + +export type ApplicationStageChangedEventPayload = { + stage: ApplicationStage +} diff --git a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts index 7d8862852..3335258ff 100644 --- a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts +++ b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts @@ -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 - getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus - - hasFirstPartySubscription(): boolean - hasMinimumRole(role: string): boolean + hasFirstPartyOfflineSubscription(): boolean setOfflineFeaturesCode(code: string): Promise - hasOfflineRepo(): boolean - deleteOfflineFeatureRepo(): Promise 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 } diff --git a/packages/services/src/Domain/Feature/FeaturesEvent.ts b/packages/services/src/Domain/Feature/FeaturesEvent.ts index 3c536a02b..c452b10db 100644 --- a/packages/services/src/Domain/Feature/FeaturesEvent.ts +++ b/packages/services/src/Domain/Feature/FeaturesEvent.ts @@ -1,5 +1,5 @@ export enum FeaturesEvent { UserRolesChanged = 'UserRolesChanged', - FeaturesUpdated = 'FeaturesUpdated', + FeaturesAvailabilityChanged = 'Features:FeaturesAvailabilityChanged', DidPurchaseSubscription = 'DidPurchaseSubscription', } diff --git a/packages/services/src/Domain/Feature/SetOfflineFeaturesFunctionResponse.ts b/packages/services/src/Domain/Feature/SetOfflineFeaturesFunctionResponse.ts index 1dab8a477..9f128d80a 100644 --- a/packages/services/src/Domain/Feature/SetOfflineFeaturesFunctionResponse.ts +++ b/packages/services/src/Domain/Feature/SetOfflineFeaturesFunctionResponse.ts @@ -1,3 +1,3 @@ import { ClientDisplayableError } from '@standardnotes/responses' -export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | undefined +export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | void diff --git a/packages/services/src/Domain/Item/ItemManagerInterface.ts b/packages/services/src/Domain/Item/ItemManagerInterface.ts index 2d490cfbe..9f7441a19 100644 --- a/packages/services/src/Domain/Item/ItemManagerInterface.ts +++ b/packages/services/src/Domain/Item/ItemManagerInterface.ts @@ -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(uuid: string): T | undefined findItems(uuids: string[]): T[] findSureItem(uuid: string): T + /** + * If item is not found, an `undefined` element will be inserted into the array. + */ + findItemsIncludingBlanks(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(payload: DecryptedPayloadInterface): T createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface getDisplayableFiles(): FileItem[] setVaultDisplayOptions(options: VaultDisplayOptions): void diff --git a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts index 14f6002ce..375568b49 100644 --- a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts +++ b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts @@ -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 changeComponent( - itemToLookupUuidFor: SNComponent, + itemToLookupUuidFor: ComponentInterface, mutate: (mutator: ComponentMutator) => void, mutationType?: MutationType, emitSource?: PayloadEmitSource, payloadSourceKey?: string, - ): Promise + ): Promise changeFeatureRepo( itemToLookupUuidFor: SNFeatureRepo, diff --git a/packages/services/src/Domain/Preferences/PreferenceServiceInterface.ts b/packages/services/src/Domain/Preferences/PreferenceServiceInterface.ts index 744d05fd9..8c3db5bac 100644 --- a/packages/services/src/Domain/Preferences/PreferenceServiceInterface.ts +++ b/packages/services/src/Domain/Preferences/PreferenceServiceInterface.ts @@ -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(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined getValue(key: K, defaultValue: PrefValue[K]): PrefValue[K] getValue(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined + setValue(key: K, value: PrefValue[K]): Promise + /** Set value without triggering sync or event notifications */ + setValueDetached(key: K, value: PrefValue[K]): Promise } diff --git a/packages/services/src/Domain/Session/SessionsClientInterface.ts b/packages/services/src/Domain/Session/SessionsClientInterface.ts index 06b8bde83..0e9c82bdb 100644 --- a/packages/services/src/Domain/Session/SessionsClientInterface.ts +++ b/packages/services/src/Domain/Session/SessionsClientInterface.ts @@ -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 diff --git a/packages/services/src/Domain/Storage/StorageKeys.ts b/packages/services/src/Domain/Storage/StorageKeys.ts index a923aaa10..7ce6cd28b 100644 --- a/packages/services/src/Domain/Storage/StorageKeys.ts +++ b/packages/services/src/Domain/Storage/StorageKeys.ts @@ -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 { diff --git a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts b/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts deleted file mode 100644 index 05b1872c3..000000000 --- a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Invitation } from '@standardnotes/models' -import { AppleIAPReceipt } from './AppleIAPReceipt' - -export interface SubscriptionClientInterface { - listSubscriptionInvitations(): Promise - inviteToSubscription(inviteeEmail: string): Promise - cancelInvitation(inviteUuid: string): Promise - acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }> - confirmAppleIAP( - receipt: AppleIAPReceipt, - subscriptionToken: string, - ): Promise<{ success: true } | { success: false; message: string }> -} diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts index 568e0cdd7..23b3411cc 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts @@ -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 @@ -16,7 +20,12 @@ describe('SubscriptionManager', () => { subscriptionApiService.invite = jest.fn() subscriptionApiService.listInvites = jest.fn() + sessions = {} as jest.Mocked + + storage = {} as jest.Mocked + internalEventBus = {} as jest.Mocked + internalEventBus.addEventHandler = jest.fn() }) it('should invite user by email to a shared subscription', async () => { diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.ts index 52f4eaab2..b0ee3f5ea 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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, diff --git a/packages/services/src/Domain/Subscription/SubscriptionManagerEvent.ts b/packages/services/src/Domain/Subscription/SubscriptionManagerEvent.ts new file mode 100644 index 000000000..85ccaf517 --- /dev/null +++ b/packages/services/src/Domain/Subscription/SubscriptionManagerEvent.ts @@ -0,0 +1,3 @@ +export enum SubscriptionManagerEvent { + DidFetchSubscription = 'Subscription:DidFetchSubscription', +} diff --git a/packages/services/src/Domain/Subscription/SubscriptionManagerInterface.ts b/packages/services/src/Domain/Subscription/SubscriptionManagerInterface.ts new file mode 100644 index 000000000..6b8e5878d --- /dev/null +++ b/packages/services/src/Domain/Subscription/SubscriptionManagerInterface.ts @@ -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 { + getOnlineSubscription(): Subscription | undefined + getAvailableSubscriptions(): AvailableSubscriptions | undefined + hasOnlineSubscription(): boolean + + get userSubscriptionName(): string + get userSubscriptionExpirationDate(): Date | undefined + get isUserSubscriptionExpired(): boolean + get isUserSubscriptionCanceled(): boolean + + listSubscriptionInvitations(): Promise + inviteToSubscription(inviteeEmail: string): Promise + cancelInvitation(inviteUuid: string): Promise + acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }> + confirmAppleIAP( + receipt: AppleIAPReceipt, + subscriptionToken: string, + ): Promise<{ success: true } | { success: false; message: string }> +} diff --git a/packages/services/src/Domain/User/AccountEvent.ts b/packages/services/src/Domain/User/AccountEvent.ts new file mode 100644 index 000000000..ac44f6ed7 --- /dev/null +++ b/packages/services/src/Domain/User/AccountEvent.ts @@ -0,0 +1,4 @@ +export enum AccountEvent { + SignedInOrRegistered = 'SignedInOrRegistered', + SignedOut = 'SignedOut', +} diff --git a/packages/services/src/Domain/User/AccountEventData.ts b/packages/services/src/Domain/User/AccountEventData.ts new file mode 100644 index 000000000..2f9307e50 --- /dev/null +++ b/packages/services/src/Domain/User/AccountEventData.ts @@ -0,0 +1,7 @@ +import { Either } from '@standardnotes/common' +import { SignedInOrRegisteredEventPayload } from './SignedInOrRegisteredEventPayload' +import { SignedOutEventPayload } from './SignedOutEventPayload' + +export interface AccountEventData { + payload: Either +} diff --git a/packages/services/src/Domain/User/CredentialsChangeFunctionResponse.ts b/packages/services/src/Domain/User/CredentialsChangeFunctionResponse.ts new file mode 100644 index 000000000..b7af5680a --- /dev/null +++ b/packages/services/src/Domain/User/CredentialsChangeFunctionResponse.ts @@ -0,0 +1,3 @@ +import { HttpError } from '@standardnotes/responses' + +export type CredentialsChangeFunctionResponse = { error?: HttpError } diff --git a/packages/services/src/Domain/User/SignedInOrRegisteredEventPayload.ts b/packages/services/src/Domain/User/SignedInOrRegisteredEventPayload.ts new file mode 100644 index 000000000..2684c593c --- /dev/null +++ b/packages/services/src/Domain/User/SignedInOrRegisteredEventPayload.ts @@ -0,0 +1,6 @@ +export interface SignedInOrRegisteredEventPayload { + ephemeral: boolean + mergeLocal: boolean + awaitSync: boolean + checkIntegrity: boolean +} diff --git a/packages/services/src/Domain/User/SignedOutEventPayload.ts b/packages/services/src/Domain/User/SignedOutEventPayload.ts new file mode 100644 index 000000000..75090dc3a --- /dev/null +++ b/packages/services/src/Domain/User/SignedOutEventPayload.ts @@ -0,0 +1,5 @@ +import { DeinitSource } from '../Application/DeinitSource' + +export interface SignedOutEventPayload { + source: DeinitSource +} diff --git a/packages/services/src/Domain/User/UserClientInterface.ts b/packages/services/src/Domain/User/UserClientInterface.ts index 62b20df58..b03b38002 100644 --- a/packages/services/src/Domain/User/UserClientInterface.ts +++ b/packages/services/src/Domain/User/UserClientInterface.ts @@ -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 -} +import { AccountEventData } from './AccountEventData' +import { AccountEvent } from './AccountEvent' export interface UserClientInterface extends AbstractService { + getUserUuid(): string isSignedIn(): boolean register( email: string, diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 2d6d9f2c6..85a9bec72 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -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 @@ -115,6 +112,10 @@ export class UserService ;(this.userApiService as unknown) = undefined } + getUserUuid(): string { + return this.sessionManager.userUuid + } + isSignedIn(): boolean { return this.sessionManager.isSignedIn() } diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index 26002e987..c27a12ddb 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -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' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 91ad40ad0..ddc38b5a5 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -29,7 +29,6 @@ import * as Models from '@standardnotes/models' import * as Responses from '@standardnotes/responses' import * as InternalServices from '../Services' import * as Utils from '@standardnotes/utils' -import { Subscription } from '@standardnotes/security' import { UuidString, ApplicationEventPayload } from '../Types' import { applicationEventForSyncEvent } from '@Lib/Application/Event' import { @@ -50,7 +49,7 @@ import { EncryptionServiceEvent, FilesBackupService, FileService, - SubscriptionClientInterface, + SubscriptionManagerInterface, SubscriptionManager, ChallengePrompt, Challenge, @@ -151,7 +150,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private declare userRequestServer: UserRequestServerInterface private declare subscriptionApiService: SubscriptionApiServiceInterface private declare subscriptionServer: SubscriptionServerInterface - private declare subscriptionManager: SubscriptionClientInterface + private declare subscriptionManager: SubscriptionManagerInterface private declare webSocketApiService: WebSocketApiServiceInterface private declare webSocketServer: WebSocketServerInterface @@ -275,7 +274,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.defineInternalEventHandlers() } - get subscriptions(): ExternalServices.SubscriptionClientInterface { + get subscriptions(): ExternalServices.SubscriptionManagerInterface { return this.subscriptionManager } @@ -407,6 +406,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.sharedVaultService } + public get preferences(): ExternalServices.PreferenceServiceInterface { + return this.preferencesService + } + public computePrivateUsername(username: string): Promise { return ComputePrivateUsername(this.options.crypto, username) } @@ -633,6 +636,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } } + this.internalEventBus.publish({ + type: event, + payload: data, + }) + void this.migrationService.handleApplicationEvent(event) } @@ -671,19 +679,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return Common.compareVersions(userVersion, Common.ProtocolVersion.V004) >= 0 } - public async getUserSubscription(): Promise { - return this.sessionManager.getSubscription() - } - - public async getAvailableSubscriptions(): Promise< - Responses.AvailableSubscriptions | Responses.ClientDisplayableError - > { - if (this.isThirdPartyHostUsed()) { - return ClientDisplayableError.FromString('Third party hosts do not support subscriptions.') - } - return this.sessionManager.getAvailableSubscriptions() - } - /** * Begin streaming items to display in the UI. The stream callback will be called * immediately with the present items that match the constraint, and over time whenever @@ -1268,9 +1263,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createSubscriptionApiService() this.createWebSocketServer() this.createWebSocketApiService() - this.createSubscriptionManager() this.createWebSocketsService() this.createSessionManager() + this.createSubscriptionManager() this.createHistoryManager() this.createSyncManager() this.createProtectionService() @@ -1498,9 +1493,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createFeaturesService() { this.featuresService = new InternalServices.SNFeaturesService( this.diskStorageService, - this.apiService, this.itemManager, this.mutator, + this.subscriptions, + this.apiService, this.webSocketsService, this.settingsService, this.userService, @@ -1517,8 +1513,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli void this.notifyEvent(ApplicationEvent.UserRolesChanged) break } - case ExternalServices.FeaturesEvent.FeaturesUpdated: { - void this.notifyEvent(ApplicationEvent.FeaturesUpdated) + case ExternalServices.FeaturesEvent.FeaturesAvailabilityChanged: { + void this.notifyEvent(ApplicationEvent.FeaturesAvailabilityChanged) break } case ExternalServices.FeaturesEvent.DidPurchaseSubscription: { @@ -1561,6 +1557,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli internalEventBus: this.internalEventBus, legacySessionStorageMapper: this.legacySessionStorageMapper, backups: this.fileBackups, + preferences: this.preferencesService, }) this.services.push(this.migrationService) } @@ -1629,7 +1626,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } private createSubscriptionManager() { - this.subscriptionManager = new SubscriptionManager(this.subscriptionApiService, this.internalEventBus) + this.subscriptionManager = new SubscriptionManager( + this.subscriptionApiService, + this.sessions, + this.storage, + this.internalEventBus, + ) + this.services.push(this.subscriptionManager) } private createItemManager() { @@ -1647,8 +1650,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.alertService, this.environment, this.platform, - this.internalEventBus, this.deviceInterface, + this.internalEventBus, ) this.services.push(this.componentManagerService) } diff --git a/packages/snjs/lib/Migrations/MigrationServices.ts b/packages/snjs/lib/Migrations/MigrationServices.ts index e6e7ee364..5dd6e4dff 100644 --- a/packages/snjs/lib/Migrations/MigrationServices.ts +++ b/packages/snjs/lib/Migrations/MigrationServices.ts @@ -5,6 +5,7 @@ import { InternalEventBusInterface, EncryptionService, MutatorClientInterface, + PreferenceServiceInterface, } from '@standardnotes/services' import { SNSessionManager } from '../Services/Session/SessionManager' import { ApplicationIdentifier } from '@standardnotes/common' @@ -23,6 +24,7 @@ export type MigrationServices = { mutator: MutatorClientInterface singletonManager: SNSingletonManager featuresService: SNFeaturesService + preferences: PreferenceServiceInterface environment: Environment platform: Platform identifier: ApplicationIdentifier diff --git a/packages/snjs/lib/Migrations/Versions/2_202_1.ts b/packages/snjs/lib/Migrations/Versions/2_202_1.ts new file mode 100644 index 000000000..152e930af --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_202_1.ts @@ -0,0 +1,98 @@ +import { ApplicationStage } from '@standardnotes/services' +import { Migration } from '@Lib/Migrations/Migration' +import { ContentType } from '@standardnotes/domain-core' +import { AllComponentPreferences, ComponentInterface, PrefKey } from '@standardnotes/models' +import { Copy, Uuids } from '@standardnotes/utils' +import { FindNativeFeature } from '@standardnotes/features' + +export class Migration2_202_1 extends Migration { + static override version(): string { + return '2.202.1' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => { + await this.migrateComponentDataToUserPreferences() + await this.migrateActiveComponentsToUserPreferences() + await this.deleteComponentsWhichAreNativeFeatures() + + this.markDone() + }) + } + + private async migrateComponentDataToUserPreferences(): Promise { + const components = this.services.itemManager.getItems(ContentType.TYPES.Component) + + if (components.length === 0) { + return + } + + const mutablePreferencesValue = Copy( + this.services.preferences.getValue(PrefKey.ComponentPreferences) ?? {}, + ) + + for (const component of components) { + const componentData = component.legacyComponentData + if (!componentData) { + continue + } + + if (Object.keys(componentData).length === 0) { + continue + } + + const preferencesLookupKey = FindNativeFeature(component.identifier) ? component.identifier : component.uuid + + const componentPreferences = mutablePreferencesValue[preferencesLookupKey] ?? {} + for (const key of Object.keys(componentData)) { + componentPreferences[key] = componentData[key] + } + + mutablePreferencesValue[preferencesLookupKey] = componentPreferences + } + + await this.services.preferences.setValueDetached(PrefKey.ComponentPreferences, mutablePreferencesValue) + } + + private async migrateActiveComponentsToUserPreferences(): Promise { + const allActiveitems = [ + ...this.services.itemManager.getItems(ContentType.TYPES.Component), + ...this.services.itemManager.getItems(ContentType.TYPES.Theme), + ].filter((component) => component.legacyActive) + + if (allActiveitems.length === 0) { + return + } + + const activeThemes = allActiveitems.filter((component) => component.isTheme()) + const activeComponents = allActiveitems.filter((component) => !component.isTheme()) + + await this.services.preferences.setValueDetached(PrefKey.ActiveThemes, Uuids(activeThemes)) + await this.services.preferences.setValueDetached(PrefKey.ActiveComponents, Uuids(activeComponents)) + } + + private async deleteComponentsWhichAreNativeFeatures(): Promise { + const componentsToDelete = [ + ...this.services.itemManager.getItems(ContentType.TYPES.Component), + ...this.services.itemManager.getItems(ContentType.TYPES.Theme), + ].filter((candidate) => { + const nativeFeature = FindNativeFeature(candidate.identifier) + if (!nativeFeature) { + return false + } + + const isDeprecatedAndThusShouldNotDeleteComponentSinceUserHasItRetained = nativeFeature.deprecated + if (isDeprecatedAndThusShouldNotDeleteComponentSinceUserHasItRetained) { + return false + } + + return true + }) + + if (componentsToDelete.length === 0) { + return + } + + await this.services.mutator.setItemsToBeDeleted(componentsToDelete) + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_42_0.ts b/packages/snjs/lib/Migrations/Versions/2_42_0.ts index 48b706280..af6fc44e0 100644 --- a/packages/snjs/lib/Migrations/Versions/2_42_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_42_0.ts @@ -1,7 +1,7 @@ import { ApplicationStage } from '@standardnotes/services' import { FeatureIdentifier } from '@standardnotes/features' import { Migration } from '@Lib/Migrations/Migration' -import { SNTheme } from '@standardnotes/models' +import { ThemeInterface } from '@standardnotes/models' import { ContentType } from '@standardnotes/domain-core' const NoDistractionIdentifier = 'org.standardnotes.theme-no-distraction' as FeatureIdentifier @@ -19,7 +19,7 @@ export class Migration2_42_0 extends Migration { } private async deleteNoDistraction(): Promise { - const themes = (this.services.itemManager.getItems(ContentType.TYPES.Theme) as SNTheme[]).filter((theme) => { + const themes = this.services.itemManager.getItems(ContentType.TYPES.Theme).filter((theme) => { return theme.identifier === NoDistractionIdentifier }) diff --git a/packages/snjs/lib/Migrations/Versions/index.ts b/packages/snjs/lib/Migrations/Versions/index.ts index bd65de105..610025c7c 100644 --- a/packages/snjs/lib/Migrations/Versions/index.ts +++ b/packages/snjs/lib/Migrations/Versions/index.ts @@ -5,6 +5,7 @@ import { Migration2_36_0 } from './2_36_0' import { Migration2_42_0 } from './2_42_0' import { Migration2_167_6 } from './2_167_6' import { Migration2_168_6 } from './2_168_6' +import { Migration2_202_1 } from './2_202_1' export const MigrationClasses = [ Migration2_0_15, @@ -14,6 +15,7 @@ export const MigrationClasses = [ Migration2_42_0, Migration2_167_6, Migration2_168_6, + Migration2_202_1, ] export { @@ -24,4 +26,5 @@ export { Migration2_42_0, Migration2_167_6, Migration2_168_6, + Migration2_202_1, } diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index 7bd602202..6745f4c76 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -1,4 +1,3 @@ -import { FeatureDescription } from '@standardnotes/features' import { joinPaths } from '@standardnotes/utils' import { AbstractService, @@ -18,7 +17,6 @@ import { API_MESSAGE_FAILED_LISTED_REGISTRATION, API_MESSAGE_FAILED_OFFLINE_ACTIVATION, API_MESSAGE_FAILED_OFFLINE_FEATURES, - API_MESSAGE_FAILED_SUBSCRIPTION_INFO, API_MESSAGE_FAILED_UPDATE_SETTINGS, API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL, API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL, @@ -41,13 +39,10 @@ import { RawSyncResponse, SessionRenewalResponse, SessionListResponse, - UserFeaturesResponse, ListSettingsResponse, UpdateSettingResponse, GetSettingResponse, DeleteSettingResponse, - GetSubscriptionResponse, - GetAvailableSubscriptionsResponse, PostSubscriptionTokensResponse, GetOfflineFeaturesResponse, ListedRegistrationResponse, @@ -84,9 +79,9 @@ import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' import { Paths } from './Paths' import { DiskStorageService } from '../Storage/DiskStorageService' import { UuidString } from '../../Types/UuidString' -import merge from 'lodash/merge' import { SettingsServerInterface } from '../Settings/SettingsServerInterface' import { Strings } from '@Lib/Strings' +import { AnyFeatureDescription } from '@standardnotes/features' /** Legacy api version field to be specified in params when calling v0 APIs. */ const V0_API_VERSION = '20200115' @@ -199,9 +194,12 @@ export class SNApiService } private params(inParams: Record): HttpRequestParams { - const params = merge(inParams, { - [ApiEndpointParam.ApiVersion]: this.apiVersion, - }) + const params = { + ...inParams, + ...{ + [ApiEndpointParam.ApiVersion]: this.apiVersion, + }, + } return params } @@ -508,19 +506,6 @@ export class SNApiService return response } - async getUserFeatures(userUuid: UuidString): Promise> { - const path = Paths.v1.userFeatures(userUuid) - const response = await this.httpService.get(path, undefined, this.getSessionAccessToken()) - - if (isErrorResponse(response)) { - this.preprocessAuthenticatedErrorResponse(response) - return this.errorResponseWithFallbackMessage(response, API_MESSAGE_GENERIC_SYNC_FAIL) - } - - this.processSuccessResponseForMetaBody(response) - return response - } - private async tokenRefreshableRequest( params: HttpRequest & { fallbackErrorMessage: string }, ): Promise> { @@ -605,25 +590,6 @@ export class SNApiService }) } - public async getSubscription(userUuid: string): Promise> { - const url = joinPaths(this.host, Paths.v1.subscription(userUuid)) - return this.tokenRefreshableRequest({ - verb: HttpVerb.Get, - url, - authentication: this.getSessionAccessToken(), - fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO, - }) - } - - public async getAvailableSubscriptions(): Promise> { - const url = joinPaths(this.host, Paths.v2.subscriptions) - return this.request({ - verb: HttpVerb.Get, - url, - fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO, - }) - } - public async getNewSubscriptionToken(): Promise { const url = joinPaths(this.host, Paths.v1.subscriptionTokens) const response = await this.request({ @@ -642,7 +608,7 @@ export class SNApiService public async downloadOfflineFeaturesFromRepo( repo: SNFeatureRepo, - ): Promise<{ features: FeatureDescription[]; roles: string[] } | ClientDisplayableError> { + ): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> { try { const featuresUrl = repo.offlineFeaturesUrl const extensionKey = repo.offlineKey diff --git a/packages/snjs/lib/Services/Api/Paths.ts b/packages/snjs/lib/Services/Api/Paths.ts index 815b555fa..80c00a4db 100644 --- a/packages/snjs/lib/Services/Api/Paths.ts +++ b/packages/snjs/lib/Services/Api/Paths.ts @@ -49,7 +49,6 @@ const SubscriptionPaths = { purchase: '/v1/purchase', subscription: (userUuid: string) => `/v1/users/${userUuid}/subscription`, subscriptionTokens: '/v1/subscription-tokens', - userFeatures: (userUuid: string) => `/v1/users/${userUuid}/features`, } const SubscriptionPathsV2 = { diff --git a/packages/snjs/lib/Services/Api/WebSocketsServiceEvent.ts b/packages/snjs/lib/Services/Api/WebSocketsServiceEvent.ts new file mode 100644 index 000000000..eab48edbb --- /dev/null +++ b/packages/snjs/lib/Services/Api/WebSocketsServiceEvent.ts @@ -0,0 +1,3 @@ +export enum WebSocketsServiceEvent { + UserRoleMessageReceived = 'WebSocketMessageReceived', +} diff --git a/packages/snjs/lib/Services/Api/WebsocketsService.ts b/packages/snjs/lib/Services/Api/WebsocketsService.ts index de84bcc1b..79aab31e7 100644 --- a/packages/snjs/lib/Services/Api/WebsocketsService.ts +++ b/packages/snjs/lib/Services/Api/WebsocketsService.ts @@ -1,19 +1,19 @@ import { isErrorResponse } from '@standardnotes/responses' import { UserRolesChangedEvent } from '@standardnotes/domain-events' -import { AbstractService, InternalEventBusInterface, StorageKey } from '@standardnotes/services' +import { + AbstractService, + InternalEventBusInterface, + StorageKey, + StorageServiceInterface, +} from '@standardnotes/services' import { WebSocketApiServiceInterface } from '@standardnotes/api' - -import { DiskStorageService } from '../Storage/DiskStorageService' - -export enum WebSocketsServiceEvent { - UserRoleMessageReceived = 'WebSocketMessageReceived', -} +import { WebSocketsServiceEvent } from './WebSocketsServiceEvent' export class SNWebSocketsService extends AbstractService { private webSocket?: WebSocket constructor( - private storageService: DiskStorageService, + private storageService: StorageServiceInterface, private webSocketUrl: string | undefined, private webSocketApiService: WebSocketApiServiceInterface, protected override internalEventBus: InternalEventBusInterface, diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts index 671f00c3b..0c5a7846c 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts @@ -7,74 +7,93 @@ import { createNote } from './../../Spec/SpecUtils' import { ComponentAction, ComponentPermission, - FeatureDescription, FindNativeFeature, FeatureIdentifier, NoteType, + UIFeatureDescriptionTypes, + IframeComponentFeatureDescription, } from '@standardnotes/features' -import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models' +import { ContentType } from '@standardnotes/domain-core' +import { + GenericItem, + SNComponent, + Environment, + Platform, + ComponentInterface, + ComponentOrNativeFeature, + ComponentContent, + DecryptedPayload, + PayloadTimestampDefaults, +} from '@standardnotes/models' import { DesktopManagerInterface, InternalEventBusInterface, AlertService, DeviceInterface, MutatorClientInterface, + ItemManagerInterface, + SyncServiceInterface, + PreferenceServiceInterface, } from '@standardnotes/services' import { ItemManager } from '@Lib/Services/Items/ItemManager' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' import { SNComponentManager } from './ComponentManager' import { SNSyncService } from '../Sync/SyncService' -import { ContentType } from '@standardnotes/domain-core' +import { ComponentPackageInfo } from '@standardnotes/models' describe('featuresService', () => { - let itemManager: ItemManager + let items: ItemManagerInterface let mutator: MutatorClientInterface - let featureService: SNFeaturesService - let alertService: AlertService - let syncService: SNSyncService - let prefsService: SNPreferencesService - let internalEventBus: InternalEventBusInterface + let features: SNFeaturesService + let alerts: AlertService + let sync: SyncServiceInterface + let prefs: PreferenceServiceInterface + let eventBus: InternalEventBusInterface let device: DeviceInterface + const nativeFeatureAsUIFeature = (identifier: FeatureIdentifier) => { + return new ComponentOrNativeFeature(FindNativeFeature(identifier)!) + } + const desktopExtHost = 'http://localhost:123' const createManager = (environment: Environment, platform: Platform) => { - const desktopManager: DesktopManagerInterface = { - // eslint-disable-next-line @typescript-eslint/no-empty-function - syncComponentsInstallation() {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function - registerUpdateObserver(_callback: (component: SNComponent) => void) { - // eslint-disable-next-line @typescript-eslint/no-empty-function - return () => {} - }, - getExtServerHost() { - return desktopExtHost - }, - } - const manager = new SNComponentManager( - itemManager, + items, mutator, - syncService, - featureService, - prefsService, - alertService, + sync, + features, + prefs, + alerts, environment, platform, - internalEventBus, device, + eventBus, ) - manager.setDesktopManager(desktopManager) + + if (environment === Environment.Desktop) { + const desktopManager: DesktopManagerInterface = { + syncComponentsInstallation() {}, + registerUpdateObserver(_callback: (component: ComponentInterface) => void) { + return () => {} + }, + getExtServerHost() { + return desktopExtHost + }, + } + manager.setDesktopManager(desktopManager) + } + return manager } beforeEach(() => { - syncService = {} as jest.Mocked - syncService.sync = jest.fn() + sync = {} as jest.Mocked + sync.sync = jest.fn() - itemManager = {} as jest.Mocked - itemManager.getItems = jest.fn().mockReturnValue([]) - itemManager.addObserver = jest.fn() + items = {} as jest.Mocked + items.getItems = jest.fn().mockReturnValue([]) + items.addObserver = jest.fn() mutator = {} as jest.Mocked mutator.createItem = jest.fn() @@ -83,62 +102,40 @@ describe('featuresService', () => { mutator.changeItem = jest.fn() mutator.changeFeatureRepo = jest.fn() - featureService = {} as jest.Mocked + features = {} as jest.Mocked - prefsService = {} as jest.Mocked + prefs = {} as jest.Mocked + prefs.addEventObserver = jest.fn() - alertService = {} as jest.Mocked - alertService.confirm = jest.fn() - alertService.alert = jest.fn() + alerts = {} as jest.Mocked + alerts.confirm = jest.fn() + alerts.alert = jest.fn() - internalEventBus = {} as jest.Mocked - internalEventBus.publish = jest.fn() + eventBus = {} as jest.Mocked + eventBus.publish = jest.fn() device = {} as jest.Mocked }) - const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => { - return new SNComponent({ - uuid: '789', - content_type: ContentType.TYPES.Component, - content: { - package_info: { + const thirdPartyFeature = () => { + const component = new SNComponent( + new DecryptedPayload({ + uuid: '789', + content_type: ContentType.TYPES.Component, + ...PayloadTimestampDefaults(), + content: { + local_url: 'sn://Extensions/non-native-identifier/dist/index.html', hosted_url: 'https://example.com/component', - identifier: identifier || FeatureIdentifier.PlusEditor, - file_type: file_type ?? 'html', - valid_until: new Date(), - }, - }, - } as never) - } + package_info: { + identifier: 'non-native-identifier' as FeatureIdentifier, + expires_at: new Date().getTime(), + availableInRoles: [], + } as unknown as jest.Mocked, + } as unknown as jest.Mocked, + }), + ) - const deprecatedComponent = () => { - return new SNComponent({ - uuid: '789', - content_type: ContentType.TYPES.Component, - content: { - package_info: { - hosted_url: 'https://example.com/component', - identifier: FeatureIdentifier.DeprecatedFileSafe, - valid_until: new Date(), - }, - }, - } as never) - } - - const thirdPartyComponent = () => { - return new SNComponent({ - uuid: '789', - content_type: ContentType.TYPES.Component, - content: { - local_url: 'sn://Extensions/non-native-identifier/dist/index.html', - hosted_url: 'https://example.com/component', - package_info: { - identifier: 'non-native-identifier', - valid_until: new Date(), - }, - }, - } as never) + return new ComponentOrNativeFeature(component) } describe('permissions', () => { @@ -152,7 +149,10 @@ describe('featuresService', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) expect( - manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownProEditor), permissions), + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor), + permissions, + ), ).toEqual(true) }) @@ -165,7 +165,12 @@ describe('featuresService', () => { ] const manager = createManager(Environment.Desktop, Platform.MacDesktop) - expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + expect( + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor), + permissions, + ), + ).toEqual(false) }) it('no extension should be able to stream multiple tags', () => { @@ -177,7 +182,12 @@ describe('featuresService', () => { ] const manager = createManager(Environment.Desktop, Platform.MacDesktop) - expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + expect( + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor), + permissions, + ), + ).toEqual(false) }) it('no extension should be able to stream multiple notes or tags', () => { @@ -189,7 +199,12 @@ describe('featuresService', () => { ] const manager = createManager(Environment.Desktop, Platform.MacDesktop) - expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + expect( + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor), + permissions, + ), + ).toEqual(false) }) it('some valid and some invalid permissions should still return invalid permissions', () => { @@ -202,7 +217,10 @@ describe('featuresService', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) expect( - manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions), + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe), + permissions, + ), ).toEqual(false) }) @@ -220,7 +238,10 @@ describe('featuresService', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) expect( - manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions), + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedFileSafe), + permissions, + ), ).toEqual(true) }) @@ -238,7 +259,10 @@ describe('featuresService', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) expect( - manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedBoldEditor), permissions), + manager.areRequestedPermissionsValid( + nativeFeatureAsUIFeature(FeatureIdentifier.DeprecatedBoldEditor), + permissions, + ), ).toEqual(true) }) @@ -255,9 +279,9 @@ describe('featuresService', () => { ] const manager = createManager(Environment.Desktop, Platform.MacDesktop) - expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual( - false, - ) + expect( + manager.areRequestedPermissionsValid(nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor), permissions), + ).toEqual(false) }) }) @@ -265,25 +289,31 @@ describe('featuresService', () => { describe('desktop', () => { it('returns native path for native component', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) - const component = nativeComponent() - const url = manager.urlForComponent(component) - const feature = FindNativeFeature(component.identifier) - expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`) + const feature = nativeFeatureAsUIFeature( + FeatureIdentifier.MarkdownProEditor, + )! + const url = manager.urlForComponent(feature) + expect(url).toEqual( + `${desktopExtHost}/components/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`, + ) }) it('returns native path for deprecated native component', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) - const component = deprecatedComponent() - const url = manager.urlForComponent(component) - const feature = FindNativeFeature(component.identifier) - expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`) + const feature = nativeFeatureAsUIFeature( + FeatureIdentifier.DeprecatedBoldEditor, + )! + const url = manager.urlForComponent(feature) + expect(url).toEqual( + `${desktopExtHost}/components/${feature?.featureIdentifier}/${feature.asFeatureDescription.index_path}`, + ) }) it('returns nonnative path for third party component', () => { const manager = createManager(Environment.Desktop, Platform.MacDesktop) - const component = thirdPartyComponent() - const url = manager.urlForComponent(component) - expect(url).toEqual(`${desktopExtHost}/Extensions/${component.identifier}/dist/index.html`) + const feature = thirdPartyFeature() + const url = manager.urlForComponent(feature) + expect(url).toEqual(`${desktopExtHost}/Extensions/${feature.featureIdentifier}/dist/index.html`) }) it('returns hosted url for third party component with no local_url', () => { @@ -299,7 +329,8 @@ describe('featuresService', () => { }, }, } as never) - const url = manager.urlForComponent(component) + const feature = new ComponentOrNativeFeature(component) + const url = manager.urlForComponent(feature) expect(url).toEqual('https://example.com/component') }) }) @@ -307,29 +338,30 @@ describe('featuresService', () => { describe('web', () => { it('returns native path for native component', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const component = nativeComponent() - const url = manager.urlForComponent(component) - const feature = FindNativeFeature(component.identifier) as FeatureDescription - expect(url).toEqual(`http://localhost/components/assets/${component.identifier}/${feature.index_path}`) + const feature = nativeFeatureAsUIFeature(FeatureIdentifier.MarkdownProEditor) + const url = manager.urlForComponent(feature) + expect(url).toEqual( + `http://localhost/components/assets/${feature.featureIdentifier}/${feature.asFeatureDescription.index_path}`, + ) }) it('returns hosted path for third party component', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const component = thirdPartyComponent() - const url = manager.urlForComponent(component) - expect(url).toEqual(component.hosted_url) + const feature = thirdPartyFeature() + const url = manager.urlForComponent(feature) + expect(url).toEqual(feature.asComponent.hosted_url) }) }) }) describe('editors', () => { - it('getEditorForNote should return undefined is note type is plain', () => { + it('getEditorForNote should return plain notes is note type is plain', () => { const note = createNote({ noteType: NoteType.Plain, }) const manager = createManager(Environment.Web, Platform.MacWeb) - expect(manager.editorForNote(note)).toBe(undefined) + expect(manager.editorForNote(note).featureIdentifier).toBe(FeatureIdentifier.PlainEditor) }) it('getEditorForNote should call legacy function if no note editorIdentifier or noteType', () => { @@ -345,60 +377,74 @@ describe('featuresService', () => { describe('editor change alert', () => { it('should not require alert switching from plain editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const component = nativeComponent() + const component = nativeFeatureAsUIFeature( + FeatureIdentifier.MarkdownProEditor, + )! const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component) expect(requiresAlert).toBe(false) }) it('should not require alert switching to plain editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const component = nativeComponent() + const component = nativeFeatureAsUIFeature( + FeatureIdentifier.MarkdownProEditor, + )! const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined) expect(requiresAlert).toBe(false) }) it('should not require alert switching from a markdown editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const htmlEditor = nativeComponent() - const markdownEditor = nativeComponent(undefined, 'md') + const htmlEditor = nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor)! + const markdownEditor = nativeFeatureAsUIFeature( + FeatureIdentifier.MarkdownProEditor, + ) const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor) expect(requiresAlert).toBe(false) }) it('should not require alert switching to a markdown editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const htmlEditor = nativeComponent() - const markdownEditor = nativeComponent(undefined, 'md') + const htmlEditor = nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor)! + const markdownEditor = nativeFeatureAsUIFeature( + FeatureIdentifier.MarkdownProEditor, + ) const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor) expect(requiresAlert).toBe(false) }) it('should not require alert switching from & to a html editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const htmlEditor = nativeComponent() + const htmlEditor = nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor)! const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor) expect(requiresAlert).toBe(false) }) it('should require alert switching from a html editor to custom editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const htmlEditor = nativeComponent() - const customEditor = nativeComponent(undefined, 'json') + const htmlEditor = nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor)! + const customEditor = nativeFeatureAsUIFeature( + FeatureIdentifier.TokenVaultEditor, + ) const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor) expect(requiresAlert).toBe(true) }) it('should require alert switching from a custom editor to html editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const htmlEditor = nativeComponent() - const customEditor = nativeComponent(undefined, 'json') + const htmlEditor = nativeFeatureAsUIFeature(FeatureIdentifier.PlusEditor)! + const customEditor = nativeFeatureAsUIFeature( + FeatureIdentifier.TokenVaultEditor, + ) const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor) expect(requiresAlert).toBe(true) }) it('should require alert switching from a custom editor to custom editor', () => { const manager = createManager(Environment.Web, Platform.MacWeb) - const customEditor = nativeComponent(undefined, 'json') + const customEditor = nativeFeatureAsUIFeature( + FeatureIdentifier.TokenVaultEditor, + ) const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor) expect(requiresAlert).toBe(true) }) diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts index eaab74abf..34c030f8e 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts @@ -1,22 +1,22 @@ import { AllowedBatchStreaming } from './Types' -import { SNPreferencesService } from '../Preferences/PreferencesService' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' -import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { ContentType } from '@standardnotes/domain-core' import { ActionObserver, SNNote, - SNTheme, - SNComponent, ComponentMutator, PayloadEmitSource, PermissionDialog, Environment, Platform, ComponentMessage, + ComponentOrNativeFeature, + ComponentInterface, + PrefKey, + ThemeInterface, + ComponentPreferencesEntry, + AllComponentPreferences, } from '@standardnotes/models' -import { SNSyncService } from '@Lib/Services/Sync/SyncService' -import find from 'lodash/find' -import uniq from 'lodash/uniq' import { ComponentArea, ComponentAction, @@ -24,9 +24,25 @@ import { FindNativeFeature, NoteType, FeatureIdentifier, + EditorFeatureDescription, + GetIframeAndNativeEditors, + FindNativeTheme, + UIFeatureDescriptionTypes, + IframeComponentFeatureDescription, + GetPlainNoteFeature, + GetSuperNoteFeature, + ComponentFeatureDescription, + ThemeFeatureDescription, } from '@standardnotes/features' -import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils' -import { UuidString } from '@Lib/Types/UuidString' +import { + Copy, + filterFromArray, + removeFromArray, + sleep, + assert, + uniqueArray, + isNotUndefined, +} from '@standardnotes/utils' import { AllowedBatchContentTypes } from '@Lib/Services/ComponentManager/Types' import { ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer' import { @@ -39,8 +55,14 @@ import { DeviceInterface, isMobileDevice, MutatorClientInterface, + PreferenceServiceInterface, + ComponentViewerItem, + PreferencesServiceEvent, + ItemManagerInterface, + SyncServiceInterface, + FeatureStatus, } from '@standardnotes/services' -import { ContentType } from '@standardnotes/domain-core' +import { permissionsStringForPermissions } from './permissionsStringForPermissions' const DESKTOP_URL_PREFIX = 'sn://' const LOCAL_HOST = 'localhost' @@ -78,22 +100,30 @@ export class SNComponentManager private permissionDialogs: PermissionDialog[] = [] constructor( - private itemManager: ItemManager, + private items: ItemManagerInterface, private mutator: MutatorClientInterface, - private syncService: SNSyncService, - private featuresService: SNFeaturesService, - private preferencesSerivce: SNPreferencesService, - protected alertService: AlertService, + private sync: SyncServiceInterface, + private features: SNFeaturesService, + private preferences: PreferenceServiceInterface, + protected alerts: AlertService, private environment: Environment, private platform: Platform, - protected override internalEventBus: InternalEventBusInterface, private device: DeviceInterface, + protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) this.loggingEnabled = false this.addItemObserver() + this.eventDisposers.push( + preferences.addEventObserver((event) => { + if (event === PreferencesServiceEvent.PreferencesChanged) { + this.postActiveThemesToAllViewers() + } + }), + ) + window.addEventListener ? window.addEventListener('focus', this.detectFocusChange, true) : window.attachEvent('onfocusout', this.detectFocusChange) @@ -112,20 +142,16 @@ export class SNComponentManager return this.environment === Environment.Mobile } - get components(): SNComponent[] { - return this.itemManager.getDisplayableComponents() + get thirdPartyComponents(): ComponentInterface[] { + return this.items.getDisplayableComponents() } - componentsForArea(area: ComponentArea): SNComponent[] { - return this.components.filter((component) => { + thirdPartyComponentsForArea(area: ComponentArea): ComponentInterface[] { + return this.thirdPartyComponents.filter((component) => { return component.area === area }) } - componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined { - return this.components.find((component) => component.identifier === identifier) - } - override deinit(): void { super.deinit() @@ -137,11 +163,11 @@ export class SNComponentManager this.permissionDialogs.length = 0 this.desktopManager = undefined - ;(this.itemManager as unknown) = undefined - ;(this.featuresService as unknown) = undefined - ;(this.syncService as unknown) = undefined - ;(this.alertService as unknown) = undefined - ;(this.preferencesSerivce as unknown) = undefined + ;(this.items as unknown) = undefined + ;(this.features as unknown) = undefined + ;(this.sync as unknown) = undefined + ;(this.alerts as unknown) = undefined + ;(this.preferences as unknown) = undefined this.removeItemObserver?.() ;(this.removeItemObserver as unknown) = undefined @@ -157,27 +183,35 @@ export class SNComponentManager } public createComponentViewer( - component: SNComponent, - contextItem?: UuidString, + component: ComponentOrNativeFeature, + item: ComponentViewerItem, actionObserver?: ActionObserver, ): ComponentViewerInterface { const viewer = new ComponentViewer( component, - this.itemManager, - this.mutator, - this.syncService, - this.alertService, - this.preferencesSerivce, - this.featuresService, - this.environment, - this.platform, { - runWithPermissions: this.runWithPermissions.bind(this), - urlsForActiveThemes: this.urlsForActiveThemes.bind(this), + items: this.items, + mutator: this.mutator, + sync: this.sync, + alerts: this.alerts, + preferences: this.preferences, + features: this.features, + }, + { + url: this.urlForComponent(component) ?? '', + item, + actionObserver, + }, + { + environment: this.environment, + platform: this.platform, + componentManagerFunctions: { + runWithPermissions: this.runWithPermissions.bind(this), + urlsForActiveThemes: this.urlsForActiveThemes.bind(this), + setComponentPreferences: this.setComponentPreferences.bind(this), + getComponentPreferences: this.getComponentPreferences.bind(this), + }, }, - this.urlForComponent(component), - contextItem, - actionObserver, ) this.viewers.push(viewer) return viewer @@ -193,7 +227,7 @@ export class SNComponentManager this.configureForDesktop() } - handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void { + private handleChangedComponents(components: ComponentInterface[], source: PayloadEmitSource): void { const acceptableSources = [ PayloadEmitSource.LocalChanged, PayloadEmitSource.RemoteRetrieved, @@ -221,8 +255,8 @@ export class SNComponentManager } } - addItemObserver(): void { - this.removeItemObserver = this.itemManager.addObserver( + private addItemObserver(): void { + this.removeItemObserver = this.items.addObserver( [ContentType.TYPES.Component, ContentType.TYPES.Theme], ({ changed, inserted, removed, source }) => { const items = [...changed, ...inserted] @@ -231,7 +265,7 @@ export class SNComponentManager const device = this.device if (isMobileDevice(device) && 'addComponentUrl' in device) { inserted.forEach((component) => { - const url = this.urlForComponent(component) + const url = this.urlForComponent(new ComponentOrNativeFeature(component)) if (url) { device.addComponentUrl(component.uuid, url) } @@ -274,9 +308,11 @@ export class SNComponentManager } configureForDesktop(): void { - this.desktopManager?.registerUpdateObserver((component: SNComponent) => { + this.desktopManager?.registerUpdateObserver((component: ComponentInterface) => { /* Reload theme if active */ - if (component.active && component.isTheme()) { + const activeComponents = this.getActiveComponents() + const isComponentActive = activeComponents.find((candidate) => candidate.uuid === component.uuid) + if (isComponentActive && component.isTheme()) { this.postActiveThemesToAllViewers() } }) @@ -288,53 +324,57 @@ export class SNComponentManager } } - getActiveThemes(): SNTheme[] { - return this.componentsForArea(ComponentArea.Themes).filter((theme) => { - return theme.active - }) as SNTheme[] + private urlForComponentOnDesktop( + uiFeature: ComponentOrNativeFeature, + ): string | undefined { + assert(this.desktopManager) + + if (uiFeature.isFeatureDescription) { + return `${this.desktopManager.getExtServerHost()}/components/${uiFeature.featureIdentifier}/${ + uiFeature.asFeatureDescription.index_path + }` + } else { + if (uiFeature.asComponent.local_url) { + return uiFeature.asComponent.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/') + } + + return uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url + } } - urlForComponent(component: SNComponent): string | undefined { - const platformSupportsOfflineOnly = this.isDesktop - if (component.offlineOnly && !platformSupportsOfflineOnly) { + private urlForNativeComponent(feature: ComponentFeatureDescription): string { + if (this.isMobile) { + const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0] + return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${feature.identifier}/${feature.index_path}` + } else { + const baseUrlRequiredForThemesInsideEditors = window.location.origin + return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${feature.identifier}/${feature.index_path}` + } + } + + urlForComponent(uiFeature: ComponentOrNativeFeature): string | undefined { + if (this.desktopManager) { + return this.urlForComponentOnDesktop(uiFeature) + } + + if (uiFeature.isFeatureDescription) { + return this.urlForNativeComponent(uiFeature.asFeatureDescription) + } + + if (uiFeature.asComponent.offlineOnly) { return undefined } - const nativeFeature = FindNativeFeature(component.identifier) - - if (this.isDesktop) { - assert(this.desktopManager) - - if (nativeFeature) { - return `${this.desktopManager.getExtServerHost()}/components/${component.identifier}/${ - nativeFeature.index_path - }` - } else if (component.local_url) { - return component.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/') - } else { - return component.hosted_url || component.legacy_url - } - } - - const isMobile = this.environment === Environment.Mobile - if (nativeFeature) { - if (isMobile) { - const baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0] - return `${baseUrlRequiredForThemesInsideEditors}/web-src/components/assets/${component.identifier}/${nativeFeature.index_path}` - } else { - const baseUrlRequiredForThemesInsideEditors = window.location.origin - return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}` - } - } - - let url = component.hosted_url || component.legacy_url + const url = uiFeature.asComponent.hosted_url || uiFeature.asComponent.legacy_url if (!url) { return undefined } + if (this.isMobile) { const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST - url = url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement) + return url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement) } + return url } @@ -350,8 +390,24 @@ export class SNComponentManager return urls } - private findComponent(uuid: UuidString): SNComponent | undefined { - return this.itemManager.findItem(uuid) + private findComponent(uuid: string): ComponentInterface | undefined { + return this.items.findItem(uuid) + } + + private findComponentOrNativeFeature( + identifier: string, + ): ComponentOrNativeFeature | undefined { + const nativeFeature = FindNativeFeature(identifier as FeatureIdentifier) + if (nativeFeature) { + return new ComponentOrNativeFeature(nativeFeature) + } + + const componentItem = this.items.findItem(identifier) + if (componentItem) { + return new ComponentOrNativeFeature(componentItem) + } + + return undefined } findComponentViewer(identifier: string): ComponentViewerInterface | undefined { @@ -362,10 +418,13 @@ export class SNComponentManager return this.viewers.find((viewer) => viewer.sessionKey === key) } - areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean { + areRequestedPermissionsValid( + uiFeature: ComponentOrNativeFeature, + permissions: ComponentPermission[], + ): boolean { for (const permission of permissions) { if (permission.name === ComponentAction.StreamItems) { - if (!AllowedBatchStreaming.includes(component.identifier)) { + if (!AllowedBatchStreaming.includes(uiFeature.featureIdentifier)) { return false } const hasNonAllowedBatchPermission = permission.content_types?.some( @@ -381,28 +440,32 @@ export class SNComponentManager } runWithPermissions( - componentUuid: UuidString, + componentIdentifier: string, requiredPermissions: ComponentPermission[], runFunction: () => void, ): void { - const component = this.findComponent(componentUuid) + const uiFeature = this.findComponentOrNativeFeature(componentIdentifier) - if (!component) { - void this.alertService.alert( - `Unable to find component with ID ${componentUuid}. Please restart the app and try again.`, + if (!uiFeature) { + void this.alerts.alert( + `Unable to find component with ID ${componentIdentifier}. Please restart the app and try again.`, 'An unexpected error occurred', ) return } - if (!this.areRequestedPermissionsValid(component, requiredPermissions)) { - console.error('Component is requesting invalid permissions', componentUuid, requiredPermissions) + if (uiFeature.isFeatureDescription) { + runFunction() return } - const nativeFeature = FindNativeFeature(component.identifier) - const acquiredPermissions = nativeFeature?.component_permissions || component.permissions + if (!this.areRequestedPermissionsValid(uiFeature, requiredPermissions)) { + console.error('Component is requesting invalid permissions', componentIdentifier, requiredPermissions) + return + } + + const acquiredPermissions = uiFeature.acquiredPermissions /* Make copy as not to mutate input values */ requiredPermissions = Copy(requiredPermissions) as ComponentPermission[] @@ -420,7 +483,7 @@ export class SNComponentManager filterFromArray(requiredPermissions, required) continue } - for (const acquiredContentType of respectiveAcquired.content_types!) { + for (const acquiredContentType of respectiveAcquired.content_types as string[]) { removeFromArray(requiredContentTypes, acquiredContentType) } if (requiredContentTypes.length === 0) { @@ -429,8 +492,8 @@ export class SNComponentManager } } if (requiredPermissions.length > 0) { - this.promptForPermissionsWithAngularAsyncRendering( - component, + this.promptForPermissionsWithDeferredRendering( + uiFeature.asComponent, requiredPermissions, // eslint-disable-next-line @typescript-eslint/require-await async (approved) => { @@ -444,8 +507,8 @@ export class SNComponentManager } } - promptForPermissionsWithAngularAsyncRendering( - component: SNComponent, + promptForPermissionsWithDeferredRendering( + component: ComponentInterface, permissions: ComponentPermission[], callback: (approved: boolean) => Promise, ): void { @@ -455,14 +518,14 @@ export class SNComponentManager } promptForPermissions( - component: SNComponent, + component: ComponentInterface, permissions: ComponentPermission[], callback: (approved: boolean) => Promise, ): void { const params: PermissionDialog = { component: component, permissions: permissions, - permissionsString: this.permissionsStringForPermissions(permissions, component), + permissionsString: permissionsStringForPermissions(permissions, component), actionBlock: callback, callback: async (approved: boolean) => { const latestComponent = this.findComponent(component.uuid) @@ -481,7 +544,7 @@ export class SNComponentManager } else { /* Permission already exists, but content_types may have been expanded */ const contentTypes = matchingPermission.content_types || [] - matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!)) + matchingPermission.content_types = uniqueArray(contentTypes.concat(permission.content_types as string[])) } } @@ -490,7 +553,7 @@ export class SNComponentManager mutator.permissions = componentPermissions }) - void this.syncService.sync() + void this.sync.sync() } this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => { @@ -528,9 +591,7 @@ export class SNComponentManager * Since these calls are asyncronous, multiple dialogs may be requested at the same time. * We only want to present one and trigger all callbacks based on one modal result */ - const existingDialog = find(this.permissionDialogs, { - component: component, - }) + const existingDialog = this.permissionDialogs.find((dialog) => dialog.component === component) this.permissionDialogs.push(params) if (!existingDialog) { this.presentPermissionsDialog(params) @@ -544,56 +605,72 @@ export class SNComponentManager throw 'Must override SNComponentManager.presentPermissionsDialog' } - async toggleTheme(uuid: UuidString): Promise { - this.log('Toggling theme', uuid) + async toggleTheme(uiFeature: ComponentOrNativeFeature): Promise { + this.log('Toggling theme', uiFeature.uniqueIdentifier) - const theme = this.findComponent(uuid) as SNTheme - if (theme.active) { - await this.mutator.changeComponent(theme, (mutator) => { - mutator.active = false - }) - } else { - const activeThemes = this.getActiveThemes() - - /* Activate current before deactivating others, so as not to flicker */ - await this.mutator.changeComponent(theme, (mutator) => { - mutator.active = true - }) - - /* Deactive currently active theme(s) if new theme is not layerable */ - if (!theme.isLayerable()) { - await sleep(10) - for (const candidate of activeThemes) { - if (candidate && !candidate.isLayerable()) { - await this.mutator.changeComponent(candidate, (mutator) => { - mutator.active = false - }) - } - } - } - } - - void this.syncService.sync() - } - - async toggleComponent(uuid: UuidString): Promise { - this.log('Toggling component', uuid) - - const component = this.findComponent(uuid) - - if (!component) { + if (this.isThemeActive(uiFeature)) { + await this.removeActiveTheme(uiFeature) return } - await this.mutator.changeComponent(component, (mutator) => { - mutator.active = !(mutator.getItem() as SNComponent).active - }) + const featureStatus = this.features.getFeatureStatus(uiFeature.featureIdentifier) + if (featureStatus !== FeatureStatus.Entitled) { + return + } - void this.syncService.sync() + /* Activate current before deactivating others, so as not to flicker */ + await this.addActiveTheme(uiFeature) + + /* Deactive currently active theme(s) if new theme is not layerable */ + if (!uiFeature.layerable) { + await sleep(10) + + const activeThemes = this.getActiveThemes() + for (const candidate of activeThemes) { + if (candidate.featureIdentifier === uiFeature.featureIdentifier) { + continue + } + + if (!candidate.layerable) { + await this.removeActiveTheme(candidate) + } + } + } } - isComponentActive(component: SNComponent): boolean { - return component.active + getActiveThemes(): ComponentOrNativeFeature[] { + const activeThemesIdentifiers = this.getActiveThemesIdentifiers() + + const thirdPartyThemes = this.items.findItems(activeThemesIdentifiers).map((item) => { + return new ComponentOrNativeFeature(item) + }) + + const nativeThemes = activeThemesIdentifiers + .map((identifier) => { + return FindNativeTheme(identifier as FeatureIdentifier) + }) + .filter(isNotUndefined) + .map((theme) => new ComponentOrNativeFeature(theme)) + + const entitledThemes = [...thirdPartyThemes, ...nativeThemes].filter((theme) => { + return this.features.getFeatureStatus(theme.featureIdentifier) === FeatureStatus.Entitled + }) + + return entitledThemes + } + + getActiveThemesIdentifiers(): string[] { + return this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] + } + + async toggleComponent(component: ComponentInterface): Promise { + this.log('Toggling component', component.uuid) + + if (this.isComponentActive(component)) { + await this.removeActiveComponent(component) + } else { + await this.addActiveComponent(component) + } } allComponentIframes(): HTMLIFrameElement[] { @@ -604,23 +681,67 @@ export class SNComponentManager return viewer.getIframe() } - editorForNote(note: SNNote): SNComponent | undefined { - if (note.noteType === NoteType.Plain || note.noteType === NoteType.Super) { - return undefined + componentOrNativeFeatureForIdentifier( + identifier: FeatureIdentifier | string, + ): ComponentOrNativeFeature | undefined { + const nativeFeature = FindNativeFeature(identifier as FeatureIdentifier) + if (nativeFeature) { + return new ComponentOrNativeFeature(nativeFeature) + } + + const component = this.thirdPartyComponents.find((component) => { + return component.identifier === identifier + }) + if (component) { + return new ComponentOrNativeFeature(component) + } + + return undefined + } + + editorForNote(note: SNNote): ComponentOrNativeFeature { + if (note.noteType === NoteType.Plain) { + return new ComponentOrNativeFeature(GetPlainNoteFeature()) + } + + if (note.noteType === NoteType.Super) { + return new ComponentOrNativeFeature(GetSuperNoteFeature()) } if (note.editorIdentifier) { - return this.componentWithIdentifier(note.editorIdentifier) + const result = this.componentOrNativeFeatureForIdentifier< + EditorFeatureDescription | IframeComponentFeatureDescription + >(note.editorIdentifier) + if (result) { + return result + } } - return this.legacyGetEditorForNote(note) + if (note.noteType && note.noteType !== NoteType.Unknown) { + const result = this.nativeEditorForNoteType(note.noteType) + if (result) { + return new ComponentOrNativeFeature(result) + } + } + + const legacyResult = this.legacyGetEditorForNote(note) + if (legacyResult) { + return new ComponentOrNativeFeature(legacyResult) + } + + return new ComponentOrNativeFeature(GetPlainNoteFeature()) + } + + private nativeEditorForNoteType(noteType: NoteType): EditorFeatureDescription | undefined { + const nativeEditors = GetIframeAndNativeEditors() + return nativeEditors.find((editor) => editor.note_type === noteType) } /** * Uses legacy approach of note/editor association. New method uses note.editorIdentifier and note.noteType directly. */ - private legacyGetEditorForNote(note: SNNote): SNComponent | undefined { - const editors = this.componentsForArea(ComponentArea.Editor) + private legacyGetEditorForNote(note: SNNote): ComponentInterface | undefined { + const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor) for (const editor of editors) { if (editor.isExplicitlyEnabledForItem(note.uuid)) { return editor @@ -635,67 +756,25 @@ export class SNComponentManager } } - legacyGetDefaultEditor(): SNComponent | undefined { - const editors = this.componentsForArea(ComponentArea.Editor) + legacyGetDefaultEditor(): ComponentInterface | undefined { + const editors = this.thirdPartyComponentsForArea(ComponentArea.Editor) return editors.filter((e) => e.legacyIsDefaultEditor())[0] } - permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string { - if (permissions.length === 0) { - return '.' + doesEditorChangeRequireAlert( + from: ComponentOrNativeFeature | undefined, + to: ComponentOrNativeFeature | undefined, + ): boolean { + if (!from || !to) { + return false } - let contentTypeStrings: string[] = [] - let contextAreaStrings: string[] = [] + const fromFileType = from.fileType + const toFileType = to.fileType + const isEitherMarkdown = fromFileType === 'md' || toFileType === 'md' + const areBothHtml = fromFileType === 'html' && toFileType === 'html' - permissions.forEach((permission) => { - switch (permission.name) { - case ComponentAction.StreamItems: - if (!permission.content_types) { - return - } - permission.content_types.forEach((contentTypeString: string) => { - const contentTypeOrError = ContentType.create(contentTypeString) - if (contentTypeOrError.isFailed()) { - return - } - const contentType = contentTypeOrError.getValue() - const desc = contentType.getDisplayName() - if (desc) { - contentTypeStrings.push(`${desc}s`) - } else { - contentTypeStrings.push(`items of type ${contentType.value}`) - } - }) - break - case ComponentAction.StreamContextItem: - { - const componentAreaMapping = { - [ComponentArea.EditorStack]: 'working note', - [ComponentArea.Editor]: 'working note', - [ComponentArea.Themes]: 'Unknown', - } - contextAreaStrings.push(componentAreaMapping[component.area]) - } - break - } - }) - - contentTypeStrings = uniq(contentTypeStrings) - contextAreaStrings = uniq(contextAreaStrings) - - if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) { - return '.' - } - return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.' - } - - doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean { - const isEitherPlainEditor = !from || !to - const isEitherMarkdown = from?.package_info.file_type === 'md' || to?.package_info.file_type === 'md' - const areBothHtml = from?.package_info.file_type === 'html' && to?.package_info.file_type === 'html' - - if (isEitherPlainEditor || isEitherMarkdown || areBothHtml) { + if (isEitherMarkdown || areBothHtml) { return false } else { return true @@ -703,7 +782,7 @@ export class SNComponentManager } async showEditorChangeAlert(): Promise { - const shouldChangeEditor = await this.alertService.confirm( + const shouldChangeEditor = await this.alerts.confirm( 'Doing so might result in minor formatting changes.', "Are you sure you want to change this note's type?", 'Yes, change it', @@ -711,4 +790,91 @@ export class SNComponentManager return shouldChangeEditor } + + async setComponentPreferences( + uiFeature: ComponentOrNativeFeature, + preferences: ComponentPreferencesEntry, + ): Promise { + const mutablePreferencesValue = Copy( + this.preferences.getValue(PrefKey.ComponentPreferences, undefined) ?? {}, + ) + + const preferencesLookupKey = uiFeature.uniqueIdentifier + + mutablePreferencesValue[preferencesLookupKey] = preferences + + await this.preferences.setValue(PrefKey.ComponentPreferences, mutablePreferencesValue) + } + + getComponentPreferences( + component: ComponentOrNativeFeature, + ): ComponentPreferencesEntry | undefined { + const preferences = this.preferences.getValue(PrefKey.ComponentPreferences, undefined) + + if (!preferences) { + return undefined + } + + const preferencesLookupKey = component.uniqueIdentifier + + return preferences[preferencesLookupKey] + } + + async addActiveTheme(theme: ComponentOrNativeFeature): Promise { + const activeThemes = (this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? []).slice() + + activeThemes.push(theme.uniqueIdentifier) + + await this.preferences.setValue(PrefKey.ActiveThemes, activeThemes) + } + + async replaceActiveTheme(theme: ComponentOrNativeFeature): Promise { + await this.preferences.setValue(PrefKey.ActiveThemes, [theme.uniqueIdentifier]) + } + + async removeActiveTheme(theme: ComponentOrNativeFeature): Promise { + const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] + + const filteredThemes = activeThemes.filter((activeTheme) => activeTheme !== theme.uniqueIdentifier) + + await this.preferences.setValue(PrefKey.ActiveThemes, filteredThemes) + } + + isThemeActive(theme: ComponentOrNativeFeature): boolean { + if (this.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled) { + return false + } + + const activeThemes = this.preferences.getValue(PrefKey.ActiveThemes, undefined) ?? [] + + return activeThemes.includes(theme.uniqueIdentifier) + } + + async addActiveComponent(component: ComponentInterface): Promise { + const activeComponents = (this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? []).slice() + + activeComponents.push(component.uuid) + + await this.preferences.setValue(PrefKey.ActiveComponents, activeComponents) + } + + async removeActiveComponent(component: ComponentInterface): Promise { + const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] + + const filteredComponents = activeComponents.filter((activeComponent) => activeComponent !== component.uuid) + + await this.preferences.setValue(PrefKey.ActiveComponents, filteredComponents) + } + + getActiveComponents(): ComponentInterface[] { + const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] + + return this.items.findItems(activeComponents) + } + + isComponentActive(component: ComponentInterface): boolean { + const activeComponents = this.preferences.getValue(PrefKey.ActiveComponents, undefined) ?? [] + + return activeComponents.includes(component.uuid) + } } diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts index 6b4c4d57d..73a80aec2 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts @@ -1,4 +1,3 @@ -import { SNPreferencesService } from '../Preferences/PreferencesService' import { ComponentViewerInterface, ComponentViewerError, @@ -6,6 +5,11 @@ import { FeaturesEvent, AlertService, MutatorClientInterface, + PreferenceServiceInterface, + ComponentViewerItem, + isComponentViewerItemReadonlyItem, + ItemManagerInterface, + SyncServiceInterface, } from '@standardnotes/services' import { SNFeaturesService } from '@Lib/Services' import { @@ -13,7 +17,6 @@ import { ComponentEventObserver, ComponentViewerEvent, ComponentMessage, - SNComponent, PrefKey, NoteContent, MutationType, @@ -36,11 +39,10 @@ import { Environment, Platform, OutgoingItemMessagePayload, + ComponentPreferencesEntry, + ComponentOrNativeFeature, + ComponentInterface, } from '@standardnotes/models' -import find from 'lodash/find' -import uniq from 'lodash/uniq' -import remove from 'lodash/remove' -import { SNSyncService } from '@Lib/Services/Sync/SyncService' import { environmentToString, platformToString } from '@Lib/Application/Platforms' import { MessageReply, @@ -48,10 +50,15 @@ import { AllowedBatchContentTypes, DeleteItemsMessageData, MessageReplyData, + ReadwriteActions, } from './Types' -import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features' -import { ItemManager } from '@Lib/Services/Items/ItemManager' -import { UuidString } from '@Lib/Types/UuidString' +import { ComponentViewerRequiresComponentManagerFunctions } from './ComponentViewerRequiresComponentManagerFunctions' +import { + ComponentAction, + ComponentPermission, + ComponentArea, + IframeComponentFeatureDescription, +} from '@standardnotes/features' import { isString, extendArray, @@ -63,30 +70,10 @@ import { Uuids, sureSearchArray, isNotUndefined, + uniqueArray, } from '@standardnotes/utils' import { ContentType } from '@standardnotes/domain-core' -type RunWithPermissionsCallback = ( - componentUuid: UuidString, - requiredPermissions: ComponentPermission[], - runFunction: () => void, -) => void - -type ComponentManagerFunctions = { - runWithPermissions: RunWithPermissionsCallback - urlsForActiveThemes: () => string[] -} - -const ReadwriteActions = [ - ComponentAction.SaveItems, - ComponentAction.CreateItem, - ComponentAction.CreateItems, - ComponentAction.DeleteItems, - ComponentAction.SetComponentData, -] - -type Writeable = { -readonly [P in keyof T]: T[P] } - export class ComponentViewer implements ComponentViewerInterface { private streamItems?: string[] private streamContextItemOriginalMessage?: ComponentMessage @@ -95,7 +82,6 @@ export class ComponentViewer implements ComponentViewerInterface { private loggingEnabled = false public identifier = nonSecureRandomIdentifier() private actionObservers: ActionObserver[] = [] - public overrideContextItem?: DecryptedItemInterface private featureStatus: FeatureStatus private removeFeaturesObserver: () => void private eventObservers: ComponentEventObserver[] = [] @@ -108,21 +94,31 @@ export class ComponentViewer implements ComponentViewerInterface { public sessionKey?: string constructor( - public readonly component: SNComponent, - private itemManager: ItemManager, - private mutator: MutatorClientInterface, - private syncService: SNSyncService, - private alertService: AlertService, - private preferencesSerivce: SNPreferencesService, - featuresService: SNFeaturesService, - private environment: Environment, - private platform: Platform, - private componentManagerFunctions: ComponentManagerFunctions, - public readonly url?: string, - private contextItemUuid?: UuidString, - actionObserver?: ActionObserver, + private componentOrFeature: ComponentOrNativeFeature, + private services: { + items: ItemManagerInterface + mutator: MutatorClientInterface + sync: SyncServiceInterface + alerts: AlertService + preferences: PreferenceServiceInterface + features: SNFeaturesService + }, + private options: { + item: ComponentViewerItem + url: string + actionObserver?: ActionObserver + }, + private config: { + environment: Environment + platform: Platform + componentManagerFunctions: ComponentViewerRequiresComponentManagerFunctions + }, ) { - this.removeItemObserver = this.itemManager.addObserver( + if (isComponentViewerItemReadonlyItem(options.item)) { + this.setReadonly(true) + this.lockReadonly = true + } + this.removeItemObserver = this.services.items.addObserver( ContentType.TYPES.Any, ({ changed, inserted, removed, source, sourceKey }) => { if (this.dealloced) { @@ -132,21 +128,22 @@ export class ComponentViewer implements ComponentViewerInterface { this.handleChangesInItems(items, source, sourceKey) }, ) - if (actionObserver) { - this.actionObservers.push(actionObserver) + if (options.actionObserver) { + this.actionObservers.push(options.actionObserver) } - this.featureStatus = featuresService.getFeatureStatus(component.identifier) + this.featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier) - this.removeFeaturesObserver = featuresService.addEventObserver((event) => { + this.removeFeaturesObserver = services.features.addEventObserver((event) => { if (this.dealloced) { return } - if (event === FeaturesEvent.FeaturesUpdated) { - const featureStatus = featuresService.getFeatureStatus(component.identifier) + if (event === FeaturesEvent.FeaturesAvailabilityChanged) { + const featureStatus = services.features.getFeatureStatus(componentOrFeature.featureIdentifier) if (featureStatus !== this.featureStatus) { this.featureStatus = featureStatus + this.postActiveThemes() this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated) } } @@ -155,12 +152,20 @@ export class ComponentViewer implements ComponentViewerInterface { this.log('Constructor', this) } + public getComponentOrFeatureItem(): ComponentOrNativeFeature { + return this.componentOrFeature + } + + get url(): string { + return this.options.url + } + get isDesktop(): boolean { - return this.environment === Environment.Desktop + return this.config.environment === Environment.Desktop } get isMobile(): boolean { - return this.environment === Environment.Mobile + return this.config.environment === Environment.Mobile } public destroy(): void { @@ -170,12 +175,10 @@ export class ComponentViewer implements ComponentViewerInterface { private deinit(): void { this.dealloced = true - ;(this.component as unknown) = undefined - ;(this.itemManager as unknown) = undefined - ;(this.syncService as unknown) = undefined - ;(this.alertService as unknown) = undefined - ;(this.preferencesSerivce as unknown) = undefined - ;(this.componentManagerFunctions as unknown) = undefined + ;(this.componentOrFeature as unknown) = undefined + ;(this.services as unknown) = undefined + ;(this.config as unknown) = undefined + ;(this.options as unknown) = undefined this.eventObservers.length = 0 this.actionObservers.length = 0 @@ -218,8 +221,8 @@ export class ComponentViewer implements ComponentViewerInterface { this.readonly = readonly } - get componentUuid(): string { - return this.component.uuid + get componentUniqueIdentifier(): string { + return this.componentOrFeature.uniqueIdentifier } public getFeatureStatus(): FeatureStatus { @@ -227,20 +230,17 @@ export class ComponentViewer implements ComponentViewerInterface { } private isOfflineRestricted(): boolean { - return this.component.offlineOnly && !this.isDesktop - } - - private isNativeFeature(): boolean { - return !!FindNativeFeature(this.component.identifier) + return this.componentOrFeature.isComponent && this.componentOrFeature.asComponent.offlineOnly && !this.isDesktop } private hasUrlError(): boolean { - if (this.isNativeFeature()) { + if (!this.componentOrFeature.isComponent) { return false } + return this.isDesktop - ? !this.component.local_url && !this.component.hasValidHostedUrl() - : !this.component.hasValidHostedUrl() + ? !this.componentOrFeature.asComponent.local_url && !this.componentOrFeature.asComponent.hasValidHostedUrl + : !this.componentOrFeature.asComponent.hasValidHostedUrl } public shouldRender(): boolean { @@ -251,6 +251,7 @@ export class ComponentViewer implements ComponentViewerInterface { if (this.isOfflineRestricted()) { return ComponentViewerError.OfflineRestricted } + if (this.hasUrlError()) { return ComponentViewerError.MissingUrl } @@ -259,10 +260,18 @@ export class ComponentViewer implements ComponentViewerInterface { } private updateOurComponentRefFromChangedItems(items: DecryptedItemInterface[]): void { - const updatedComponent = items.find((item) => item.uuid === this.component.uuid) - if (updatedComponent && isDecryptedItem(updatedComponent)) { - ;(this.component as Writeable) = updatedComponent as SNComponent + if (!this.componentOrFeature.isComponent) { + return } + + const updatedComponent = items.find((item) => item.uuid === this.componentUniqueIdentifier) as ComponentInterface + if (!updatedComponent) { + return + } + + const item = new ComponentOrNativeFeature(updatedComponent) + + this.componentOrFeature = item } handleChangesInItems( @@ -275,7 +284,7 @@ export class ComponentViewer implements ComponentViewerInterface { this.updateOurComponentRefFromChangedItems(nondeletedItems) - const areWeOriginator = sourceKey && sourceKey === this.component.uuid + const areWeOriginator = sourceKey && sourceKey === this.componentUniqueIdentifier if (areWeOriginator) { return } @@ -291,7 +300,12 @@ export class ComponentViewer implements ComponentViewerInterface { } if (this.streamContextItemOriginalMessage) { - const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid }) + const optionsItem = this.options.item + if (isComponentViewerItemReadonlyItem(optionsItem)) { + return + } + + const matchingItem = nondeletedItems.find((item) => item.uuid === optionsItem.uuid) if (matchingItem) { this.sendContextItemThroughBridge(matchingItem, source) } @@ -302,13 +316,17 @@ export class ComponentViewer implements ComponentViewerInterface { const requiredPermissions: ComponentPermission[] = [ { name: ComponentAction.StreamItems, - content_types: this.streamItems!.sort(), + content_types: this.streamItems?.sort(), }, ] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { - this.sendItemsInReply(items, this.streamItemsOriginalMessage!) - }) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredPermissions, + () => { + this.sendItemsInReply(items, this.streamItemsOriginalMessage as ComponentMessage) + }, + ) } sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void { @@ -317,21 +335,25 @@ export class ComponentViewer implements ComponentViewerInterface { name: ComponentAction.StreamContextItem, }, ] as ComponentPermission[] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredContextPermissions, () => { - this.log( - 'Send context item in reply', - 'component:', - this.component, - 'item: ', - item, - 'originalMessage: ', - this.streamContextItemOriginalMessage, - ) - const response: MessageReplyData = { - item: this.jsonForItem(item, source), - } - this.replyToMessage(this.streamContextItemOriginalMessage!, response) - }) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredContextPermissions, + () => { + this.log( + 'Send context item in reply', + 'component:', + this.componentOrFeature, + 'item: ', + item, + 'originalMessage: ', + this.streamContextItemOriginalMessage, + ) + const response: MessageReplyData = { + item: this.jsonForItem(item, source), + } + this.replyToMessage(this.streamContextItemOriginalMessage as ComponentMessage, response) + }, + ) } private log(message: string, ...args: unknown[]): void { @@ -345,7 +367,7 @@ export class ComponentViewer implements ComponentViewerInterface { message: ComponentMessage, source?: PayloadEmitSource, ): void { - this.log('Send items in reply', this.component, items, message) + this.log('Send items in reply', this.componentOrFeature, items, message) const responseData: MessageReplyData = {} @@ -377,9 +399,7 @@ export class ComponentViewer implements ComponentViewerInterface { if (isDecryptedItem(item)) { params.content = this.contentForItem(item) - const globalComponentData = item.getDomainData(ComponentDataDomain) || {} - const thisComponentData = globalComponentData[this.component.getClientDataKey()] || {} - params.clientData = thisComponentData as Record + params.clientData = this.getClientData(item) } else { params.deleted = true } @@ -387,13 +407,19 @@ export class ComponentViewer implements ComponentViewerInterface { return this.responseItemsByRemovingPrivateProperties([params])[0] } + private getClientData(item: DecryptedItemInterface): Record { + const globalComponentData = item.getDomainData(ComponentDataDomain) || {} + const thisComponentData = globalComponentData[this.componentUniqueIdentifier] || {} + return thisComponentData as Record + } + contentForItem(item: DecryptedItemInterface): ItemContent | undefined { if (isNote(item)) { const content = item.content const spellcheck = item.spellcheck != undefined ? item.spellcheck - : this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true) + : this.services.preferences.getValue(PrefKey.EditorSpellcheck, true) return { ...content, @@ -421,21 +447,21 @@ export class ComponentViewer implements ComponentViewerInterface { const permissibleActionsWhileHidden = [ComponentAction.ComponentRegistered, ComponentAction.ActivateThemes] if (this.hidden && !permissibleActionsWhileHidden.includes(message.action)) { - this.log('Component disabled for current item, ignoring messages.', this.component.name) + this.log('Component disabled for current item, ignoring messages.', this.componentOrFeature.displayName) return } if (!this.window && message.action === ComponentAction.Reply) { - this.log('Component has been deallocated in between message send and reply', this.component, message) + this.log('Component has been deallocated in between message send and reply', this.componentOrFeature, message) return } - this.log('Send message to component', this.component, 'message: ', message) + this.log('Send message to component', this.componentOrFeature, 'message: ', message) - let origin = this.url + let origin = this.options.url if (!origin || !this.window) { if (essential) { - void this.alertService.alert( - `Standard Notes is trying to communicate with ${this.component.name}, ` + + void this.services.alerts.alert( + `Standard Notes is trying to communicate with ${this.componentOrFeature.displayName}, ` + 'but an error is occurring. Please restart this extension and try again.', ) } @@ -498,20 +524,22 @@ export class ComponentViewer implements ComponentViewerInterface { throw Error('Attempting to override component viewer window. Create a new component viewer instead.') } - this.log('setWindow', 'component: ', this.component, 'window: ', window) + this.log('setWindow', 'component: ', this.componentOrFeature, 'window: ', window) this.window = window this.sessionKey = UuidGenerator.GenerateUuid() + const componentData = this.config.componentManagerFunctions.getComponentPreferences(this.componentOrFeature) ?? {} + this.sendMessage({ action: ComponentAction.ComponentRegistered, sessionKey: this.sessionKey, - componentData: this.component.componentData, + componentData: componentData, data: { - uuid: this.component.uuid, - environment: environmentToString(this.environment), - platform: platformToString(this.platform), - activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(), + uuid: this.componentUniqueIdentifier, + environment: environmentToString(this.config.environment), + platform: platformToString(this.config.platform), + activeThemeUrls: this.config.componentManagerFunctions.urlsForActiveThemes(), }, }) @@ -521,7 +549,7 @@ export class ComponentViewer implements ComponentViewerInterface { } postActiveThemes(): void { - const urls = this.componentManagerFunctions.urlsForActiveThemes() + const urls = this.config.componentManagerFunctions.urlsForActiveThemes() const data: MessageData = { themes: urls, } @@ -547,24 +575,24 @@ export class ComponentViewer implements ComponentViewerInterface { } if (this.streamItems) { - this.handleStreamItemsMessage(this.streamItemsOriginalMessage!) + this.handleStreamItemsMessage(this.streamItemsOriginalMessage as ComponentMessage) } } } handleMessage(message: ComponentMessage): void { this.log('Handle message', message, this) - if (!this.component) { + if (!this.componentOrFeature) { this.log('Component not defined for message, returning', message) - void this.alertService.alert( + void this.services.alerts.alert( 'A component is trying to communicate with Standard Notes, ' + 'but there is an error establishing a bridge. Please restart the app and try again.', ) return } if (this.readonly && ReadwriteActions.includes(message.action)) { - void this.alertService.alert( - `${this.component.name} is trying to save, but it is in a locked state and cannot accept changes.`, + void this.services.alerts.alert( + `${this.componentOrFeature.displayName} is trying to save, but it is in a locked state and cannot accept changes.`, ) return } @@ -572,7 +600,7 @@ export class ComponentViewer implements ComponentViewerInterface { const messageHandlers: Partial void>> = { [ComponentAction.StreamItems]: this.handleStreamItemsMessage.bind(this), [ComponentAction.StreamContextItem]: this.handleStreamContextItemMessage.bind(this), - [ComponentAction.SetComponentData]: this.handleSetComponentDataMessage.bind(this), + [ComponentAction.SetComponentData]: this.handleSetComponentPreferencesMessage.bind(this), [ComponentAction.DeleteItems]: this.handleDeleteItemsMessage.bind(this), [ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this), [ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this), @@ -597,18 +625,22 @@ export class ComponentViewer implements ComponentViewerInterface { content_types: types, }, ] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { - if (!this.streamItems) { - this.streamItems = types - this.streamItemsOriginalMessage = message - } - /* Push immediately now */ - const items: DecryptedItemInterface[] = [] - for (const contentType of types) { - extendArray(items, this.itemManager.getItems(contentType)) - } - this.sendItemsInReply(items, message) - }) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredPermissions, + () => { + if (!this.streamItems) { + this.streamItems = types + this.streamItemsOriginalMessage = message + } + /* Push immediately now */ + const items: DecryptedItemInterface[] = [] + for (const contentType of types) { + extendArray(items, this.services.items.getItems(contentType)) + } + this.sendItemsInReply(items, message) + }, + ) } handleStreamContextItemMessage(message: ComponentMessage): void { @@ -618,15 +650,21 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { - if (!this.streamContextItemOriginalMessage) { - this.streamContextItemOriginalMessage = message - } - const matchingItem = this.overrideContextItem || this.itemManager.findItem(this.contextItemUuid!) - if (matchingItem) { - this.sendContextItemThroughBridge(matchingItem) - } - }) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredPermissions, + () => { + if (!this.streamContextItemOriginalMessage) { + this.streamContextItemOriginalMessage = message + } + const matchingItem = isComponentViewerItemReadonlyItem(this.options.item) + ? this.options.item.readonlyItem + : this.services.items.findItem(this.options.item.uuid) + if (matchingItem) { + this.sendContextItemThroughBridge(matchingItem) + } + }, + ) } /** @@ -640,8 +678,12 @@ export class ComponentViewer implements ComponentViewerInterface { /* Pending as in needed to be accounted for in permissions. */ const pendingResponseItems = responsePayloads.slice() + if (isComponentViewerItemReadonlyItem(this.options.item)) { + return + } + for (const responseItem of responsePayloads.slice()) { - if (responseItem.uuid === this.contextItemUuid) { + if (responseItem.uuid === this.options.item.uuid) { requiredPermissions.push({ name: ComponentAction.StreamContextItem, }) @@ -653,7 +695,7 @@ export class ComponentViewer implements ComponentViewerInterface { /* Check to see if additional privileges are required */ if (pendingResponseItems.length > 0) { - const requiredContentTypes = uniq( + const requiredContentTypes = uniqueArray( pendingResponseItems.map((item) => { return item.content_type }), @@ -665,8 +707,8 @@ export class ComponentViewer implements ComponentViewerInterface { } as ComponentPermission) } - this.componentManagerFunctions.runWithPermissions( - this.component.uuid, + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, requiredPermissions, async () => { @@ -674,7 +716,7 @@ export class ComponentViewer implements ComponentViewerInterface { /* Filter locked items */ const uuids = Uuids(responsePayloads) - const items = this.itemManager.findItemsIncludingBlanks(uuids) + const items = this.services.items.findItemsIncludingBlanks(uuids) let lockedCount = 0 let lockedNoteCount = 0 @@ -684,7 +726,9 @@ export class ComponentViewer implements ComponentViewerInterface { } if (item.locked) { - remove(responsePayloads, { uuid: item.uuid }) + responsePayloads = responsePayloads.filter((responseItem) => { + return responseItem.uuid !== item.uuid + }) lockedCount++ if (item.content_type === ContentType.TYPES.Note) { lockedNoteCount++ @@ -693,7 +737,7 @@ export class ComponentViewer implements ComponentViewerInterface { } if (lockedNoteCount === 1) { - void this.alertService.alert( + void this.services.alerts.alert( 'The note you are attempting to save has editing disabled', 'Note has Editing Disabled', ) @@ -701,7 +745,7 @@ export class ComponentViewer implements ComponentViewerInterface { } else if (lockedCount > 0) { const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items' const auxVerb = lockedCount === 1 ? 'has' : 'have' - void this.alertService.alert( + void this.services.alerts.alert( `${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`, 'Items have Editing Disabled', ) @@ -714,14 +758,14 @@ export class ComponentViewer implements ComponentViewerInterface { }) for (const contextualPayload of contextualPayloads) { - const item = this.itemManager.findItem(contextualPayload.uuid) + const item = this.services.items.findItem(contextualPayload.uuid) if (!item) { const payload = new DecryptedPayload({ ...PayloadTimestampDefaults(), ...contextualPayload, }) const template = CreateDecryptedItemFromPayload(payload) - await this.mutator.insertItem(template) + await this.services.mutator.insertItem(template) } else { if (contextualPayload.content_type !== item.content_type) { throw Error('Extension is trying to modify content type of item.') @@ -729,7 +773,7 @@ export class ComponentViewer implements ComponentViewerInterface { } } - await this.mutator.changeItems( + await this.services.mutator.changeItems( items.filter(isNotUndefined), (mutator) => { const contextualPayload = sureSearchArray(contextualPayloads, { @@ -743,17 +787,19 @@ export class ComponentViewer implements ComponentViewerInterface { }) if (responseItem.clientData) { - const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {}) - allComponentData[this.component.getClientDataKey()] = responseItem.clientData + const allComponentData = Copy>( + mutator.getItem().getDomainData(ComponentDataDomain) || {}, + ) + allComponentData[this.componentUniqueIdentifier] = responseItem.clientData mutator.setDomainData(allComponentData, ComponentDataDomain) } }, MutationType.UpdateUserTimestamps, PayloadEmitSource.ComponentRetrieved, - this.component.uuid, + this.componentUniqueIdentifier, ) - this.syncService + this.services.sync .sync({ onPresyncSave: () => { this.replyToMessage(message, {}) @@ -771,7 +817,7 @@ export class ComponentViewer implements ComponentViewerInterface { handleCreateItemsMessage(message: ComponentMessage): void { let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[] - const uniqueContentTypes = uniq( + const uniqueContentTypes = uniqueArray( responseItems.map((item) => { return item.content_type }), @@ -784,59 +830,65 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => { - responseItems = this.responseItemsByRemovingPrivateProperties(responseItems) - const processedItems = [] + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredPermissions, + async () => { + responseItems = this.responseItemsByRemovingPrivateProperties(responseItems) + const processedItems = [] - for (const responseItem of responseItems) { - if (!responseItem.uuid) { - responseItem.uuid = UuidGenerator.GenerateUuid() + for (const responseItem of responseItems) { + if (!responseItem.uuid) { + responseItem.uuid = UuidGenerator.GenerateUuid() + } + + const contextualPayload = createComponentCreatedContextPayload(responseItem) + const payload = new DecryptedPayload({ + ...PayloadTimestampDefaults(), + ...contextualPayload, + }) + + const template = CreateDecryptedItemFromPayload(payload) + const item = await this.services.mutator.insertItem(template) + + await this.services.mutator.changeItem( + item, + (mutator) => { + if (responseItem.clientData) { + const allComponentClientData = Copy>( + item.getDomainData(ComponentDataDomain) || {}, + ) + allComponentClientData[this.componentUniqueIdentifier] = responseItem.clientData + mutator.setDomainData(allComponentClientData, ComponentDataDomain) + } + }, + MutationType.UpdateUserTimestamps, + PayloadEmitSource.ComponentCreated, + this.componentUniqueIdentifier, + ) + processedItems.push(item) } - const contextualPayload = createComponentCreatedContextPayload(responseItem) - const payload = new DecryptedPayload({ - ...PayloadTimestampDefaults(), - ...contextualPayload, - }) + void this.services.sync.sync() - const template = CreateDecryptedItemFromPayload(payload) - const item = await this.mutator.insertItem(template) - - await this.mutator.changeItem( - item, - (mutator) => { - if (responseItem.clientData) { - const allComponentData = Copy(item.getDomainData(ComponentDataDomain) || {}) - allComponentData[this.component.getClientDataKey()] = responseItem.clientData - mutator.setDomainData(allComponentData, ComponentDataDomain) - } - }, - MutationType.UpdateUserTimestamps, - PayloadEmitSource.ComponentCreated, - this.component.uuid, - ) - processedItems.push(item) - } - - void this.syncService.sync() - - const reply = - message.action === ComponentAction.CreateItem - ? { item: this.jsonForItem(processedItems[0]) } - : { - items: processedItems.map((item) => { - return this.jsonForItem(item) - }), - } - this.replyToMessage(message, reply) - }) + const reply = + message.action === ComponentAction.CreateItem + ? { item: this.jsonForItem(processedItems[0]) } + : { + items: processedItems.map((item) => { + return this.jsonForItem(item) + }), + } + this.replyToMessage(message, reply) + }, + ) } handleDeleteItemsMessage(message: ComponentMessage): void { const data = message.data as DeleteItemsMessageData const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type)) - const requiredContentTypes = uniq(items.map((item) => item.content_type)).sort() + const requiredContentTypes = uniqueArray(items.map((item) => item.content_type)).sort() const requiredPermissions: ComponentPermission[] = [ { @@ -845,48 +897,60 @@ export class ComponentViewer implements ComponentViewerInterface { }, ] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => { - const itemsData = items - const noun = itemsData.length === 1 ? 'item' : 'items' - let reply = null - const didConfirm = await this.alertService.confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + requiredPermissions, + async () => { + const itemsData = items + const noun = itemsData.length === 1 ? 'item' : 'items' + let reply = null + const didConfirm = await this.services.alerts.confirm( + `Are you sure you want to delete ${itemsData.length} ${noun}?`, + ) - if (didConfirm) { - /* Filter for any components and deactivate before deleting */ - for (const itemData of itemsData) { - const item = this.itemManager.findItem(itemData.uuid) - if (!item) { - void this.alertService.alert('The item you are trying to delete cannot be found.') - continue + if (didConfirm) { + /* Filter for any components and deactivate before deleting */ + for (const itemData of itemsData) { + const item = this.services.items.findItem(itemData.uuid) + if (!item) { + void this.services.alerts.alert('The item you are trying to delete cannot be found.') + continue + } + await this.services.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved) } - await this.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved) + + void this.services.sync.sync() + + reply = { deleted: true } + } else { + /* Rejected by user */ + reply = { deleted: false } } - void this.syncService.sync() - - reply = { deleted: true } - } else { - /* Rejected by user */ - reply = { deleted: false } - } - - this.replyToMessage(message, reply) - }) + this.replyToMessage(message, reply) + }, + ) } - handleSetComponentDataMessage(message: ComponentMessage): void { + handleSetComponentPreferencesMessage(message: ComponentMessage): void { const noPermissionsRequired: ComponentPermission[] = [] - this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => { - await this.mutator.changeComponent(this.component, (mutator) => { - mutator.componentData = message.data.componentData || {} - }) + this.config.componentManagerFunctions.runWithPermissions( + this.componentUniqueIdentifier, + noPermissionsRequired, + async () => { + const newPreferences = message.data.componentData - void this.syncService.sync() - }) + if (!newPreferences) { + return + } + + await this.config.componentManagerFunctions.setComponentPreferences(this.componentOrFeature, newPreferences) + }, + ) } handleSetSizeEvent(message: ComponentMessage): void { - if (this.component.area !== ComponentArea.EditorStack) { + if (this.componentOrFeature.area !== ComponentArea.EditorStack) { return } diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts new file mode 100644 index 000000000..99dce34d0 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewerRequiresComponentManagerFunctions.ts @@ -0,0 +1,15 @@ +import { ComponentOrNativeFeature, ComponentPreferencesEntry } from '@standardnotes/models' +import { RunWithPermissionsCallback } from './Types' +import { IframeComponentFeatureDescription } from '@standardnotes/features' + +export interface ComponentViewerRequiresComponentManagerFunctions { + runWithPermissions: RunWithPermissionsCallback + urlsForActiveThemes: () => string[] + setComponentPreferences( + component: ComponentOrNativeFeature, + preferences: ComponentPreferencesEntry, + ): Promise + getComponentPreferences( + component: ComponentOrNativeFeature, + ): ComponentPreferencesEntry | undefined +} diff --git a/packages/snjs/lib/Services/ComponentManager/Types.ts b/packages/snjs/lib/Services/ComponentManager/Types.ts index e2f23cd60..35a8e7d80 100644 --- a/packages/snjs/lib/Services/ComponentManager/Types.ts +++ b/packages/snjs/lib/Services/ComponentManager/Types.ts @@ -1,8 +1,30 @@ -import { ComponentArea, ComponentAction, FeatureIdentifier, LegacyFileSafeIdentifier } from '@standardnotes/features' +import { + ComponentArea, + ComponentAction, + FeatureIdentifier, + LegacyFileSafeIdentifier, + ComponentPermission, +} from '@standardnotes/features' import { ComponentMessage, MessageData, OutgoingItemMessagePayload } from '@standardnotes/models' import { UuidString } from '@Lib/Types/UuidString' import { ContentType } from '@standardnotes/domain-core' +export type RunWithPermissionsCallback = ( + componentUuid: UuidString, + requiredPermissions: ComponentPermission[], + runFunction: () => void, +) => void + +export const ReadwriteActions = [ + ComponentAction.SaveItems, + ComponentAction.CreateItem, + ComponentAction.CreateItems, + ComponentAction.DeleteItems, + ComponentAction.SetComponentData, +] + +export type Writeable = { -readonly [P in keyof T]: T[P] } + /** * Extensions allowed to batch stream AllowedBatchContentTypes */ diff --git a/packages/snjs/lib/Services/ComponentManager/permissionsStringForPermissions.ts b/packages/snjs/lib/Services/ComponentManager/permissionsStringForPermissions.ts new file mode 100644 index 000000000..c36c50186 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/permissionsStringForPermissions.ts @@ -0,0 +1,57 @@ +import { ContentType } from '@standardnotes/domain-core' +import { ComponentAction, ComponentArea, ComponentPermission } from '@standardnotes/features' +import { ComponentInterface } from '@standardnotes/models' +import { uniqueArray } from '@standardnotes/utils' + +export function permissionsStringForPermissions( + permissions: ComponentPermission[], + component: ComponentInterface, +): string { + if (permissions.length === 0) { + return '.' + } + + let contentTypeStrings: string[] = [] + let contextAreaStrings: string[] = [] + + permissions.forEach((permission) => { + switch (permission.name) { + case ComponentAction.StreamItems: + if (!permission.content_types) { + return + } + permission.content_types.forEach((contentTypeString: string) => { + const contentTypeOrError = ContentType.create(contentTypeString) + if (contentTypeOrError.isFailed()) { + return + } + const contentType = contentTypeOrError.getValue() + const desc = contentType.getDisplayName() + if (desc) { + contentTypeStrings.push(`${desc}s`) + } else { + contentTypeStrings.push(`items of type ${contentType.value}`) + } + }) + break + case ComponentAction.StreamContextItem: + { + const componentAreaMapping = { + [ComponentArea.EditorStack]: 'working note', + [ComponentArea.Editor]: 'working note', + [ComponentArea.Themes]: 'Unknown', + } + contextAreaStrings.push(componentAreaMapping[component.area]) + } + break + } + }) + + contentTypeStrings = uniqueArray(contentTypeStrings) + contextAreaStrings = uniqueArray(contextAreaStrings) + + if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) { + return '.' + } + return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.' +} diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts index 7e644f54b..cf5c02543 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -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 - items = [] as jest.Mocked storageService = {} as jest.Mocked @@ -85,14 +74,6 @@ describe('featuresService', () => { apiService = {} as jest.Mocked 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 @@ -107,6 +88,10 @@ describe('featuresService', () => { mutator.changeItem = jest.fn() mutator.changeFeatureRepo = jest.fn() + subscriptions = {} as jest.Mocked + subscriptions.getOnlineSubscription = jest.fn() + subscriptions.addEventObserver = jest.fn() + webSocketsService = {} as jest.Mocked webSocketsService.addEventObserver = jest.fn() @@ -132,6 +117,7 @@ describe('featuresService', () => { internalEventBus = {} as jest.Mocked 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 - - 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 - - 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 - - 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) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index c5ee89de3..bf3357bce 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -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 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(StorageKey.UserRoles, undefined, []) + this.onlineRoles = this.storage.getValue(StorageKey.UserRoles, undefined, []) - this.offlineRoles = this.storageService.getValue(StorageKey.OfflineUserRoles, undefined, []) + this.offlineRoles = this.storage.getValue(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 { 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 { - 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([ - 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([ContentType.TYPES.Component, ContentType.TYPES.Theme]) + const component = this.items + .getItems([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 { 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 { - const result = await this.apiService.downloadOfflineFeaturesFromRepo(repo) + private async downloadOfflineRoles(repo: SNFeatureRepo): Promise { + 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 { - 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 { + const usecase = new MigrateFeatureRepoToUserSettingUseCase(this.mutator, this.settings) + await usecase.execute(featureRepos) + } + + public async migrateFeatureRepoToOfflineEntitlements(featureRepos: SNFeatureRepo[] = []): Promise { + 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 { - 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 { 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 { - 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 { + 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 { + 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 { - 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 = { - 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 { - const currentItems = this.itemManager.getItems([ - 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 { - 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 { + public async downloadRemoteThirdPartyFeature(urlOrCode: string): Promise { 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 { - 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) - - 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 { - return Promise.resolve({ - features: { - roles: this.onlineRoles, - features: this.features, - enabledExperimentalFeatures: this.enabledExperimentalFeatures, - needsInitialFeaturesUpdate: this.needsInitialFeaturesUpdate, - completedSuccessfulFeaturesRetrieval: this.completedSuccessfulFeaturesRetrieval, - }, - }) } } diff --git a/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts b/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts new file mode 100644 index 000000000..cfad78adb --- /dev/null +++ b/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts @@ -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 { + 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({ + 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( + rawFeature.content_type, + content, + ) + + return component + } +} diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts new file mode 100644 index 000000000..e51b389a9 --- /dev/null +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts @@ -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 + let usecase: GetFeatureStatusUseCase + + beforeEach(() => { + items = { + getDisplayableComponents: jest.fn(), + } as unknown as jest.Mocked + 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, + 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, + 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 + + 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 + + items.getDisplayableComponents.mockReturnValue([mockComponent]) + + expect( + usecase.execute({ + featureId: 'thirdPartyFeature', + hasPaidAnyPartyOnlineOrOfflineSubscription: false, + firstPartyOnlineSubscription: undefined, + firstPartyRoles: undefined, + }), + ).toEqual(FeatureStatus.InCurrentPlanButExpired) + }) + }) +}) diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts new file mode 100644 index 000000000..1c45bc4ae --- /dev/null +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts @@ -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) + } +} diff --git a/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts new file mode 100644 index 000000000..e18d4e014 --- /dev/null +++ b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts @@ -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 { + 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 + } +} diff --git a/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToUserSetting.ts b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToUserSetting.ts new file mode 100644 index 000000000..28b89f068 --- /dev/null +++ b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToUserSetting.ts @@ -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 { + 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 + }) + } + } + } +} diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index 3eb238cd7..7770d5f87 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -35,8 +35,8 @@ export class ItemManager extends Services.AbstractService implements Services.It > private tagDisplayController!: Models.ItemDisplayController private itemsKeyDisplayController!: Models.ItemDisplayController - private componentDisplayController!: Models.ItemDisplayController - private themeDisplayController!: Models.ItemDisplayController + private componentDisplayController!: Models.ItemDisplayController + private themeDisplayController!: Models.ItemDisplayController private fileDisplayController!: Models.ItemDisplayController private smartViewDisplayController!: Models.ItemDisplayController @@ -120,7 +120,7 @@ export class ItemManager extends Services.AbstractService implements Services.It return this.invalidItems.filter((item) => !item.key_system_identifier) } - public createItemFromPayload(payload: Models.DecryptedPayloadInterface): Models.DecryptedItemInterface { + public createItemFromPayload(payload: Models.DecryptedPayloadInterface): T { return Models.CreateDecryptedItemFromPayload(payload) } @@ -224,7 +224,7 @@ export class ItemManager extends Services.AbstractService implements Services.It return this.itemsKeyDisplayController.items() } - public getDisplayableComponents(): (Models.SNComponent | Models.SNTheme)[] { + public getDisplayableComponents(): (Models.ComponentInterface | Models.ThemeInterface)[] { return [...this.componentDisplayController.items(), ...this.themeDisplayController.items()] } @@ -275,10 +275,6 @@ export class ItemManager extends Services.AbstractService implements Services.It return this.collection.findAllDecrypted(uuids) as T[] } - /** - * If item is not found, an `undefined` element - * will be inserted into the array. - */ findItemsIncludingBlanks(uuids: UuidString[]): (T | undefined)[] { return this.collection.findAllDecryptedWithBlanks(uuids) as (T | undefined)[] } diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.ts b/packages/snjs/lib/Services/Mutator/MutatorService.ts index 1652f682e..4e37fc288 100644 --- a/packages/snjs/lib/Services/Mutator/MutatorService.ts +++ b/packages/snjs/lib/Services/Mutator/MutatorService.ts @@ -12,6 +12,7 @@ import { PayloadManager } from '../Payloads/PayloadManager' import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders' import { ActionsExtensionMutator, + ComponentInterface, ComponentMutator, CreateDecryptedMutatorForItem, DecryptedItemInterface, @@ -39,7 +40,6 @@ import { SmartViewContent, SmartViewDefaultIconName, SNActionsExtension, - SNComponent, SNFeatureRepo, SNNote, SNTag, @@ -205,19 +205,19 @@ export class MutatorService extends AbstractService implements MutatorClientInte } async changeComponent( - itemToLookupUuidFor: SNComponent, + itemToLookupUuidFor: ComponentInterface, mutate: (mutator: ComponentMutator) => void, mutationType: MutationType = MutationType.UpdateUserTimestamps, emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, - ): Promise { - const component = this.itemManager.findItem(itemToLookupUuidFor.uuid) + ): Promise { + const component = this.itemManager.findItem(itemToLookupUuidFor.uuid) if (!component) { throw Error('Attempting to change non-existant component') } const mutator = new ComponentMutator(component, mutationType) await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) } async changeFeatureRepo( diff --git a/packages/snjs/lib/Services/Preferences/PreferencesService.ts b/packages/snjs/lib/Services/Preferences/PreferencesService.ts index cac7e01fc..6f9c60805 100644 --- a/packages/snjs/lib/Services/Preferences/PreferencesService.ts +++ b/packages/snjs/lib/Services/Preferences/PreferencesService.ts @@ -24,19 +24,19 @@ export class SNPreferencesService private removeSyncObserver?: () => void constructor( - private singletonManager: SNSingletonManager, - itemManager: ItemManager, + private singletons: SNSingletonManager, + items: ItemManager, private mutator: MutatorClientInterface, - private syncService: SNSyncService, + private sync: SNSyncService, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) - this.removeItemObserver = itemManager.addObserver(ContentType.TYPES.UserPrefs, () => { + this.removeItemObserver = items.addObserver(ContentType.TYPES.UserPrefs, () => { this.shouldReload = true }) - this.removeSyncObserver = syncService.addEventObserver((event) => { + this.removeSyncObserver = sync.addEventObserver((event) => { if (event === SyncEvent.SyncCompletedWithAllItemsUploaded || event === SyncEvent.LocalDataIncrementalLoad) { void this.reload() } @@ -46,7 +46,7 @@ export class SNPreferencesService override deinit(): void { this.removeItemObserver?.() this.removeSyncObserver?.() - ;(this.singletonManager as unknown) = undefined + ;(this.singletons as unknown) = undefined ;(this.mutator as unknown) = undefined super.deinit() @@ -57,7 +57,7 @@ export class SNPreferencesService if (stage === ApplicationStage.LoadedDatabase_12) { /** Try to read preferences singleton from storage */ - this.preferences = this.singletonManager.findSingleton( + this.preferences = this.singletons.findSingleton( ContentType.TYPES.UserPrefs, SNUserPrefs.singletonPredicate, ) @@ -75,6 +75,14 @@ export class SNPreferencesService } async setValue(key: K, value: PrefValue[K]): Promise { + await this.setValueDetached(key, value) + + void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged) + + void this.sync.sync({ sourceDescription: 'PreferencesService.setValue' }) + } + + async setValueDetached(key: K, value: PrefValue[K]): Promise { if (!this.preferences) { return } @@ -82,10 +90,6 @@ export class SNPreferencesService this.preferences = (await this.mutator.changeItem(this.preferences, (m) => { m.setPref(key, value) })) as SNUserPrefs - - void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged) - - void this.syncService.sync({ sourceDescription: 'PreferencesService.setValue' }) } private async reload() { @@ -98,7 +102,7 @@ export class SNPreferencesService try { const previousRef = this.preferences - this.preferences = await this.singletonManager.findOrCreateContentTypeSingleton( + this.preferences = await this.singletons.findOrCreateContentTypeSingleton( ContentType.TYPES.UserPrefs, FillItemContent({}), ) diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index 50f2d077e..ac397124d 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -32,14 +32,12 @@ import { } from '@standardnotes/services' import { Base64String, PkcKeyPair } from '@standardnotes/sncrypto-common' import { - ClientDisplayableError, SessionBody, ErrorTag, HttpResponse, isErrorResponse, SessionListEntry, User, - AvailableSubscriptions, KeyParamsResponse, SignInResponse, ChangeCredentialsResponse, @@ -50,7 +48,6 @@ import { import { CopyPayloadWithContentOverride, RootKeyWithKeyPairsInterface } from '@standardnotes/models' import { LegacySession, MapperInterface, Result, Session, SessionToken } from '@standardnotes/domain-core' import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey } from '@standardnotes/encryption' -import { Subscription } from '@standardnotes/security' import * as Common from '@standardnotes/common' import { RawStorageValue } from './Sessions/Types' @@ -313,28 +310,6 @@ export class SNSessionManager }) } - public async getSubscription(): Promise { - const result = await this.apiService.getSubscription(this.getSureUser().uuid) - - if (isErrorResponse(result)) { - return ClientDisplayableError.FromNetworkError(result) - } - - const subscription = result.data.subscription - - return subscription - } - - public async getAvailableSubscriptions(): Promise { - const response = await this.apiService.getAvailableSubscriptions() - - if (isErrorResponse(response)) { - return ClientDisplayableError.FromNetworkError(response) - } - - return response.data - } - private async promptForU2FVerification(username: string): Promise | undefined> { const challenge = new Challenge( [ diff --git a/packages/snjs/lib/Services/Storage/DiskStorageService.ts b/packages/snjs/lib/Services/Storage/DiskStorageService.ts index bd0c53ef7..f2a8762e1 100644 --- a/packages/snjs/lib/Services/Storage/DiskStorageService.ts +++ b/packages/snjs/lib/Services/Storage/DiskStorageService.ts @@ -224,7 +224,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv * either as a plain object, or an encrypted item. */ private async generatePersistableValues() { - const rawContent = Copy(this.values) as Partial + const rawContent = >Copy(this.values) const valuesToWrap = rawContent[Services.ValueModesKeys.Unwrapped] rawContent[Services.ValueModesKeys.Unwrapped] = undefined diff --git a/packages/snjs/mocha/auth-fringe-cases.test.js b/packages/snjs/mocha/auth-fringe-cases.test.js index e15f0c929..582a1812f 100644 --- a/packages/snjs/mocha/auth-fringe-cases.test.js +++ b/packages/snjs/mocha/auth-fringe-cases.test.js @@ -27,7 +27,7 @@ describe('auth fringe cases', () => { localStorage.clear() }) - const clearApplicationLocalStorage = function () { + const clearApplicationLocalStorageOfNonItems = function () { const keys = Object.keys(localStorage) for (const key of keys) { if (!key.toLowerCase().includes('item')) { @@ -43,7 +43,7 @@ describe('auth fringe cases', () => { const context = await createContext() await context.application.register(context.email, context.password) const note = await Factory.createSyncedNote(context.application) - clearApplicationLocalStorage() + clearApplicationLocalStorageOfNonItems() console.warn("Expecting errors 'Unable to find operator for version undefined'") @@ -58,7 +58,7 @@ describe('auth fringe cases', () => { const context = await createContext() await context.application.register(context.email, context.password) const note = await Factory.createSyncedNote(context.application) - clearApplicationLocalStorage() + clearApplicationLocalStorageOfNonItems() const restartedApplication = await Factory.restartApplication(context.application) console.warn( diff --git a/packages/snjs/mocha/features.test.js b/packages/snjs/mocha/features.test.js index 4b242eba5..77616d701 100644 --- a/packages/snjs/mocha/features.test.js +++ b/packages/snjs/mocha/features.test.js @@ -7,42 +7,14 @@ describe('features', () => { let application let email let password - let midnightThemeFeature - let plusEditorFeature - let tagNestingFeature - let getUserFeatures beforeEach(async function () { application = await Factory.createInitAppWithFakeCrypto() - const now = new Date() - const tomorrow = now.setDate(now.getDate() + 1) - - midnightThemeFeature = { - ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.MidnightTheme), - expires_at: tomorrow, - } - plusEditorFeature = { - ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.PlusEditor), - expires_at: tomorrow, - } - tagNestingFeature = { - ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.TagNesting), - expires_at: tomorrow, - } - sinon.spy(application.mutator, 'createItem') sinon.spy(application.mutator, 'changeComponent') sinon.spy(application.mutator, 'setItemsToBeDeleted') - getUserFeatures = sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => { - return Promise.resolve({ - data: { - features: [midnightThemeFeature, plusEditorFeature, tagNestingFeature], - }, - }) - }) - email = UuidGenerator.GenerateUuid() password = UuidGenerator.GenerateUuid() @@ -63,129 +35,11 @@ describe('features', () => { expect(application.featuresService.onlineRoles).to.have.lengthOf(1) expect(application.featuresService.onlineRoles[0]).to.equal('CORE_USER') - expect(application.featuresService.features).to.have.lengthOf(3) - expect(application.featuresService.features[0]).to.containSubset(midnightThemeFeature) - expect(application.featuresService.features[1]).to.containSubset(plusEditorFeature) - const storedRoles = await application.getValue(StorageKey.UserRoles) expect(storedRoles).to.have.lengthOf(1) expect(storedRoles[0]).to.equal('CORE_USER') - - const storedFeatures = await application.getValue(StorageKey.UserFeatures) - - expect(storedFeatures).to.have.lengthOf(3) - expect(storedFeatures[0]).to.containSubset(midnightThemeFeature) - expect(storedFeatures[1]).to.containSubset(plusEditorFeature) - expect(storedFeatures[2]).to.containSubset(tagNestingFeature) }) - - it('should fetch user features and create items for features with content type', async () => { - expect(application.apiService.getUserFeatures.callCount).to.equal(1) - expect(application.mutator.createItem.callCount).to.equal(2) - - const themeItems = application.items.getItems(ContentType.TYPES.Theme) - const systemThemeCount = 1 - expect(themeItems).to.have.lengthOf(1 + systemThemeCount) - expect(themeItems[1].content).to.containSubset( - JSON.parse( - JSON.stringify({ - name: midnightThemeFeature.name, - package_info: midnightThemeFeature, - valid_until: new Date(midnightThemeFeature.expires_at), - }), - ), - ) - - const editorItems = application.items.getItems(ContentType.TYPES.Component) - expect(editorItems).to.have.lengthOf(1) - expect(editorItems[0].content).to.containSubset( - JSON.parse( - JSON.stringify({ - name: plusEditorFeature.name, - area: plusEditorFeature.area, - package_info: plusEditorFeature, - valid_until: new Date(midnightThemeFeature.expires_at), - }), - ), - ) - }) - - it('should update content for existing feature items', async () => { - // Wipe items from initial sync - await application.itemManager.removeAllItemsFromMemory() - // Wipe roles from initial sync - await application.featuresService.setOnlineRoles([]) - // Create pre-existing item for theme without all the info - await application.mutator.createItem( - ContentType.TYPES.Theme, - FillItemContent({ - package_info: { - identifier: FeatureIdentifier.MidnightTheme, - }, - }), - ) - // Call sync intentionally to get roles again in meta - await application.sync.sync() - // Timeout since we don't await for features update - await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(application.mutator.changeComponent.callCount).to.equal(1) - const themeItems = application.items.getItems(ContentType.TYPES.Theme) - expect(themeItems).to.have.lengthOf(1) - expect(themeItems[0].content).to.containSubset( - JSON.parse( - JSON.stringify({ - package_info: midnightThemeFeature, - valid_until: new Date(midnightThemeFeature.expires_at), - }), - ), - ) - }) - - it('should delete theme item if feature has expired', async () => { - const now = new Date() - const yesterday = now.setDate(now.getDate() - 1) - - getUserFeatures.restore() - sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => { - return Promise.resolve({ - data: { - features: [ - { - ...midnightThemeFeature, - expires_at: yesterday, - }, - ], - }, - }) - }) - - const themeItem = application.items - .getItems(ContentType.TYPES.Theme) - .find((theme) => theme.identifier === midnightThemeFeature.identifier) - - // Wipe roles from initial sync - await application.featuresService.setOnlineRoles([]) - - // Call sync intentionally to get roles again in meta - await application.sync.sync() - - // Timeout since we don't await for features update - await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(application.mutator.setItemsToBeDeleted.calledWith([sinon.match({ uuid: themeItem.uuid })])).to.equal( - true, - ) - - const noTheme = application.items - .getItems(ContentType.TYPES.Theme) - .find((theme) => theme.identifier === midnightThemeFeature.identifier) - expect(noTheme).to.not.be.ok - }) - }) - - it('should provide feature', async () => { - const feature = application.features.getFeatureThatOriginallyCameFromServer(FeatureIdentifier.PlusEditor) - expect(feature).to.containSubset(plusEditorFeature) }) describe('extension repo items observer', () => { @@ -194,7 +48,11 @@ describe('features', () => { return false }) - expect(await application.settings.getDoesSensitiveSettingExist(SettingName.create(SettingName.NAMES.ExtensionKey).getValue())).to.equal(false) + expect( + await application.settings.getDoesSensitiveSettingExist( + SettingName.create(SettingName.NAMES.ExtensionKey).getValue(), + ), + ).to.equal(false) const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') diff --git a/packages/snjs/mocha/lib/BaseItemCounts.js b/packages/snjs/mocha/lib/BaseItemCounts.js index 837ca8d6c..2fa6f9300 100644 --- a/packages/snjs/mocha/lib/BaseItemCounts.js +++ b/packages/snjs/mocha/lib/BaseItemCounts.js @@ -1,16 +1,16 @@ const ExpectedItemCountsWithVaultFeatureEnabled = { - Items: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, - ItemsWithAccount: ['ItemsKey', 'UserPreferences', 'DarkTheme', 'TrustedSelfContact'].length, - ItemsWithAccountWithoutItemsKey: ['UserPreferences', 'DarkTheme', 'TrustedSelfContact'].length, - ItemsNoAccounNoItemsKey: ['UserPreferences', 'DarkTheme'].length, + Items: ['ItemsKey', 'UserPreferences'].length, + ItemsWithAccount: ['ItemsKey', 'UserPreferences', 'TrustedSelfContact'].length, + ItemsWithAccountWithoutItemsKey: ['UserPreferences', 'TrustedSelfContact'].length, + ItemsNoAccounNoItemsKey: ['UserPreferences'].length, BackupFileRootKeyEncryptedItems: ['TrustedSelfContact'].length, } const ExpectedItemCountsWithVaultFeatureDisabled = { - Items: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, - ItemsWithAccount: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, - ItemsWithAccountWithoutItemsKey: ['UserPreferences', 'DarkTheme'].length, - ItemsNoAccounNoItemsKey: ['UserPreferences', 'DarkTheme'].length, + Items: ['ItemsKey', 'UserPreferences'].length, + ItemsWithAccount: ['ItemsKey', 'UserPreferences'].length, + ItemsWithAccountWithoutItemsKey: ['UserPreferences'].length, + ItemsNoAccounNoItemsKey: ['UserPreferences'].length, BackupFileRootKeyEncryptedItems: [].length, } diff --git a/packages/snjs/mocha/migrations/migration.test.js b/packages/snjs/mocha/migrations/migration.test.js index 70ab122ec..5afc60eb8 100644 --- a/packages/snjs/mocha/migrations/migration.test.js +++ b/packages/snjs/mocha/migrations/migration.test.js @@ -3,7 +3,7 @@ chai.use(chaiAsPromised) const expect = chai.expect describe('migrations', () => { - const allMigrations = ['2.0.15', '2.7.0', '2.20.0', '2.36.0', '2.42.0', '2.167.6', '2.168.6'] + const allMigrationsLength = MigrationClasses.length beforeEach(async () => { localStorage.clear() @@ -21,11 +21,11 @@ describe('migrations', () => { }) it('should return correct required migrations if stored version is 1.0.0', async function () { - expect((await SNMigrationService.getRequiredMigrations('1.0.0')).length).to.equal(allMigrations.length) + expect((await SNMigrationService.getRequiredMigrations('1.0.0')).length).to.equal(allMigrationsLength) }) it('should return correct required migrations if stored version is 2.0.0', async function () { - expect((await SNMigrationService.getRequiredMigrations('2.0.0')).length).to.equal(allMigrations.length) + expect((await SNMigrationService.getRequiredMigrations('2.0.0')).length).to.equal(allMigrationsLength) }) it('should return 0 required migrations if stored version is futuristic', async function () { @@ -110,16 +110,82 @@ describe('migrations', () => { await application.mutator.insertItem(noDistractionItem) await application.sync.sync() - const systemThemeCount = 1 - expect(application.items.getItems(ContentType.TYPES.Theme).length).to.equal(1 + systemThemeCount) + expect(application.items.getItems(ContentType.TYPES.Theme).length).to.equal(1) /** Run migration */ const migration = new Migration2_42_0(application.migrationService.services) await migration.handleStage(ApplicationStage.FullSyncCompleted_13) await application.sync.sync() - expect(application.items.getItems(ContentType.TYPES.Theme).length).to.equal(systemThemeCount) + expect(application.items.getItems(ContentType.TYPES.Theme).length).to.equal(0) await Factory.safeDeinit(application) }) + + describe('2.202.1', () => { + let application + + beforeEach(async () => { + application = await Factory.createAppWithRandNamespace() + + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + }) + + afterEach(async () => { + await Factory.safeDeinit(application) + }) + + it('remove components that are available as native features', async function () { + const editor = CreateDecryptedItemFromPayload( + new DecryptedPayload({ + uuid: '123', + content_type: ContentType.TYPES.Component, + content: FillItemContent({ + package_info: { + identifier: FeatureIdentifier.MarkdownProEditor, + }, + }), + }), + ) + await application.mutator.insertItem(editor) + await application.sync.sync() + + expect(application.items.getItems(ContentType.TYPES.Component).length).to.equal(1) + + /** Run migration */ + const migration = new Migration2_202_1(application.migrationService.services) + await migration.handleStage(ApplicationStage.FullSyncCompleted_13) + await application.sync.sync() + + expect(application.items.getItems(ContentType.TYPES.Component).length).to.equal(0) + }) + + it('do not remove components that are available as native features but deprecated', async function () { + const editor = CreateDecryptedItemFromPayload( + new DecryptedPayload({ + uuid: '123', + content_type: ContentType.TYPES.Component, + content: FillItemContent({ + package_info: { + identifier: FeatureIdentifier.DeprecatedBoldEditor, + }, + }), + }), + ) + await application.mutator.insertItem(editor) + await application.sync.sync() + + expect(application.items.getItems(ContentType.TYPES.Component).length).to.equal(1) + + /** Run migration */ + const migration = new Migration2_202_1(application.migrationService.services) + await migration.handleStage(ApplicationStage.FullSyncCompleted_13) + await application.sync.sync() + + expect(application.items.getItems(ContentType.TYPES.Component).length).to.equal(1) + }) + }) }) diff --git a/packages/snjs/mocha/model_tests/appmodels.test.js b/packages/snjs/mocha/model_tests/appmodels.test.js index 014cd2bc1..9171d1d18 100644 --- a/packages/snjs/mocha/model_tests/appmodels.test.js +++ b/packages/snjs/mocha/model_tests/appmodels.test.js @@ -360,7 +360,7 @@ describe('app models', () => { }) it('maintains editor reference when duplicating note', async function () { - const editor = await this.application.mutator.createItem( + const component = await this.application.mutator.createItem( ContentType.TYPES.Component, { area: ComponentArea.Editor, package_info: { identifier: 'foo-editor' } }, true, @@ -369,9 +369,9 @@ describe('app models', () => { editorIdentifier: 'foo-editor', }) - expect(this.application.componentManager.editorForNote(note).uuid).to.equal(editor.uuid) + expect(this.application.componentManager.editorForNote(note).uniqueIdentifier).to.equal(component.uuid) const duplicate = await this.application.mutator.duplicateItem(note, true) - expect(this.application.componentManager.editorForNote(duplicate).uuid).to.equal(editor.uuid) + expect(this.application.componentManager.editorForNote(duplicate).uniqueIdentifier).to.equal(component.uuid) }) }) diff --git a/packages/snjs/package.json b/packages/snjs/package.json index d7bd4819c..e8cf241df 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -54,7 +54,6 @@ "@types/jest": "^29.2.3", "@types/jsdom": "^20.0.1", "@types/libsodium-wrappers": "^0.7.10", - "@types/lodash": "^4.14.189", "@types/semver": "^7.3.13", "@typescript-eslint/eslint-plugin": "*", "babel-jest": "^29.3.1", @@ -70,7 +69,6 @@ "jest-environment-jsdom": "^29.3.1", "jsdom": "^20.0.2", "libsodium-wrappers": "^0.7.10", - "lodash": "^4.17.21", "nock": "^13.2.9", "otplib": "^12.0.1", "reflect-metadata": "^0.1.13", diff --git a/packages/ui-services/src/Theme/GetAllThemesUseCase.ts b/packages/ui-services/src/Theme/GetAllThemesUseCase.ts new file mode 100644 index 000000000..8d290a291 --- /dev/null +++ b/packages/ui-services/src/Theme/GetAllThemesUseCase.ts @@ -0,0 +1,29 @@ +import { FindNativeTheme, GetNativeThemes, ThemeFeatureDescription } from '@standardnotes/features' +import { ComponentOrNativeFeature, ThemeInterface } from '@standardnotes/models' +import { ItemManagerInterface } from '@standardnotes/services' + +export class GetAllThemesUseCase { + constructor(private readonly items: ItemManagerInterface) {} + + execute(options: { excludeLayerable: boolean }): { + thirdParty: ComponentOrNativeFeature[] + native: ComponentOrNativeFeature[] + } { + const nativeThemes = GetNativeThemes().filter((feature) => (options.excludeLayerable ? !feature.layerable : true)) + + const allThirdPartyThemes = this.items + .getDisplayableComponents() + .filter( + (component) => component.isTheme() && FindNativeTheme(component.identifier) === undefined, + ) as ThemeInterface[] + + const filteredThirdPartyThemes = allThirdPartyThemes.filter((theme) => { + return options.excludeLayerable ? !theme.layerable : true + }) + + return { + thirdParty: filteredThirdPartyThemes.map((theme) => new ComponentOrNativeFeature(theme)), + native: nativeThemes.map((theme) => new ComponentOrNativeFeature(theme)), + } + } +} diff --git a/packages/ui-services/src/Theme/ThemeManager.ts b/packages/ui-services/src/Theme/ThemeManager.ts index 5bc10ebea..ae3d9fc1a 100644 --- a/packages/ui-services/src/Theme/ThemeManager.ts +++ b/packages/ui-services/src/Theme/ThemeManager.ts @@ -1,42 +1,105 @@ import { dismissToast, ToastType, addTimedToast } from '@standardnotes/toast' -import { ContentType } from '@standardnotes/domain-core' import { + ComponentOrNativeFeature, CreateDecryptedLocalStorageContextPayload, LocalStorageDecryptedContextualPayload, - PayloadEmitSource, PrefKey, - SNTheme, + ThemeInterface, } from '@standardnotes/models' import { removeFromArray } from '@standardnotes/utils' -import { InternalEventBusInterface, ApplicationEvent, StorageValueModes, FeatureStatus } from '@standardnotes/services' -import { FeatureIdentifier } from '@standardnotes/features' +import { + InternalEventBusInterface, + ApplicationEvent, + StorageValueModes, + FeatureStatus, + PreferenceServiceInterface, + PreferencesServiceEvent, + ComponentManagerInterface, +} from '@standardnotes/services' +import { FeatureIdentifier, FindNativeTheme, ThemeFeatureDescription } from '@standardnotes/features' import { WebApplicationInterface } from '../WebApplication/WebApplicationInterface' import { AbstractUIServicee } from '../Abstract/AbstractUIService' +import { GetAllThemesUseCase } from './GetAllThemesUseCase' const CachedThemesKey = 'cachedThemes' const TimeBeforeApplyingColorScheme = 5 const DefaultThemeIdentifier = 'Default' export class ThemeManager extends AbstractUIServicee { - private activeThemes: string[] = [] - private unregisterDesktop?: () => void - private unregisterStream!: () => void + private themesActiveInTheUI: string[] = [] private lastUseDeviceThemeSettings = false - constructor(application: WebApplicationInterface, internalEventBus: InternalEventBusInterface) { + constructor( + application: WebApplicationInterface, + private preferences: PreferenceServiceInterface, + private components: ComponentManagerInterface, + internalEventBus: InternalEventBusInterface, + ) { super(application, internalEventBus) this.colorSchemeEventHandler = this.colorSchemeEventHandler.bind(this) } override async onAppStart() { - this.registerObservers() + const desktopService = this.application.getDesktopService() + if (desktopService) { + this.eventDisposers.push( + desktopService.registerUpdateObserver((component) => { + const uiFeature = new ComponentOrNativeFeature(component) + if (uiFeature.isThemeComponent) { + if (this.components.isThemeActive(uiFeature)) { + this.deactivateThemeInTheUI(uiFeature.uniqueIdentifier) + setTimeout(() => { + this.activateTheme(uiFeature) + this.cacheThemeState().catch(console.error) + }, 10) + } + } + }), + ) + } + + this.eventDisposers.push( + this.preferences.addEventObserver(async (event) => { + if (event !== PreferencesServiceEvent.PreferencesChanged) { + return + } + + let hasChange = false + + const activeThemes = this.components.getActiveThemesIdentifiers() + for (const uiActiveTheme of this.themesActiveInTheUI) { + if (!activeThemes.includes(uiActiveTheme)) { + this.deactivateThemeInTheUI(uiActiveTheme) + hasChange = true + } + } + + for (const activeTheme of activeThemes) { + if (!this.themesActiveInTheUI.includes(activeTheme)) { + const theme = + FindNativeTheme(activeTheme as FeatureIdentifier) ?? + this.application.items.findItem(activeTheme) + + if (theme) { + const uiFeature = new ComponentOrNativeFeature(theme) + this.activateTheme(uiFeature) + hasChange = true + } + } + } + + if (hasChange) { + this.cacheThemeState().catch(console.error) + } + }), + ) } override async onAppEvent(event: ApplicationEvent) { switch (event) { case ApplicationEvent.SignedOut: { this.deactivateAllThemes() - this.activeThemes = [] + this.themesActiveInTheUI = [] this.application?.removeValue(CachedThemesKey, StorageValueModes.Nonwrapped).catch(console.error) break } @@ -44,8 +107,8 @@ export class ThemeManager extends AbstractUIServicee { await this.activateCachedThemes() break } - case ApplicationEvent.FeaturesUpdated: { - this.handleFeaturesUpdated() + case ApplicationEvent.FeaturesAvailabilityChanged: { + this.handleFeaturesAvailabilityChanged() break } case ApplicationEvent.Launched: { @@ -96,12 +159,7 @@ export class ThemeManager extends AbstractUIServicee { } override deinit() { - this.activeThemes.length = 0 - - this.unregisterDesktop?.() - this.unregisterStream() - ;(this.unregisterDesktop as unknown) = undefined - ;(this.unregisterStream as unknown) = undefined + this.themesActiveInTheUI = [] const mq = window.matchMedia('(prefers-color-scheme: dark)') if (mq.removeEventListener != undefined) { @@ -113,42 +171,37 @@ export class ThemeManager extends AbstractUIServicee { super.deinit() } - private handleFeaturesUpdated(): void { + private handleFeaturesAvailabilityChanged(): void { let hasChange = false - for (const themeUuid of this.activeThemes) { - const theme = this.application.items.findItem(themeUuid) as SNTheme + for (const themeUuid of this.themesActiveInTheUI) { + const theme = this.application.items.findItem(themeUuid) if (!theme) { - this.deactivateTheme(themeUuid) + this.deactivateThemeInTheUI(themeUuid) hasChange = true + continue } const status = this.application.features.getFeatureStatus(theme.identifier) if (status !== FeatureStatus.Entitled) { - if (theme.active) { - this.application.componentManager.toggleTheme(theme.uuid).catch(console.error) - } else { - this.deactivateTheme(theme.uuid) - } + this.deactivateThemeInTheUI(theme.uuid) hasChange = true } } - const activeThemes = (this.application.items.getItems(ContentType.TYPES.Theme) as SNTheme[]).filter( - (theme) => theme.active, - ) + const activeThemes = this.components.getActiveThemes() for (const theme of activeThemes) { - if (!this.activeThemes.includes(theme.uuid)) { + if (!this.themesActiveInTheUI.includes(theme.uniqueIdentifier)) { this.activateTheme(theme) hasChange = true } } if (hasChange) { - this.cacheThemeState().catch(console.error) + void this.cacheThemeState() } } @@ -190,21 +243,23 @@ export class ThemeManager extends AbstractUIServicee { private setThemeAsPerColorScheme(prefersDarkColorScheme: boolean) { const preference = prefersDarkColorScheme ? PrefKey.AutoDarkThemeIdentifier : PrefKey.AutoLightThemeIdentifier + const preferenceDefault = preference === PrefKey.AutoDarkThemeIdentifier ? FeatureIdentifier.DarkTheme : DefaultThemeIdentifier - const themes = this.application.items - .getDisplayableComponents() - .filter((component) => component.isTheme()) as SNTheme[] + const usecase = new GetAllThemesUseCase(this.application.items) + const { thirdParty, native } = usecase.execute({ excludeLayerable: false }) + const themes = [...thirdParty, ...native] - const activeTheme = themes.find((theme) => theme.active && !theme.isLayerable()) - const activeThemeIdentifier = activeTheme ? activeTheme.identifier : DefaultThemeIdentifier + const activeTheme = themes.find((theme) => this.components.isThemeActive(theme) && !theme.layerable) - const themeIdentifier = this.application.getPreference(preference, preferenceDefault) as string + const activeThemeIdentifier = activeTheme ? activeTheme.featureIdentifier : DefaultThemeIdentifier + + const themeIdentifier = this.preferences.getValue(preference, preferenceDefault) const toggleActiveTheme = () => { if (activeTheme) { - void this.application.componentManager.toggleTheme(activeTheme.uuid) + void this.components.toggleTheme(activeTheme) } } @@ -212,9 +267,9 @@ export class ThemeManager extends AbstractUIServicee { if (themeIdentifier === DefaultThemeIdentifier) { toggleActiveTheme() } else { - const theme = themes.find((theme) => theme.package_info.identifier === themeIdentifier) - if (theme && !theme.active) { - this.application.componentManager.toggleTheme(theme.uuid).catch(console.error) + const theme = themes.find((theme) => theme.featureIdentifier === themeIdentifier) + if (theme && !this.components.isThemeActive(theme)) { + this.components.toggleTheme(theme).catch(console.error) } } } @@ -227,56 +282,28 @@ export class ThemeManager extends AbstractUIServicee { } private async activateCachedThemes() { - const cachedThemes = await this.getCachedThemes() + const cachedThemes = this.getCachedThemes() for (const theme of cachedThemes) { this.activateTheme(theme, true) } } - private registerObservers() { - this.unregisterDesktop = this.application.getDesktopService()?.registerUpdateObserver((component) => { - if (component.active && component.isTheme()) { - this.deactivateTheme(component.uuid) - setTimeout(() => { - this.activateTheme(component as SNTheme) - this.cacheThemeState().catch(console.error) - }, 10) - } - }) - - this.unregisterStream = this.application.streamItems(ContentType.TYPES.Theme, ({ changed, inserted, source }) => { - const items = changed.concat(inserted) - const themes = items as SNTheme[] - for (const theme of themes) { - if (theme.active) { - this.activateTheme(theme) - } else { - this.deactivateTheme(theme.uuid) - } - } - - if (source !== PayloadEmitSource.LocalRetrieved) { - this.cacheThemeState().catch(console.error) - } - }) - } - private deactivateAllThemes() { - const activeThemes = this.activeThemes.slice() + const activeThemes = this.themesActiveInTheUI.slice() for (const uuid of activeThemes) { - this.deactivateTheme(uuid) + this.deactivateThemeInTheUI(uuid) } } - private activateTheme(theme: SNTheme, skipEntitlementCheck = false) { - if (this.activeThemes.find((uuid) => uuid === theme.uuid)) { + private activateTheme(theme: ComponentOrNativeFeature, skipEntitlementCheck = false) { + if (this.themesActiveInTheUI.find((uuid) => uuid === theme.uniqueIdentifier)) { return } if ( !skipEntitlementCheck && - this.application.features.getFeatureStatus(theme.identifier) !== FeatureStatus.Entitled + this.application.features.getFeatureStatus(theme.featureIdentifier) !== FeatureStatus.Entitled ) { return } @@ -286,28 +313,47 @@ export class ThemeManager extends AbstractUIServicee { return } - this.activeThemes.push(theme.uuid) + this.themesActiveInTheUI.push(theme.uniqueIdentifier) const link = document.createElement('link') link.href = url link.type = 'text/css' link.rel = 'stylesheet' link.media = 'screen,print' - link.id = theme.uuid + link.id = theme.uniqueIdentifier link.onload = () => { this.syncThemeColorMetadata() if (this.application.isNativeMobileWeb()) { + const packageInfo = theme.featureDescription setTimeout(() => { this.application .mobileDevice() - .handleThemeSchemeChange(theme.package_info.isDark ?? false, this.getBackgroundColor()) + .handleThemeSchemeChange(packageInfo.isDark ?? false, this.getBackgroundColor()) }) } } document.getElementsByTagName('head')[0].appendChild(link) } + private deactivateThemeInTheUI(uuid: string) { + if (!this.themesActiveInTheUI.includes(uuid)) { + return + } + + const element = document.getElementById(uuid) as HTMLLinkElement + if (element) { + element.disabled = true + element.parentNode?.removeChild(element) + } + + removeFromArray(this.themesActiveInTheUI, uuid) + + if (this.themesActiveInTheUI.length === 0 && this.application.isNativeMobileWeb()) { + this.application.mobileDevice().handleThemeSchemeChange(false, '#ffffff') + } + } + private getBackgroundColor() { const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--sn-stylekit-background-color').trim() return bgColor.length ? bgColor : '#ffffff' @@ -326,26 +372,8 @@ export class ThemeManager extends AbstractUIServicee { themeColorMetaElement.setAttribute('content', this.getBackgroundColor()) } - private deactivateTheme(uuid: string) { - if (!this.activeThemes.includes(uuid)) { - return - } - - const element = document.getElementById(uuid) as HTMLLinkElement - if (element) { - element.disabled = true - element.parentNode?.removeChild(element) - } - - removeFromArray(this.activeThemes, uuid) - - if (this.activeThemes.length === 0 && this.application.isNativeMobileWeb()) { - this.application.mobileDevice().handleThemeSchemeChange(false, '#ffffff') - } - } - private async cacheThemeState() { - const themes = this.application.items.findItems(this.activeThemes) as SNTheme[] + const themes = this.application.items.findItems(this.themesActiveInTheUI) const mapped = themes.map((theme) => { const payload = theme.payloadRepresentation() @@ -355,20 +383,20 @@ export class ThemeManager extends AbstractUIServicee { return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped) } - private async getCachedThemes() { - const cachedThemes = (await this.application.getValue( + private getCachedThemes(): ComponentOrNativeFeature[] { + const cachedThemes = this.application.getValue( CachedThemesKey, StorageValueModes.Nonwrapped, - )) as LocalStorageDecryptedContextualPayload[] + ) if (cachedThemes) { - const themes = [] + const themes: ThemeInterface[] = [] for (const cachedTheme of cachedThemes) { const payload = this.application.items.createPayloadFromObject(cachedTheme) - const theme = this.application.items.createItemFromPayload(payload) as SNTheme + const theme = this.application.items.createItemFromPayload(payload) themes.push(theme) } - return themes + return themes.map((theme) => new ComponentOrNativeFeature(theme)) } else { return [] } diff --git a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts index dfb128f14..0f2602839 100644 --- a/packages/ui-services/src/WebApplication/WebApplicationInterface.ts +++ b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts @@ -4,6 +4,7 @@ import { MobileDeviceInterface, WebAppEvent, } from '@standardnotes/services' +import { KeyboardService } from '../Keyboard/KeyboardService' export interface WebApplicationInterface extends ApplicationInterface { notifyWebEvent(event: WebAppEvent, data?: unknown): void @@ -23,4 +24,5 @@ export interface WebApplicationInterface extends ApplicationInterface { addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined setAndroidBackHandlerFallbackListener(listener: () => boolean): void generateUUID(): string + get keyboardService(): KeyboardService } diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index b64941b7d..74cfb2bb7 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -28,7 +28,10 @@ export * from './Route/RouteServiceInterface' export * from './Route/RouteServiceEvent' export * from './Security/AutolockService' export * from './Storage/LocalStorage' + export * from './Theme/ThemeManager' +export * from './Theme/GetAllThemesUseCase' + export * from './Toast/ToastService' export * from './Toast/ToastServiceInterface' export * from './StatePersistence/StatePersistence' diff --git a/packages/utils/src/Domain/Utils/Utils.ts b/packages/utils/src/Domain/Utils/Utils.ts index eff23e62d..b2e9930a0 100644 --- a/packages/utils/src/Domain/Utils/Utils.ts +++ b/packages/utils/src/Domain/Utils/Utils.ts @@ -276,7 +276,7 @@ export function objectToValueArray(object: AnyRecord) { * Returns a key-sorted copy of the object. * For example, sortedCopy({b: '1', a: '2'}) returns {a: '2', b: '1'} */ -export function sortedCopy(object: any) { +export function sortedCopy(object: any): T { const keys = Object.keys(object).sort() const result: any = {} for (const key of keys) { @@ -429,9 +429,9 @@ export function joinPaths(...args: string[]) { * the string (if the input is an object). If input is date, a Date copy will be created, * and if input is a primitive value, it will be returned as-is. */ -export function Copy(object: any) { +export function Copy(object: any): T { if (object instanceof Date) { - return new Date(object) + return new Date(object) as T } else if (isObject(object)) { return JSON.parse(JSON.stringify(object)) } else { @@ -463,7 +463,7 @@ export function deepMerge(a: AnyRecord, b: AnyRecord) { /** * Returns a new object by selecting certain keys from input object. */ -export function pickByCopy(object: T, keys: Array) { +export function pickByCopy(object: T, keys: Array): T { const result = {} as T for (const key of keys) { result[key] = object[key] diff --git a/packages/web/src/components/assets/org.standardnotes.theme-dynamic/index.css b/packages/web/src/components/assets/org.standardnotes.theme-dynamic/index.css index fb4c9e02a..a13043078 100644 --- a/packages/web/src/components/assets/org.standardnotes.theme-dynamic/index.css +++ b/packages/web/src/components/assets/org.standardnotes.theme-dynamic/index.css @@ -5,7 +5,7 @@ #navigation { flex: none !important; - width: 10px !important; + width: 49px !important; transition: width 0.25s; } diff --git a/packages/web/src/javascripts/Application/DevMode.ts b/packages/web/src/javascripts/Application/DevMode.ts index f7b3a1370..180482589 100644 --- a/packages/web/src/javascripts/Application/DevMode.ts +++ b/packages/web/src/javascripts/Application/DevMode.ts @@ -9,7 +9,7 @@ export class DevMode { /** Valid only when running a mock event publisher on port 3124 */ async purchaseMockSubscription() { - const subscriptionId = 2000 + const subscriptionId = 2002 const email = this.application.getUser()?.email const response = await fetch('http://localhost:3124/events', { method: 'POST', diff --git a/packages/web/src/javascripts/Application/Device/DesktopManager.ts b/packages/web/src/javascripts/Application/Device/DesktopManager.ts index 1fd92848f..4fe1a151e 100644 --- a/packages/web/src/javascripts/Application/Device/DesktopManager.ts +++ b/packages/web/src/javascripts/Application/Device/DesktopManager.ts @@ -16,6 +16,7 @@ import { WebAppEvent, BackupServiceInterface, DesktopWatchedDirectoriesChanges, + ComponentInterface, } from '@standardnotes/snjs' import { WebApplicationInterface } from '@standardnotes/ui-services' @@ -122,11 +123,11 @@ export class DesktopManager * Sending a component in its raw state is really slow for the desktop app * Keys are not passed into ItemParams, so the result is not encrypted */ - convertComponentForTransmission(component: SNComponent) { + convertComponentForTransmission(component: ComponentInterface) { return component.payloadRepresentation().ejected() } - syncComponentsInstallation(components: SNComponent[]) { + syncComponentsInstallation(components: ComponentInterface[]) { Promise.all( components.map((component) => { return this.convertComponentForTransmission(component) @@ -138,7 +139,7 @@ export class DesktopManager .catch(console.error) } - registerUpdateObserver(callback: (component: SNComponent) => void): () => void { + registerUpdateObserver(callback: (component: ComponentInterface) => void): () => void { const observer = { callback: callback, } diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index fce0ae26d..c6f4e20a8 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -24,6 +24,7 @@ import { BackupServiceInterface, InternalFeatureService, InternalFeatureServiceInterface, + PrefDefaults, NoteContent, SNNote, } from '@standardnotes/snjs' @@ -48,7 +49,6 @@ import { } from '@standardnotes/ui-services' import { MobileWebReceiver, NativeMobileEventListener } from '../NativeMobileWeb/MobileWebReceiver' import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler' -import { PrefDefaults } from '@/Constants/PrefDefaults' import { setCustomViewportHeight } from '@/setViewportHeightWithFallback' import { WebServices } from './WebServices' import { FeatureName } from '@/Controllers/FeatureName' @@ -121,7 +121,12 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.webServices = {} as WebServices this.webServices.keyboardService = new KeyboardService(platform, this.environment) this.webServices.archiveService = new ArchiveManager(this) - this.webServices.themeService = new ThemeManager(this, this.internalEventBus) + this.webServices.themeService = new ThemeManager( + this, + this.preferences, + this.componentManager, + this.internalEventBus, + ) this.webServices.autolockService = this.isNativeMobileWeb() ? undefined : new AutolockService(this, this.internalEventBus) @@ -232,7 +237,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.webServices.vaultDisplayService } - public getViewControllerManager(): ViewControllerManager { + public get controllers(): ViewControllerManager { return this.webServices.viewControllerManager } @@ -265,7 +270,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter } public get featuresController() { - return this.getViewControllerManager().featuresController + return this.controllers.featuresController } public get desktopDevice(): DesktopDeviceInterface | undefined { @@ -388,14 +393,14 @@ export class WebApplication extends SNApplication implements WebApplicationInter } handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void { - const filesController = this.getViewControllerManager().filesController + const filesController = this.controllers.filesController const blob = getBlobFromBase64(file.data, file.mimeType) const mappedFile = new File([blob], file.name, { type: file.mimeType }) void filesController.uploadNewFile(mappedFile, true) } async handleReceivedTextEvent({ text, title }: { text: string; title?: string | undefined }) { - const titleForNote = title || this.getViewControllerManager().itemListController.titleForNewNote() + const titleForNote = title || this.controllers.itemListController.titleForNewNote() const note = this.items.createTemplateItem(ContentType.TYPES.Note, { title: titleForNote, @@ -405,7 +410,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter const insertedNote = await this.mutator.insertItem(note) - this.getViewControllerManager().selectionController.selectItem(insertedNote.uuid, true).catch(console.error) + this.controllers.selectionController.selectItem(insertedNote.uuid, true).catch(console.error) } private async lockApplicationAfterMobileEventIfApplicable(): Promise { @@ -457,23 +462,23 @@ export class WebApplication extends SNApplication implements WebApplicationInter } entitledToPerTagPreferences(): boolean { - return this.hasValidSubscription() + return this.hasValidFirstPartySubscription() } get entitledToFiles(): boolean { - return this.getViewControllerManager().featuresController.entitledToFiles + return this.controllers.featuresController.entitledToFiles } showPremiumModal(featureName?: FeatureName): void { - void this.getViewControllerManager().featuresController.showPremiumAlert(featureName) + void this.controllers.featuresController.showPremiumAlert(featureName) } - hasValidSubscription(): boolean { - return this.getViewControllerManager().subscriptionController.hasValidSubscription() + hasValidFirstPartySubscription(): boolean { + return this.controllers.subscriptionController.hasFirstPartyOnlineOrOfflineSubscription } async openPurchaseFlow() { - await this.getViewControllerManager().purchaseFlowController.openPurchaseFlow() + await this.controllers.purchaseFlowController.openPurchaseFlow() } addNativeMobileEventListener = (listener: NativeMobileEventListener) => { @@ -485,11 +490,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter } showAccountMenu(): void { - this.getViewControllerManager().accountMenuController.setShow(true) + this.controllers.accountMenuController.setShow(true) } hideAccountMenu(): void { - this.getViewControllerManager().accountMenuController.setShow(false) + this.controllers.accountMenuController.setShow(false) } /** @@ -510,9 +515,9 @@ export class WebApplication extends SNApplication implements WebApplicationInter } openPreferences(pane?: PreferenceId): void { - this.getViewControllerManager().preferencesController.openPreferences() + this.controllers.preferencesController.openPreferences() if (pane) { - this.getViewControllerManager().preferencesController.setCurrentPane(pane) + this.controllers.preferencesController.setCurrentPane(pane) } } diff --git a/packages/web/src/javascripts/Components/Abstract/PureComponent.tsx b/packages/web/src/javascripts/Components/Abstract/PureComponent.tsx index 9097e855a..2a8d19751 100644 --- a/packages/web/src/javascripts/Components/Abstract/PureComponent.tsx +++ b/packages/web/src/javascripts/Components/Abstract/PureComponent.tsx @@ -38,7 +38,7 @@ export abstract class AbstractComponent

void): void { diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index bc9c0ec07..3a55ce4af 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -47,7 +47,7 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio const currentWriteErrorDialog = useRef | null>(null) const currentLoadErrorDialog = useRef | null>(null) - const viewControllerManager = application.getViewControllerManager() + const viewControllerManager = application.controllers useEffect(() => { const desktopService = application.getDesktopService() @@ -198,7 +198,7 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio - + = ({ application, mainApplicatio - + = ({ application, mainApplicatio application={application} historyModalController={viewControllerManager.historyModalController} selectionController={viewControllerManager.selectionController} - subscriptionController={viewControllerManager.subscriptionController} /> {renderChallenges()} diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index 297489a17..fa1d1d9b3 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -8,7 +8,7 @@ import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintFor import { CHANGE_EDITOR_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services' import { useApplication } from '../ApplicationProvider' import { NoteViewController } from '../NoteView/Controller/NoteViewController' -import { noteTypeForEditorIdentifier } from '@standardnotes/snjs' +import { NoteType, noteTypeForEditorIdentifier } from '@standardnotes/snjs' type Props = { viewControllerManager: ViewControllerManager @@ -30,6 +30,7 @@ const ChangeEditorButton: FunctionComponent = ({ const [selectedEditor, setSelectedEditor] = useState(() => { return note ? application.componentManager.editorForNote(note) : undefined }) + const noteType = noteViewController?.isTemplateNote ? noteTypeForEditorIdentifier( application.geDefaultEditorIdentifier( @@ -38,9 +39,12 @@ const ChangeEditorButton: FunctionComponent = ({ : undefined, ), ) - : note + : note && note.noteType != NoteType.Unknown ? note.noteType - : selectedEditor?.package_info.note_type + : selectedEditor + ? selectedEditor.noteType + : NoteType.Unknown + const [selectedEditorIcon, selectedEditorIconTint] = getIconAndTintForNoteType(noteType, true) const [isClickOutsideDisabled, setIsClickOutsideDisabled] = useState(false) diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 765face2f..a86b58fa0 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -3,7 +3,16 @@ import Menu from '@/Components/Menu/Menu' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { WebApplication } from '@/Application/WebApplication' -import { ComponentArea, NoteMutator, NoteType, PrefKey, SNComponent, SNNote } from '@standardnotes/snjs' +import { + ComponentOrNativeFeature, + EditorFeatureDescription, + FeatureIdentifier, + IframeComponentFeatureDescription, + NoteMutator, + NoteType, + PrefKey, + SNNote, +} from '@standardnotes/snjs' import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' @@ -21,7 +30,7 @@ type ChangeEditorMenuProps = { closeMenu: () => void isVisible: boolean note: SNNote | undefined - onSelect?: (component: SNComponent | undefined) => void + onSelect?: (component: ComponentOrNativeFeature) => void setDisableClickOutside?: (value: boolean) => void } @@ -35,25 +44,23 @@ const ChangeEditorMenu: FunctionComponent = ({ onSelect, setDisableClickOutside, }) => { - const editors = useMemo( - () => - application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { - return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1 - }), - [application.componentManager], - ) - const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors]) - const [currentComponent, setCurrentComponent] = useState() + const groups = useMemo(() => createEditorMenuGroups(application), [application]) + const [currentFeature, setCurrentFeature] = + useState>() const [pendingConversionItem, setPendingConversionItem] = useState(null) const showSuperNoteImporter = - !!pendingConversionItem && note?.noteType !== NoteType.Super && pendingConversionItem.noteType === NoteType.Super + !!pendingConversionItem && + note?.noteType !== NoteType.Super && + pendingConversionItem.uiFeature.noteType === NoteType.Super const showSuperNoteConverter = - !!pendingConversionItem && note?.noteType === NoteType.Super && pendingConversionItem.noteType !== NoteType.Super + !!pendingConversionItem && + note?.noteType === NoteType.Super && + pendingConversionItem.uiFeature.noteType !== NoteType.Super useEffect(() => { if (note) { - setCurrentComponent(application.componentManager.editorForNote(note)) + setCurrentFeature(application.componentManager.editorForNote(note)) } }, [application, note]) @@ -61,61 +68,52 @@ const ChangeEditorMenu: FunctionComponent = ({ const isSelected = useCallback( (item: EditorMenuItem) => { - if (currentComponent) { - return item.component?.identifier === currentComponent.identifier + if (currentFeature) { + return item.uiFeature.featureIdentifier === currentFeature.featureIdentifier } - const itemNoteTypeIsSameAsCurrentNoteType = item.noteType === note?.noteType - const noteDoesntHaveTypeAndItemIsPlain = !note?.noteType && item.noteType === NoteType.Plain - const unknownNoteTypeAndItemIsPlain = note?.noteType === NoteType.Unknown && item.noteType === NoteType.Plain + const itemNoteTypeIsSameAsCurrentNoteType = item.uiFeature.noteType === note?.noteType + const noteDoesntHaveTypeAndItemIsPlain = !note?.noteType && item.uiFeature.noteType === NoteType.Plain + const unknownNoteTypeAndItemIsPlain = + note?.noteType === NoteType.Unknown && item.uiFeature.noteType === NoteType.Plain return itemNoteTypeIsSameAsCurrentNoteType || noteDoesntHaveTypeAndItemIsPlain || unknownNoteTypeAndItemIsPlain }, - [currentComponent, note], + [currentFeature, note], ) const selectComponent = useCallback( - async (component: SNComponent, note: SNNote) => { - if (component.conflictOf) { - void application.changeAndSaveItem(component, (mutator) => { + async ( + uiFeature: ComponentOrNativeFeature, + note: SNNote, + ) => { + if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { + void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { mutator.conflictOf = undefined }) } - await application.getViewControllerManager().itemListController.insertCurrentIfTemplate() + await application.controllers.itemListController.insertCurrentIfTemplate() await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator - noteMutator.noteType = component.noteType - noteMutator.editorIdentifier = component.identifier + noteMutator.noteType = uiFeature.noteType + noteMutator.editorIdentifier = uiFeature.featureIdentifier }) - setCurrentComponent(application.componentManager.editorForNote(note)) + setCurrentFeature(application.componentManager.editorForNote(note)) + + if (uiFeature.featureIdentifier === FeatureIdentifier.PlainEditor) { + reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) + } }, [application], ) - const selectNonComponent = useCallback( - async (item: EditorMenuItem, note: SNNote) => { - await application.getViewControllerManager().itemListController.insertCurrentIfTemplate() - - reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) - - await application.changeAndSaveItem(note, (mutator) => { - const noteMutator = mutator as NoteMutator - noteMutator.noteType = item.noteType - noteMutator.editorIdentifier = undefined - }) - - setCurrentComponent(undefined) - }, - [application], - ) - - const selectItem = useCallback( - async (itemToBeSelected: EditorMenuItem) => { - if (!itemToBeSelected.isEntitled) { - premiumModal.activate(itemToBeSelected.name) + const handleMenuSelection = useCallback( + async (menuItem: EditorMenuItem) => { + if (!menuItem.isEntitled) { + premiumModal.activate(menuItem.uiFeature.displayName) return } @@ -128,28 +126,28 @@ const ChangeEditorMenu: FunctionComponent = ({ return } - if (itemToBeSelected.noteType === NoteType.Super) { + if (menuItem.uiFeature.noteType === NoteType.Super) { if (note.noteType === NoteType.Super) { return } - setPendingConversionItem(itemToBeSelected) + setPendingConversionItem(menuItem) setDisableClickOutside?.(true) return } if (note.noteType === NoteType.Super && note.text.length > 0) { - setPendingConversionItem(itemToBeSelected) + setPendingConversionItem(menuItem) setDisableClickOutside?.(true) return } let shouldMakeSelection = true - if (itemToBeSelected.component) { + if (menuItem.uiFeature) { const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert( - currentComponent, - itemToBeSelected.component, + currentFeature, + menuItem.uiFeature, ) if (changeRequiresAlert) { @@ -158,17 +156,13 @@ const ChangeEditorMenu: FunctionComponent = ({ } if (shouldMakeSelection) { - if (itemToBeSelected.component) { - selectComponent(itemToBeSelected.component, note).catch(console.error) - } else { - selectNonComponent(itemToBeSelected, note).catch(console.error) - } + selectComponent(menuItem.uiFeature, note).catch(console.error) } closeMenu() if (onSelect) { - onSelect(itemToBeSelected.component) + onSelect(menuItem.uiFeature) } }, [ @@ -179,9 +173,8 @@ const ChangeEditorMenu: FunctionComponent = ({ application.alertService, application.componentManager, setDisableClickOutside, - currentComponent, + currentFeature, selectComponent, - selectNonComponent, ], ) @@ -190,15 +183,9 @@ const ChangeEditorMenu: FunctionComponent = ({ return } - if (pendingConversionItem.component) { - selectComponent(pendingConversionItem.component, note).catch(console.error) - closeMenu() - return - } - - selectNonComponent(pendingConversionItem, note).catch(console.error) + selectComponent(pendingConversionItem.uiFeature, note).catch(console.error) closeMenu() - }, [pendingConversionItem, note, selectNonComponent, closeMenu, selectComponent]) + }, [pendingConversionItem, note, closeMenu, selectComponent]) const closeSuperNoteImporter = () => { setPendingConversionItem(null) @@ -220,30 +207,30 @@ const ChangeEditorMenu: FunctionComponent = ({ return (

- {group.items.map((item) => { + {group.items.map((menuItem) => { const onClickEditorItem = () => { - selectItem(item).catch(console.error) + handleMenuSelection(menuItem).catch(console.error) } return (
{group.icon && } - {item.name} - {item.isLabs && ( + {menuItem.uiFeature.displayName} + {menuItem.isLabs && ( Labs )}
- {!item.isEntitled && ( + {!menuItem.isEntitled && ( )}
diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx similarity index 65% rename from packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx rename to packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx index 8e76f5ada..e4eb6f44f 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMultipleMenu.tsx @@ -2,7 +2,14 @@ import { WebApplication } from '@/Application/WebApplication' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { createEditorMenuGroups } from '@/Utils/createEditorMenuGroups' -import { ComponentArea, NoteMutator, NoteType, SNComponent, SNNote } from '@standardnotes/snjs' +import { + ComponentOrNativeFeature, + EditorFeatureDescription, + IframeComponentFeatureDescription, + NoteMutator, + NoteType, + SNNote, +} from '@standardnotes/snjs' import { Fragment, useCallback, useMemo, useState } from 'react' import Icon from '../Icon/Icon' import { PremiumFeatureIconName, PremiumFeatureIconClass } from '../Icon/PremiumFeatureIcon' @@ -22,7 +29,7 @@ type Props = { setDisableClickOutside: (value: boolean) => void } -const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Props) => { +const ChangeEditorMultipleMenu = ({ application, notes, setDisableClickOutside }: Props) => { const premiumModal = usePremiumModal() const [itemToBeSelected, setItemToBeSelected] = useState() @@ -30,47 +37,32 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop const hasSelectedLockedNotes = useMemo(() => notes.some((note) => note.locked), [notes]) - const editors = useMemo( - () => - application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { - return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1 - }), - [application.componentManager], - ) - const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors]) + const groups = useMemo(() => createEditorMenuGroups(application), [application]) const selectComponent = useCallback( - async (component: SNComponent, note: SNNote) => { - if (component.conflictOf) { - void application.changeAndSaveItem(component, (mutator) => { + async ( + uiFeature: ComponentOrNativeFeature, + note: SNNote, + ) => { + if (uiFeature.isComponent && uiFeature.asComponent.conflictOf) { + void application.changeAndSaveItem(uiFeature.asComponent, (mutator) => { mutator.conflictOf = undefined }) } await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator - noteMutator.noteType = component.noteType - noteMutator.editorIdentifier = component.identifier + noteMutator.noteType = uiFeature.noteType + noteMutator.editorIdentifier = uiFeature.featureIdentifier }) }, [application], ) - const selectNonComponent = useCallback( - async (item: EditorMenuItem, note: SNNote) => { - await application.changeAndSaveItem(note, (mutator) => { - const noteMutator = mutator as NoteMutator - noteMutator.noteType = item.noteType - noteMutator.editorIdentifier = undefined - }) - }, - [application], - ) - - const selectItem = useCallback( + const handleMenuSelection = useCallback( async (itemToBeSelected: EditorMenuItem) => { if (!itemToBeSelected.isEntitled) { - premiumModal.activate(itemToBeSelected.name) + premiumModal.activate(itemToBeSelected.uiFeature.displayName) return } @@ -79,35 +71,27 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop return } - if (itemToBeSelected.noteType === NoteType.Super) { + if (itemToBeSelected.uiFeature.noteType === NoteType.Super) { setDisableClickOutside(true) setItemToBeSelected(itemToBeSelected) setConfirmationQueue(notes) return } - if (itemToBeSelected.component) { - const changeRequiresConfirmation = notes.some((note) => { - const editorForNote = application.componentManager.editorForNote(note) - return application.componentManager.doesEditorChangeRequireAlert(editorForNote, itemToBeSelected.component) - }) + const changeRequiresConfirmation = notes.some((note) => { + const editorForNote = application.componentManager.editorForNote(note) + return application.componentManager.doesEditorChangeRequireAlert(editorForNote, itemToBeSelected.uiFeature) + }) - if (changeRequiresConfirmation) { - const canChange = await application.componentManager.showEditorChangeAlert() - if (!canChange) { - return - } + if (changeRequiresConfirmation) { + const canChange = await application.componentManager.showEditorChangeAlert() + if (!canChange) { + return } - - for (const note of notes) { - void selectComponent(itemToBeSelected.component, note) - } - - return } for (const note of notes) { - void selectNonComponent(itemToBeSelected, note) + void selectComponent(itemToBeSelected.uiFeature, note) } }, [ @@ -117,14 +101,13 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop notes, premiumModal, selectComponent, - selectNonComponent, setDisableClickOutside, ], ) const groupsWithItems = groups.filter((group) => group.items && group.items.length) - const showSuperImporter = itemToBeSelected?.noteType === NoteType.Super && confirmationQueue.length > 0 + const showSuperImporter = itemToBeSelected?.uiFeature.noteType === NoteType.Super && confirmationQueue.length > 0 const closeCurrentSuperNoteImporter = useCallback(() => { const remainingNotes = confirmationQueue.slice(1) @@ -144,10 +127,10 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop return } - void selectNonComponent(itemToBeSelected, confirmationQueue[0]) + void selectComponent(itemToBeSelected.uiFeature, confirmationQueue[0]) closeCurrentSuperNoteImporter() - }, [closeCurrentSuperNoteImporter, confirmationQueue, itemToBeSelected, selectNonComponent]) + }, [closeCurrentSuperNoteImporter, confirmationQueue, itemToBeSelected, selectComponent]) return ( <> @@ -157,14 +140,18 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop
{group.items.map((item) => { const onClickEditorItem = () => { - selectItem(item).catch(console.error) + handleMenuSelection(item).catch(console.error) } return ( - +
{group.icon && } - {item.name} + {item.uiFeature.displayName} {item.isLabs && ( Labs @@ -194,4 +181,4 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop ) } -export default ChangeMultipleMenu +export default ChangeEditorMultipleMenu diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleButton.tsx index f61327c06..22f1fc9ea 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleButton.tsx @@ -3,7 +3,7 @@ import { NotesController } from '@/Controllers/NotesController/NotesController' import { useRef, useState } from 'react' import RoundIconButton from '../Button/RoundIconButton' import Popover from '../Popover/Popover' -import ChangeMultipleMenu from './ChangeMultipleMenu' +import ChangeEditorMultipleMenu from './ChangeEditorMultipleMenu' type Props = { application: WebApplication @@ -27,7 +27,7 @@ const ChangeMultipleButton = ({ application, notesController }: Props) => { open={isChangeMenuOpen} className="pt-2 md:pt-0" > - application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled, ) const isEntitledRef = useStateRef(isEntitledToExtension) - const hasSubscription = application.hasValidSubscription() + const hasSubscription = application.hasValidFirstPartySubscription() useEffect(() => { return application.addEventObserver(async (event) => { switch (event) { @@ -74,7 +74,7 @@ const ClipperView = ({ setUser(application.getUser()) setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled) break - case ApplicationEvent.FeaturesUpdated: + case ApplicationEvent.FeaturesAvailabilityChanged: setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled) break } diff --git a/packages/web/src/javascripts/Components/ComponentView/ComponentView.tsx b/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx similarity index 78% rename from packages/web/src/javascripts/Components/ComponentView/ComponentView.tsx rename to packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx index f1a7d4d37..5186d5846 100644 --- a/packages/web/src/javascripts/Components/ComponentView/ComponentView.tsx +++ b/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx @@ -1,27 +1,25 @@ import { ComponentAction, FeatureStatus, - SNComponent, - dateToLocalizedString, ComponentViewerInterface, ComponentViewerEvent, ComponentViewerError, + ComponentInterface, + SubscriptionManagerEvent, } from '@standardnotes/snjs' -import { WebApplication } from '@/Application/WebApplication' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' import { observer } from 'mobx-react-lite' import OfflineRestricted from '@/Components/ComponentView/OfflineRestricted' import UrlMissing from '@/Components/ComponentView/UrlMissing' import IsDeprecated from '@/Components/ComponentView/IsDeprecated' -import IsExpired from '@/Components/ComponentView/IsExpired' +import NotEntitledBanner from '@/Components/ComponentView/NotEntitledBanner' import IssueOnLoading from '@/Components/ComponentView/IssueOnLoading' -import { openSubscriptionDashboard } from '@/Utils/ManageSubscription' +import { useApplication } from '../ApplicationProvider' -interface IProps { - application: WebApplication +interface Props { componentViewer: ComponentViewerInterface requestReload?: (viewer: ComponentViewerInterface, force?: boolean) => void - onLoad?: (component: SNComponent) => void + onLoad?: () => void } /** @@ -32,7 +30,9 @@ const MaxLoadThreshold = 4000 const VisibilityChangeKey = 'visibilitychange' const MSToWaitAfterIframeLoadToAvoidFlicker = 35 -const ComponentView: FunctionComponent = ({ application, onLoad, componentViewer, requestReload }) => { +const IframeFeatureView: FunctionComponent = ({ onLoad, componentViewer, requestReload }) => { + const application = useApplication() + const iframeRef = useRef(null) const [loadTimeout, setLoadTimeout] = useState | undefined>(undefined) @@ -45,11 +45,7 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false) const [didAttemptReload, setDidAttemptReload] = useState(false) - const component: SNComponent = componentViewer.component - - const manageSubscription = useCallback(() => { - void openSubscriptionDashboard(application) - }, [application]) + const uiFeature = componentViewer.getComponentOrFeatureItem() const reloadValidityStatus = useCallback(() => { setFeatureStatus(componentViewer.getFeatureStatus()) @@ -63,13 +59,21 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone } setError(componentViewer.getError()) - setDeprecationMessage(component.deprecationMessage) - }, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading]) + setDeprecationMessage(uiFeature.deprecationMessage) + }, [componentViewer, uiFeature, featureStatus, isComponentValid, isLoading]) useEffect(() => { reloadValidityStatus() }, [reloadValidityStatus]) + useEffect(() => { + return application.subscriptions.addEventObserver((event) => { + if (event === SubscriptionManagerEvent.DidFetchSubscription) { + reloadValidityStatus() + } + }) + }, [application.subscriptions, reloadValidityStatus]) + const dismissDeprecationMessage = () => { setIsDeprecationMessageDismissed(true) } @@ -123,9 +127,9 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone setTimeout(() => { setIsLoading(false) setHasIssueLoading(false) - onLoad?.(component) + onLoad?.() }, MSToWaitAfterIframeLoadToAvoidFlicker) - }, [componentViewer, onLoad, component, loadTimeout]) + }, [componentViewer, onLoad, loadTimeout]) useEffect(() => { const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => { @@ -149,7 +153,7 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone application.keyboardService.handleComponentKeyUp(data.keyboardModifier) break case ComponentAction.Click: - application.getViewControllerManager().notesController.setContextMenuOpen(false) + application.controllers.notesController.setContextMenuOpen(false) break default: return @@ -163,8 +167,8 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone useEffect(() => { const unregisterDesktopObserver = application .getDesktopService() - ?.registerUpdateObserver((updatedComponent: SNComponent) => { - if (updatedComponent.uuid === component.uuid && updatedComponent.active) { + ?.registerUpdateObserver((updatedComponent: ComponentInterface) => { + if (updatedComponent.uuid === uiFeature.uniqueIdentifier) { requestReload?.(componentViewer) } }) @@ -172,13 +176,13 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone return () => { unregisterDesktopObserver?.() } - }, [application, requestReload, componentViewer, component.uuid]) + }, [application, requestReload, componentViewer, uiFeature]) return ( <> {hasIssueLoading && ( { reloadValidityStatus(), requestReload?.(componentViewer, true) }} @@ -186,19 +190,17 @@ const ComponentView: FunctionComponent = ({ application, onLoad, compone )} {featureStatus !== FeatureStatus.Entitled && ( - + )} {deprecationMessage && !isDeprecationMessageDismissed && ( )} + {error === ComponentViewerError.OfflineRestricted && } - {error === ComponentViewerError.MissingUrl && } - {component.uuid && isComponentValid && ( + + {error === ComponentViewerError.MissingUrl && } + + {uiFeature.uniqueIdentifier && isComponentValid && (