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
This commit is contained in:
@@ -6,6 +6,7 @@ const PREFERENCE_IDS = [
|
|||||||
'account',
|
'account',
|
||||||
'appearance',
|
'appearance',
|
||||||
'security',
|
'security',
|
||||||
|
'extensions',
|
||||||
'listed',
|
'listed',
|
||||||
'shortcuts',
|
'shortcuts',
|
||||||
'accessibility',
|
'accessibility',
|
||||||
@@ -28,6 +29,7 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
|||||||
{ id: 'account', label: 'Account', icon: 'user' },
|
{ id: 'account', label: 'Account', icon: 'user' },
|
||||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||||
{ id: 'security', label: 'Security', icon: 'security' },
|
{ id: 'security', label: 'Security', icon: 'security' },
|
||||||
|
{ id: 'extensions', label: 'Extensions', icon: 'tune' },
|
||||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
||||||
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
|
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
|
||||||
@@ -65,7 +67,7 @@ export class PreferencesMenu {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectPane(key: PreferenceId) {
|
selectPane(key: PreferenceId): void {
|
||||||
this._selectedPane = key;
|
this._selectedPane = key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { WebApplication } from '@/ui_models/application';
|
|||||||
import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
||||||
import { AppState } from '@/ui_models/app_state';
|
import { AppState } from '@/ui_models/app_state';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
import { Extensions } from './panes/Extensions';
|
||||||
|
|
||||||
interface PreferencesProps extends MfaProps {
|
interface PreferencesProps extends MfaProps {
|
||||||
application: WebApplication;
|
application: WebApplication;
|
||||||
@@ -40,6 +41,8 @@ const PaneSelector: FunctionComponent<
|
|||||||
application={props.application}
|
application={props.application}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'extensions':
|
||||||
|
return <Extensions application={props.application} />;
|
||||||
case 'listed':
|
case 'listed':
|
||||||
return <Listed application={props.application} />;
|
return <Listed application={props.application} />;
|
||||||
case 'shortcuts':
|
case 'shortcuts':
|
||||||
|
|||||||
112
app/assets/javascripts/preferences/panes/Extensions.tsx
Normal file
112
app/assets/javascripts/preferences/panes/Extensions.tsx
Normal file
@@ -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<SNComponent | 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: 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 (
|
||||||
|
<PreferencesPane>
|
||||||
|
<PreferencesGroup>
|
||||||
|
{
|
||||||
|
extensions
|
||||||
|
.filter(extension => extension.package_info.identifier !== 'org.standardnotes.extensions-manager')
|
||||||
|
.sort((e1, e2) => e1.name.toLowerCase().localeCompare(e2.name.toLowerCase()))
|
||||||
|
.map((extension, i) => (
|
||||||
|
<ExtensionItem application={application} extension={extension}
|
||||||
|
first={i === 0} uninstall={uninstallExtension} toggleActivate={toggleActivateExtension} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</PreferencesGroup>
|
||||||
|
|
||||||
|
<PreferencesGroup>
|
||||||
|
{!confirmableExtension &&
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Title>Install Custom Extension</Title>
|
||||||
|
<div className="min-h-2" />
|
||||||
|
<DecoratedInput
|
||||||
|
placeholder={'Enter Extension URL'}
|
||||||
|
text={customUrl}
|
||||||
|
onChange={(value) => { setCustomUrl(value); }}
|
||||||
|
/>
|
||||||
|
<div className="min-h-1" />
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
label="Install"
|
||||||
|
onClick={() => submitExtensionUrl(customUrl)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</PreferencesSegment>
|
||||||
|
}
|
||||||
|
{confirmableExtension &&
|
||||||
|
<PreferencesSegment>
|
||||||
|
<ConfirmCustomExtension
|
||||||
|
component={confirmableExtension}
|
||||||
|
callback={handleConfirmExtensionSubmit}
|
||||||
|
/>
|
||||||
|
<div ref={confirmableEnd} />
|
||||||
|
</PreferencesSegment>
|
||||||
|
}
|
||||||
|
</PreferencesGroup>
|
||||||
|
</PreferencesPane>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { displayStringForContentType, SNComponent } from '@standardnotes/snjs';
|
||||||
|
import { Button } from '@/components/Button';
|
||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Subtitle,
|
||||||
|
PreferencesSegment,
|
||||||
|
} from '../../components';
|
||||||
|
|
||||||
|
export const ConfirmCustomExtension: FunctionComponent<{
|
||||||
|
component: SNComponent,
|
||||||
|
callback: (confirmed: boolean) => void
|
||||||
|
}> = ({ component, callback }) => {
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
value: component.package_info.name
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Description',
|
||||||
|
value: component.package_info.description
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Version',
|
||||||
|
value: component.package_info.version
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hosted URL',
|
||||||
|
value: component.package_info.url
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Download URL',
|
||||||
|
value: component.package_info.download_url
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Extension Type',
|
||||||
|
value: displayStringForContentType(component.content_type)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Title>Confirm Extension</Title>
|
||||||
|
|
||||||
|
{fields.map((field) => {
|
||||||
|
if (!field.value) { return undefined; }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Subtitle>{field.label}</Subtitle>
|
||||||
|
<Text>{field.value}</Text>
|
||||||
|
<div className="min-h-2" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="min-h-3" />
|
||||||
|
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Button
|
||||||
|
className="min-w-20"
|
||||||
|
type="primary"
|
||||||
|
label="Install"
|
||||||
|
onClick={() => callback(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-3" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="min-w-20"
|
||||||
|
type="primary"
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => callback(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</PreferencesSegment>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="flex flex-col flex-grow">
|
||||||
|
<Subtitle>Installed version <b>{extension.package_info.version}</b></Subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AutoUpdateLocal: FunctionComponent<{
|
||||||
|
autoupdateDisabled: boolean,
|
||||||
|
toggleAutoupdate: () => void
|
||||||
|
}> = ({ autoupdateDisabled, toggleAutoupdate }) => (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Subtitle className="flex-grow">Autoupdate local installation</Subtitle>
|
||||||
|
<Switch onChange={toggleAutoupdate} checked={!autoupdateDisabled} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UseHosted: FunctionComponent<{
|
||||||
|
offlineOnly: boolean, toggleOfllineOnly: () => void
|
||||||
|
}> = ({ offlineOnly, toggleOfllineOnly }) => (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Subtitle className="flex-grow">Use hosted when local is unavailable</Subtitle>
|
||||||
|
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RenameExtension: FunctionComponent<{
|
||||||
|
extensionName: string, changeName: (newName: string) => void
|
||||||
|
}> = ({ extensionName, changeName }) => {
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName);
|
||||||
|
|
||||||
|
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 == undefined || newExtensionName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeName(newExtensionName);
|
||||||
|
setIsRenaming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row mr-3 items-center">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
disabled={!isRenaming}
|
||||||
|
autocomplete='off'
|
||||||
|
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
|
||||||
|
type="text"
|
||||||
|
value={newExtensionName}
|
||||||
|
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-3" />
|
||||||
|
{isRenaming ?
|
||||||
|
<>
|
||||||
|
<a className="pt-1 cursor-pointer" onClick={confirmRename}>Confirm</a>
|
||||||
|
<div className="min-w-3" />
|
||||||
|
<a className="pt-1 cursor-pointer" onClick={cancelRename}>Cancel</a>
|
||||||
|
</> :
|
||||||
|
<a className="pt-1 cursor-pointer" onClick={startRenaming}>Rename</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PreferencesSegment>
|
||||||
|
{first && <>
|
||||||
|
<Title>Extensions</Title>
|
||||||
|
<div className="w-full min-h-3" />
|
||||||
|
</>}
|
||||||
|
|
||||||
|
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
|
||||||
|
<div className="min-h-2" />
|
||||||
|
|
||||||
|
<ExtensionVersions extension={extension} />
|
||||||
|
|
||||||
|
{localInstallable && <AutoUpdateLocal autoupdateDisabled={autoupdateDisabled} toggleAutoupdate={toggleAutoupdate} />}
|
||||||
|
{localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
|
||||||
|
|
||||||
|
{isEditorOrTags || isExternal &&
|
||||||
|
<>
|
||||||
|
<div className="min-h-2" />
|
||||||
|
<div className="flex flex-row">
|
||||||
|
{isEditorOrTags && (
|
||||||
|
<>
|
||||||
|
{extension.active ?
|
||||||
|
<Button type="normal" label="Deactivate" onClick={() => toggleActivate(extension)} /> :
|
||||||
|
<Button type="primary" label="Activate" onClick={() => toggleActivate(extension)} />
|
||||||
|
}
|
||||||
|
<div className="min-w-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isExternal && <Button type="normal" label="Uninstall" onClick={() => uninstall(extension)} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</PreferencesSegment >
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ConfirmCustomExtension';
|
||||||
|
export * from './ExtensionItem';
|
||||||
Reference in New Issue
Block a user