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',