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:
Mo
2023-11-29 10:18:55 -06:00
committed by GitHub
parent bd971d5473
commit c43b593c60
58 changed files with 1106 additions and 680 deletions

View File

@@ -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'),

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 },
]

View File

@@ -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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

@@ -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" />
</>
)
}

View File

@@ -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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -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',