chore: improve preferences folder hierarchies (#1186)

This commit is contained in:
Mo
2022-06-30 13:49:03 -05:00
committed by GitHub
parent eac62fef1a
commit 7c2b8fe545
31 changed files with 88 additions and 72 deletions

View File

@@ -0,0 +1,39 @@
import { FunctionComponent } from 'react'
import OfflineSubscription from '@/Components/Preferences/Panes/General/Advanced/OfflineSubscription'
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
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'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
extensionsLatestVersions: PackageProvider
}
const Advanced: FunctionComponent<Props> = ({ application, viewControllerManager, extensionsLatestVersions }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced Options'}>
<div className="flex flex-row items-center">
<div className="flex flex-grow flex-col">
<OfflineSubscription application={application} viewControllerManager={viewControllerManager} />
<PackagesPreferencesSection
className={'mt-3'}
application={application}
extensionsLatestVersions={extensionsLatestVersions}
/>
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(Advanced)

View File

@@ -0,0 +1,127 @@
import React, { FunctionComponent, useEffect, useState } from 'react'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
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
viewControllerManager: ViewControllerManager
}
const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
const [activationCode, setActivationCode] = useState('')
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] = useState(false)
useEffect(() => {
if (application.features.hasOfflineRepo()) {
setHasUserPreviouslyStoredCode(true)
}
}, [application])
const shouldShowOfflineSubscription = () => {
return !application.hasAccount() || application.isThirdPartyHostUsed() || hasUserPreviouslyStoredCode
}
const handleSubscriptionCodeSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const result = await application.features.setOfflineFeaturesCode(activationCode)
if (result instanceof ClientDisplayableError) {
await application.alertService.alert(result.text)
} else {
setIsSuccessfullyActivated(true)
setHasUserPreviouslyStoredCode(true)
setIsSuccessfullyRemoved(false)
}
}
const handleRemoveOfflineKey = async () => {
await application.features.deleteOfflineFeatureRepo()
setIsSuccessfullyActivated(false)
setHasUserPreviouslyStoredCode(false)
setActivationCode('')
setIsSuccessfullyRemoved(true)
}
const handleRemoveClick = async () => {
application.alertService
.confirm(
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
'Remove offline key?',
'Remove Offline Key',
ButtonType.Danger,
'Cancel',
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await handleRemoveOfflineKey()
}
})
.catch((err: string) => {
application.alertService.alert(err).catch(console.error)
})
}
if (!shouldShowOfflineSubscription()) {
return null
}
return (
<>
<div className="flex items-center justify-between">
<div className="mt-3 flex w-full flex-col">
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
<form onSubmit={handleSubscriptionCodeSubmit}>
<div className={'mt-2'}>
{!hasUserPreviouslyStoredCode && (
<DecoratedInput
onChange={(code) => setActivationCode(code)}
placeholder={'Offline Subscription Code'}
value={activationCode}
disabled={isSuccessfullyActivated}
className={{ container: 'mb-3' }}
/>
)}
</div>
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
<div className={'info mt-3 mb-3'}>
Your offline subscription code has been successfully {isSuccessfullyActivated ? 'activated' : 'removed'}
.
</div>
)}
{hasUserPreviouslyStoredCode && (
<Button
colorStyle="danger"
label="Remove offline key"
onClick={() => {
handleRemoveClick().catch(console.error)
}}
/>
)}
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
<Button
label={'Submit'}
primary
disabled={activationCode === ''}
onClick={(event) => handleSubscriptionCodeSubmit(event)}
/>
)}
</form>
</div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
</>
)
}
export default observer(OfflineSubscription)

View File

@@ -0,0 +1,69 @@
import { DisplayStringForContentType } 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'
const ConfirmCustomPackage: FunctionComponent<{
component: AnyPackageType
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.thirdPartyPackageInfo.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 (
<Fragment key={field.value}>
<Subtitle>{field.label}</Subtitle>
<Text className={'wrap'}>{field.value}</Text>
<div className="min-h-2" />
</Fragment>
)
})}
<div className="min-h-3" />
<div className="flex flex-row">
<Button className="min-w-20" label="Cancel" onClick={() => callback(false)} />
<div className="min-w-3" />
<Button className="min-w-20" label="Install" onClick={() => callback(true)} />
</div>
</PreferencesSegment>
)
}
export default ConfirmCustomPackage

View File

