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',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
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