From 7b6c99d1883ab9a11d776dd5afa57ad77065daff Mon Sep 17 00:00:00 2001 From: Gorjan Petrovski Date: Fri, 8 Oct 2021 09:20:46 +0200 Subject: [PATCH] feat: extension manager in preferences (#670) * feat: add extensions pane * fix: rename extensions folder for MacOS compatibility * feat: extension toggles and uninstall * feat: implement extension renaming, activation, deactivation and UI/UX fixes * feat(preferences): improve extension item design * feat(preferences): hide custom extension input when installation confirmed --- .../preferences/PreferencesMenu.ts | 4 +- .../preferences/PreferencesView.tsx | 3 + .../preferences/panes/Extensions.tsx | 112 ++++++++++ .../ConfirmCustomExtension.tsx | 80 +++++++ .../extensions-segments/ExtensionItem.tsx | 195 ++++++++++++++++++ .../panes/extensions-segments/index.ts | 2 + 6 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/preferences/panes/Extensions.tsx create mode 100644 app/assets/javascripts/preferences/panes/extensions-segments/ConfirmCustomExtension.tsx create mode 100644 app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx create mode 100644 app/assets/javascripts/preferences/panes/extensions-segments/index.ts diff --git a/app/assets/javascripts/preferences/PreferencesMenu.ts b/app/assets/javascripts/preferences/PreferencesMenu.ts index 2d1c75856..d538eb404 100644 --- a/app/assets/javascripts/preferences/PreferencesMenu.ts +++ b/app/assets/javascripts/preferences/PreferencesMenu.ts @@ -6,6 +6,7 @@ const PREFERENCE_IDS = [ 'account', 'appearance', 'security', + 'extensions', 'listed', 'shortcuts', 'accessibility', @@ -28,6 +29,7 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ { id: 'account', label: 'Account', icon: 'user' }, { id: 'appearance', label: 'Appearance', icon: 'themes' }, { id: 'security', label: 'Security', icon: 'security' }, + { id: 'extensions', label: 'Extensions', icon: 'tune' }, { id: 'listed', label: 'Listed', icon: 'listed' }, { id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' }, { id: 'accessibility', label: 'Accessibility', icon: 'accessibility' }, @@ -65,7 +67,7 @@ export class PreferencesMenu { ); } - selectPane(key: PreferenceId) { + selectPane(key: PreferenceId): void { this._selectedPane = key; } } diff --git a/app/assets/javascripts/preferences/PreferencesView.tsx b/app/assets/javascripts/preferences/PreferencesView.tsx index 74839c310..e6b2b47bd 100644 --- a/app/assets/javascripts/preferences/PreferencesView.tsx +++ b/app/assets/javascripts/preferences/PreferencesView.tsx @@ -9,6 +9,7 @@ import { WebApplication } from '@/ui_models/application'; import { MfaProps } from './panes/two-factor-auth/MfaProps'; import { AppState } from '@/ui_models/app_state'; import { useEffect } from 'preact/hooks'; +import { Extensions } from './panes/Extensions'; interface PreferencesProps extends MfaProps { application: WebApplication; @@ -40,6 +41,8 @@ const PaneSelector: FunctionComponent< application={props.application} /> ); + case 'extensions': + return ; case 'listed': return ; case 'shortcuts': diff --git a/app/assets/javascripts/preferences/panes/Extensions.tsx b/app/assets/javascripts/preferences/panes/Extensions.tsx new file mode 100644 index 000000000..185cdb31e --- /dev/null +++ b/app/assets/javascripts/preferences/panes/Extensions.tsx @@ -0,0 +1,112 @@ +import { ContentType, SNComponent } from '@standardnotes/snjs'; +import { Button } from '@/components/Button'; +import { DecoratedInput } from '@/components/DecoratedInput'; +import { WebApplication } from '@/ui_models/application'; +import { FunctionComponent } from 'preact'; +import { + Title, + PreferencesGroup, + PreferencesPane, + PreferencesSegment, +} from '../components'; +import { ConfirmCustomExtension, ExtensionItem } from './extensions-segments'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +const loadExtensions = (application: WebApplication) => application.getItems([ + ContentType.ActionsExtension, + ContentType.Component, + ContentType.Theme, +]) as SNComponent[]; + +export const Extensions: FunctionComponent<{ + application: WebApplication +}> = ({ application }) => { + + const [customUrl, setCustomUrl] = useState(''); + const [confirmableExtension, setConfirmableExtension] = useState(undefined); + const [extensions, setExtensions] = useState(loadExtensions(application)); + + const confirmableEnd = useRef(null); + + useEffect(() => { + if (confirmableExtension) { + confirmableEnd.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [confirmableExtension, confirmableEnd]); + + const uninstallExtension = async (extension: SNComponent) => { + await application.deleteItem(extension); + setExtensions(loadExtensions(application)); + }; + + const submitExtensionUrl = async (url: string) => { + const component = await application.downloadExternalFeature(url); + if (component) { + setConfirmableExtension(component); + } + }; + + const handleConfirmExtensionSubmit = async (confirm: boolean) => { + if (confirm) { + confirmExtension(); + } + setConfirmableExtension(undefined); + setCustomUrl(''); + }; + + const confirmExtension = async () => { + await application.insertItem(confirmableExtension as SNComponent); + setExtensions(loadExtensions(application)); + }; + + const toggleActivateExtension = (extension: SNComponent) => { + application.toggleComponent(extension); + setExtensions(loadExtensions(application)); + }; + + return ( + + + { + extensions + .filter(extension => extension.package_info.identifier !== 'org.standardnotes.extensions-manager') + .sort((e1, e2) => e1.name.toLowerCase().localeCompare(e2.name.toLowerCase())) + .map((extension, i) => ( + + )) + } + + + + {!confirmableExtension && + + Install Custom Extension +
+ { setCustomUrl(value); }} + /> +
+
+ + + ); +}; diff --git a/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx b/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx new file mode 100644 index 000000000..d6d535bb3 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx @@ -0,0 +1,195 @@ +import { FunctionComponent } from "preact"; +import { SNComponent } from "@standardnotes/snjs"; + +import { PreferencesSegment, Subtitle, Title } from "@/preferences/components"; +import { Switch } from "@/components/Switch"; +import { WebApplication } from "@/ui_models/application"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { Button } from "@/components/Button"; + +const ExtensionVersions: FunctionComponent<{ + extension: SNComponent +}> = ({ extension }) => { + return ( +
+
+ Installed version {extension.package_info.version} +
+
+ ); +}; + +const AutoUpdateLocal: FunctionComponent<{ + autoupdateDisabled: boolean, + toggleAutoupdate: () => void +}> = ({ autoupdateDisabled, toggleAutoupdate }) => ( +
+ Autoupdate local installation + +
+); + +const UseHosted: FunctionComponent<{ + offlineOnly: boolean, toggleOfllineOnly: () => void +}> = ({ offlineOnly, toggleOfllineOnly }) => ( +
+ Use hosted when local is unavailable + +
+); + +const RenameExtension: FunctionComponent<{ + extensionName: string, changeName: (newName: string) => void +}> = ({ extensionName, changeName }) => { + const [isRenaming, setIsRenaming] = useState(false); + const [newExtensionName, setNewExtensionName] = useState(extensionName); + + const inputRef = useRef(null); + + useEffect(() => { + if (isRenaming) { + inputRef.current.focus(); + } + }, [inputRef, isRenaming]); + + const startRenaming = () => { + setNewExtensionName(extensionName); + setIsRenaming(true); + }; + + const cancelRename = () => { + setNewExtensionName(extensionName); + setIsRenaming(false); + }; + + const confirmRename = () => { + if (newExtensionName == undefined || newExtensionName === '') { + return; + } + changeName(newExtensionName); + setIsRenaming(false); + }; + + return ( +
+ setNewExtensionName((input as HTMLInputElement)?.value)} + /> +
+ {isRenaming ? + <> + Confirm +
+ Cancel + : + Rename + } +
+ ); +}; + +export const ExtensionItem: FunctionComponent<{ + application: WebApplication, + extension: SNComponent, + first: boolean, + uninstall: (extension: SNComponent) => void, + toggleActivate: (extension: SNComponent) => void, +}> = ({ application, extension, first, uninstall, toggleActivate }) => { + const [autoupdateDisabled, setAutoupdateDisabled] = useState(extension.autoupdateDisabled ?? false); + const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false); + const [extensionName, setExtensionName] = useState(extension.name); + + const toggleAutoupdate = () => { + const newAutoupdateValue = !autoupdateDisabled; + setAutoupdateDisabled(newAutoupdateValue); + application + .changeAndSaveItem(extension.uuid, (m: any) => { + if (m.content == undefined) m.content = {}; + m.content.autoupdateDisabled = newAutoupdateValue; + }) + .then((item) => { + const component = (item as SNComponent); + setAutoupdateDisabled(component.autoupdateDisabled); + }) + .catch(e => { + console.error(e); + }); + }; + + const toggleOffllineOnly = () => { + const newOfflineOnly = !offlineOnly; + setOfflineOnly(newOfflineOnly); + application + .changeAndSaveItem(extension.uuid, (m: any) => { + if (m.content == undefined) m.content = {}; + m.content.offlineOnly = newOfflineOnly; + }) + .then((item) => { + const component = (item as SNComponent); + setOfflineOnly(component.offlineOnly); + }) + .catch(e => { + console.error(e); + }); + }; + + const changeExtensionName = (newName: string) => { + setExtensionName(newName); + application + .changeAndSaveItem(extension.uuid, (m: any) => { + if (m.content == undefined) m.content = {}; + m.content.name = newName; + }) + .then((item) => { + const component = (item as SNComponent); + setExtensionName(component.name); + }); + }; + + const localInstallable = extension.package_info.download_url; + + const isExternal = !extension.package_info.identifier.startsWith('org.standardnotes.'); + + const isEditorOrTags = ['editor-stack', 'tags-list'].includes(extension.area); + + return ( + + {first && <> + Extensions +
+ } + + +
+ + + + {localInstallable && } + {localInstallable && } + + {isEditorOrTags || isExternal && + <> +
+
+ {isEditorOrTags && ( + <> + {extension.active ? +
+ + } + + ); +}; diff --git a/app/assets/javascripts/preferences/panes/extensions-segments/index.ts b/app/assets/javascripts/preferences/panes/extensions-segments/index.ts new file mode 100644 index 000000000..20694952c --- /dev/null +++ b/app/assets/javascripts/preferences/panes/extensions-segments/index.ts @@ -0,0 +1,2 @@ +export * from './ConfirmCustomExtension'; +export * from './ExtensionItem';