@@ -0,0 +1,84 @@
import { FunctionComponent, useState } from 'react'
import { ComponentMutator, SNComponent } 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/Application'
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 SNComponent ? extension.offlineOnly : false)
const [extensionName, setExtensionName] = useState(extension.displayName)
const toggleOfflineOnly = () => {
const newOfflineOnly = !offlineOnly
setOfflineOnly(newOfflineOnly)
application.mutator
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.offlineOnly = newOfflineOnly
})
.then((item) => {
const component = item as SNComponent
setOfflineOnly(component.offlineOnly)
})
.catch((e) => {
console.error(e)
})
}
const changeExtensionName = (newName: string) => {
setExtensionName(newName)
application.mutator
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.name = newName
})
.then((item) => {
const component = item as SNComponent
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

@@ -0,0 +1,76 @@
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="mr-3 flex flex-row items-center">
<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)}
/>
<div className="min-w-3" />
{isRenaming && (
<>
<a className="cursor-pointer pt-1" onClick={confirmRename}>
Confirm
</a>
<div className="min-w-3" />
<a className="cursor-pointer pt-1" onClick={cancelRename}>
Cancel
</a>
</>
)}
{renameable && !isRenaming && (
<a className="cursor-pointer pt-1" onClick={startRenaming}>
Rename
</a>
)}
</div>
)
}
export default PackageEntrySubInfo

View File

@@ -0,0 +1,43 @@
import { WebApplication } from '@/Application/Application'
import { ClientDisplayableError, FeatureDescription } from '@standardnotes/snjs'
import { makeAutoObservable, observable } from 'mobx'
import { AnyPackageType } from '../Types/AnyPackageType'
import { ComponentChecksumsType } from '@standardnotes/components-meta'
import RawComponentChecksumsFile from '@standardnotes/components-meta/dist/zips/checksums.json'
const ComponentChecksums = RawComponentChecksumsFile as ComponentChecksumsType
export class PackageProvider {
static async load(application: WebApplication): Promise<PackageProvider | undefined> {
const response = await application.getAvailableSubscriptions()
if (response instanceof ClientDisplayableError) {
return undefined
}
const versionMap: Map<string, string> = new Map()
collectFeatures(response.PLUS_PLAN?.features as FeatureDescription[], versionMap)
collectFeatures(response.PRO_PLAN?.features as FeatureDescription[], versionMap)
return new PackageProvider(versionMap)
}
constructor(private readonly latestVersionsMap: Map<string, string>) {
makeAutoObservable<PackageProvider, 'latestVersionsMap'>(this, {
latestVersionsMap: observable.ref,
})
}
getVersion(extension: AnyPackageType): string | undefined {
return this.latestVersionsMap.get(extension.package_info.identifier)
}
}
function collectFeatures(features: FeatureDescription[] | undefined, versionMap: Map<string, string>) {
if (features == undefined) {
return
}
for (const feature of features) {
versionMap.set(feature.identifier, ComponentChecksums[feature.identifier].version)
}
}

View File

@@ -0,0 +1,151 @@
import { ButtonType, ContentType, SNComponent } from '@standardnotes/snjs'
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { WebApplication } from '@/Application/Application'
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.ActionsExtension,
ContentType.Component,
ContentType.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.alertService
.confirm(
'Are you sure you want to uninstall this extension? Note that extensions managed by your subscription will automatically be re-installed on application restart.',
'Uninstall Extension?',
'Uninstall',
ButtonType.Danger,
'Cancel',
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await application.mutator.deleteItem(extension)
setExtensions(loadExtensions(application))
}
})
.catch((err: string) => {
application.alertService.alert(err).catch(console.error)
})
}
const submitExtensionUrl = async (url: string) => {
const component = await application.features.downloadExternalFeature(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
}
if (extension instanceof SNComponent) {
return !['modal', 'rooms'].includes(extension.area)
}
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 Package</Subtitle>
<div className={'mt-2'}>
<DecoratedInput
placeholder={'Enter Package 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

@@ -0,0 +1,3 @@
import { SNActionsExtension, SNComponent, SNTheme } from '@standardnotes/snjs'
export type AnyPackageType = SNComponent | SNTheme | SNActionsExtension

View File

@@ -1,18 +1,18 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FunctionComponent } from 'react'
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
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/Account/Advanced'
import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSection'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
extensionsLatestVersions: ExtensionsLatestVersions
extensionsLatestVersions: PackageProvider
}
const General: FunctionComponent<Props> = ({ viewControllerManager, application, extensionsLatestVersions }) => (