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)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -32,11 +32,7 @@ export class NativeFeatureIdentifier extends ValueObject<NativeFeatureIdentifier
|
||||
PlainEditor: 'com.standardnotes.plain-text',
|
||||
SuperEditor: 'com.standardnotes.super-editor',
|
||||
|
||||
CodeEditor: 'org.standardnotes.code-editor',
|
||||
MarkdownProEditor: 'org.standardnotes.advanced-markdown-editor',
|
||||
PlusEditor: 'org.standardnotes.plus-editor',
|
||||
SheetsEditor: 'org.standardnotes.standard-sheets',
|
||||
TaskEditor: 'org.standardnotes.simple-task-editor',
|
||||
TokenVaultEditor: 'org.standardnotes.token-vault',
|
||||
|
||||
Clipper: 'org.standardnotes.clipper',
|
||||
@@ -44,6 +40,10 @@ export class NativeFeatureIdentifier extends ValueObject<NativeFeatureIdentifier
|
||||
Vaults: 'org.standardnotes.vaults',
|
||||
SharedVaults: 'org.standardnotes.shared-vaults',
|
||||
|
||||
DeprecatedCodeEditor: 'org.standardnotes.code-editor',
|
||||
DeprecatedMarkdownProEditor: 'org.standardnotes.advanced-markdown-editor',
|
||||
DeprecatedPlusEditor: 'org.standardnotes.plus-editor',
|
||||
DeprecatedTaskEditor: 'org.standardnotes.simple-task-editor',
|
||||
DeprecatedMarkdownVisualEditor: 'org.standardnotes.markdown-visual-editor',
|
||||
DeprecatedBoldEditor: 'org.standardnotes.bold-editor',
|
||||
DeprecatedMarkdownBasicEditor: 'org.standardnotes.simple-markdown-editor',
|
||||
|
||||
@@ -2,4 +2,6 @@ import { ComponentFeatureDescription } from './ComponentFeatureDescription'
|
||||
|
||||
export type ThirdPartyFeatureDescription = ComponentFeatureDescription & {
|
||||
url: string
|
||||
version: string
|
||||
download_url?: string
|
||||
}
|
||||
|
||||
@@ -10,6 +10,66 @@ import { ComponentAction } from '../Component/ComponentAction'
|
||||
import { ComponentArea } from '../Component/ComponentArea'
|
||||
|
||||
export function GetDeprecatedFeatures(): AnyFeatureDescription[] {
|
||||
const code = FillIframeEditorDefaults({
|
||||
name: 'Code',
|
||||
spellcheckControl: true,
|
||||
identifier: NativeFeatureIdentifier.TYPES.DeprecatedCodeEditor,
|
||||
permission_name: PermissionName.DeprecatedCodeEditor,
|
||||
note_type: NoteType.Code,
|
||||
file_type: 'txt',
|
||||
interchangeable: true,
|
||||
deprecated: true,
|
||||
index_path: 'index.html',
|
||||
description:
|
||||
'Syntax highlighting and convenient keyboard shortcuts for over 120 programming' +
|
||||
' languages. Ideal for code snippets and procedures.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/code.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const plus = FillIframeEditorDefaults({
|
||||
name: 'Rich Text',
|
||||
note_type: NoteType.RichText,
|
||||
file_type: 'html',
|
||||
identifier: NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor,
|
||||
permission_name: PermissionName.DeprecatedPlusEditor,
|
||||
spellcheckControl: true,
|
||||
deprecated: true,
|
||||
description:
|
||||
'From highlighting to custom font sizes and colors, to tables and lists, this editor is perfect for crafting any document.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/plus-editor.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const markdown = FillIframeEditorDefaults({
|
||||
name: 'Markdown',
|
||||
identifier: NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor,
|
||||
note_type: NoteType.Markdown,
|
||||
file_type: 'md',
|
||||
permission_name: PermissionName.DeprecatedMarkdownProEditor,
|
||||
spellcheckControl: true,
|
||||
deprecated: true,
|
||||
description:
|
||||
'A fully featured Markdown editor that supports live preview, a styling toolbar, and split pane support.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/adv-markdown.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const task = FillIframeEditorDefaults({
|
||||
name: 'Checklist',
|
||||
identifier: NativeFeatureIdentifier.TYPES.DeprecatedTaskEditor,
|
||||
note_type: NoteType.Task,
|
||||
spellcheckControl: true,
|
||||
file_type: 'md',
|
||||
interchangeable: false,
|
||||
deprecated: true,
|
||||
permission_name: PermissionName.DeprecatedTaskEditor,
|
||||
description:
|
||||
'A great way to manage short-term and long-term to-do"s. You can mark tasks as completed, change their order, and edit the text naturally in place.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/task-editor.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const bold: EditorFeatureDescription = FillIframeEditorDefaults({
|
||||
name: 'Alternative Rich Text',
|
||||
identifier: NativeFeatureIdentifier.TYPES.DeprecatedBoldEditor,
|
||||
@@ -118,5 +178,5 @@ export function GetDeprecatedFeatures(): AnyFeatureDescription[] {
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
return [bold, markdownBasic, markdownMinimist, markdownMath, markdownAlt, filesafe]
|
||||
return [code, plus, markdown, task, bold, markdownBasic, markdownMinimist, markdownMath, markdownAlt, filesafe]
|
||||
}
|
||||
|
||||
@@ -6,62 +6,6 @@ import { RoleName } from '@standardnotes/domain-core'
|
||||
import { IframeComponentFeatureDescription } from '../Feature/IframeComponentFeatureDescription'
|
||||
|
||||
export function IframeEditors(): IframeComponentFeatureDescription[] {
|
||||
const code = FillIframeEditorDefaults({
|
||||
name: 'Code',
|
||||
spellcheckControl: true,
|
||||
identifier: NativeFeatureIdentifier.TYPES.CodeEditor,
|
||||
permission_name: PermissionName.CodeEditor,
|
||||
note_type: NoteType.Code,
|
||||
file_type: 'txt',
|
||||
interchangeable: true,
|
||||
index_path: 'index.html',
|
||||
description:
|
||||
'Syntax highlighting and convenient keyboard shortcuts for over 120 programming' +
|
||||
' languages. Ideal for code snippets and procedures.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/code.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const plus = FillIframeEditorDefaults({
|
||||
name: 'Rich Text',
|
||||
note_type: NoteType.RichText,
|
||||
file_type: 'html',
|
||||
identifier: NativeFeatureIdentifier.TYPES.PlusEditor,
|
||||
permission_name: PermissionName.PlusEditor,
|
||||
spellcheckControl: true,
|
||||
description:
|
||||
'From highlighting to custom font sizes and colors, to tables and lists, this editor is perfect for crafting any document.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/plus-editor.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const markdown = FillIframeEditorDefaults({
|
||||
name: 'Markdown',
|
||||
identifier: NativeFeatureIdentifier.TYPES.MarkdownProEditor,
|
||||
note_type: NoteType.Markdown,
|
||||
file_type: 'md',
|
||||
permission_name: PermissionName.MarkdownProEditor,
|
||||
spellcheckControl: true,
|
||||
description:
|
||||
'A fully featured Markdown editor that supports live preview, a styling toolbar, and split pane support.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/adv-markdown.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const task = FillIframeEditorDefaults({
|
||||
name: 'Checklist',
|
||||
identifier: NativeFeatureIdentifier.TYPES.TaskEditor,
|
||||
note_type: NoteType.Task,
|
||||
spellcheckControl: true,
|
||||
file_type: 'md',
|
||||
interchangeable: false,
|
||||
permission_name: PermissionName.TaskEditor,
|
||||
description:
|
||||
'A great way to manage short-term and long-term to-do"s. You can mark tasks as completed, change their order, and edit the text naturally in place.',
|
||||
thumbnail_url: 'https://assets.standardnotes.com/screenshots/models/editors/task-editor.jpg',
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
const tokenvault = FillIframeEditorDefaults({
|
||||
name: 'Authenticator',
|
||||
note_type: NoteType.Authentication,
|
||||
@@ -88,5 +32,5 @@ export function IframeEditors(): IframeComponentFeatureDescription[] {
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
})
|
||||
|
||||
return [code, plus, markdown, task, tokenvault, spreadsheets]
|
||||
return [tokenvault, spreadsheets]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export enum PermissionName {
|
||||
AccountSwitcher = 'app:account-switcher',
|
||||
AutobiographyTheme = 'theme:autobiography',
|
||||
BoldEditor = 'editor:bold',
|
||||
CodeEditor = 'editor:code-editor',
|
||||
DeprecatedCodeEditor = 'editor:code-editor',
|
||||
ComponentFilesafe = 'component:filesafe',
|
||||
ComponentFolders = 'component:folders',
|
||||
DailyEmailBackup = 'server:daily-email-backup',
|
||||
@@ -16,20 +16,20 @@ export enum PermissionName {
|
||||
MarkdownBasicEditor = 'editor:markdown-basic',
|
||||
MarkdownMathEditor = 'editor:markdown-math',
|
||||
MarkdownMinimistEditor = 'editor:markdown-minimist',
|
||||
MarkdownProEditor = 'editor:markdown-pro',
|
||||
DeprecatedMarkdownProEditor = 'editor:markdown-pro',
|
||||
MarkdownVisualEditor = 'editor:markdown-visual',
|
||||
MidnightTheme = 'theme:midnight',
|
||||
NoteHistory30Days = 'server:note-history-30-days',
|
||||
NoteHistory365Days = 'server:note-history-365-days',
|
||||
NoteHistoryUnlimited = 'server:note-history-unlimited',
|
||||
PlainEditor = 'editor:plain',
|
||||
PlusEditor = 'editor:plus',
|
||||
DeprecatedPlusEditor = 'editor:plus',
|
||||
SheetsEditor = 'editor:sheets',
|
||||
SignInAlerts = 'server:sign-in-alerts',
|
||||
SmartFilters = 'app:smart-filters',
|
||||
SolarizedDarkTheme = 'theme:solarized-dark',
|
||||
TagNesting = 'app:tag-nesting',
|
||||
TaskEditor = 'editor:task-editor',
|
||||
DeprecatedTaskEditor = 'editor:task-editor',
|
||||
ThemeDynamic = 'theme:dynamic',
|
||||
TitaniumTheme = 'theme:titanium',
|
||||
TokenVaultEditor = 'editor:token-vault',
|
||||
|
||||
@@ -736,7 +736,7 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 57d2868c099736d80fcd648bf211b4431e51a558
|
||||
boost: a7c83b31436843459a1961bfd74b96033dc77234
|
||||
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
|
||||
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
|
||||
FBLazyVector: 71803c074f6325f10b5ec891c443b6bbabef0ca7
|
||||
@@ -756,7 +756,7 @@ SPEC CHECKSUMS:
|
||||
MMKV: 9c4663aa7ca255d478ff10f2f5cb7d17c1651ccd
|
||||
MMKVCore: 89f5c8a66bba2dcd551779dea4d412eeec8ff5bb
|
||||
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
|
||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||
RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda
|
||||
RCTRequired: df81ab637d35fac9e6eb94611cfd20f0feb05455
|
||||
RCTTypeSafety: 4636e4a36c7c2df332bda6d59b19b41c443d4287
|
||||
React: e0cc5197a804031a6c53fb38483c3485fcb9d6f3
|
||||
|
||||
@@ -991,7 +991,6 @@
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
"-Wl -ld_classic ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1060,7 +1059,6 @@
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
"-Wl -ld_classic ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -16,9 +16,9 @@ describe('note mutator', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface LegacyApiServiceInterface
|
||||
|
||||
downloadOfflineFeaturesFromRepo(dto: {
|
||||
repo: SNFeatureRepo
|
||||
trustedFeatureHosts: string[]
|
||||
}): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError>
|
||||
|
||||
downloadFeatureUrl(url: string): Promise<HttpResponse>
|
||||
|
||||
@@ -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<ComponentInterface | undefined>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const PREFERENCE_PANE_IDS = [
|
||||
'appearance',
|
||||
'backups',
|
||||
'listed',
|
||||
'plugins',
|
||||
'shortcuts',
|
||||
'accessibility',
|
||||
'get-free-month',
|
||||
|
||||
@@ -16,6 +16,7 @@ export class StatusService extends AbstractService<StatusServiceEvent, string> i
|
||||
backups: 0,
|
||||
listed: 0,
|
||||
shortcuts: 0,
|
||||
plugins: 0,
|
||||
accessibility: 0,
|
||||
'get-free-month': 0,
|
||||
'help-feedback': 0,
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('editor change alert', () => {
|
||||
|
||||
it('should not require alert switching from plain editor', () => {
|
||||
const component = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.PlusEditor,
|
||||
NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor,
|
||||
)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.PlusEditor,
|
||||
NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor,
|
||||
)!
|
||||
const markdownEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.PlusEditor,
|
||||
NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor,
|
||||
)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.PlusEditor,
|
||||
NativeFeatureIdentifier.TYPES.DeprecatedPlusEditor,
|
||||
)!
|
||||
const customEditor = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.TokenVaultEditor,
|
||||
|
||||
@@ -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<ComponentItem>
|
||||
|
||||
@@ -55,6 +55,6 @@ describe('getDefaultEditorIdentifier', () => {
|
||||
|
||||
const editorIdentifier = usecase.execute().getValue()
|
||||
|
||||
expect(editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.MarkdownProEditor)
|
||||
expect(editorIdentifier).toEqual(NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('GetFeatureUrl', () => {
|
||||
|
||||
it('returns native path for native component', () => {
|
||||
const feature = nativeFeatureAsUIFeature<IframeComponentFeatureDescription>(
|
||||
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<IframeComponentFeatureDescription>(
|
||||
NativeFeatureIdentifier.TYPES.MarkdownProEditor,
|
||||
NativeFeatureIdentifier.TYPES.DeprecatedMarkdownProEditor,
|
||||
)
|
||||
const url = usecase.execute(feature)
|
||||
expect(url).toEqual(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<SetOfflineFeaturesFunctionResponse> {
|
||||
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<ComponentInterface | undefined> {
|
||||
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
|
||||
|
||||
@@ -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<ComponentInterface | undefined> {
|
||||
const response = await this.api.downloadFeatureUrl(url)
|
||||
if (response.data?.error) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let rawFeature = response.data as ThirdPartyFeatureDescription
|
||||
|
||||
if (isString(rawFeature)) {
|
||||
try {
|
||||
rawFeature = JSON.parse(rawFeature)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!rawFeature.content_type) {
|
||||
return
|
||||
}
|
||||
|
||||
const isValidContentType = [
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
ContentType.TYPES.ActionsExtension,
|
||||
ContentType.TYPES.ExtensionRepo,
|
||||
].includes(rawFeature.content_type)
|
||||
|
||||
if (!isValidContentType) {
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(rawFeature.identifier)
|
||||
if (nativeFeature) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
|
||||
if (rawFeature.url) {
|
||||
for (const nativeFeature of GetFeatures()) {
|
||||
if (rawFeature.url.includes(nativeFeature.identifier)) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = FillItemContentSpecialized<ComponentContentSpecialized, ComponentContent>({
|
||||
area: rawFeature.area,
|
||||
name: rawFeature.name ?? '',
|
||||
package_info: rawFeature,
|
||||
valid_until: new Date(rawFeature.expires_at || 0),
|
||||
hosted_url: rawFeature.url,
|
||||
})
|
||||
|
||||
const component = this.items.createTemplateItem<ComponentContent, ComponentInterface>(
|
||||
rawFeature.content_type,
|
||||
content,
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
10
packages/ui-services/src/Plugins/PluginListing.ts
Normal file
10
packages/ui-services/src/Plugins/PluginListing.ts
Normal file
@@ -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[]
|
||||
95
packages/ui-services/src/Plugins/PluginsService.spec.ts
Normal file
95
packages/ui-services/src/Plugins/PluginsService.spec.ts
Normal file
@@ -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<LegacyApiServiceInterface>
|
||||
apiService.addEventObserver = jest.fn()
|
||||
itemManager = {} as jest.Mocked<ItemManagerInterface>
|
||||
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
crypto.base64Decode = jest.fn()
|
||||
|
||||
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
|
||||
itemManager.addObserver = jest.fn()
|
||||
|
||||
let alertService: AlertService
|
||||
alertService = {} as jest.Mocked<AlertService>
|
||||
alertService.confirm = jest.fn().mockReturnValue(true)
|
||||
alertService.alert = jest.fn()
|
||||
|
||||
mutator = {} as jest.Mocked<MutatorClientInterface>
|
||||
mutator.createItem = jest.fn()
|
||||
mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
|
||||
mutator.setItemsToBeDeleted = jest.fn()
|
||||
mutator.changeItem = jest.fn()
|
||||
mutator.changeFeatureRepo = jest.fn()
|
||||
|
||||
syncService = {} as jest.Mocked<SyncServiceInterface>
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
180
packages/ui-services/src/Plugins/PluginsService.ts
Normal file
180
packages/ui-services/src/Plugins/PluginsService.ts
Normal file
@@ -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<PluginsList> {
|
||||
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<PluginsList> {
|
||||
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<ComponentInterface | undefined> {
|
||||
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<ComponentContentSpecialized, ComponentContent>({
|
||||
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<ComponentContent, ComponentInterface>(plugin.content_type, content)
|
||||
|
||||
await this.mutator.insertItem(component)
|
||||
void this.sync.sync()
|
||||
|
||||
return component
|
||||
}
|
||||
|
||||
public async getPluginDetailsFromUrl(urlOrCode: string): Promise<ThirdPartyFeatureDescription | undefined> {
|
||||
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<ComponentInterface | undefined> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
10
packages/ui-services/src/Plugins/PluginsServiceInterface.ts
Normal file
10
packages/ui-services/src/Plugins/PluginsServiceInterface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ComponentInterface } from '@standardnotes/models'
|
||||
import { PluginListing, PluginsList } from './PluginListing'
|
||||
import { ThirdPartyFeatureDescription } from '@standardnotes/features'
|
||||
|
||||
export interface PluginsServiceInterface {
|
||||
getInstallablePlugins(): Promise<PluginsList>
|
||||
installPlugin(plugin: PluginListing): Promise<ComponentInterface | undefined>
|
||||
getPluginDetailsFromUrl(urlOrCode: string): Promise<ThirdPartyFeatureDescription | undefined>
|
||||
installExternalPlugin(plugin: PluginListing | ThirdPartyFeatureDescription): Promise<ComponentInterface | undefined>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<IsNativeMobileWeb>(Web_TYPES.IsNativeMobileWeb))
|
||||
})
|
||||
|
||||
@@ -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<ChangelogService>(Web_TYPES.ChangelogService)
|
||||
}
|
||||
|
||||
get pluginsService(): PluginsServiceInterface {
|
||||
return this.deps.get<PluginsServiceInterface>(Web_TYPES.PluginsService)
|
||||
}
|
||||
|
||||
get momentsService(): MomentsService {
|
||||
return this.deps.get<MomentsService>(Web_TYPES.MomentsService)
|
||||
}
|
||||
|
||||
@@ -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<ChangeEditorMenuProps> = ({
|
||||
onSelect,
|
||||
setDisableClickOutside,
|
||||
}) => {
|
||||
const groups = useMemo(() => createEditorMenuGroups(application), [application])
|
||||
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
|
||||
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<UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>>()
|
||||
const [pendingConversionItem, setPendingConversionItem] = useState<EditorMenuItem | null>(null)
|
||||
@@ -195,6 +225,13 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
],
|
||||
)
|
||||
|
||||
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<ChangeEditorMenuProps> = ({
|
||||
setDisableClickOutside?.(false)
|
||||
}
|
||||
|
||||
const managePlugins = useCallback(() => {
|
||||
application.openPreferences('plugins')
|
||||
}, [application])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu className="pb-1 pt-0.5" a11yLabel="Change note type menu">
|
||||
<MenuSection>
|
||||
<div className="flex items-center justify-between pr-4 py-3 md:pt-0 md:pb-1">
|
||||
<div className="px-3">
|
||||
<h2 className="text-base font-bold">Choose a note type</h2>
|
||||
{unableToFindEditor && (
|
||||
<p className="mr-2 pt-1 text-xs text-warning">
|
||||
Unable to find system editor for this note. Select Manage Plugins to reinstall this editor.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="cursor-pointer whitespace-nowrap text-right text-xs text-info" onClick={managePlugins}>
|
||||
Manage Plugins
|
||||
</button>
|
||||
</div>
|
||||
</MenuSection>
|
||||
|
||||
{groups
|
||||
.filter((group) => group.items && group.items.length)
|
||||
.map((group) => {
|
||||
@@ -236,6 +293,13 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
Labs
|
||||
</Pill>
|
||||
)}
|
||||
{menuItem.uiFeature.featureIdentifier === NativeFeatureIdentifier.TYPES.SuperEditor &&
|
||||
!isSelected(menuItem) &&
|
||||
recommendSuper && (
|
||||
<Pill className="px-1.5 py-0.5 text-[9px]" style="info">
|
||||
Recommended
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
{!menuItem.isEntitled && (
|
||||
<Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<PreferencesProps & { menu: PreferencesSessionController }> = ({
|
||||
menu,
|
||||
@@ -19,7 +20,7 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesSess
|
||||
}) => {
|
||||
switch (menu.selectedPaneId) {
|
||||
case 'general':
|
||||
return <General application={application} extensionsLatestVersions={menu.extensionsLatestVersions} />
|
||||
return <General />
|
||||
case 'account':
|
||||
return <AccountPreferences application={application} />
|
||||
case 'appearance':
|
||||
@@ -36,6 +37,8 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesSess
|
||||
return <Listed application={application} />
|
||||
case 'shortcuts':
|
||||
return null
|
||||
case 'plugins':
|
||||
return <PluginsPane pluginsLatestVersions={menu.extensionsLatestVersions} />
|
||||
case 'accessibility':
|
||||
return null
|
||||
case 'get-free-month':
|
||||
@@ -45,7 +48,7 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesSess
|
||||
case 'whats-new':
|
||||
return <WhatsNew application={application} />
|
||||
default:
|
||||
return <General application={application} extensionsLatestVersions={menu.extensionsLatestVersions} />
|
||||
return <General />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className="flex flex-row">
|
||||
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
|
||||
<Switch onChange={toggleOfflineOnly} checked={!offlineOnly} />
|
||||
</div>
|
||||
)
|
||||
|
||||
interface PackageEntryProps {
|
||||
application: WebApplication
|
||||
extension: AnyPackageType
|
||||
first: boolean
|
||||
latestVersion: string | undefined
|
||||
uninstall: (extension: AnyPackageType) => void
|
||||
toggleActivate?: (extension: AnyPackageType) => void
|
||||
}
|
||||
|
||||
const PackageEntry: FunctionComponent<PackageEntryProps> = ({ 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<ComponentMutator>(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<ComponentMutator>(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 (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
<PackageEntrySubInfo isThirdParty={isThirdParty} extensionName={extensionName} changeName={changeExtensionName} />
|
||||
|
||||
<div className="my-1" />
|
||||
|
||||
{isThirdParty && localInstallable && (
|
||||
<UseHosted offlineOnly={offlineOnly} toggleOfflineOnly={toggleOfflineOnly} />
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-row">
|
||||
<Button className="min-w-20" label={'Uninstall'} onClick={() => uninstall(extension)} />
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
)
|
||||
}
|
||||
|
||||
export default PackageEntry
|
||||
@@ -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<Props> = ({ extensionName, changeName, isThirdParty }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName)
|
||||
|
||||
const renameable = isThirdParty
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex flex-row flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={!isRenaming || !renameable}
|
||||
autoComplete="off"
|
||||
className="no-border flex-grow bg-default px-0 text-base font-bold text-text"
|
||||
type="text"
|
||||
value={newExtensionName}
|
||||
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
|
||||
/>
|
||||
|
||||
{isRenaming && (
|
||||
<>
|
||||
<Button small className="cursor-pointer" onClick={confirmRename}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button small className="cursor-pointer" onClick={cancelRename}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renameable && !isRenaming && (
|
||||
<Button small className="cursor-pointer" onClick={startRenaming}>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PackageEntrySubInfo
|
||||
@@ -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<Props> = ({
|
||||
application,
|
||||
extensionsLatestVersions,
|
||||
className = '',
|
||||
}) => {
|
||||
const [customUrl, setCustomUrl] = useState('')
|
||||
const [confirmableExtension, setConfirmableExtension] = useState<AnyPackageType | undefined>(undefined)
|
||||
const [extensions, setExtensions] = useState(loadExtensions(application))
|
||||
|
||||
const confirmableEnd = useRef<HTMLDivElement>(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 (
|
||||
<div className={className}>
|
||||
{visibleExtensions.length > 0 && (
|
||||
<div>
|
||||
{visibleExtensions
|
||||
.sort((e1, e2) => e1.displayName?.toLowerCase().localeCompare(e2.displayName?.toLowerCase()))
|
||||
.map((extension, i) => (
|
||||
<PackageEntry
|
||||
key={extension.uuid}
|
||||
application={application}
|
||||
extension={extension}
|
||||
latestVersion={extensionsLatestVersions.getVersion(extension)}
|
||||
first={i === 0}
|
||||
uninstall={uninstallExtension}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{!confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Install External Plugin</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Enter Plugin URL'}
|
||||
value={customUrl}
|
||||
onChange={(value) => {
|
||||
setCustomUrl(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={customUrl.length === 0}
|
||||
className="mt-3 min-w-20"
|
||||
primary
|
||||
label="Install"
|
||||
onClick={() => submitExtensionUrl(customUrl)}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
{confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<ConfirmCustomPackage component={confirmableExtension} callback={handleConfirmExtensionSubmit} />
|
||||
<div ref={confirmableEnd} />
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(PackagesPreferencesSection)
|
||||
@@ -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 (
|
||||
<PreferencesPane>
|
||||
<Persistence application={application} />
|
||||
<Defaults application={application} />
|
||||
<NewNoteDefaults />
|
||||
<Tools application={application} />
|
||||
<SmartViews application={application} featuresController={application.featuresController} />
|
||||
<Moments application={application} />
|
||||
<LabsPane application={application} />
|
||||
<OfflineActivation />
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
|
||||
const General: FunctionComponent<Props> = ({ application, extensionsLatestVersions }) => (
|
||||
<PreferencesPane>
|
||||
<Persistence application={application} />
|
||||
<Defaults application={application} />
|
||||
<NewNoteDefaults />
|
||||
<Tools application={application} />
|
||||
<SmartViews application={application} featuresController={application.featuresController} />
|
||||
<Moments application={application} />
|
||||
<LabsPane application={application} />
|
||||
<Advanced application={application} extensionsLatestVersions={extensionsLatestVersions} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
|
||||
export default observer(General)
|
||||
|
||||
@@ -114,13 +114,9 @@ const Moments: FunctionComponent<Props> = ({ application }: Props) => {
|
||||
<PreferencesSegment>
|
||||
<Text>
|
||||
Moments lets you capture photos of yourself throughout the day, creating a visual record of your life, one
|
||||
photo at a time.
|
||||
</Text>
|
||||
|
||||
<Text className="mt-3">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<div className="mt-5 flex flex-row flex-wrap gap-3">
|
||||
|
||||
@@ -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<Props> = ({ application, extensionsLatestVersions }) => {
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Advanced options'}>
|
||||
<AccordionItem title={'Offline activation'}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex max-w-full flex-grow flex-col">
|
||||
{application.platform !== Platform.Ios && <OfflineSubscription application={application} />}
|
||||
<PackagesPreferencesSection
|
||||
className={'mt-3'}
|
||||
application={application}
|
||||
extensionsLatestVersions={extensionsLatestVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
@@ -35,4 +37,4 @@ const Advanced: FunctionComponent<Props> = ({ application, extensionsLatestVersi
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Advanced)
|
||||
export default observer(OfflineActivation)
|
||||
@@ -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<Props> = ({ application, onSuccess
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mt-3 flex w-full flex-col">
|
||||
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
|
||||
<a
|
||||
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-info"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
<form onSubmit={handleSubscriptionCodeSubmit}>
|
||||
<div className={'mt-2'}>
|
||||
{!hasUserPreviouslyStoredCode && (
|
||||
@@ -141,6 +150,7 @@ const OfflineSubscription: FunctionComponent<Props> = ({ application, onSuccess
|
||||
)}
|
||||
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
|
||||
<Button
|
||||
hidden={activationCode.length === 0}
|
||||
label={'Submit'}
|
||||
primary
|
||||
disabled={activationCode === ''}
|
||||
@@ -150,7 +160,6 @@ const OfflineSubscription: FunctionComponent<Props> = ({ application, onSuccess
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<PluginsList | null>(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 (
|
||||
<div className="relative">
|
||||
<PreferencesSegment>
|
||||
<Title>Browse Plugins</Title>
|
||||
<Text className="text-neutral">
|
||||
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 <strong>built-in Super note type</strong> can encapsulate any
|
||||
functionality found in plugins.
|
||||
</Text>
|
||||
|
||||
{!plugins && (
|
||||
<div className="mb-3 mt-5 flex h-full w-full items-center">
|
||||
<span className="w-full font-bold">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
{plugins?.map((plugin, index) => {
|
||||
return (
|
||||
<div key={plugin.name}>
|
||||
<PluginRowView plugin={plugin} />
|
||||
{index < plugins.length - 1 && <HorizontalSeparator />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<HorizontalSeparator />
|
||||
<Text className="mt-4 text-danger">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
{!hasSubscription && <PreferencesPremiumOverlay />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(BrowsePlugins)
|
||||
@@ -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<Props> = ({ 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 (
|
||||
<div className="align-center my-2.5 flex items-center justify-between md:items-center">
|
||||
<div className="mr-5">
|
||||
<Subtitle className="mb-0 text-info">{plugin.name}</Subtitle>
|
||||
<SmallText className="mb-1">
|
||||
A <strong>{pluginType}</strong> by {plugin.publisher}
|
||||
</SmallText>
|
||||
{plugin.description && <SmallText className="text-neutral">{plugin.description}</SmallText>}
|
||||
</div>
|
||||
|
||||
<Button disabled={!hasSubscription} small className="cursor-pointer" onClick={install}>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginRowView
|
||||
@@ -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
|
||||
@@ -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<Props> = ({ className = '' }) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [customUrl, setCustomUrl] = useState('')
|
||||
const [confirmablePlugin, setConfirmablePlugin] = useState<ThirdPartyFeatureDescription | undefined>(undefined)
|
||||
|
||||
const confirmableEnd = useRef<HTMLDivElement>(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 (
|
||||
<div className={className}>
|
||||
<div>
|
||||
{!confirmablePlugin && (
|
||||
<PreferencesSegment>
|
||||
<div>
|
||||
<DecoratedInput
|
||||
placeholder={'Enter Plugin URL'}
|
||||
value={customUrl}
|
||||
onChange={(value) => {
|
||||
setCustomUrl(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
hidden={customUrl.length === 0}
|
||||
disabled={customUrl.length === 0}
|
||||
className="mt-4 min-w-20"
|
||||
primary
|
||||
label="Install"
|
||||
onClick={() => submitPluginUrl(customUrl)}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
{confirmablePlugin && (
|
||||
<PreferencesSegment>
|
||||
<ConfirmCustomPlugin plugin={confirmablePlugin} callback={confirmPlugin} />
|
||||
<div ref={confirmableEnd} />
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(InstallCustomPlugin)
|
||||
@@ -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<Props> = ({ 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 (
|
||||
<div className={className}>
|
||||
{visiblePlugins.length === 0 && <div className="text-neutral">No plugins installed.</div>}
|
||||
{visiblePlugins.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{visiblePlugins
|
||||
.sort((e1, e2) => e1.displayName?.toLowerCase().localeCompare(e2.displayName?.toLowerCase()))
|
||||
.map((extension) => {
|
||||
return (
|
||||
<PackageEntry
|
||||
plugin={extension}
|
||||
latestVersion={pluginsLatestVersions.getVersion(extension)}
|
||||
key={extension.uuid}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ManagePlugins)
|
||||
@@ -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<PackageEntryProps> = ({ plugin }) => {
|
||||
return (
|
||||
<PreferencesSegment>
|
||||
<PluginEntrySubInfo plugin={plugin} />
|
||||
</PreferencesSegment>
|
||||
)
|
||||
}
|
||||
|
||||
export default PackageEntry
|
||||
@@ -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<Props> = ({ plugin }) => {
|
||||
const application = useApplication()
|
||||
|
||||
const isThirdParty = 'identifier' in plugin && application.features.isThirdPartyFeature(plugin.identifier)
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newPluginName, setNewPluginName] = useState<string>(plugin.name)
|
||||
|
||||
const renameable = isThirdParty
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(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<ComponentMutator>(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 (
|
||||
<div className="align-center my-2.5 flex items-center justify-between md:items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={!isRenaming || !renameable}
|
||||
autoComplete="off"
|
||||
className="no-border mr-2 flex-grow rounded-sm bg-default px-0 py-1 text-sm font-bold text-text"
|
||||
type="text"
|
||||
value={newPluginName}
|
||||
onChange={({ target: input }) => setNewPluginName((input as HTMLInputElement)?.value)}
|
||||
/>
|
||||
|
||||
{isRenaming && (
|
||||
<div className="flex gap-1">
|
||||
<Button small className="cursor-pointer" onClick={confirmRename}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button small className="cursor-pointer" onClick={cancelRename}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<div className="flex flex-row flex-wrap justify-end gap-2">
|
||||
{renameable && !isRenaming && (
|
||||
<Button small className="cursor-pointer" onClick={startRenaming}>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button small className="min-w-20" label={'Uninstall'} onClick={uninstall} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginEntrySubInfo
|
||||
@@ -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<PackageProvider | undefined> {
|
||||
@@ -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<Props> = ({ pluginsLatestVersions }) => {
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<BrowsePlugins />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Manage Plugins</Title>
|
||||
<ManagePlugins className={'mt-3'} pluginsLatestVersions={pluginsLatestVersions} />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Install Custom Plugin</Title>
|
||||
<InstallCustomPlugin className={'mt-3'} />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(PluginsPane)
|
||||
@@ -23,6 +23,10 @@ export const Text: FunctionComponent<Props> = ({ children, className }) => (
|
||||
<p className={classNames('text-base lg:text-xs', className)}>{children}</p>
|
||||
)
|
||||
|
||||
export const SmallText: FunctionComponent<Props> = ({ children, className }) => (
|
||||
<p className={classNames('text-sm lg:text-xs', className)}>{children}</p>
|
||||
)
|
||||
|
||||
export const LinkButton: FunctionComponent<{
|
||||
label: string
|
||||
link: string
|
||||
|
||||
@@ -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<HTMLButtonElement>(null)
|
||||
|
||||
const application = useApplication()
|
||||
|
||||
const hasSubscription = application.hasValidFirstPartySubscription()
|
||||
|
||||
const onClick = () => {
|
||||
application.preferencesController.closePreferences()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center">
|
||||
<div className="absolute h-full w-full bg-default opacity-[86%]"></div>
|
||||
<div className="border-1 z-10 rounded border border-border bg-default p-5">
|
||||
<UpgradePrompt
|
||||
featureName={'Plugin Gallery'}
|
||||
ctaRef={ctaButtonRef}
|
||||
application={application}
|
||||
hasSubscription={hasSubscription}
|
||||
inline={true}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(PreferencesPremiumOverlay)
|
||||
@@ -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<HTMLButtonElement>
|
||||
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<HTMLButtonElement>
|
||||
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 (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex justify-end p-1">
|
||||
<button
|
||||
className="flex cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon className="text-neutral" type="close" />
|
||||
</button>
|
||||
{!inline && (
|
||||
<button
|
||||
className="flex cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<Icon className="text-neutral" type="close" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
|
||||
|
||||
@@ -33,7 +33,10 @@ export function getDropdownItemsForAllEditors(application: WebApplicationInterfa
|
||||
options.push(
|
||||
...application.componentManager
|
||||
.thirdPartyComponentsForArea(ComponentArea.Editor)
|
||||
.filter((component) => FindNativeFeature(component.identifier) === undefined)
|
||||
.filter((component) => {
|
||||
const nativeFeature = FindNativeFeature(component.identifier)
|
||||
return !nativeFeature || nativeFeature.deprecated
|
||||
})
|
||||
.map((editor): EditorOption => {
|
||||
const [iconType, tint] = getIconAndTintForNoteType(editor.noteType)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user