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:
Gorjan Petrovski
2021-10-08 09:20:46 +02:00
committed by GitHub
parent 92699d23f4
commit 7b6c99d188
6 changed files with 395 additions and 1 deletions

View File

@@ -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;
}
}

View File

@@ -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 <Extensions application={props.application} />;
case 'listed':
return <Listed application={props.application} />;
case 'shortcuts':

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 >
);
};

View File

@@ -0,0 +1,2 @@
export * from './ConfirmCustomExtension';
export * from './ExtensionItem';