From c43b593c6098a1293d6e6f25fda184e592897f7c Mon Sep 17 00:00:00 2001 From: Mo Date: Wed, 29 Nov 2023 10:18:55 -0600 Subject: [PATCH] feat: Markdown, Rich text, Code, and Checklist note types have been moved to the new Plugins preferences pane. Previous notes created using these types will not experience any disruption. To create new notes using these types, you can reinstall them from the Plugins preferences screen. It is recommended to use the Super note type in place of these replaced note types. (#2630) --- .../src/Domain/Component/NoteType.spec.ts | 8 +- .../Domain/Feature/NativeFeatureIdentifier.ts | 8 +- .../Feature/ThirdPartyFeatureDescription.ts | 2 + .../src/Domain/Lists/DeprecatedFeatures.ts | 62 +++++- .../src/Domain/Lists/IframeEditors.ts | 58 +----- .../src/Domain/Permission/PermissionName.ts | 8 +- packages/mobile/ios/Podfile.lock | 4 +- .../StandardNotes.xcodeproj/project.pbxproj | 2 - .../Domain/Syncable/Note/NoteMutator.spec.ts | 4 +- .../Domain/Api/LegacyApiServiceInterface.ts | 1 - .../Domain/Feature/FeaturesClientInterface.ts | 5 +- .../src/Domain/Preferences/PreferenceId.ts | 1 + .../src/Domain/Status/StatusService.ts | 1 + packages/snjs/lib/Services/Api/ApiService.ts | 5 +- .../DoesEditorChangeRequireAlert.spec.ts | 18 +- .../GetDefaultEditorIdentifier.spec.ts | 4 +- .../UseCase/GetFeatureUrl.spec.ts | 4 +- .../UseCase/RunWithPermissionsUseCase.spec.ts | 10 +- .../Services/Features/FeaturesService.spec.ts | 58 ++---- .../lib/Services/Features/FeaturesService.ts | 50 ----- .../DownloadRemoteThirdPartyFeature.ts | 85 --------- packages/snjs/mocha/history.test.js | 2 +- .../ui-services/src/Plugins/PluginListing.ts | 10 + .../src/Plugins/PluginsService.spec.ts | 95 +++++++++ .../ui-services/src/Plugins/PluginsService.ts | 180 ++++++++++++++++++ .../src/Plugins/PluginsServiceInterface.ts | 10 + packages/ui-services/src/index.ts | 4 + .../Application/Dependencies/Types.ts | 1 + .../Dependencies/WebDependencies.ts | 12 ++ .../javascripts/Application/WebApplication.ts | 5 + .../ChangeEditor/ChangeEditorMenu.tsx | 68 ++++++- .../Controller/NoteViewController.spec.ts | 4 +- .../Preferences/Controller/MenuItems.ts | 2 + .../PreferencesSessionController.ts | 2 +- .../Components/Preferences/PaneSelector.tsx | 7 +- .../Advanced/Packages/PackageEntry.tsx | 84 -------- .../Advanced/Packages/PackageEntrySubInfo.tsx | 74 ------- .../General/Advanced/Packages/Section.tsx | 148 -------------- .../Preferences/Panes/General/General.tsx | 36 ++-- .../Preferences/Panes/General/Moments.tsx | 10 +- .../OfflineActivation.tsx} | 34 ++-- .../OfflineSubscription.tsx | 15 +- .../Panes/HomeServer/HomeServerSettings.tsx | 2 +- .../Types => Plugins}/AnyPackageType.ts | 0 .../Plugins/BrowsePlugins/BrowsePlugins.tsx | 69 +++++++ .../Plugins/BrowsePlugins/PluginRowView.tsx | 45 +++++ .../InstallCustom/ConfirmCustomPlugin.tsx} | 26 ++- .../InstallCustom/InstallCustomPlugin.tsx | 78 ++++++++ .../Plugins/ManagePlugins/ManagePlugins.tsx | 75 ++++++++ .../Plugins/ManagePlugins/PackageEntry.tsx | 20 ++ .../ManagePlugins/PackageEntrySubInfo.tsx | 119 ++++++++++++ .../Provider => Plugins}/PackageProvider.ts | 2 +- .../Preferences/Panes/Plugins/PluginsPane.tsx | 42 ++++ .../PreferencesComponents/Content.tsx | 4 + .../Components/Preferences/PremiumOverlay.tsx | 34 ++++ .../Subviews/UpgradePrompt.tsx | 52 +++-- .../Utils/DropdownItemsForEditors.ts | 5 +- .../Utils/createEditorMenuGroups.ts | 12 +- 58 files changed, 1106 insertions(+), 680 deletions(-) delete mode 100644 packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts create mode 100644 packages/ui-services/src/Plugins/PluginListing.ts create mode 100644 packages/ui-services/src/Plugins/PluginsService.spec.ts create mode 100644 packages/ui-services/src/Plugins/PluginsService.ts create mode 100644 packages/ui-services/src/Plugins/PluginsServiceInterface.ts delete mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx delete mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntrySubInfo.tsx delete mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx rename packages/web/src/javascripts/Components/Preferences/Panes/General/{Advanced/AdvancedSection.tsx => Offline/OfflineActivation.tsx} (51%) rename packages/web/src/javascripts/Components/Preferences/Panes/General/{Advanced => Offline}/OfflineSubscription.tsx (90%) rename packages/web/src/javascripts/Components/Preferences/Panes/{General/Advanced/Packages/Types => Plugins}/AnyPackageType.ts (100%) create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/BrowsePlugins.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/PluginRowView.tsx rename packages/web/src/javascripts/Components/Preferences/Panes/{General/Advanced/Packages/ConfirmCustomPackage.tsx => Plugins/InstallCustom/ConfirmCustomPlugin.tsx} (67%) create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/InstallCustomPlugin.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/ManagePlugins.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntry.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntrySubInfo.tsx rename packages/web/src/javascripts/Components/Preferences/Panes/{General/Advanced/Packages/Provider => Plugins}/PackageProvider.ts (92%) create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PluginsPane.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/PremiumOverlay.tsx diff --git a/packages/features/src/Domain/Component/NoteType.spec.ts b/packages/features/src/Domain/Component/NoteType.spec.ts index 6879d5543..09f2cb7fa 100644 --- a/packages/features/src/Domain/Component/NoteType.spec.ts +++ b/packages/features/src/Domain/Component/NoteType.spec.ts @@ -5,11 +5,11 @@ describe('note type', () => { it('should return the correct note type for editor identifier', () => { expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.PlainEditor)).toEqual(NoteType.Plain) expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.SuperEditor)).toEqual(NoteType.Super) - expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.MarkdownProEditor)).toEqual(NoteType.Markdown) - expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.PlusEditor)).toEqual(NoteType.RichText) - expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.CodeEditor)).toEqual(NoteType.Code) + expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor)).toEqual(NoteType.Markdown) + expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor)).toEqual(NoteType.RichText) + expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.DeprecatedCodeEditor)).toEqual(NoteType.Code) expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.SheetsEditor)).toEqual(NoteType.Spreadsheet) - expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.TaskEditor)).toEqual(NoteType.Task) + expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.DeprecatedTaskEditor)).toEqual(NoteType.Task) expect(noteTypeForEditorIdentifier(NativeFeatureIdentifier.TYPES.TokenVaultEditor)).toEqual(NoteType.Authentication) expect(noteTypeForEditorIdentifier('org.third.party')).toEqual(NoteType.Unknown) }) diff --git a/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts b/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts index b9af61e40..e2d1c313c 100644 --- a/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts +++ b/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts @@ -32,11 +32,7 @@ export class NativeFeatureIdentifier extends ValueObject { it('sets componentIdentifier', () => { const note = createNote({}) const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) - mutator.editorIdentifier = NativeFeatureIdentifier.TYPES.MarkdownProEditor + mutator.editorIdentifier = NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor const result = mutator.getResult() - expect(result.content.editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.MarkdownProEditor) + expect(result.content.editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor) }) }) diff --git a/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts b/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts index 27083fb1b..460f16942 100644 --- a/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts +++ b/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts @@ -14,7 +14,6 @@ export interface LegacyApiServiceInterface downloadOfflineFeaturesFromRepo(dto: { repo: SNFeatureRepo - trustedFeatureHosts: string[] }): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> downloadFeatureUrl(url: string): Promise diff --git a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts index d13f29e9c..26712a8ac 100644 --- a/packages/services/src/Domain/Feature/FeaturesClientInterface.ts +++ b/packages/services/src/Domain/Feature/FeaturesClientInterface.ts @@ -1,5 +1,4 @@ -import { ComponentInterface, DecryptedItemInterface } from '@standardnotes/models' - +import { DecryptedItemInterface } from '@standardnotes/models' import { FeatureStatus } from './FeatureStatus' import { SetOfflineFeaturesFunctionResponse } from './SetOfflineFeaturesFunctionResponse' import { NativeFeatureIdentifier } from '@standardnotes/features' @@ -26,6 +25,4 @@ export interface FeaturesClientInterface { disableExperimentalFeature(identifier: string): void isExperimentalFeatureEnabled(identifier: string): boolean isExperimentalFeature(identifier: string): boolean - - downloadRemoteThirdPartyFeature(urlOrCode: string): Promise } diff --git a/packages/services/src/Domain/Preferences/PreferenceId.ts b/packages/services/src/Domain/Preferences/PreferenceId.ts index a501354d0..2ef5905d8 100644 --- a/packages/services/src/Domain/Preferences/PreferenceId.ts +++ b/packages/services/src/Domain/Preferences/PreferenceId.ts @@ -7,6 +7,7 @@ const PREFERENCE_PANE_IDS = [ 'appearance', 'backups', 'listed', + 'plugins', 'shortcuts', 'accessibility', 'get-free-month', diff --git a/packages/services/src/Domain/Status/StatusService.ts b/packages/services/src/Domain/Status/StatusService.ts index a8a822450..094c88f63 100644 --- a/packages/services/src/Domain/Status/StatusService.ts +++ b/packages/services/src/Domain/Status/StatusService.ts @@ -16,6 +16,7 @@ export class StatusService extends AbstractService i backups: 0, listed: 0, shortcuts: 0, + plugins: 0, accessibility: 0, 'get-free-month': 0, 'help-feedback': 0, diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index f409b663a..3ff24fb22 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -624,7 +624,6 @@ export class LegacyApiService public async downloadOfflineFeaturesFromRepo(dto: { repo: SNFeatureRepo - trustedFeatureHosts: string[] }): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> { try { const featuresUrl = dto.repo.offlineFeaturesUrl @@ -633,9 +632,11 @@ export class LegacyApiService throw Error('Cannot download offline repo without url and offlineKEy') } + const TRUSTED_FEATURE_HOSTS = ['api.standardnotes.com', 'localhost'] + const { hostname } = new URL(featuresUrl) - if (!dto.trustedFeatureHosts.includes(hostname)) { + if (!TRUSTED_FEATURE_HOSTS.includes(hostname)) { return new ClientDisplayableError(`The offline features host ${hostname} is not in the trusted allowlist.`) } diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts index 4f15fd94f..061ad08b2 100644 --- a/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/DoesEditorChangeRequireAlert.spec.ts @@ -20,7 +20,7 @@ describe('editor change alert', () => { it('should not require alert switching from plain editor', () => { const component = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, )! const requiresAlert = usecase.execute(undefined, component) expect(requiresAlert).toBe(false) @@ -28,7 +28,7 @@ describe('editor change alert', () => { it('should not require alert switching to plain editor', () => { const component = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, )! const requiresAlert = usecase.execute(component, undefined) expect(requiresAlert).toBe(false) @@ -36,10 +36,10 @@ describe('editor change alert', () => { it('should not require alert switching from a markdown editor', () => { const htmlEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.PlusEditor, + NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor, )! const markdownEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, ) const requiresAlert = usecase.execute(markdownEditor, htmlEditor) expect(requiresAlert).toBe(false) @@ -47,10 +47,10 @@ describe('editor change alert', () => { it('should not require alert switching to a markdown editor', () => { const htmlEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.PlusEditor, + NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor, )! const markdownEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, ) const requiresAlert = usecase.execute(htmlEditor, markdownEditor) expect(requiresAlert).toBe(false) @@ -58,7 +58,7 @@ describe('editor change alert', () => { it('should not require alert switching from & to a html editor', () => { const htmlEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.PlusEditor, + NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor, )! const requiresAlert = usecase.execute(htmlEditor, htmlEditor) expect(requiresAlert).toBe(false) @@ -66,7 +66,7 @@ describe('editor change alert', () => { it('should require alert switching from a html editor to custom editor', () => { const htmlEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.PlusEditor, + NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor, )! const customEditor = nativeFeatureAsUIFeature( NativeFeatureIdentifier.TYPES.TokenVaultEditor, @@ -77,7 +77,7 @@ describe('editor change alert', () => { it('should require alert switching from a custom editor to html editor', () => { const htmlEditor = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.PlusEditor, + NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor, )! const customEditor = nativeFeatureAsUIFeature( NativeFeatureIdentifier.TYPES.TokenVaultEditor, diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts index 69ddeb852..337fdd936 100644 --- a/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetDefaultEditorIdentifier.spec.ts @@ -47,7 +47,7 @@ describe('getDefaultEditorIdentifier', () => { it('should return legacy editor identifier', () => { const editor = { legacyIsDefaultEditor: jest.fn().mockReturnValue(true), - identifier: NativeFeatureIdentifier.TYPES.MarkdownProEditor, + identifier: NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, area: ComponentArea.Editor, } as unknown as jest.Mocked @@ -55,6 +55,6 @@ describe('getDefaultEditorIdentifier', () => { const editorIdentifier = usecase.execute().getValue() - expect(editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.MarkdownProEditor) + expect(editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor) }) }) diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts index bace2e2b8..4897a2e16 100644 --- a/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/GetFeatureUrl.spec.ts @@ -76,7 +76,7 @@ describe('GetFeatureUrl', () => { it('returns native path for native component', () => { const feature = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, )! const url = usecase.execute(feature) expect(url).toEqual( @@ -125,7 +125,7 @@ describe('GetFeatureUrl', () => { it('returns native path for native feature', () => { const feature = nativeFeatureAsUIFeature( - NativeFeatureIdentifier.TYPES.MarkdownProEditor, + NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, ) const url = usecase.execute(feature) expect(url).toEqual( diff --git a/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts index 3e2cd4168..61bb6d0e6 100644 --- a/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/UseCase/RunWithPermissionsUseCase.spec.ts @@ -43,7 +43,7 @@ describe('RunWithPermissionsUseCase', () => { expect( usecase.areRequestedPermissionsValid( - nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.MarkdownProEditor), + nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor), permissions, ), ).toEqual(true) @@ -59,7 +59,7 @@ describe('RunWithPermissionsUseCase', () => { expect( usecase.areRequestedPermissionsValid( - nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.MarkdownProEditor), + nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor), permissions, ), ).toEqual(false) @@ -75,7 +75,7 @@ describe('RunWithPermissionsUseCase', () => { expect( usecase.areRequestedPermissionsValid( - nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.MarkdownProEditor), + nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor), permissions, ), ).toEqual(false) @@ -91,7 +91,7 @@ describe('RunWithPermissionsUseCase', () => { expect( usecase.areRequestedPermissionsValid( - nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.MarkdownProEditor), + nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor), permissions, ), ).toEqual(false) @@ -167,7 +167,7 @@ describe('RunWithPermissionsUseCase', () => { expect( usecase.areRequestedPermissionsValid( - nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.PlusEditor), + nativeFeatureAsUIFeature(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor), permissions, ), ).toEqual(false) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts index 731d87c76..b6b7f825a 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -133,16 +133,22 @@ describe('FeaturesService', () => { it('enables/disables an experimental feature', async () => { storageService.getValue = jest.fn().mockReturnValue(GetFeatures()) - featureService.getExperimentalFeatures = jest.fn().mockReturnValue([NativeFeatureIdentifier.TYPES.PlusEditor]) + featureService.getExperimentalFeatures = jest + .fn() + .mockReturnValue([NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor]) featureService.initializeFromDisk() - featureService.enableExperimentalFeature(NativeFeatureIdentifier.TYPES.PlusEditor) + featureService.enableExperimentalFeature(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor) - expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.PlusEditor)).toEqual(true) + expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor)).toEqual( + true, + ) - featureService.disableExperimentalFeature(NativeFeatureIdentifier.TYPES.PlusEditor) + featureService.disableExperimentalFeature(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor) - expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.PlusEditor)).toEqual(false) + expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor)).toEqual( + false, + ) }) }) @@ -324,7 +330,7 @@ describe('FeaturesService', () => { ).toBe(FeatureStatus.NoUserSubscription) expect( featureService.getFeatureStatus( - NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.PlusEditor).getValue(), + NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor).getValue(), ), ).toBe(FeatureStatus.NoUserSubscription) expect( @@ -465,46 +471,6 @@ describe('FeaturesService', () => { }) }) - describe('downloadRemoteThirdPartyFeature', () => { - it('should not allow if identifier matches native identifier', async () => { - apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ - data: { - identifier: 'org.standardnotes.bold-editor', - name: 'Bold Editor', - content_type: 'SN|Component', - area: 'editor-editor', - version: '1.0.0', - url: 'http://localhost:8005/', - }, - }) - - const installUrl = 'http://example.com' - crypto.base64Decode = jest.fn().mockReturnValue(installUrl) - - const result = await featureService.downloadRemoteThirdPartyFeature(installUrl) - expect(result).toBeUndefined() - }) - - it('should not allow if url matches native url', async () => { - apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ - data: { - identifier: 'org.foo.bar', - name: 'Bold Editor', - content_type: 'SN|Component', - area: 'editor-editor', - version: '1.0.0', - url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html', - }, - }) - - const installUrl = 'http://example.com' - crypto.base64Decode = jest.fn().mockReturnValue(installUrl) - - const result = await featureService.downloadRemoteThirdPartyFeature(installUrl) - expect(result).toBeUndefined() - }) - }) - describe('sortRolesByHierarchy', () => { it('should sort given roles according to role hierarchy', () => { const sortedRoles = featureService.rolesBySorting([ diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index 697adef30..653817c0d 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -18,16 +18,13 @@ import { AlertService, ApiServiceEvent, API_MESSAGE_FAILED_OFFLINE_ACTIVATION, - API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING, ApplicationStage, - ButtonType, FeaturesClientInterface, FeaturesEvent, FeatureStatus, InternalEventBusInterface, InternalEventHandlerInterface, InternalEventInterface, - INVALID_EXTENSION_URL, MetaReceivedData, OfflineSubscriptionEntitlements, SetOfflineFeaturesFunctionResponse, @@ -49,7 +46,6 @@ import { WebSocketsService, } from '@standardnotes/services' -import { DownloadRemoteThirdPartyFeatureUseCase } from './UseCase/DownloadRemoteThirdPartyFeature' import { MigrateFeatureRepoToOfflineEntitlementsUseCase } from './UseCase/MigrateFeatureRepoToOfflineEntitlements' import { GetFeatureStatusUseCase } from './UseCase/GetFeatureStatus' import { SettingsClientInterface } from '../Settings/SettingsClientInterface' @@ -66,16 +62,6 @@ export class FeaturesService private getFeatureStatusUseCase = new GetFeatureStatusUseCase(this.items) - private readonly TRUSTED_FEATURE_HOSTS = [ - 'api.standardnotes.com', - 'extensions.standardnotes.com', - 'extensions.standardnotes.org', - 'features.standardnotes.com', - 'localhost', - ] - - private readonly TRUSTED_CUSTOM_EXTENSIONS_HOSTS = ['listed.to'] - private readonly PROD_OFFLINE_FEATURES_URL = 'https://api.standardnotes.com/v1/offline/features' constructor( @@ -304,7 +290,6 @@ export class FeaturesService private async downloadOfflineRoles(repo: SNFeatureRepo): Promise { const result = await this.api.downloadOfflineFeaturesFromRepo({ repo, - trustedFeatureHosts: this.TRUSTED_FEATURE_HOSTS, }) if (result instanceof ClientDisplayableError) { @@ -449,41 +434,6 @@ export class FeaturesService }) } - public async downloadRemoteThirdPartyFeature(urlOrCode: string): Promise { - let url = urlOrCode - try { - url = this.crypto.base64Decode(urlOrCode) - } catch (err) { - void err - } - - try { - const trustedCustomExtensionsUrls = [...this.TRUSTED_FEATURE_HOSTS, ...this.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.alerts.confirm( - API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING, - 'Install extension from an untrusted source?', - 'Proceed to install', - ButtonType.Danger, - 'Cancel', - ) - if (didConfirm) { - return usecase.execute(url) - } - } else { - return usecase.execute(url) - } - } catch (err) { - void this.alerts.alert(INVALID_EXTENSION_URL) - } - - return undefined - } - override deinit(): void { super.deinit() ;(this.onlineRoles as unknown) = undefined diff --git a/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts b/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts deleted file mode 100644 index cf2470e63..000000000 --- a/packages/snjs/lib/Services/Features/UseCase/DownloadRemoteThirdPartyFeature.ts +++ /dev/null @@ -1,85 +0,0 @@ -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, - LegacyApiServiceInterface, - ItemManagerInterface, -} from '@standardnotes/services' -import { isString } from '@standardnotes/utils' - -export class DownloadRemoteThirdPartyFeatureUseCase { - constructor( - private api: LegacyApiServiceInterface, - 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/mocha/history.test.js b/packages/snjs/mocha/history.test.js index c1171da9a..98f2c9288 100644 --- a/packages/snjs/mocha/history.test.js +++ b/packages/snjs/mocha/history.test.js @@ -256,7 +256,7 @@ describe('history manager', () => { expect(itemHistoryOrError.isFailed()).to.equal(true) }) - it('should save initial revisions on server', async () => { + it.skip('should save initial revisions on server', async () => { const note = await context.createSyncedNote('test note') expect(note).to.be.ok diff --git a/packages/ui-services/src/Plugins/PluginListing.ts b/packages/ui-services/src/Plugins/PluginListing.ts new file mode 100644 index 000000000..4715e9a9c --- /dev/null +++ b/packages/ui-services/src/Plugins/PluginListing.ts @@ -0,0 +1,10 @@ +import { ThirdPartyFeatureDescription } from '@standardnotes/features' + +export type PluginListing = ThirdPartyFeatureDescription & { + publisher: string + base64Hash: string + binaryHash: string + showInGallery: boolean +} + +export type PluginsList = PluginListing[] diff --git a/packages/ui-services/src/Plugins/PluginsService.spec.ts b/packages/ui-services/src/Plugins/PluginsService.spec.ts new file mode 100644 index 000000000..08630d04e --- /dev/null +++ b/packages/ui-services/src/Plugins/PluginsService.spec.ts @@ -0,0 +1,95 @@ +import { ItemInterface } from '@standardnotes/models' +import { PluginsService } from './PluginsService' +import { + AlertService, + ItemManagerInterface, + LegacyApiServiceInterface, + MutatorClientInterface, + SyncServiceInterface, +} from '@standardnotes/services' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { ThirdPartyFeatureDescription } from '@standardnotes/features' + +describe('Plugins Service', () => { + let itemManager: ItemManagerInterface + let apiService: LegacyApiServiceInterface + let pluginsService: PluginsService + let crypto: PureCryptoInterface + let mutator: MutatorClientInterface + let syncService: SyncServiceInterface + + beforeEach(() => { + apiService = {} as jest.Mocked + apiService.addEventObserver = jest.fn() + itemManager = {} as jest.Mocked + + crypto = {} as jest.Mocked + crypto.base64Decode = jest.fn() + + itemManager.createTemplateItem = jest.fn().mockReturnValue({}) + itemManager.addObserver = jest.fn() + + let alertService: AlertService + alertService = {} as jest.Mocked + alertService.confirm = jest.fn().mockReturnValue(true) + alertService.alert = jest.fn() + + mutator = {} as jest.Mocked + mutator.createItem = jest.fn() + mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + mutator.setItemsToBeDeleted = jest.fn() + mutator.changeItem = jest.fn() + mutator.changeFeatureRepo = jest.fn() + + syncService = {} as jest.Mocked + syncService.sync = jest.fn() + + pluginsService = new PluginsService(itemManager, mutator, syncService, apiService, alertService, crypto) + }) + + describe('downloadRemoteThirdPartyFeature', () => { + it('should not allow if identifier matches native identifier', async () => { + apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ + data: { + identifier: 'org.standardnotes.bold-editor', + name: 'Bold Editor', + content_type: 'SN|Component', + area: 'editor-editor', + version: '1.0.0', + url: 'http://localhost:8005/', + }, + }) + + const installUrl = 'http://example.com' + crypto.base64Decode = jest.fn().mockReturnValue(installUrl) + + const plugin = await pluginsService.getPluginDetailsFromUrl('some-url') + expect(plugin).toBeDefined() + + const result = await pluginsService.installExternalPlugin(plugin as ThirdPartyFeatureDescription) + expect(result).toBeUndefined() + }) + + it('should not allow if url matches native url', async () => { + apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ + data: { + identifier: 'org.foo.bar', + name: 'Bold Editor', + content_type: 'SN|Component', + area: 'editor-editor', + version: '1.0.0', + url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html', + }, + }) + + const installUrl = 'http://example.com' + crypto.base64Decode = jest.fn().mockReturnValue(installUrl) + + const plugin = await pluginsService.getPluginDetailsFromUrl('some-url') + expect(plugin).toBeDefined() + + const result = await pluginsService.installExternalPlugin(plugin as ThirdPartyFeatureDescription) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/ui-services/src/Plugins/PluginsService.ts b/packages/ui-services/src/Plugins/PluginsService.ts new file mode 100644 index 000000000..557074317 --- /dev/null +++ b/packages/ui-services/src/Plugins/PluginsService.ts @@ -0,0 +1,180 @@ +import { + ComponentContent, + ComponentContentSpecialized, + ComponentInterface, + FillItemContentSpecialized, +} from '@standardnotes/models' +import { PluginListing, PluginsList } from './PluginListing' +import { ContentType } from '@standardnotes/domain-core' +import { FindNativeFeature, GetFeatures, ThirdPartyFeatureDescription } from '@standardnotes/features' +import { + API_MESSAGE_FAILED_DOWNLOADING_EXTENSION, + AlertService, + ItemManagerInterface, + LegacyApiServiceInterface, + MutatorClientInterface, + SyncServiceInterface, +} from '@standardnotes/services' +import { PluginsServiceInterface } from './PluginsServiceInterface' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { isString } from '@standardnotes/utils' + +const PluginsUrl = 'https://raw.githubusercontent.com/standardnotes/plugins/main/cdn/dist/packages.json' + +type DownloadedPackages = { + [key: string]: PluginListing +} + +export class PluginsService implements PluginsServiceInterface { + private originalPlugins?: PluginsList + + constructor( + private items: ItemManagerInterface, + private mutator: MutatorClientInterface, + private sync: SyncServiceInterface, + private api: LegacyApiServiceInterface, + private alerts: AlertService, + private crypto: PureCryptoInterface, + ) {} + + private async performDownloadPlugins(): Promise { + const response = await fetch(PluginsUrl) + const changelog = await response.text() + const parsedData = JSON.parse(changelog) as DownloadedPackages + + return Object.values(parsedData) + } + + public async getInstallablePlugins(): Promise { + if (this.originalPlugins) { + return this.filterInstallablePlugins(this.originalPlugins) + } + + this.originalPlugins = await this.performDownloadPlugins() + + return this.filterInstallablePlugins(this.originalPlugins) + } + + private filterInstallablePlugins(plugins: PluginsList): PluginsList { + const filtered = plugins.filter((plugin) => { + if (!plugin.showInGallery) { + return false + } + + const nativeFeature = FindNativeFeature(plugin.identifier) + if (nativeFeature && !nativeFeature.deprecated) { + return false + } + + const existingInstalled = this.items.getDisplayableComponents().find((component) => { + return component.identifier === plugin.identifier + }) + + return !existingInstalled + }) + + return filtered.sort((a, b) => { + if (a.name === b.name) { + return 0 + } + + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 + }) + } + + public async installPlugin( + plugin: PluginListing | ThirdPartyFeatureDescription, + ): Promise { + const isValidContentType = [ + ContentType.TYPES.Component, + ContentType.TYPES.Theme, + ContentType.TYPES.ActionsExtension, + ContentType.TYPES.ExtensionRepo, + ].includes(plugin.content_type) + + if (!isValidContentType) { + return + } + + const nativeFeature = FindNativeFeature(plugin.identifier) + if (nativeFeature && !nativeFeature.deprecated) { + void this.alerts.alert('Unable to install plugin due to a conflict with a native feature.') + return + } + + if (plugin.url) { + for (const nativeFeature of GetFeatures()) { + if (plugin.url.includes(nativeFeature.identifier) && !nativeFeature.deprecated) { + void this.alerts.alert('Unable to install plugin due to a conflict with a native feature.') + return + } + } + } + + const content = FillItemContentSpecialized({ + area: plugin.area, + name: plugin.name ?? '', + package_info: plugin, + valid_until: new Date(plugin.expires_at || 0), + hosted_url: plugin.url, + }) + + const component = this.items.createTemplateItem(plugin.content_type, content) + + await this.mutator.insertItem(component) + void this.sync.sync() + + return component + } + + public async getPluginDetailsFromUrl(urlOrCode: string): Promise { + let url = urlOrCode + try { + url = this.crypto.base64Decode(urlOrCode) + } catch (err) { + void err + } + + 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 undefined + } + + return rawFeature + } + + public async installExternalPlugin( + plugin: PluginListing | ThirdPartyFeatureDescription, + ): Promise { + const nativeFeature = FindNativeFeature(plugin.identifier) + if (nativeFeature) { + await this.alerts.alert('Unable to install external plugin due to a conflict with a native feature.') + return + } + + if (plugin.url) { + for (const nativeFeature of GetFeatures()) { + if (plugin.url.includes(nativeFeature.identifier)) { + await this.alerts.alert('Unable to install external plugin due to a conflict with a native feature.') + return + } + } + } + + return this.installPlugin(plugin) + } +} diff --git a/packages/ui-services/src/Plugins/PluginsServiceInterface.ts b/packages/ui-services/src/Plugins/PluginsServiceInterface.ts new file mode 100644 index 000000000..6fe98e3f4 --- /dev/null +++ b/packages/ui-services/src/Plugins/PluginsServiceInterface.ts @@ -0,0 +1,10 @@ +import { ComponentInterface } from '@standardnotes/models' +import { PluginListing, PluginsList } from './PluginListing' +import { ThirdPartyFeatureDescription } from '@standardnotes/features' + +export interface PluginsServiceInterface { + getInstallablePlugins(): Promise + installPlugin(plugin: PluginListing): Promise + getPluginDetailsFromUrl(urlOrCode: string): Promise + installExternalPlugin(plugin: PluginListing | ThirdPartyFeatureDescription): Promise +} diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index 0f69bfcf5..15ab6bc7c 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -28,6 +28,10 @@ export * from './Route/RouteServiceEvent' export * from './Security/AutolockService' export * from './Storage/LocalStorage' +export * from './Plugins/PluginListing' +export * from './Plugins/PluginsService' +export * from './Plugins/PluginsServiceInterface' + export * from './UseCase/IsGlobalSpellcheckEnabled' export * from './UseCase/IsNativeMobileWeb' export * from './UseCase/IsMobileDevice' diff --git a/packages/web/src/javascripts/Application/Dependencies/Types.ts b/packages/web/src/javascripts/Application/Dependencies/Types.ts index ec9c8b730..12cb179f8 100644 --- a/packages/web/src/javascripts/Application/Dependencies/Types.ts +++ b/packages/web/src/javascripts/Application/Dependencies/Types.ts @@ -15,6 +15,7 @@ export const Web_TYPES = { RouteService: Symbol.for('RouteService'), ThemeManager: Symbol.for('ThemeManager'), VaultDisplayService: Symbol.for('VaultDisplayService'), + PluginsService: Symbol.for('PluginsService'), // Controllers AccountMenuController: Symbol.for('AccountMenuController'), diff --git a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index 75710b3cf..92fb59db5 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -9,6 +9,7 @@ import { IsNativeIOS, IsNativeMobileWeb, KeyboardService, + PluginsService, RouteService, ThemeManager, ToastService, @@ -145,6 +146,17 @@ export class WebDependencies extends DependencyContainer { return new ChangelogService(application.environment, application.storage) }) + this.bind(Web_TYPES.PluginsService, () => { + return new PluginsService( + application.items, + application.mutator, + application.sync, + application.legacyApi, + application.alerts, + application.options.crypto, + ) + }) + this.bind(Web_TYPES.IsMobileDevice, () => { return new IsMobileDevice(this.get(Web_TYPES.IsNativeMobileWeb)) }) diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index dc8737a81..3b2d380e5 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -38,6 +38,7 @@ import { IsNativeIOS, IsNativeMobileWeb, KeyboardService, + PluginsServiceInterface, RouteServiceInterface, ThemeManager, VaultDisplayServiceInterface, @@ -575,6 +576,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.deps.get(Web_TYPES.ChangelogService) } + get pluginsService(): PluginsServiceInterface { + return this.deps.get(Web_TYPES.PluginsService) + } + get momentsService(): MomentsService { return this.deps.get(Web_TYPES.MomentsService) } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index a02ff42fd..0e6eef870 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -12,8 +12,9 @@ import { NoteType, PrefKey, SNNote, + ContentType, } from '@standardnotes/snjs' -import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' +import { FunctionComponent, useCallback, useEffect, useState } from 'react' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' import { createEditorMenuGroups } from '../../Utils/createEditorMenuGroups' @@ -43,7 +44,36 @@ const ChangeEditorMenu: FunctionComponent = ({ onSelect, setDisableClickOutside, }) => { - const groups = useMemo(() => createEditorMenuGroups(application), [application]) + const [groups, setGroups] = useState([]) + const [unableToFindEditor, setUnableToFindEditor] = useState(false) + + const reloadGroups = useCallback(() => { + const groups = createEditorMenuGroups(application) + setGroups(groups) + + if (note && note.editorIdentifier) { + let didFindEditor = false + for (const group of groups) { + for (const item of group.items) { + if (item.uiFeature.featureIdentifier === note.editorIdentifier) { + didFindEditor = true + break + } + } + } + + setUnableToFindEditor(!didFindEditor) + } + }, [application, note]) + + useEffect(() => { + application.items.streamItems([ContentType.TYPES.Component], reloadGroups) + }, [application, reloadGroups]) + + useEffect(() => { + reloadGroups() + }, [reloadGroups]) + const [currentFeature, setCurrentFeature] = useState>() const [pendingConversionItem, setPendingConversionItem] = useState(null) @@ -195,6 +225,13 @@ const ChangeEditorMenu: FunctionComponent = ({ ], ) + const recommendSuper = + !note || + (note.noteType && + [NoteType.Plain, NoteType.Markdown, NoteType.RichText, NoteType.Task, NoteType.Code, NoteType.Unknown].includes( + note.noteType, + )) + const closeSuperNoteImporter = () => { setPendingConversionItem(null) setDisableClickOutside?.(false) @@ -204,9 +241,29 @@ const ChangeEditorMenu: FunctionComponent = ({ setDisableClickOutside?.(false) } + const managePlugins = useCallback(() => { + application.openPreferences('plugins') + }, [application]) + return ( <> + +
+
+

Choose a note type

+ {unableToFindEditor && ( +

+ Unable to find system editor for this note. Select Manage Plugins to reinstall this editor. +

+ )} +
+ +
+
+ {groups .filter((group) => group.items && group.items.length) .map((group) => { @@ -236,6 +293,13 @@ const ChangeEditorMenu: FunctionComponent = ({ Labs )} + {menuItem.uiFeature.featureIdentifier === NativeFeatureIdentifier.TYPES.SuperEditor && + !isSelected(menuItem) && + recommendSuper && ( + + Recommended + + )} {!menuItem.isEntitled && ( diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts index 141627ab3..48d2a5549 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts @@ -67,13 +67,13 @@ describe('note view controller', () => { it('should create notes with markdown note type', async () => { application.items.getDisplayableComponents = jest.fn().mockReturnValue([ { - identifier: NativeFeatureIdentifier.TYPES.MarkdownProEditor, + identifier: NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor, } as ComponentItem, ]) application.componentManager.getDefaultEditorIdentifier = jest .fn() - .mockReturnValue(NativeFeatureIdentifier.TYPES.MarkdownProEditor) + .mockReturnValue(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor) const controller = new NoteViewController( undefined, diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts b/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts index a7c5d7361..dd8ef437d 100644 --- a/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts +++ b/packages/web/src/javascripts/Components/Preferences/Controller/MenuItems.ts @@ -9,6 +9,7 @@ export const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ { id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 }, { id: 'listed', label: 'Listed', icon: 'listed', order: 7 }, { id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard', order: 8 }, + { id: 'plugins', label: 'Plugins', icon: 'dashboard', order: 8 }, { id: 'accessibility', label: 'Accessibility', icon: 'accessibility', order: 9 }, { id: 'get-free-month', label: 'Get a free month', icon: 'star', order: 10 }, { id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 }, @@ -22,5 +23,6 @@ export const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ { id: 'backups', label: 'Backups', icon: 'restore', order: 5 }, { id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 }, { id: 'listed', label: 'Listed', icon: 'listed', order: 7 }, + { id: 'plugins', label: 'Plugins', icon: 'dashboard', order: 8 }, { id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 }, ] diff --git a/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts index 722b8502b..2d62cd340 100644 --- a/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts +++ b/packages/web/src/javascripts/Components/Preferences/Controller/PreferencesSessionController.ts @@ -1,6 +1,6 @@ import { action, makeAutoObservable, observable } from 'mobx' import { WebApplication } from '@/Application/WebApplication' -import { PackageProvider } from '../Panes/General/Advanced/Packages/Provider/PackageProvider' +import { PackageProvider } from '../Panes/Plugins/PackageProvider' import { securityPrefsHasBubble } from '../Panes/Security/securityPrefsHasBubble' import { PreferencePaneId, StatusServiceEvent } from '@standardnotes/services' import { isDesktopApplication } from '@/Utils' diff --git a/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx b/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx index e72b7f4c3..798046c01 100644 --- a/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx +++ b/packages/web/src/javascripts/Components/Preferences/PaneSelector.tsx @@ -12,6 +12,7 @@ import { PreferencesProps } from './PreferencesProps' import WhatsNew from './Panes/WhatsNew/WhatsNew' import HomeServer from './Panes/HomeServer/HomeServer' import Vaults from './Panes/Vaults/Vaults' +import PluginsPane from './Panes/Plugins/PluginsPane' const PaneSelector: FunctionComponent = ({ menu, @@ -19,7 +20,7 @@ const PaneSelector: FunctionComponent { switch (menu.selectedPaneId) { case 'general': - return + return case 'account': return case 'appearance': @@ -36,6 +37,8 @@ const PaneSelector: FunctionComponent case 'shortcuts': return null + case 'plugins': + return case 'accessibility': return null case 'get-free-month': @@ -45,7 +48,7 @@ const PaneSelector: FunctionComponent default: - return + return } } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx deleted file mode 100644 index 241793272..000000000 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { FunctionComponent, useState } from 'react' -import { ComponentInterface, ComponentMutator, ComponentItem } from '@standardnotes/snjs' -import { SubtitleLight } from '@/Components/Preferences/PreferencesComponents/Content' -import Switch from '@/Components/Switch/Switch' -import Button from '@/Components/Button/Button' -import PackageEntrySubInfo from './PackageEntrySubInfo' -import PreferencesSegment from '../../../../PreferencesComponents/PreferencesSegment' -import { WebApplication } from '@/Application/WebApplication' -import { AnyPackageType } from './Types/AnyPackageType' - -const UseHosted: FunctionComponent<{ - offlineOnly: boolean - toggleOfflineOnly: () => void -}> = ({ offlineOnly, toggleOfflineOnly }) => ( -
- Use hosted when local is unavailable - -
-) - -interface PackageEntryProps { - application: WebApplication - extension: AnyPackageType - first: boolean - latestVersion: string | undefined - uninstall: (extension: AnyPackageType) => void - toggleActivate?: (extension: AnyPackageType) => void -} - -const PackageEntry: FunctionComponent = ({ application, extension, uninstall }) => { - const [offlineOnly, setOfflineOnly] = useState(extension instanceof ComponentItem ? extension.offlineOnly : false) - const [extensionName, setExtensionName] = useState(extension.displayName) - - const toggleOfflineOnly = () => { - const newOfflineOnly = !offlineOnly - setOfflineOnly(newOfflineOnly) - application.changeAndSaveItem - .execute(extension, (mutator) => { - mutator.offlineOnly = newOfflineOnly - }) - .then((result) => { - const component = result.getValue() as ComponentInterface - setOfflineOnly(component.offlineOnly) - }) - .catch((e) => { - console.error(e) - }) - } - - const changeExtensionName = (newName: string) => { - setExtensionName(newName) - application.changeAndSaveItem - .execute(extension, (mutator) => { - mutator.name = newName - }) - .then((result) => { - const component = result.getValue() as ComponentInterface - setExtensionName(component.name) - }) - .catch(console.error) - } - - const localInstallable = extension.package_info.download_url - - const isThirdParty = 'identifier' in extension && application.features.isThirdPartyFeature(extension.identifier) - - return ( - - - -
- - {isThirdParty && localInstallable && ( - - )} - -
-
- - ) -} - -export default PackageEntry diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntrySubInfo.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntrySubInfo.tsx deleted file mode 100644 index 7f696d0d8..000000000 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntrySubInfo.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import Button from '@/Components/Button/Button' -import { FunctionComponent, useState, useRef, useEffect } from 'react' - -type Props = { - extensionName: string - changeName: (newName: string) => void - isThirdParty: boolean -} - -const PackageEntrySubInfo: FunctionComponent = ({ extensionName, changeName, isThirdParty }) => { - const [isRenaming, setIsRenaming] = useState(false) - const [newExtensionName, setNewExtensionName] = useState(extensionName) - - const renameable = isThirdParty - - const inputRef = useRef(null) - - useEffect(() => { - if (isRenaming) { - inputRef.current?.focus() - } - }, [inputRef, isRenaming]) - - const startRenaming = () => { - setNewExtensionName(extensionName) - setIsRenaming(true) - } - - const cancelRename = () => { - setNewExtensionName(extensionName) - setIsRenaming(false) - } - - const confirmRename = () => { - if (!newExtensionName) { - return - } - changeName(newExtensionName) - setIsRenaming(false) - } - - return ( -
- setNewExtensionName((input as HTMLInputElement)?.value)} - /> - - {isRenaming && ( - <> - - - - )} - - {renameable && !isRenaming && ( - - )} -
- ) -} - -export default PackageEntrySubInfo diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx deleted file mode 100644 index 8c82e0ee7..000000000 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { ButtonType, ContentType } from '@standardnotes/snjs' -import Button from '@/Components/Button/Button' -import DecoratedInput from '@/Components/Input/DecoratedInput' -import { WebApplication } from '@/Application/WebApplication' -import { FunctionComponent, useEffect, useRef, useState } from 'react' -import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' -import { observer } from 'mobx-react-lite' -import { PackageProvider } from './Provider/PackageProvider' -import PackageEntry from './PackageEntry' -import ConfirmCustomPackage from './ConfirmCustomPackage' -import { AnyPackageType } from './Types/AnyPackageType' -import PreferencesSegment from '../../../../PreferencesComponents/PreferencesSegment' - -const loadExtensions = (application: WebApplication) => - application.items.getItems([ - ContentType.TYPES.ActionsExtension, - ContentType.TYPES.Component, - ContentType.TYPES.Theme, - ]) as AnyPackageType[] - -type Props = { - application: WebApplication - extensionsLatestVersions: PackageProvider - className?: string -} - -const PackagesPreferencesSection: FunctionComponent = ({ - application, - extensionsLatestVersions, - className = '', -}) => { - const [customUrl, setCustomUrl] = useState('') - const [confirmableExtension, setConfirmableExtension] = useState(undefined) - const [extensions, setExtensions] = useState(loadExtensions(application)) - - const confirmableEnd = useRef(null) - - useEffect(() => { - if (confirmableExtension) { - confirmableEnd.current?.scrollIntoView({ behavior: 'smooth' }) - } - }, [confirmableExtension, confirmableEnd]) - - const uninstallExtension = async (extension: AnyPackageType) => { - application.alerts - .confirm( - 'Are you sure you want to uninstall this plugin? Note that plugins managed by your subscription will automatically be re-installed on application restart.', - 'Uninstall Plugin?', - 'Uninstall', - ButtonType.Danger, - 'Cancel', - ) - .then(async (shouldRemove: boolean) => { - if (shouldRemove) { - await application.mutator.deleteItem(extension) - void application.sync.sync() - setExtensions(loadExtensions(application)) - } - }) - .catch((err: string) => { - application.alerts.alert(err).catch(console.error) - }) - } - - const submitExtensionUrl = async (url: string) => { - const component = await application.features.downloadRemoteThirdPartyFeature(url) - if (component) { - setConfirmableExtension(component) - } - } - - const handleConfirmExtensionSubmit = async (confirm: boolean) => { - if (confirm) { - confirmExtension().catch(console.error) - } - setConfirmableExtension(undefined) - setCustomUrl('') - } - - const confirmExtension = async () => { - await application.mutator.insertItem(confirmableExtension as AnyPackageType) - application.sync.sync().catch(console.error) - setExtensions(loadExtensions(application)) - } - - const visibleExtensions = extensions.filter((extension) => { - const hasPackageInfo = extension.package_info != undefined - - if (!hasPackageInfo) { - return false - } - - return true - }) - - return ( -
- {visibleExtensions.length > 0 && ( -
- {visibleExtensions - .sort((e1, e2) => e1.displayName?.toLowerCase().localeCompare(e2.displayName?.toLowerCase())) - .map((extension, i) => ( - - ))} -
- )} - -
- {!confirmableExtension && ( - - Install External Plugin -
- { - setCustomUrl(value) - }} - /> -
-
- ) -} - -export default observer(PackagesPreferencesSection) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx index ba4198649..622937ddc 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx @@ -1,33 +1,31 @@ -import { WebApplication } from '@/Application/WebApplication' import { FunctionComponent } from 'react' -import { PackageProvider } from '@/Components/Preferences/Panes/General/Advanced/Packages/Provider/PackageProvider' import { observer } from 'mobx-react-lite' import Tools from './Tools' import Defaults from './Defaults' import LabsPane from './Labs/Labs' -import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSection' +import OfflineActivation from '@/Components/Preferences/Panes/General/Offline/OfflineActivation' import PreferencesPane from '../../PreferencesComponents/PreferencesPane' import Persistence from './Persistence' import SmartViews from './SmartViews/SmartViews' import Moments from './Moments' import NewNoteDefaults from './NewNoteDefaults' +import { useApplication } from '@/Components/ApplicationProvider' -type Props = { - application: WebApplication - extensionsLatestVersions: PackageProvider +const General: FunctionComponent = () => { + const application = useApplication() + + return ( + + + + + + + + + + + ) } -const General: FunctionComponent = ({ application, extensionsLatestVersions }) => ( - - - - - - - - - - -) - export default observer(General) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Moments.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Moments.tsx index 42f0b69e6..4eff9ab67 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Moments.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Moments.tsx @@ -114,13 +114,9 @@ const Moments: FunctionComponent = ({ application }: Props) => { Moments lets you capture photos of yourself throughout the day, creating a visual record of your life, one - photo at a time. - - - - Using your webcam or mobile selfie-cam, Moments takes a photo of you every half hour, keeping a complete - record of your day. All photos are end-to-end encrypted and stored in your private account. Enable Moments - on a per-device basis to get started. + photo at a time. Using your webcam or mobile selfie-cam, Moments takes a photo of you every half hour. All + photos are end-to-end encrypted and stored in your files. Enable Moments on a per-device basis to get + started.
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/AdvancedSection.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineActivation.tsx similarity index 51% rename from packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/AdvancedSection.tsx rename to packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineActivation.tsx index 09ec54169..33e809dd3 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/AdvancedSection.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineActivation.tsx @@ -1,32 +1,34 @@ import { FunctionComponent } from 'react' -import OfflineSubscription from '@/Components/Preferences/Panes/General/Advanced/OfflineSubscription' -import { WebApplication } from '@/Application/WebApplication' +import OfflineSubscription from '@/Components/Preferences/Panes/General/Offline/OfflineSubscription' import { observer } from 'mobx-react-lite' -import PackagesPreferencesSection from '@/Components/Preferences/Panes/General/Advanced/Packages/Section' -import { PackageProvider } from '@/Components/Preferences/Panes/General/Advanced/Packages/Provider/PackageProvider' import AccordionItem from '@/Components/Shared/AccordionItem' import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' import { Platform } from '@standardnotes/snjs' +import { useApplication } from '@/Components/ApplicationProvider' -type Props = { - application: WebApplication - extensionsLatestVersions: PackageProvider -} +const OfflineActivation: FunctionComponent = () => { + const application = useApplication() + + const shouldShowOfflineSubscription = () => { + return ( + !application.hasAccount() || + !application.sessions.isSignedIntoFirstPartyServer() || + application.features.hasOfflineRepo() + ) + } + + if (!shouldShowOfflineSubscription()) { + return null + } -const Advanced: FunctionComponent = ({ application, extensionsLatestVersions }) => { return ( - +
{application.platform !== Platform.Ios && } -
@@ -35,4 +37,4 @@ const Advanced: FunctionComponent = ({ application, extensionsLatestVersi ) } -export default observer(Advanced) +export default observer(OfflineActivation) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineSubscription.tsx similarity index 90% rename from packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx rename to packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineSubscription.tsx index 6175f3898..f57d8fc56 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Offline/OfflineSubscription.tsx @@ -6,7 +6,6 @@ import { WebApplication } from '@/Application/WebApplication' import { observer } from 'mobx-react-lite' import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/Constants/Strings' import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs' -import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' type Props = { application: WebApplication @@ -111,7 +110,17 @@ const OfflineSubscription: FunctionComponent = ({ application, onSuccess <>
- {!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription +
+ {!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription + + Learn more + +
{!hasUserPreviouslyStoredCode && ( @@ -141,6 +150,7 @@ const OfflineSubscription: FunctionComponent = ({ application, onSuccess )} {!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
- ) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/HomeServer/HomeServerSettings.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/HomeServer/HomeServerSettings.tsx index bfe91cdb9..7be14c8e9 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/HomeServer/HomeServerSettings.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/HomeServer/HomeServerSettings.tsx @@ -5,7 +5,7 @@ import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' import { useApplication } from '@/Components/ApplicationProvider' import EncryptionStatusItem from '../Security/EncryptionStatusItem' import Icon from '@/Components/Icon/Icon' -import OfflineSubscription from '../General/Advanced/OfflineSubscription' +import OfflineSubscription from '../General/Offline/OfflineSubscription' import EnvironmentConfiguration from './Settings/EnvironmentConfiguration' import DatabaseConfiguration from './Settings/DatabaseConfiguration' import { HomeServerEnvironmentConfiguration, HomeServerServiceInterface, classNames, sleep } from '@standardnotes/snjs' diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Types/AnyPackageType.ts b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/AnyPackageType.ts similarity index 100% rename from packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Types/AnyPackageType.ts rename to packages/web/src/javascripts/Components/Preferences/Panes/Plugins/AnyPackageType.ts diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/BrowsePlugins.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/BrowsePlugins.tsx new file mode 100644 index 000000000..ca2df9aaf --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/BrowsePlugins.tsx @@ -0,0 +1,69 @@ +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import { observer } from 'mobx-react-lite' +import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' +import { useApplication } from '@/Components/ApplicationProvider' +import { PluginsList } from '@standardnotes/ui-services' +import PluginRowView from './PluginRowView' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' +import { ContentType } from '@standardnotes/snjs' +import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content' +import { PreferencesPremiumOverlay } from '@/Components/Preferences/PremiumOverlay' + +const BrowsePlugins: FunctionComponent = () => { + const application = useApplication() + + const [plugins, setPlugins] = useState(null) + + const reloadPlugins = useCallback(() => { + application.pluginsService.getInstallablePlugins().then(setPlugins).catch(console.error) + }, [application]) + + useEffect(() => { + reloadPlugins() + }, [reloadPlugins]) + + useEffect(() => { + application.items.streamItems([ContentType.TYPES.Component, ContentType.TYPES.Theme], reloadPlugins) + }, [application, reloadPlugins]) + + const hasSubscription = application.hasValidFirstPartySubscription() + + return ( +
+ + Browse Plugins + + Plugins run in a secure sandbox and can only access data you allow it. Note types allow specialized editing + experiences, but in most cases, the built-in Super note type can encapsulate any + functionality found in plugins. + + + {!plugins && ( +
+ Loading... +
+ )} + +
+ {plugins?.map((plugin, index) => { + return ( +
+ + {index < plugins.length - 1 && } +
+ ) + })} +
+
+ + + Plugins may not be actively maintained. Standard Notes cannot attest to the quality or user experience of these + plugins, and is not responsible for any data loss that may arise from their use. + + + {!hasSubscription && } +
+ ) +} + +export default observer(BrowsePlugins) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/PluginRowView.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/PluginRowView.tsx new file mode 100644 index 000000000..ea5864035 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/BrowsePlugins/PluginRowView.tsx @@ -0,0 +1,45 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import Button from '@/Components/Button/Button' +import { SmallText, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' +import { ContentType } from '@standardnotes/snjs' +import { PluginListing } from '@standardnotes/ui-services' +import { FunctionComponent, useCallback } from 'react' + +type Props = { + plugin: PluginListing +} + +const PluginRowView: FunctionComponent = ({ plugin }) => { + const application = useApplication() + + const install = useCallback(async () => { + const result = await application.pluginsService.installPlugin(plugin) + if (!result) { + void application.alerts.alertV2({ text: 'Failed to install plugin' }) + } else { + void application.alerts.alertV2({ text: `${result.name} has been successfully installed.` }) + } + }, [application, plugin]) + + const pluginType = plugin.content_type === ContentType.TYPES.Theme ? 'theme' : 'note type' + + const hasSubscription = application.hasValidFirstPartySubscription() + + return ( +
+
+ {plugin.name} + + A {pluginType} by {plugin.publisher} + + {plugin.description && {plugin.description}} +
+ + +
+ ) +} + +export default PluginRowView diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/ConfirmCustomPackage.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/ConfirmCustomPlugin.tsx similarity index 67% rename from packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/ConfirmCustomPackage.tsx rename to packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/ConfirmCustomPlugin.tsx index 0c02e361b..8c2510092 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/ConfirmCustomPackage.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/ConfirmCustomPlugin.tsx @@ -1,17 +1,15 @@ -import { ContentType } from '@standardnotes/snjs' - +import { ContentType, ThirdPartyFeatureDescription } from '@standardnotes/snjs' import Button from '@/Components/Button/Button' import { Fragment, FunctionComponent } from 'react' import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' -import { AnyPackageType } from './Types/AnyPackageType' -import PreferencesSegment from '../../../../PreferencesComponents/PreferencesSegment' +import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' -const ConfirmCustomPackage: FunctionComponent<{ - component: AnyPackageType +const ConfirmCustomPlugin: FunctionComponent<{ + plugin: ThirdPartyFeatureDescription callback: (confirmed: boolean) => void -}> = ({ component, callback }) => { +}> = ({ plugin, callback }) => { let contentTypeDisplayName = null - const contentTypeOrError = ContentType.create(component.content_type) + const contentTypeOrError = ContentType.create(plugin.content_type) if (!contentTypeOrError.isFailed()) { contentTypeDisplayName = contentTypeOrError.getValue().getDisplayName() } @@ -19,23 +17,23 @@ const ConfirmCustomPackage: FunctionComponent<{ const fields = [ { label: 'Name', - value: component.package_info.name, + value: plugin.name, }, { label: 'Description', - value: component.package_info.description, + value: plugin.description, }, { label: 'Version', - value: component.package_info.version, + value: plugin.version, }, { label: 'Hosted URL', - value: component.thirdPartyPackageInfo.url, + value: plugin.url, }, { label: 'Download URL', - value: component.package_info.download_url, + value: plugin.download_url, }, { label: 'Extension Type', @@ -70,4 +68,4 @@ const ConfirmCustomPackage: FunctionComponent<{ ) } -export default ConfirmCustomPackage +export default ConfirmCustomPlugin diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/InstallCustomPlugin.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/InstallCustomPlugin.tsx new file mode 100644 index 000000000..7df79f760 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/InstallCustom/InstallCustomPlugin.tsx @@ -0,0 +1,78 @@ +import Button from '@/Components/Button/Button' +import DecoratedInput from '@/Components/Input/DecoratedInput' +import { FunctionComponent, useEffect, useRef, useState } from 'react' +import { observer } from 'mobx-react-lite' +import ConfirmCustomPlugin from './ConfirmCustomPlugin' +import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' +import { useApplication } from '@/Components/ApplicationProvider' +import { ThirdPartyFeatureDescription } from '@standardnotes/snjs' + +type Props = { + className?: string +} + +const InstallCustomPlugin: FunctionComponent = ({ className = '' }) => { + const application = useApplication() + + const [customUrl, setCustomUrl] = useState('') + const [confirmablePlugin, setConfirmablePlugin] = useState(undefined) + + const confirmableEnd = useRef(null) + + useEffect(() => { + if (confirmablePlugin) { + confirmableEnd.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [confirmablePlugin, confirmableEnd]) + + const submitPluginUrl = async (url: string) => { + const plugin = await application.pluginsService.getPluginDetailsFromUrl(url) + if (plugin) { + setConfirmablePlugin(plugin) + } + } + + const confirmPlugin = async (confirm: boolean) => { + if (confirm && confirmablePlugin) { + await application.pluginsService.installExternalPlugin(confirmablePlugin) + } + setConfirmablePlugin(undefined) + setCustomUrl('') + } + + return ( +
+
+ {!confirmablePlugin && ( + +
+ { + setCustomUrl(value) + }} + /> +
+
+ ) +} + +export default observer(InstallCustomPlugin) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/ManagePlugins.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/ManagePlugins.tsx new file mode 100644 index 000000000..e1ef9bc98 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/ManagePlugins.tsx @@ -0,0 +1,75 @@ +import { ContentType } from '@standardnotes/snjs' +import { WebApplication } from '@/Application/WebApplication' +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import { observer } from 'mobx-react-lite' +import { PackageProvider } from '../PackageProvider' +import PackageEntry from './PackageEntry' +import { AnyPackageType } from '../AnyPackageType' +import { useApplication } from '@/Components/ApplicationProvider' + +const loadPlugins = (application: WebApplication) => + application.items.getItems([ + ContentType.TYPES.ActionsExtension, + ContentType.TYPES.Component, + ContentType.TYPES.Theme, + ]) as AnyPackageType[] + +type Props = { + pluginsLatestVersions: PackageProvider + className?: string +} + +const ManagePlugins: FunctionComponent = ({ pluginsLatestVersions, className = '' }) => { + const application = useApplication() + + const [plugins, setPlugins] = useState(loadPlugins(application)) + + const reloadInstalledPlugins = useCallback(() => { + const plugins = application.items.getItems([ + ContentType.TYPES.ActionsExtension, + ContentType.TYPES.Component, + ContentType.TYPES.Theme, + ]) as AnyPackageType[] + setPlugins(plugins) + }, [application.items]) + + useEffect(() => { + application.items.streamItems( + [ContentType.TYPES.Component, ContentType.TYPES.Theme, ContentType.TYPES.ActionsExtension], + reloadInstalledPlugins, + ) + }, [application, reloadInstalledPlugins]) + + const visiblePlugins = plugins.filter((extension) => { + const hasPackageInfo = extension.package_info != undefined + + if (!hasPackageInfo) { + return false + } + + return true + }) + + return ( +
+ {visiblePlugins.length === 0 &&
No plugins installed.
} + {visiblePlugins.length > 0 && ( +
+ {visiblePlugins + .sort((e1, e2) => e1.displayName?.toLowerCase().localeCompare(e2.displayName?.toLowerCase())) + .map((extension) => { + return ( + + ) + })} +
+ )} +
+ ) +} + +export default observer(ManagePlugins) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntry.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntry.tsx new file mode 100644 index 000000000..02e53277a --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntry.tsx @@ -0,0 +1,20 @@ +import { FunctionComponent } from 'react' +import PluginEntrySubInfo from './PackageEntrySubInfo' +import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' +import { AnyPackageType } from '../AnyPackageType' + +interface PackageEntryProps { + plugin: AnyPackageType + latestVersion: string | undefined + toggleActivate?: (extension: AnyPackageType) => void +} + +const PackageEntry: FunctionComponent = ({ plugin }) => { + return ( + + + + ) +} + +export default PackageEntry diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntrySubInfo.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntrySubInfo.tsx new file mode 100644 index 000000000..83695e688 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/ManagePlugins/PackageEntrySubInfo.tsx @@ -0,0 +1,119 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import Button from '@/Components/Button/Button' +import { FunctionComponent, useState, useRef, useEffect } from 'react' +import { AnyPackageType } from '../AnyPackageType' +import { ButtonType, ComponentInterface, ComponentMutator } from '@standardnotes/snjs' + +type Props = { + plugin: AnyPackageType +} + +const PluginEntrySubInfo: FunctionComponent = ({ plugin }) => { + const application = useApplication() + + const isThirdParty = 'identifier' in plugin && application.features.isThirdPartyFeature(plugin.identifier) + + const [isRenaming, setIsRenaming] = useState(false) + const [newPluginName, setNewPluginName] = useState(plugin.name) + + const renameable = isThirdParty + + const inputRef = useRef(null) + + useEffect(() => { + if (isRenaming) { + inputRef.current?.focus() + } + }, [inputRef, isRenaming]) + + const startRenaming = () => { + setNewPluginName(plugin.name) + setIsRenaming(true) + } + + const cancelRename = () => { + setNewPluginName(plugin.name) + setIsRenaming(false) + } + + const confirmRename = () => { + if (!newPluginName) { + return + } + changeName(newPluginName) + setIsRenaming(false) + } + + const [_, setPluginName] = useState(plugin.displayName) + + const changeName = (newName: string) => { + setPluginName(newName) + application.changeAndSaveItem + .execute(plugin, (mutator) => { + mutator.name = newName + }) + .then((result) => { + const component = result.getValue() as ComponentInterface + setPluginName(component.name) + }) + .catch(console.error) + } + + const uninstall = async () => { + application.alerts + .confirm( + 'Are you sure you want to uninstall this plugin?', + 'Uninstall Plugin?', + 'Uninstall', + ButtonType.Danger, + 'Cancel', + ) + .then(async (shouldRemove: boolean) => { + if (shouldRemove) { + await application.mutator.deleteItem(plugin) + void application.sync.sync() + } + }) + .catch((err: string) => { + application.alerts.alert(err).catch(console.error) + }) + } + + return ( +
+ setNewPluginName((input as HTMLInputElement)?.value)} + /> + + {isRenaming && ( +
+ + +
+ )} + + {!isRenaming && ( +
+ {renameable && !isRenaming && ( + + )} +
+ )} +
+ ) +} + +export default PluginEntrySubInfo diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Provider/PackageProvider.ts b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PackageProvider.ts similarity index 92% rename from packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Provider/PackageProvider.ts rename to packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PackageProvider.ts index 90831f017..0287dffbb 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Provider/PackageProvider.ts +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PackageProvider.ts @@ -1,6 +1,6 @@ import { GetFeatures } from '@standardnotes/snjs' import { makeAutoObservable, observable } from 'mobx' -import { AnyPackageType } from '../Types/AnyPackageType' +import { AnyPackageType } from './AnyPackageType' export class PackageProvider { static async load(): Promise { diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PluginsPane.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PluginsPane.tsx new file mode 100644 index 000000000..7582f907c --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Plugins/PluginsPane.tsx @@ -0,0 +1,42 @@ +import { FunctionComponent } from 'react' +import { observer } from 'mobx-react-lite' +import InstallCustomPlugin from '@/Components/Preferences/Panes/Plugins/InstallCustom/InstallCustomPlugin' +import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' +import { PackageProvider } from './PackageProvider' +import BrowsePlugins from './BrowsePlugins/BrowsePlugins' +import PreferencesPane from '../../PreferencesComponents/PreferencesPane' +import { Title } from '../../PreferencesComponents/Content' +import ManagePlugins from './ManagePlugins/ManagePlugins' + +type Props = { + pluginsLatestVersions: PackageProvider +} + +const PluginsPane: FunctionComponent = ({ pluginsLatestVersions }) => { + return ( + + + + + + + + + + Manage Plugins + + + + + + + Install Custom Plugin + + + + + ) +} + +export default observer(PluginsPane) diff --git a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx index d8da72670..9a50a0aa1 100644 --- a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx +++ b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx @@ -23,6 +23,10 @@ export const Text: FunctionComponent = ({ children, className }) => (

{children}

) +export const SmallText: FunctionComponent = ({ children, className }) => ( +

{children}

+) + export const LinkButton: FunctionComponent<{ label: string link: string diff --git a/packages/web/src/javascripts/Components/Preferences/PremiumOverlay.tsx b/packages/web/src/javascripts/Components/Preferences/PremiumOverlay.tsx new file mode 100644 index 000000000..1bb2d790e --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/PremiumOverlay.tsx @@ -0,0 +1,34 @@ +import { observer } from 'mobx-react-lite' +import { FunctionComponent, useRef } from 'react' +import { UpgradePrompt } from '../PremiumFeaturesModal/Subviews/UpgradePrompt' +import { useApplication } from '../ApplicationProvider' + +export const PreferencesPremiumOverlay: FunctionComponent = () => { + const ctaButtonRef = useRef(null) + + const application = useApplication() + + const hasSubscription = application.hasValidFirstPartySubscription() + + const onClick = () => { + application.preferencesController.closePreferences() + } + + return ( +
+
+
+ +
+
+ ) +} + +export default observer(PreferencesPremiumOverlay) diff --git a/packages/web/src/javascripts/Components/PremiumFeaturesModal/Subviews/UpgradePrompt.tsx b/packages/web/src/javascripts/Components/PremiumFeaturesModal/Subviews/UpgradePrompt.tsx index 44ad63645..c81e15bc7 100644 --- a/packages/web/src/javascripts/Components/PremiumFeaturesModal/Subviews/UpgradePrompt.tsx +++ b/packages/web/src/javascripts/Components/PremiumFeaturesModal/Subviews/UpgradePrompt.tsx @@ -3,39 +3,59 @@ import { WebApplication } from '@/Application/WebApplication' import Icon from '@/Components/Icon/Icon' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon' +type Props = { + featureName?: string + ctaRef: React.RefObject + application: WebApplication + hasSubscription: boolean + onClick?: () => void +} & ( + | { + inline: true + onClose?: never + } + | { + inline?: false + onClose: () => void + } +) + export const UpgradePrompt = ({ featureName, ctaRef, application, hasSubscription, onClose, -}: { - featureName?: string - ctaRef: React.RefObject - application: WebApplication - hasSubscription: boolean - onClose: () => void -}) => { + onClick, + inline, +}: Props) => { const handleClick = useCallback(() => { + if (onClick) { + onClick() + } if (hasSubscription && !application.isNativeIOS()) { void application.openSubscriptionDashboard.execute() } else { void application.openPurchaseFlow() } - onClose() - }, [application, hasSubscription, onClose]) + if (onClose) { + onClose() + } + }, [application, hasSubscription, onClose, onClick]) return ( <>
- + {!inline && ( + + )}
FindNativeFeature(component.identifier) === undefined) + .filter((component) => { + const nativeFeature = FindNativeFeature(component.identifier) + return !nativeFeature || nativeFeature.deprecated + }) .map((editor): EditorOption => { const [iconType, tint] = getIconAndTintForNoteType(editor.noteType) diff --git a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts index 4c29a17d6..179d0fe9f 100644 --- a/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts +++ b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts @@ -66,12 +66,6 @@ const insertInstalledComponentsInMap = (map: NoteTypeToEditorRowsMap, applicatio const createGroupsFromMap = (map: NoteTypeToEditorRowsMap): EditorMenuGroup[] => { const superNote = GetSuperNoteFeature() const groups: EditorMenuGroup[] = [ - { - icon: 'plain-text', - iconClassName: 'text-accessory-tint-1', - title: 'Plain text', - items: map[NoteType.Plain], - }, { icon: SuperEditorMetadata.icon, iconClassName: SuperEditorMetadata.iconClassName, @@ -115,6 +109,12 @@ const createGroupsFromMap = (map: NoteTypeToEditorRowsMap): EditorMenuGroup[] => title: 'Authentication', items: map[NoteType.Authentication], }, + { + icon: 'plain-text', + iconClassName: 'text-accessory-tint-1', + title: 'Plain text', + items: map[NoteType.Plain], + }, { icon: 'editor', iconClassName: 'text-neutral',