refactor: format and lint codebase (#971)
This commit is contained in:
135
app/assets/javascripts/Components/Abstract/PureComponent.tsx
Normal file
135
app/assets/javascripts/Components/Abstract/PureComponent.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState, AppStateEvent } from '@/UIModels/AppState'
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
|
||||
import { Component } from 'preact'
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
|
||||
|
||||
export type PureComponentState = Partial<Record<string, any>>
|
||||
export type PureComponentProps = Partial<Record<string, any>>
|
||||
|
||||
export abstract class PureComponent<
|
||||
P = PureComponentProps,
|
||||
S = PureComponentState,
|
||||
> extends Component<P, S> {
|
||||
private unsubApp!: () => void
|
||||
private unsubState!: () => void
|
||||
private reactionDisposers: IReactionDisposer[] = []
|
||||
|
||||
constructor(props: P, protected application: WebApplication) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
this.addAppEventObserver()
|
||||
this.addAppStateObserver()
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.unsubApp?.()
|
||||
this.unsubState?.()
|
||||
for (const disposer of this.reactionDisposers) {
|
||||
disposer()
|
||||
}
|
||||
this.reactionDisposers.length = 0
|
||||
;(this.unsubApp as unknown) = undefined
|
||||
;(this.unsubState as unknown) = undefined
|
||||
}
|
||||
|
||||
protected dismissModal(): void {
|
||||
const elem = this.getElement()
|
||||
if (!elem) {
|
||||
return
|
||||
}
|
||||
|
||||
const parent = elem.parentElement
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
parent.remove()
|
||||
unmountComponentAtNode(parent)
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.deinit()
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>Must override</div>
|
||||
}
|
||||
|
||||
public get appState(): AppState {
|
||||
return this.application.getAppState()
|
||||
}
|
||||
|
||||
protected getElement(): Element | null {
|
||||
return findDOMNode(this)
|
||||
}
|
||||
|
||||
autorun(view: (r: IReactionPublic) => void): void {
|
||||
this.reactionDisposers.push(autorun(view))
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.unsubState = this.application.getAppState().addObserver(async (eventName, data) => {
|
||||
this.onAppStateEvent(eventName, data)
|
||||
})
|
||||
}
|
||||
|
||||
onAppStateEvent(_eventName: AppStateEvent, _data: unknown) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
if (this.application.isStarted()) {
|
||||
this.onAppStart().catch(console.error)
|
||||
}
|
||||
if (this.application.isLaunched()) {
|
||||
this.onAppLaunch().catch(console.error)
|
||||
}
|
||||
this.unsubApp = this.application.addEventObserver(async (eventName, data: unknown) => {
|
||||
this.onAppEvent(eventName, data)
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart()
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch()
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync()
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync()
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange().catch(console.error)
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onAppEvent(_eventName: ApplicationEvent, _data?: unknown) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppIncrementalSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppFullSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { Checkbox } from '@/Components/Checkbox'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
disabled?: boolean
|
||||
onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void
|
||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
||||
}
|
||||
|
||||
export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
({ appState, application, disabled = false, onVaultChange, onStrictSignInChange, children }) => {
|
||||
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
const [vaultName, setVaultName] = useState('')
|
||||
const [vaultUserphrase, setVaultUserphrase] = useState('')
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const recomputeVaultedEmail = async () => {
|
||||
const vaultedEmail = await application.vaultToEmail(vaultName, vaultUserphrase)
|
||||
|
||||
if (!vaultedEmail) {
|
||||
if (vaultName?.length > 0 && vaultUserphrase?.length > 0) {
|
||||
application.alertService.alert('Unable to compute vault name.').catch(console.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
onVaultChange?.(true, vaultedEmail)
|
||||
}
|
||||
|
||||
if (vaultName && vaultUserphrase) {
|
||||
recomputeVaultedEmail().catch(console.error)
|
||||
}
|
||||
}, [vaultName, vaultUserphrase, application, onVaultChange])
|
||||
|
||||
useEffect(() => {
|
||||
onVaultChange?.(isVault)
|
||||
}, [isVault, onVaultChange])
|
||||
|
||||
const handleIsVaultChange = () => {
|
||||
setIsVault(!isVault)
|
||||
}
|
||||
|
||||
const handleVaultNameChange = (name: string) => {
|
||||
setVaultName(name)
|
||||
}
|
||||
|
||||
const handleVaultUserphraseChange = (userphrase: string) => {
|
||||
setVaultUserphrase(userphrase)
|
||||
}
|
||||
|
||||
const handleServerOptionChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setEnableServerOption(e.target.checked)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncServerChange = (server: string) => {
|
||||
setServer(server)
|
||||
application.setCustomHost(server).catch(console.error)
|
||||
}
|
||||
|
||||
const handleStrictSigninChange = () => {
|
||||
const newValue = !isStrictSignin
|
||||
setIsStrictSignin(newValue)
|
||||
onStrictSignInChange?.(newValue)
|
||||
}
|
||||
|
||||
const toggleShowAdvanced = () => {
|
||||
setShowAdvanced(!showAdvanced)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
|
||||
onClick={toggleShowAdvanced}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Advanced options
|
||||
<Icon type="chevron-down" className="color-grey-1 ml-1" />
|
||||
</div>
|
||||
</button>
|
||||
{showAdvanced ? (
|
||||
<div className="px-3 my-2">
|
||||
{children}
|
||||
|
||||
{appState.enableUnfinishedFeatures && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="vault-mode"
|
||||
label="Vault Mode"
|
||||
checked={isVault}
|
||||
disabled={disabled}
|
||||
onChange={handleIsVaultChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/80"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appState.enableUnfinishedFeatures && isVault && (
|
||||
<>
|
||||
<DecoratedInput
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="folder" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Vault name"
|
||||
value={vaultName}
|
||||
onChange={handleVaultNameChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DecoratedInput
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="server" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Vault userphrase"
|
||||
value={vaultUserphrase}
|
||||
onChange={handleVaultUserphraseChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onStrictSignInChange && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="use-strict-signin"
|
||||
label="Use strict sign-in"
|
||||
checked={isStrictSignin}
|
||||
disabled={disabled}
|
||||
onChange={handleStrictSigninChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/security"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
name="custom-sync-server"
|
||||
label="Custom sync server"
|
||||
checked={enableServerOption}
|
||||
onChange={handleServerOptionChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DecoratedInput
|
||||
type="text"
|
||||
left={[<Icon type="server" className="color-neutral" />]}
|
||||
placeholder="https://api.standardnotes.com"
|
||||
value={server}
|
||||
onChange={handleSyncServerChange}
|
||||
disabled={!enableServerOption && !disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Checkbox } from '@/Components/Checkbox'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export const ConfirmPassword: FunctionComponent<Props> = observer(
|
||||
({ application, appState, setMenuPane, email, password }) => {
|
||||
const { notesAndTagsCount } = appState.accountMenu
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isRegistering, setIsRegistering] = useState(false)
|
||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
passwordInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
setConfirmPassword(text)
|
||||
}
|
||||
|
||||
const handleEphemeralChange = () => {
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}
|
||||
|
||||
const handleShouldMergeChange = () => {
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirmFormSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmFormSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!password) {
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (password === confirmPassword) {
|
||||
setIsRegistering(true)
|
||||
application
|
||||
.register(email, password, isEphemeral, shouldMergeLocal)
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
setError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRegistering(false)
|
||||
})
|
||||
} else {
|
||||
setError(STRING_NON_MATCHING_PASSWORDS)
|
||||
setConfirmPassword('')
|
||||
passwordInputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral"
|
||||
onClick={handleGoBack}
|
||||
focusable={true}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Confirm password</div>
|
||||
</div>
|
||||
<div className="px-3 mb-3 text-sm">
|
||||
Because your notes are encrypted using your password,{' '}
|
||||
<span className="color-dark-red">
|
||||
Standard Notes does not have a password reset option
|
||||
</span>
|
||||
. If you forget your password, you will permanently lose access to your data.
|
||||
</div>
|
||||
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
|
||||
<DecoratedPasswordInput
|
||||
className="mb-2"
|
||||
disabled={isRegistering}
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Confirm password"
|
||||
ref={passwordInputRef}
|
||||
value={confirmPassword}
|
||||
/>
|
||||
{error ? <div className="color-dark-red my-2">{error}</div> : null}
|
||||
<Button
|
||||
className="btn-w-full mt-1 mb-3"
|
||||
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
|
||||
variant="primary"
|
||||
onClick={handleConfirmFormSubmit}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<Checkbox
|
||||
name="is-ephemeral"
|
||||
label="Stay signed in"
|
||||
checked={!isEphemeral}
|
||||
onChange={handleEphemeralChange}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
{notesAndTagsCount > 0 ? (
|
||||
<Checkbox
|
||||
name="should-merge-local"
|
||||
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
||||
checked={shouldMergeLocal}
|
||||
onChange={handleShouldMergeChange}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
130
app/assets/javascripts/Components/AccountMenu/CreateAccount.tsx
Normal file
130
app/assets/javascripts/Components/AccountMenu/CreateAccount.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
email: string
|
||||
setEmail: StateUpdater<string>
|
||||
password: string
|
||||
setPassword: StateUpdater<string>
|
||||
}
|
||||
|
||||
export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => {
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef.current) {
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEmailChange = (text: string) => {
|
||||
setEmail(text)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
setPassword(text)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRegisterFormSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegisterFormSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
setEmail(email)
|
||||
setPassword(password)
|
||||
setMenuPane(AccountMenuPane.ConfirmPassword)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setMenuPane(AccountMenuPane.GeneralMenu)
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
}
|
||||
|
||||
const onVaultChange = (isVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(isVault)
|
||||
if (isVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral"
|
||||
onClick={handleClose}
|
||||
focusable={true}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Create account</div>
|
||||
</div>
|
||||
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
|
||||
<DecoratedInput
|
||||
className="mb-2"
|
||||
disabled={isVault}
|
||||
left={[<Icon type="email" className="color-neutral" />]}
|
||||
onChange={handleEmailChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Email"
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<DecoratedPasswordInput
|
||||
className="mb-2"
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Password"
|
||||
ref={passwordInputRef}
|
||||
value={password}
|
||||
/>
|
||||
<Button
|
||||
className="btn-w-full mt-1"
|
||||
label="Next"
|
||||
variant="primary"
|
||||
onClick={handleRegisterFormSubmit}
|
||||
/>
|
||||
</form>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
onVaultChange={onVaultChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { formatLastSyncDate } from '@/Components/Preferences/Panes/Account/Sync'
|
||||
import { SyncQueueStrategy } from '@standardnotes/snjs'
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Menu } from '@/Components/Menu/Menu'
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
closeMenu: () => void
|
||||
}
|
||||
|
||||
const iconClassName = 'color-neutral mr-2'
|
||||
|
||||
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||
const [lastSyncDate, setLastSyncDate] = useState(
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date),
|
||||
)
|
||||
|
||||
const doSynchronization = async () => {
|
||||
setIsSyncingInProgress(true)
|
||||
|
||||
application.sync
|
||||
.sync({
|
||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||
checkIntegrity: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && (res as any).error) {
|
||||
throw new Error()
|
||||
} else {
|
||||
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSyncingInProgress(false)
|
||||
})
|
||||
}
|
||||
|
||||
const user = application.getUser()
|
||||
|
||||
const CREATE_ACCOUNT_INDEX = 1
|
||||
const SWITCHER_INDEX = 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 mt-1 mb-1">
|
||||
<div className="sn-account-menu-headline">Account</div>
|
||||
<div className="flex cursor-pointer" onClick={closeMenu}>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</div>
|
||||
</div>
|
||||
{user ? (
|
||||
<>
|
||||
<div className="px-3 mb-3 color-foreground text-sm">
|
||||
<div>You're signed in as:</div>
|
||||
<div className="my-0.5 font-bold wrap">{user.email}</div>
|
||||
<span className="color-neutral">{application.getHost()}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between px-3 mb-3">
|
||||
{isSyncingInProgress ? (
|
||||
<div className="flex items-center color-info font-semibold">
|
||||
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
|
||||
Syncing...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start">
|
||||
<Icon type="check-circle" className="mr-2 success" />
|
||||
<div>
|
||||
<div class="font-semibold success">Last synced:</div>
|
||||
<div class="color-text">{lastSyncDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex cursor-pointer color-grey-1" onClick={doSynchronization}>
|
||||
<Icon type="sync" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 mb-1">
|
||||
<div className="mb-3 color-foreground">
|
||||
You’re offline. Sign in to sync your notes and preferences across all your devices
|
||||
and enable end-to-end encryption.
|
||||
</div>
|
||||
<div className="flex items-center color-grey-1">
|
||||
<Icon type="cloud-off" className="mr-2" />
|
||||
<span className="font-semibold">Offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Menu
|
||||
isOpen={appState.accountMenu.show}
|
||||
a11yLabel="General account menu"
|
||||
closeMenu={closeMenu}
|
||||
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
|
||||
>
|
||||
<MenuItemSeparator />
|
||||
<WorkspaceSwitcherOption
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
appState={appState}
|
||||
/>
|
||||
<MenuItemSeparator />
|
||||
{user ? (
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.preferences.setCurrentPane('account')
|
||||
appState.preferences.openPreferences()
|
||||
}}
|
||||
>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}}
|
||||
>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
Create free account
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
setMenuPane(AccountMenuPane.SignIn)
|
||||
}}
|
||||
>
|
||||
<Icon type="signIn" className={iconClassName} />
|
||||
Sign in
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
className="justify-between"
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.preferences.setCurrentPane('help-feedback')
|
||||
appState.preferences.openPreferences()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="help" className={iconClassName} />
|
||||
Help & feedback
|
||||
</div>
|
||||
<span className="color-neutral">v{appState.version}</span>
|
||||
</MenuItem>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItemSeparator />
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="signOut" className={iconClassName} />
|
||||
Sign out workspace
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
206
app/assets/javascripts/Components/AccountMenu/SignIn.tsx
Normal file
206
app/assets/javascripts/Components/AccountMenu/SignIn.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { isDev } from '@/Utils'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Checkbox } from '@/Components/Checkbox'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
}
|
||||
|
||||
export const SignInPane: FunctionComponent<Props> = observer(
|
||||
({ application, appState, setMenuPane }) => {
|
||||
const { notesAndTagsCount } = appState.accountMenu
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef?.current) {
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
if (isDev && window.devAccountEmail) {
|
||||
setEmail(window.devAccountEmail)
|
||||
setPassword(window.devAccountPassword as string)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetInvalid = () => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailChange = (text: string) => {
|
||||
setEmail(text)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
setPassword(text)
|
||||
}
|
||||
|
||||
const handleEphemeralChange = () => {
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}
|
||||
|
||||
const handleStrictSigninChange = () => {
|
||||
setIsStrictSignin(!isStrictSignin)
|
||||
}
|
||||
|
||||
const handleShouldMergeChange = () => {
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}
|
||||
|
||||
const signIn = () => {
|
||||
setIsSigningIn(true)
|
||||
emailInputRef?.current?.blur()
|
||||
passwordInputRef?.current?.blur()
|
||||
|
||||
application
|
||||
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
setError(err.message ?? err.toString())
|
||||
setPassword('')
|
||||
passwordInputRef?.current?.blur()
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSigningIn(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSignInFormSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onVaultChange = useCallback(
|
||||
(newIsVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(newIsVault)
|
||||
if (newIsVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail)
|
||||
}
|
||||
},
|
||||
[setEmail],
|
||||
)
|
||||
|
||||
const handleSignInFormSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
signIn()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral"
|
||||
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
|
||||
focusable={true}
|
||||
disabled={isSigningIn}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Sign in</div>
|
||||
</div>
|
||||
<div className="px-3 mb-1">
|
||||
<DecoratedInput
|
||||
className={`mb-2 ${error ? 'border-dark-red' : null}`}
|
||||
left={[<Icon type="email" className="color-neutral" />]}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onFocus={resetInvalid}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSigningIn || isVault}
|
||||
ref={emailInputRef}
|
||||
/>
|
||||
<DecoratedPasswordInput
|
||||
className={`mb-2 ${error ? 'border-dark-red' : null}`}
|
||||
disabled={isSigningIn}
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onFocus={resetInvalid}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Password"
|
||||
ref={passwordInputRef}
|
||||
value={password}
|
||||
/>
|
||||
{error ? <div className="color-dark-red my-2">{error}</div> : null}
|
||||
<Button
|
||||
className="btn-w-full mt-1 mb-3"
|
||||
label={isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||
variant="primary"
|
||||
onClick={handleSignInFormSubmit}
|
||||
disabled={isSigningIn}
|
||||
/>
|
||||
<Checkbox
|
||||
name="is-ephemeral"
|
||||
label="Stay signed in"
|
||||
checked={!isEphemeral}
|
||||
disabled={isSigningIn}
|
||||
onChange={handleEphemeralChange}
|
||||
/>
|
||||
{notesAndTagsCount > 0 ? (
|
||||
<Checkbox
|
||||
name="should-merge-local"
|
||||
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
||||
checked={shouldMergeLocal}
|
||||
disabled={isSigningIn}
|
||||
onChange={handleShouldMergeChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions
|
||||
appState={appState}
|
||||
application={application}
|
||||
disabled={isSigningIn}
|
||||
onVaultChange={onVaultChange}
|
||||
onStrictSignInChange={handleStrictSigninChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
44
app/assets/javascripts/Components/AccountMenu/User.tsx
Normal file
44
app/assets/javascripts/Components/AccountMenu/User.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { User as UserType } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const User = observer(({ appState, application }: Props) => {
|
||||
const { server } = appState.accountMenu
|
||||
const user = application.getUser() as UserType
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
{appState.sync.errorMessage && (
|
||||
<div className="sk-notification danger">
|
||||
<div className="sk-notification-title">Sync Unreachable</div>
|
||||
<div className="sk-notification-text">
|
||||
Hmm...we can't seem to sync your account. The reason: {appState.sync.errorMessage}
|
||||
</div>
|
||||
<a
|
||||
className="sk-a info-contrast sk-bold sk-panel-row"
|
||||
href="https://standardnotes.com/help"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Need help?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-h1 sk-bold wrap">{user.email}</div>
|
||||
<div className="sk-subtitle neutral">{server}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-row" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default User
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs/dist/@types'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
descriptor: ApplicationDescriptor
|
||||
onClick: () => void
|
||||
onDelete: () => void
|
||||
renameDescriptor: (label: string) => void
|
||||
}
|
||||
|
||||
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
descriptor,
|
||||
onClick,
|
||||
onDelete,
|
||||
renameDescriptor,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = (event: FocusEvent) => {
|
||||
const name = (event.target as HTMLInputElement).value
|
||||
renameDescriptor(name)
|
||||
setIsRenaming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
type={MenuItemType.RadioButton}
|
||||
className="sn-dropdown-item py-2 focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={onClick}
|
||||
checked={descriptor.primary}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={descriptor.label}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div>{descriptor.label}</div>
|
||||
)}
|
||||
{descriptor.primary && (
|
||||
<div>
|
||||
<button
|
||||
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsRenaming((isRenaming) => !isRenaming)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
||||
</button>
|
||||
<button
|
||||
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Icon type="trash" className="sn-icon--mid color-danger" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Menu } from '@/Components/Menu/Menu'
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
import { WorkspaceMenuItem } from './WorkspaceMenuItem'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
appState: AppState
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer(
|
||||
({ mainApplicationGroup, appState, isOpen }) => {
|
||||
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>(
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||
setApplicationDescriptors(applicationDescriptors)
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAppGroupObserver()
|
||||
}
|
||||
}, [mainApplicationGroup])
|
||||
|
||||
return (
|
||||
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
|
||||
{applicationDescriptors.map((descriptor) => (
|
||||
<WorkspaceMenuItem
|
||||
descriptor={descriptor}
|
||||
onDelete={() => {
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
mainApplicationGroup.loadApplicationForDescriptor(descriptor).catch(console.error)
|
||||
}}
|
||||
renameDescriptor={(label: string) =>
|
||||
mainApplicationGroup.renameDescriptor(descriptor, label)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<MenuItemSeparator />
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
mainApplicationGroup.addNewApplication().catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="user-add" className="color-neutral mr-2" />
|
||||
Add another workspace
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
|
||||
({ mainApplicationGroup, appState }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitem"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="user-switch" className="color-neutral mr-2" />
|
||||
Switch workspace
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto"
|
||||
style={menuStyle}
|
||||
>
|
||||
<WorkspaceSwitcherMenu
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
appState={appState}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
127
app/assets/javascripts/Components/AccountMenu/index.tsx
Normal file
127
app/assets/javascripts/Components/AccountMenu/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useRef, useState } from 'preact/hooks'
|
||||
import { GeneralAccountMenu } from './GeneralAccountMenu'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { SignInPane } from './SignIn'
|
||||
import { CreateAccount } from './CreateAccount'
|
||||
import { ConfirmPassword } from './ConfirmPassword'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
|
||||
export enum AccountMenuPane {
|
||||
GeneralMenu,
|
||||
SignIn,
|
||||
Register,
|
||||
ConfirmPassword,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
onClickOutside: () => void
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type PaneSelectorProps = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
menuPane: AccountMenuPane
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
closeMenu: () => void
|
||||
}
|
||||
|
||||
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
switch (menuPane) {
|
||||
case AccountMenuPane.GeneralMenu:
|
||||
return (
|
||||
<GeneralAccountMenu
|
||||
appState={appState}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
setMenuPane={setMenuPane}
|
||||
closeMenu={closeMenu}
|
||||
/>
|
||||
)
|
||||
case AccountMenuPane.SignIn:
|
||||
return (
|
||||
<SignInPane appState={appState} application={application} setMenuPane={setMenuPane} />
|
||||
)
|
||||
case AccountMenuPane.Register:
|
||||
return (
|
||||
<CreateAccount
|
||||
appState={appState}
|
||||
application={application}
|
||||
setMenuPane={setMenuPane}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
)
|
||||
case AccountMenuPane.ConfirmPassword:
|
||||
return (
|
||||
<ConfirmPassword
|
||||
appState={appState}
|
||||
application={application}
|
||||
setMenuPane={setMenuPane}
|
||||
email={email}
|
||||
password={password}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickOutside, mainApplicationGroup }) => {
|
||||
const { currentPane, setCurrentPane, shouldAnimateCloseMenu, closeAccountMenu } =
|
||||
appState.accountMenu
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useCloseOnClickOutside(ref, () => {
|
||||
onClickOutside()
|
||||
})
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
if (currentPane === AccountMenuPane.GeneralMenu) {
|
||||
closeAccountMenu()
|
||||
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
|
||||
setCurrentPane(AccountMenuPane.Register)
|
||||
} else {
|
||||
setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} id="account-menu" className="sn-component">
|
||||
<div
|
||||
className={`sn-menu-border sn-account-menu sn-dropdown ${
|
||||
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
|
||||
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MenuPaneSelector
|
||||
appState={appState}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
menuPane={currentPane}
|
||||
setMenuPane={setCurrentPane}
|
||||
closeMenu={closeAccountMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { Component } from 'preact'
|
||||
import { ApplicationView } from '@/Components/ApplicationView'
|
||||
|
||||
type State = {
|
||||
activeApplication?: WebApplication
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
export class ApplicationGroupView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
|
||||
props.mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
const activeApplication = props.mainApplicationGroup.primaryApplication as WebApplication
|
||||
this.setState({ activeApplication })
|
||||
})
|
||||
|
||||
props.mainApplicationGroup.initialize().catch(console.error)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.state.activeApplication && (
|
||||
<div id={this.state.activeApplication.identifier}>
|
||||
<ApplicationView
|
||||
key={this.state.activeApplication.ephemeralIdentifier}
|
||||
mainApplicationGroup={this.props.mainApplicationGroup}
|
||||
application={this.state.activeApplication}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
216
app/assets/javascripts/Components/ApplicationView/index.tsx
Normal file
216
app/assets/javascripts/Components/ApplicationView/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { getPlatformString, getWindowUrlParams } from '@/Utils'
|
||||
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState'
|
||||
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs'
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
|
||||
import { alertDialog } from '@/Services/AlertService'
|
||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { Navigation } from '@/Components/Navigation'
|
||||
import { NotesView } from '@/Components/NotesView'
|
||||
import { NoteGroupView } from '@/Components/NoteGroupView'
|
||||
import { Footer } from '@/Components/Footer'
|
||||
import { SessionsModal } from '@/Components/SessionsModal'
|
||||
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper'
|
||||
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal'
|
||||
import { NotesContextMenu } from '@/Components/NotesContextMenu'
|
||||
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
import { render } from 'preact'
|
||||
import { PermissionsModal } from '@/Components/PermissionsModal'
|
||||
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
|
||||
import { PremiumModalProvider } from '@/Hooks/usePremiumModal'
|
||||
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal'
|
||||
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
|
||||
import { ToastContainer } from '@standardnotes/stylekit'
|
||||
import { FilePreviewModalProvider } from '@/Components/Files/FilePreviewModalProvider'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type State = {
|
||||
started?: boolean
|
||||
launched?: boolean
|
||||
needsUnlock?: boolean
|
||||
appClass: string
|
||||
challenges: Challenge[]
|
||||
}
|
||||
|
||||
export class ApplicationView extends PureComponent<Props, State> {
|
||||
public readonly platformString = getPlatformString()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
appClass: '',
|
||||
challenges: [],
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
;(this.application as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.loadApplication().catch(console.error)
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
this.application.componentManager.setDesktopManager(this.application.getDesktopService())
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
const challenges = this.state.challenges.slice()
|
||||
challenges.push(challenge)
|
||||
this.setState({ challenges: challenges })
|
||||
},
|
||||
})
|
||||
await this.application.launch()
|
||||
}
|
||||
|
||||
public removeChallenge = async (challenge: Challenge) => {
|
||||
const challenges = this.state.challenges.slice()
|
||||
removeFromArray(challenges, challenge)
|
||||
this.setState({ challenges: challenges })
|
||||
}
|
||||
|
||||
override async onAppStart() {
|
||||
super.onAppStart().catch(console.error)
|
||||
this.setState({
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
})
|
||||
|
||||
this.application.componentManager.presentPermissionsDialog = this.presentPermissionsDialog
|
||||
}
|
||||
|
||||
override async onAppLaunch() {
|
||||
super.onAppLaunch().catch(console.error)
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
})
|
||||
this.handleDemoSignInFromParams().catch(console.error)
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.application.notifyWebEvent(WebAppEvent.NewUpdateAvailable)
|
||||
}
|
||||
|
||||
override async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName)
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.LocalDatabaseReadError:
|
||||
alertDialog({
|
||||
text: 'Unable to load local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
break
|
||||
case ApplicationEvent.LocalDatabaseWriteError:
|
||||
alertDialog({
|
||||
text: 'Unable to write to local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
override async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
|
||||
if (eventName === AppStateEvent.PanelResized) {
|
||||
const { panel, collapsed } = data as PanelResizedData
|
||||
let appClass = ''
|
||||
if (panel === PANEL_NAME_NOTES && collapsed) {
|
||||
appClass += 'collapsed-notes'
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
|
||||
appClass += ' collapsed-navigation'
|
||||
}
|
||||
this.setState({ appClass })
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
if (!(await this.application.isLocked())) {
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDemoSignInFromParams() {
|
||||
const token = getWindowUrlParams().get('demo-token')
|
||||
if (!token || this.application.hasAccount()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.sessions.populateSessionFromDemoShareToken(token)
|
||||
}
|
||||
|
||||
presentPermissionsDialog = (dialog: PermissionDialog) => {
|
||||
render(
|
||||
<PermissionsModal
|
||||
application={this.application}
|
||||
callback={dialog.callback}
|
||||
component={dialog.component}
|
||||
permissionsString={dialog.permissionsString}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div')),
|
||||
)
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.application['dealloced'] === true) {
|
||||
console.error('Attempting to render dealloced application')
|
||||
return <div></div>
|
||||
}
|
||||
|
||||
const renderAppContents = !this.state.needsUnlock && this.state.launched
|
||||
|
||||
return (
|
||||
<FilePreviewModalProvider application={this.application}>
|
||||
<PremiumModalProvider application={this.application} appState={this.appState}>
|
||||
<div className={this.platformString + ' main-ui-view sn-component'}>
|
||||
{renderAppContents && (
|
||||
<div id="app" className={this.state.appClass + ' app app-column-container'}>
|
||||
<Navigation application={this.application} />
|
||||
<NotesView application={this.application} appState={this.appState} />
|
||||
<NoteGroupView application={this.application} />
|
||||
</div>
|
||||
)}
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<Footer
|
||||
application={this.application}
|
||||
applicationGroup={this.props.mainApplicationGroup}
|
||||
/>
|
||||
<SessionsModal application={this.application} appState={this.appState} />
|
||||
<PreferencesViewWrapper appState={this.appState} application={this.application} />
|
||||
<RevisionHistoryModalWrapper
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{this.state.challenges.map((challenge) => {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<ChallengeModal
|
||||
key={challenge.id}
|
||||
application={this.application}
|
||||
challenge={challenge}
|
||||
onDismiss={this.removeChallenge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<NotesContextMenu application={this.application} appState={this.appState} />
|
||||
<TagsContextMenu appState={this.appState} />
|
||||
<PurchaseFlowWrapper application={this.application} appState={this.appState} />
|
||||
<ConfirmSignoutContainer appState={this.appState} application={this.application} />
|
||||
<ToastContainer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
</FilePreviewModalProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import {
|
||||
ChallengeReason,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
SNFile,
|
||||
} from '@standardnotes/snjs'
|
||||
import { confirmDialog } from '@/Services/AlertService'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||
import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
import { AttachedFilesPopover, PopoverTabs } from './AttachedFilesPopover'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const createDragOverlay = () => {
|
||||
if (document.getElementById('drag-overlay')) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlayElementTemplate =
|
||||
'<div class="sn-component" id="drag-overlay"><div class="absolute top-0 left-0 w-full h-full z-index-1001"></div></div>'
|
||||
const overlayFragment = document.createRange().createContextualFragment(overlayElementTemplate)
|
||||
document.body.appendChild(overlayFragment)
|
||||
}
|
||||
|
||||
const removeDragOverlay = () => {
|
||||
document.getElementById('drag-overlay')?.remove()
|
||||
}
|
||||
|
||||
export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickPreprocessing }) => {
|
||||
const premiumModal = usePremiumModal()
|
||||
const note = Object.values(appState.notes.selectedNotes)[0]
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
|
||||
|
||||
const [attachedFilesCount, setAttachedFilesCount] = useState(
|
||||
note ? application.items.getFilesForNote(note).length : 0,
|
||||
)
|
||||
|
||||
const reloadAttachedFilesCount = useCallback(() => {
|
||||
setAttachedFilesCount(note ? application.items.getFilesForNote(note).length : 0)
|
||||
}, [application.items, note])
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
||||
reloadAttachedFilesCount()
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterFileStream()
|
||||
}
|
||||
}, [application, reloadAttachedFilesCount])
|
||||
|
||||
const toggleAttachedFilesMenu = useCallback(async () => {
|
||||
if (
|
||||
application.features.getFeatureStatus(FeatureIdentifier.Files) !== FeatureStatus.Entitled
|
||||
) {
|
||||
premiumModal.activate('Files')
|
||||
return
|
||||
}
|
||||
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
|
||||
const newOpenState = !open
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setOpen(newOpenState)
|
||||
}
|
||||
}, [application.features, onClickPreprocessing, open, premiumModal])
|
||||
|
||||
const deleteFile = async (file: SNFile) => {
|
||||
const shouldDelete = await confirmDialog({
|
||||
text: `Are you sure you want to permanently delete "${file.name}"?`,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
if (shouldDelete) {
|
||||
const deletingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Deleting file "${file.name}"...`,
|
||||
})
|
||||
await application.files.deleteFile(file)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Deleted file "${file.name}"`,
|
||||
})
|
||||
dismissToast(deletingToastId)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async (file: SNFile) => {
|
||||
appState.files.downloadFile(file).catch(console.error)
|
||||
}
|
||||
|
||||
const attachFileToNote = useCallback(
|
||||
async (file: SNFile) => {
|
||||
await application.items.associateFileWithNote(file, note)
|
||||
},
|
||||
[application.items, note],
|
||||
)
|
||||
|
||||
const detachFileFromNote = async (file: SNFile) => {
|
||||
await application.items.disassociateFileWithNote(file, note)
|
||||
}
|
||||
|
||||
const toggleFileProtection = async (file: SNFile) => {
|
||||
let result: SNFile | undefined
|
||||
if (file.protected) {
|
||||
keepMenuOpen(true)
|
||||
result = await application.mutator.unprotectFile(file)
|
||||
keepMenuOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
} else {
|
||||
result = await application.mutator.protectFile(file)
|
||||
}
|
||||
const isProtected = result ? result.protected : file.protected
|
||||
return isProtected
|
||||
}
|
||||
|
||||
const authorizeProtectedActionForFile = async (
|
||||
file: SNFile,
|
||||
challengeReason: ChallengeReason,
|
||||
) => {
|
||||
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles(
|
||||
[file],
|
||||
challengeReason,
|
||||
)
|
||||
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
|
||||
return isAuthorized
|
||||
}
|
||||
|
||||
const renameFile = async (file: SNFile, fileName: string) => {
|
||||
await application.items.renameFile(file, fileName)
|
||||
}
|
||||
|
||||
const handleFileAction = async (action: PopoverFileItemAction) => {
|
||||
const file =
|
||||
action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
|
||||
let isAuthorizedForAction = true
|
||||
|
||||
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
|
||||
keepMenuOpen(true)
|
||||
isAuthorizedForAction = await authorizeProtectedActionForFile(
|
||||
file,
|
||||
ChallengeReason.AccessProtectedFile,
|
||||
)
|
||||
keepMenuOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
|
||||
if (!isAuthorizedForAction) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case PopoverFileItemActionType.AttachFileToNote:
|
||||
await attachFileToNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DetachFileToNote:
|
||||
await detachFileFromNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DeleteFile:
|
||||
await deleteFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DownloadFile:
|
||||
await downloadFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.ToggleFileProtection: {
|
||||
const isProtected = await toggleFileProtection(file)
|
||||
action.callback(isProtected)
|
||||
break
|
||||
}
|
||||
case PopoverFileItemActionType.RenameFile:
|
||||
await renameFile(file, action.payload.name)
|
||||
break
|
||||
}
|
||||
|
||||
application.sync.sync().catch(console.error)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const handleDrag = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragIn = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current + 1
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
setIsDraggingFiles(true)
|
||||
createDragOverlay()
|
||||
if (!open) {
|
||||
toggleAttachedFilesMenu().catch(console.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, toggleAttachedFilesMenu],
|
||||
)
|
||||
|
||||
const handleDragOut = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current - 1
|
||||
|
||||
if (dragCounter.current > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
removeDragOverlay()
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
}
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
removeDragOverlay()
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
Array.from(event.dataTransfer.items).forEach(async (item) => {
|
||||
const fileOrHandle = StreamingFileReader.available()
|
||||
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
|
||||
: item.getAsFile()
|
||||
|
||||
if (!fileOrHandle) {
|
||||
return
|
||||
}
|
||||
|
||||
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
|
||||
|
||||
if (!uploadedFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
attachFileToNote(file).catch(console.error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
event.dataTransfer.clearData()
|
||||
dragCounter.current = 0
|
||||
}
|
||||
},
|
||||
[appState.files, attachFileToNote, currentTab],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragenter', handleDragIn)
|
||||
window.addEventListener('dragleave', handleDragOut)
|
||||
window.addEventListener('dragover', handleDrag)
|
||||
window.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragIn)
|
||||
window.removeEventListener('dragleave', handleDragOut)
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Disclosure open={open} onChange={toggleAttachedFilesMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
ref={buttonRef}
|
||||
className={`sn-icon-button border-contrast ${
|
||||
attachedFilesCount > 0 ? 'py-1 px-3' : ''
|
||||
}`}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<VisuallyHidden>Attached files</VisuallyHidden>
|
||||
<Icon type="attachment-file" className="block" />
|
||||
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{open && (
|
||||
<AttachedFilesPopover
|
||||
application={application}
|
||||
appState={appState}
|
||||
note={note}
|
||||
handleFileAction={handleFileAction}
|
||||
currentTab={currentTab}
|
||||
closeOnBlur={closeOnBlur}
|
||||
setCurrentTab={setCurrentTab}
|
||||
isDraggingFiles={isDraggingFiles}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { ContentType, SNFile, SNNote } from '@standardnotes/snjs'
|
||||
import { FilesIllustration } from '@standardnotes/stylekit'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { PopoverFileItem } from './PopoverFileItem'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles,
|
||||
AllFiles,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
currentTab: PopoverTabs
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||
isDraggingFiles: boolean
|
||||
note: SNNote
|
||||
setCurrentTab: StateUpdater<PopoverTabs>
|
||||
}
|
||||
|
||||
export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
currentTab,
|
||||
closeOnBlur,
|
||||
handleFileAction,
|
||||
isDraggingFiles,
|
||||
note,
|
||||
setCurrentTab,
|
||||
}) => {
|
||||
const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([])
|
||||
const [allFiles, setAllFiles] = useState<SNFile[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
|
||||
|
||||
const filteredList =
|
||||
searchQuery.length > 0
|
||||
? filesList.filter(
|
||||
(file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1,
|
||||
)
|
||||
: filesList
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
||||
setAttachedFiles(
|
||||
application.items
|
||||
.getFilesForNote(note)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)),
|
||||
)
|
||||
|
||||
setAllFiles(
|
||||
application.items
|
||||
.getItems(ContentType.File)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) as SNFile[],
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterFileStream()
|
||||
}
|
||||
}, [application, note])
|
||||
|
||||
const handleAttachFilesClick = async () => {
|
||||
const uploadedFiles = await appState.files.uploadNewFile()
|
||||
if (!uploadedFiles) {
|
||||
return
|
||||
}
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: file,
|
||||
}).catch(console.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
style={{
|
||||
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
||||
}}
|
||||
>
|
||||
<div className="flex border-0 border-b-1 border-solid border-main">
|
||||
<button
|
||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||
currentTab === PopoverTabs.AttachedFiles
|
||||
? 'color-info font-medium shadow-bottom'
|
||||
: 'color-text'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Attached
|
||||
</button>
|
||||
<button
|
||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||
currentTab === PopoverTabs.AllFiles
|
||||
? 'color-info font-medium shadow-bottom'
|
||||
: 'color-text'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AllFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
All files
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 max-h-110 overflow-y-auto">
|
||||
{filteredList.length > 0 || searchQuery.length > 0 ? (
|
||||
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onInput={(e) => {
|
||||
setSearchQuery((e.target as HTMLInputElement).value)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<button
|
||||
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
searchInputRef.current?.focus()
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="clear-circle-filled" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{filteredList.length > 0 ? (
|
||||
filteredList.map((file: SNFile) => {
|
||||
return (
|
||||
<PopoverFileItem
|
||||
key={file.uuid}
|
||||
file={file}
|
||||
isAttachedToNote={attachedFiles.includes(file)}
|
||||
handleFileAction={handleFileAction}
|
||||
getIconType={application.iconsController.getIconForFileType}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||
<div className="w-18 h-18 mb-2">
|
||||
<FilesIllustration />
|
||||
</div>
|
||||
<div className="text-sm font-medium mb-3">
|
||||
{searchQuery.length > 0
|
||||
? 'No result found'
|
||||
: currentTab === PopoverTabs.AttachedFiles
|
||||
? 'No files attached to this note'
|
||||
: 'No files found in this account'}
|
||||
</div>
|
||||
<Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||
</Button>
|
||||
<div className="text-xs color-grey-0 mt-3">Or drop your files here</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredList.length > 0 && (
|
||||
<button
|
||||
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
|
||||
onClick={handleAttachFilesClick}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="add" className="mr-2 color-neutral" />
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { IconType, SNFile } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon, ICONS } from '@/Components/Icon'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
import { PopoverFileSubmenu } from './PopoverFileSubmenu'
|
||||
|
||||
export const getFileIconComponent = (iconType: string, className: string) => {
|
||||
const IconComponent = ICONS[iconType as keyof typeof ICONS]
|
||||
|
||||
return <IconComponent className={className} />
|
||||
}
|
||||
|
||||
export type PopoverFileItemProps = {
|
||||
file: SNFile
|
||||
isAttachedToNote: boolean
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||
getIconType(type: string): IconType
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
}
|
||||
|
||||
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
file,
|
||||
isAttachedToNote,
|
||||
handleFileAction,
|
||||
getIconType,
|
||||
closeOnBlur,
|
||||
}) => {
|
||||
const [fileName, setFileName] = useState(file.name)
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const fileNameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenamingFile) {
|
||||
fileNameInputRef.current?.focus()
|
||||
}
|
||||
}, [isRenamingFile])
|
||||
|
||||
const renameFile = async (file: SNFile, name: string) => {
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file,
|
||||
name,
|
||||
},
|
||||
})
|
||||
setIsRenamingFile(false)
|
||||
}
|
||||
|
||||
const handleFileNameInput = (event: Event) => {
|
||||
setFileName((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
itemRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileNameInputBlur = () => {
|
||||
renameFile(file, fileName).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="flex items-center justify-between p-3 focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
|
||||
<div className="flex flex-col mx-4">
|
||||
{isRenamingFile ? (
|
||||
<input
|
||||
type="text"
|
||||
className="text-input px-1.5 py-1 mb-1 border-1 border-solid border-main bg-transparent color-foreground"
|
||||
value={fileName}
|
||||
ref={fileNameInputRef}
|
||||
onInput={handleFileNameInput}
|
||||
onKeyDown={handleFileNameInputKeyDown}
|
||||
onBlur={handleFileNameInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm mb-1 break-word">
|
||||
<span className="vertical-middle">{file.name}</span>
|
||||
{file.protected && (
|
||||
<Icon
|
||||
type="lock-filled"
|
||||
className="sn-icon--small ml-2 color-neutral vertical-middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs color-grey-0">
|
||||
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PopoverFileSubmenu
|
||||
file={file}
|
||||
isAttachedToNote={isAttachedToNote}
|
||||
handleFileAction={handleFileAction}
|
||||
setIsRenamingFile={setIsRenamingFile}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
|
||||
export enum PopoverFileItemActionType {
|
||||
AttachFileToNote,
|
||||
DetachFileToNote,
|
||||
DeleteFile,
|
||||
DownloadFile,
|
||||
RenameFile,
|
||||
ToggleFileProtection,
|
||||
}
|
||||
|
||||
export type PopoverFileItemAction =
|
||||
| {
|
||||
type: Exclude<
|
||||
PopoverFileItemActionType,
|
||||
PopoverFileItemActionType.RenameFile | PopoverFileItemActionType.ToggleFileProtection
|
||||
>
|
||||
payload: SNFile
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.ToggleFileProtection
|
||||
payload: SNFile
|
||||
callback: (isProtected: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.RenameFile
|
||||
payload: {
|
||||
file: SNFile
|
||||
name: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { useFilePreviewModal } from '@/Components/Files/FilePreviewModalProvider'
|
||||
import { PopoverFileItemProps } from './PopoverFileItem'
|
||||
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
|
||||
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
|
||||
setIsRenamingFile: StateUpdater<boolean>
|
||||
}
|
||||
|
||||
export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
file,
|
||||
isAttachedToNote,
|
||||
handleFileAction,
|
||||
setIsRenamingFile,
|
||||
}) => {
|
||||
const filePreviewModal = useFilePreviewModal()
|
||||
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [isFileProtected, setIsFileProtected] = useState(file.protected)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
|
||||
|
||||
const closeMenu = () => {
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}
|
||||
|
||||
const recalculateMenuStyle = useCallback(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}, [isMenuOpen, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isMenuOpen} onChange={toggleMenu}>
|
||||
<DisclosureButton
|
||||
ref={menuButtonRef}
|
||||
onBlur={closeOnBlur}
|
||||
className="w-7 h-7 p-1 rounded-full border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
>
|
||||
<Icon type="more" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-60 py-1 fixed overflow-y-auto"
|
||||
>
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
filePreviewModal.activate(file)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="file" className="mr-2 color-neutral" />
|
||||
Preview file
|
||||
</button>
|
||||
{isAttachedToNote ? (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DetachFileToNote,
|
||||
payload: file,
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link-off" className="mr-2 color-neutral" />
|
||||
Detach from note
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: file,
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link" className="mr-2 color-neutral" />
|
||||
Attach to note
|
||||
</button>
|
||||
)}
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.ToggleFileProtection,
|
||||
payload: file,
|
||||
callback: (isProtected: boolean) => {
|
||||
setIsFileProtected(isProtected)
|
||||
},
|
||||
}).catch(console.error)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className="mr-2 color-neutral" />
|
||||
Password protection
|
||||
</span>
|
||||
<Switch
|
||||
className="px-0 pointer-events-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
checked={isFileProtected}
|
||||
/>
|
||||
</button>
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DownloadFile,
|
||||
payload: file,
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="download" className="mr-2 color-neutral" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
setIsRenamingFile(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="mr-2 color-neutral" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DeleteFile,
|
||||
payload: file,
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="mr-2 color-danger" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
app/assets/javascripts/Components/Bubble/index.tsx
Normal file
23
app/assets/javascripts/Components/Bubble/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface BubbleProperties {
|
||||
label: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
const styles = {
|
||||
base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast',
|
||||
unselected: 'color-neutral border-secondary',
|
||||
selected: 'border-info bg-info color-neutral-contrast',
|
||||
}
|
||||
|
||||
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => (
|
||||
<span
|
||||
role="tab"
|
||||
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
export default Bubble
|
||||
68
app/assets/javascripts/Components/Button/Button.tsx
Normal file
68
app/assets/javascripts/Components/Button/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { ComponentChildren, FunctionComponent, Ref } from 'preact'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
|
||||
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
|
||||
|
||||
type ButtonVariant = 'normal' | 'primary'
|
||||
|
||||
const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean) => {
|
||||
const borders = variant === 'normal' ? 'border-solid border-main border-1' : 'no-border'
|
||||
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
|
||||
let colors = variant === 'normal' ? 'bg-default color-text' : 'bg-info color-info-contrast'
|
||||
|
||||
let focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
|
||||
: 'hover:brightness-130 focus:outline-none focus:brightness-130'
|
||||
|
||||
if (danger) {
|
||||
colors = variant === 'normal' ? 'bg-default color-danger' : 'bg-danger color-info-contrast'
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast'
|
||||
focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-default focus:outline-none hover:bg-default'
|
||||
: 'focus:brightness-default focus:outline-none hover:brightness-default'
|
||||
}
|
||||
|
||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
|
||||
}
|
||||
|
||||
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & {
|
||||
children?: ComponentChildren
|
||||
className?: string
|
||||
variant?: ButtonVariant
|
||||
dangerStyle?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
variant = 'normal',
|
||||
label,
|
||||
className = '',
|
||||
dangerStyle: danger = false,
|
||||
disabled = false,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${getClassName(variant, danger, disabled)} ${className}`}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{label ?? children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
56
app/assets/javascripts/Components/Button/IconButton.tsx
Normal file
56
app/assets/javascripts/Components/Button/IconButton.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* onClick - preventDefault is handled within the component
|
||||
*/
|
||||
onClick: () => void
|
||||
|
||||
className?: string
|
||||
|
||||
icon: IconType
|
||||
|
||||
iconClassName?: string
|
||||
|
||||
/**
|
||||
* Button tooltip
|
||||
*/
|
||||
title: string
|
||||
|
||||
focusable: boolean
|
||||
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* IconButton component with an icon
|
||||
* preventDefault is already handled within the component
|
||||
*/
|
||||
export const IconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
className = '',
|
||||
icon,
|
||||
title,
|
||||
focusable,
|
||||
iconClassName = '',
|
||||
disabled = false,
|
||||
}) => {
|
||||
const click = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
const focusableClass = focusable ? '' : 'focus:shadow-none'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
|
||||
onClick={click}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon type={icon} className={iconClassName} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
40
app/assets/javascripts/Components/Button/RoundIconButton.tsx
Normal file
40
app/assets/javascripts/Components/Button/RoundIconButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
type ButtonType = 'normal' | 'primary'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* onClick - preventDefault is handled within the component
|
||||
*/
|
||||
onClick: () => void
|
||||
|
||||
type: ButtonType
|
||||
|
||||
className?: string
|
||||
|
||||
icon: IconType
|
||||
}
|
||||
|
||||
/**
|
||||
* IconButton component with an icon
|
||||
* preventDefault is already handled within the component
|
||||
*/
|
||||
export const RoundIconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
type,
|
||||
className,
|
||||
icon: iconType,
|
||||
}) => {
|
||||
const click = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
const classes = type === 'primary' ? 'info ' : ''
|
||||
return (
|
||||
<button className={`sn-icon-button ${classes} ${className ?? ''}`} onClick={click}>
|
||||
<Icon type={iconType} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import {
|
||||
ButtonType,
|
||||
Challenge,
|
||||
ChallengePrompt,
|
||||
ChallengeReason,
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ProtectedIllustration } from '@standardnotes/stylekit'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { ChallengeModalPrompt } from './ChallengePrompt'
|
||||
|
||||
type InputValue = {
|
||||
prompt: ChallengePrompt
|
||||
value: string | number | boolean
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
challenge: Challenge
|
||||
onDismiss: (challenge: Challenge) => Promise<void>
|
||||
}
|
||||
|
||||
const validateValues = (
|
||||
values: ChallengeModalValues,
|
||||
prompts: ChallengePrompt[],
|
||||
): ChallengeModalValues | undefined => {
|
||||
let hasInvalidValues = false
|
||||
const validatedValues = { ...values }
|
||||
for (const prompt of prompts) {
|
||||
const value = validatedValues[prompt.id]
|
||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||
validatedValues[prompt.id].invalid = true
|
||||
hasInvalidValues = true
|
||||
}
|
||||
}
|
||||
if (!hasInvalidValues) {
|
||||
return validatedValues
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const ChallengeModal: FunctionComponent<Props> = ({ application, challenge, onDismiss }) => {
|
||||
const [values, setValues] = useState<ChallengeModalValues>(() => {
|
||||
const values = {} as ChallengeModalValues
|
||||
for (const prompt of challenge.prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
|
||||
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
|
||||
const shouldShowForgotPasscode = [
|
||||
ChallengeReason.ApplicationUnlock,
|
||||
ChallengeReason.Migration,
|
||||
].includes(challenge.reason)
|
||||
|
||||
const submit = async () => {
|
||||
const validatedValues = validateValues(values, challenge.prompts)
|
||||
if (!validatedValues) {
|
||||
return
|
||||
}
|
||||
if (isSubmitting || isProcessing) {
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
setIsProcessing(true)
|
||||
const valuesToProcess: ChallengeValue[] = []
|
||||
for (const inputValue of Object.values(validatedValues)) {
|
||||
const rawValue = inputValue.value
|
||||
const value = new ChallengeValue(inputValue.prompt, rawValue)
|
||||
valuesToProcess.push(value)
|
||||
}
|
||||
const processingPrompts = valuesToProcess.map((v) => v.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
setProcessingPrompts(processingPrompts)
|
||||
/**
|
||||
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||
* (crypto key generation)
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (valuesToProcess.length > 0) {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
} else {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string | number, prompt: ChallengePrompt) => {
|
||||
const newValues = { ...values }
|
||||
newValues[prompt.id].invalid = false
|
||||
newValues[prompt.id].value = value
|
||||
setValues(newValues)
|
||||
},
|
||||
[values],
|
||||
)
|
||||
|
||||
const closeModal = () => {
|
||||
if (challenge.cancelable) {
|
||||
onDismiss(challenge).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const removeChallengeObserver = application.addChallengeObserver(challenge, {
|
||||
onValidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = false
|
||||
return newValues
|
||||
})
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
},
|
||||
onInvalidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = true
|
||||
return newValues
|
||||
})
|
||||
/** If custom validation, treat all values together and not individually */
|
||||
if (!value.prompt.validates) {
|
||||
setProcessingPrompts([])
|
||||
setIsProcessing(false)
|
||||
} else {
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
onDismiss(challenge).catch(console.error)
|
||||
},
|
||||
onCancel: () => {
|
||||
onDismiss(challenge).catch(console.error)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeChallengeObserver()
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
if (!challenge.prompts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className={`sn-component ${
|
||||
challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
|
||||
}`}
|
||||
onDismiss={closeModal}
|
||||
dangerouslyBypassFocusLock={bypassModalFocusLock}
|
||||
>
|
||||
<DialogContent
|
||||
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
||||
challenge.reason !== ChallengeReason.ApplicationUnlock
|
||||
? 'shadow-overlay-light border-1 border-solid border-main'
|
||||
: 'focus:shadow-none'
|
||||
}`}
|
||||
>
|
||||
{challenge.cancelable && (
|
||||
<button
|
||||
onClick={closeModal}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
<ProtectedIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
|
||||
<div className="text-center text-sm max-w-76 mb-4">{challenge.subheading}</div>
|
||||
<form
|
||||
className="flex flex-col items-center min-w-76 mb-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit().catch(console.error)
|
||||
}}
|
||||
>
|
||||
{challenge.prompts.map((prompt, index) => (
|
||||
<ChallengeModalPrompt
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
values={values}
|
||||
index={index}
|
||||
onValueChange={onValueChange}
|
||||
isInvalid={values[prompt.id].invalid}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isProcessing}
|
||||
className="min-w-76 mb-3.5"
|
||||
onClick={() => {
|
||||
submit().catch(console.error)
|
||||
}}
|
||||
>
|
||||
{isProcessing ? 'Generating Keys...' : 'Unlock'}
|
||||
</Button>
|
||||
{shouldShowForgotPasscode && (
|
||||
<Button
|
||||
className="flex items-center justify-center min-w-76"
|
||||
onClick={() => {
|
||||
setBypassModalFocusLock(true)
|
||||
application.alertService
|
||||
.confirm(
|
||||
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
|
||||
'Forgot passcode?',
|
||||
'Delete local data',
|
||||
ButtonType.Danger,
|
||||
)
|
||||
.then((shouldDeleteLocalData) => {
|
||||
if (shouldDeleteLocalData) {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBypassModalFocusLock(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="help" className="mr-2 color-neutral" />
|
||||
Forgot passcode?
|
||||
</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ChallengePrompt,
|
||||
ChallengeValidation,
|
||||
ProtectionSessionDurations,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { ChallengeModalValues } from './ChallengeModal'
|
||||
|
||||
type Props = {
|
||||
prompt: ChallengePrompt
|
||||
values: ChallengeModalValues
|
||||
index: number
|
||||
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
|
||||
isInvalid: boolean
|
||||
}
|
||||
|
||||
export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
prompt,
|
||||
values,
|
||||
index,
|
||||
onValueChange,
|
||||
isInvalid,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (index === 0) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [index])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInvalid) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isInvalid])
|
||||
|
||||
return (
|
||||
<>
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div className="mt-3 min-w-76">
|
||||
<div className="text-sm font-medium mb-2">Allow protected access for</div>
|
||||
<div className="flex items-center justify-between bg-grey-4 rounded p-1">
|
||||
{ProtectionSessionDurations.map((option) => {
|
||||
const selected = option.valueInSeconds === values[prompt.id].value
|
||||
return (
|
||||
<label
|
||||
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||
selected
|
||||
? 'bg-default color-foreground font-semibold'
|
||||
: 'color-grey-0 hover:bg-grey-3'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`session-duration-${prompt.id}`}
|
||||
className={'appearance-none m-0 focus:shadow-none focus:outline-none'}
|
||||
style={{
|
||||
marginRight: 0,
|
||||
}}
|
||||
checked={selected}
|
||||
onChange={(event) => {
|
||||
event.preventDefault()
|
||||
onValueChange(option.valueInSeconds, prompt)
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : prompt.secureTextEntry ? (
|
||||
<DecoratedPasswordInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
) : (
|
||||
<DecoratedInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div className="text-sm color-danger mt-2">Invalid authentication, please try again.</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { ChangeEditorMenu } from './ChangeEditorMenu'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickPreprocessing }) => {
|
||||
const note = Object.values(appState.notes.selectedNotes)[0]
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
|
||||
|
||||
const toggleChangeEditorMenu = async () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
|
||||
const newOpenState = !isOpen
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setIsOpen(newOpenState)
|
||||
setTimeout(() => {
|
||||
setIsVisible(newOpenState)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button border-contrast"
|
||||
>
|
||||
<VisuallyHidden>Change editor</VisuallyHidden>
|
||||
<Icon type="dashboard" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{isOpen && (
|
||||
<ChangeEditorMenu
|
||||
closeOnBlur={closeOnBlur}
|
||||
application={application}
|
||||
isVisible={isVisible}
|
||||
note={note}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Menu } from '@/Components/Menu/Menu'
|
||||
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/Components/NoteView/NoteView'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ComponentArea,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Fragment, FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
||||
import { createEditorMenuGroups, PLAIN_EDITOR_NAME } from './createEditorMenuGroups'
|
||||
|
||||
type ChangeEditorMenuProps = {
|
||||
application: WebApplication
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
closeMenu: () => void
|
||||
isVisible: boolean
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
||||
|
||||
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
application,
|
||||
closeOnBlur,
|
||||
closeMenu,
|
||||
isVisible,
|
||||
note,
|
||||
}) => {
|
||||
const [editors] = useState<SNComponent[]>(() =>
|
||||
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
|
||||
}),
|
||||
)
|
||||
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
|
||||
const [currentEditor, setCurrentEditor] = useState<SNComponent>()
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(createEditorMenuGroups(application, editors))
|
||||
}, [application, editors])
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
}
|
||||
}, [application, note])
|
||||
|
||||
const premiumModal = usePremiumModal()
|
||||
|
||||
const isSelectedEditor = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
if (currentEditor) {
|
||||
if (item?.component?.identifier === currentEditor.identifier) {
|
||||
return true
|
||||
}
|
||||
} else if (item.name === PLAIN_EDITOR_NAME) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[currentEditor],
|
||||
)
|
||||
|
||||
const selectComponent = async (component: SNComponent | null, note: SNNote) => {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
const transactions: TransactionalMutation[] = []
|
||||
|
||||
if (application.getAppState().getActiveNoteController()?.isTemplateNote) {
|
||||
await application.getAppState().getActiveNoteController().insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (note.locked) {
|
||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
if (!note.prefersPlainEditor) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator
|
||||
noteMutator.prefersPlainEditor = true
|
||||
},
|
||||
})
|
||||
}
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
const prefersPlain = note.prefersPlainEditor
|
||||
if (prefersPlain) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator
|
||||
noteMutator.prefersPlainEditor = false
|
||||
},
|
||||
})
|
||||
}
|
||||
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
|
||||
}
|
||||
|
||||
await application.mutator.runTransactionalMutations(transactions)
|
||||
/** Dirtying can happen above */
|
||||
application.sync.sync().catch(console.error)
|
||||
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
}
|
||||
|
||||
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
|
||||
if (!itemToBeSelected.isEntitled) {
|
||||
premiumModal.activate(itemToBeSelected.name)
|
||||
return
|
||||
}
|
||||
|
||||
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
|
||||
|
||||
if (areBothEditorsPlain) {
|
||||
return
|
||||
}
|
||||
|
||||
let shouldSelectEditor = true
|
||||
|
||||
if (itemToBeSelected.component) {
|
||||
const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
|
||||
currentEditor,
|
||||
itemToBeSelected.component,
|
||||
)
|
||||
|
||||
if (changeRequiresAlert) {
|
||||
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSelectEditor) {
|
||||
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu className="pt-0.5 pb-1" a11yLabel="Change editor menu" isOpen={isVisible}>
|
||||
{groups
|
||||
.filter((group) => group.items && group.items.length)
|
||||
.map((group, index) => {
|
||||
const groupId = getGroupId(group)
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
<div
|
||||
className={`flex items-center px-2.5 py-2 text-xs font-semibold color-text border-0 border-y-1px border-solid border-main ${
|
||||
index === 0 ? 'border-t-0 mb-2' : 'my-2'
|
||||
}`}
|
||||
>
|
||||
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
|
||||
<div className="font-semibold text-input">{group.title}</div>
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const onClickEditorItem = () => {
|
||||
selectEditor(item).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={onClickEditorItem}
|
||||
className={
|
||||
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none'
|
||||
}
|
||||
onBlur={closeOnBlur}
|
||||
checked={isSelectedEditor(item)}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
{item.name}
|
||||
{!item.isEntitled && <Icon type="premium-feature" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ContentType,
|
||||
FeatureStatus,
|
||||
SNComponent,
|
||||
ComponentArea,
|
||||
FeatureDescription,
|
||||
GetFeatures,
|
||||
NoteType,
|
||||
} from '@standardnotes/snjs'
|
||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
||||
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor'
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others'
|
||||
|
||||
const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => {
|
||||
if (featureDescription.note_type) {
|
||||
return featureDescription.note_type
|
||||
} else if (featureDescription.file_type) {
|
||||
switch (featureDescription.file_type) {
|
||||
case 'txt':
|
||||
return 'plain'
|
||||
case 'html':
|
||||
return NoteType.RichText
|
||||
case 'md':
|
||||
return NoteType.Markdown
|
||||
default:
|
||||
return 'others'
|
||||
}
|
||||
}
|
||||
return 'others'
|
||||
}
|
||||
|
||||
export const createEditorMenuGroups = (application: WebApplication, editors: SNComponent[]) => {
|
||||
const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
|
||||
plain: [
|
||||
{
|
||||
name: PLAIN_EDITOR_NAME,
|
||||
isEntitled: true,
|
||||
},
|
||||
],
|
||||
'rich-text': [],
|
||||
markdown: [],
|
||||
task: [],
|
||||
code: [],
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
}
|
||||
|
||||
GetFeatures()
|
||||
.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Component && feature.area === ComponentArea.Editor,
|
||||
)
|
||||
.forEach((editorFeature) => {
|
||||
const notInstalled = !editors.find((editor) => editor.identifier === editorFeature.identifier)
|
||||
const isExperimental = application.features.isExperimentalFeature(editorFeature.identifier)
|
||||
if (notInstalled && !isExperimental) {
|
||||
editorItems[getEditorGroup(editorFeature)].push({
|
||||
name: editorFeature.name as string,
|
||||
isEntitled: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
editors.forEach((editor) => {
|
||||
const editorItem: EditorMenuItem = {
|
||||
name: editor.name,
|
||||
component: editor,
|
||||
isEntitled:
|
||||
application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
|
||||
}
|
||||
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem)
|
||||
})
|
||||
|
||||
const editorMenuGroups: EditorMenuGroup[] = [
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Plain text',
|
||||
items: editorItems.plain,
|
||||
},
|
||||
{
|
||||
icon: 'rich-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Rich text',
|
||||
items: editorItems['rich-text'],
|
||||
},
|
||||
{
|
||||
icon: 'markdown',
|
||||
iconClassName: 'color-accessory-tint-2',
|
||||
title: 'Markdown text',
|
||||
items: editorItems.markdown,
|
||||
},
|
||||
{
|
||||
icon: 'tasks',
|
||||
iconClassName: 'color-accessory-tint-3',
|
||||
title: 'Todo',
|
||||
items: editorItems.task,
|
||||
},
|
||||
{
|
||||
icon: 'code',
|
||||
iconClassName: 'color-accessory-tint-4',
|
||||
title: 'Code',
|
||||
items: editorItems.code,
|
||||
},
|
||||
{
|
||||
icon: 'spreadsheets',
|
||||
iconClassName: 'color-accessory-tint-5',
|
||||
title: 'Spreadsheet',
|
||||
items: editorItems.spreadsheet,
|
||||
},
|
||||
{
|
||||
icon: 'authenticator',
|
||||
iconClassName: 'color-accessory-tint-6',
|
||||
title: 'Authentication',
|
||||
items: editorItems.authentication,
|
||||
},
|
||||
{
|
||||
icon: 'editor',
|
||||
iconClassName: 'color-neutral',
|
||||
title: 'Others',
|
||||
items: editorItems.others,
|
||||
},
|
||||
]
|
||||
|
||||
return editorMenuGroups
|
||||
}
|
||||
32
app/assets/javascripts/Components/Checkbox/index.tsx
Normal file
32
app/assets/javascripts/Components/Checkbox/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
type CheckboxProps = {
|
||||
name: string
|
||||
checked: boolean
|
||||
onChange: (e: Event) => void
|
||||
disabled?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export const Checkbox: FunctionComponent<CheckboxProps> = ({
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<label htmlFor={name} className="flex items-center fit-content mb-2">
|
||||
<input
|
||||
className="mr-2"
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={name}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
deprecationMessage: string | undefined
|
||||
dismissDeprecationMessage: () => void
|
||||
}
|
||||
|
||||
export const IsDeprecated: FunctionalComponent<IProps> = ({
|
||||
deprecationMessage,
|
||||
dismissDeprecationMessage,
|
||||
}) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-label warning'}>
|
||||
{deprecationMessage || 'This extension is deprecated.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
|
||||
<button className={'sn-button small info'}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FeatureStatus } from '@standardnotes/snjs'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
expiredDate: string
|
||||
componentName: string
|
||||
featureStatus: FeatureStatus
|
||||
manageSubscription: () => void
|
||||
}
|
||||
|
||||
const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => {
|
||||
switch (featureStatus) {
|
||||
case FeatureStatus.InCurrentPlanButExpired:
|
||||
return `Your subscription expired on ${expiredDate}`
|
||||
case FeatureStatus.NoUserSubscription:
|
||||
return 'You do not have an active subscription'
|
||||
case FeatureStatus.NotInCurrentPlan:
|
||||
return `Please upgrade your plan to access ${componentName}`
|
||||
default:
|
||||
return `${componentName} is valid and you should not be seeing this message`
|
||||
}
|
||||
}
|
||||
|
||||
export const IsExpired: FunctionalComponent<IProps> = ({
|
||||
expiredDate,
|
||||
featureStatus,
|
||||
componentName,
|
||||
manageSubscription,
|
||||
}) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-app-bar-item-column'}>
|
||||
<div className={'sk-circle danger small'} />
|
||||
</div>
|
||||
<div className={'sk-app-bar-item-column'}>
|
||||
<div>
|
||||
<strong>{statusString(featureStatus, expiredDate, componentName)}</strong>
|
||||
<div className={'sk-p'}>{componentName} is in a read-only state.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={() => manageSubscription()}>
|
||||
<button className={'sn-button small success'}>Manage Subscription</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
componentName: string
|
||||
reloadIframe: () => void
|
||||
}
|
||||
|
||||
export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-label.warning'}>There was an issue loading {componentName}.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={reloadIframe}>
|
||||
<button className={'sn-button small info'}>Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
export const OfflineRestricted: FunctionalComponent = () => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-panel static'}>
|
||||
<div className={'sk-panel-content'}>
|
||||
<div className={'sk-panel-section stretch'}>
|
||||
<div className={'sk-panel-column'} />
|
||||
<div className={'sk-h1 sk-bold'}>
|
||||
You have restricted this component to not use a hosted version.
|
||||
</div>
|
||||
<div className={'sk-subtitle'}>
|
||||
Locally-installed components are not available in the web application.
|
||||
</div>
|
||||
<div className={'sk-panel-row'} />
|
||||
<div className={'sk-panel-row'}>
|
||||
<div className={'sk-panel-column'}>
|
||||
<div className={'sk-p'}>To continue, choose from the following options:</div>
|
||||
<ul>
|
||||
<li className={'sk-p'}>
|
||||
Enable the Hosted option for this component by opening the Preferences {'>'}{' '}
|
||||
General {'>'} Advanced Settings menu and toggling 'Use hosted when local is
|
||||
unavailable' under this component's options. Then press Reload.
|
||||
</li>
|
||||
<li className={'sk-p'}>Use the desktop application.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
componentName: string
|
||||
}
|
||||
|
||||
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-panel static'}>
|
||||
<div className={'sk-panel-content'}>
|
||||
<div className={'sk-panel-section stretch'}>
|
||||
<div className={'sk-panel-section-title'}>
|
||||
This extension is missing its URL property.
|
||||
</div>
|
||||
<p>
|
||||
In order to access your note immediately, please switch from {componentName} to the
|
||||
Plain Editor.
|
||||
</p>
|
||||
<br />
|
||||
<p>Please contact help@standardnotes.com to remedy this issue.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
app/assets/javascripts/Components/ComponentView/index.tsx
Normal file
221
app/assets/javascripts/Components/ComponentView/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
ComponentAction,
|
||||
FeatureStatus,
|
||||
SNComponent,
|
||||
dateToLocalizedString,
|
||||
ComponentViewer,
|
||||
ComponentViewerEvent,
|
||||
ComponentViewerError,
|
||||
} from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted'
|
||||
import { UrlMissing } from '@/Components/ComponentView/UrlMissing'
|
||||
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated'
|
||||
import { IsExpired } from '@/Components/ComponentView/IsExpired'
|
||||
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
componentViewer: ComponentViewer
|
||||
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
|
||||
onLoad?: (component: SNComponent) => void
|
||||
manualDealloc?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum amount of time we'll wait for a component
|
||||
* to load before displaying error
|
||||
*/
|
||||
const MaxLoadThreshold = 4000
|
||||
const VisibilityChangeKey = 'visibilitychange'
|
||||
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
|
||||
|
||||
export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
({ application, onLoad, componentViewer, requestReload }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const excessiveLoadingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const [hasIssueLoading, setHasIssueLoading] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
|
||||
componentViewer.getFeatureStatus(),
|
||||
)
|
||||
const [isComponentValid, setIsComponentValid] = useState(true)
|
||||
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
|
||||
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
|
||||
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
|
||||
const [didAttemptReload, setDidAttemptReload] = useState(false)
|
||||
|
||||
const component = componentViewer.component
|
||||
|
||||
const manageSubscription = useCallback(() => {
|
||||
openSubscriptionDashboard(application)
|
||||
}, [application])
|
||||
|
||||
const reloadValidityStatus = useCallback(() => {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||
if (!componentViewer.lockReadonly) {
|
||||
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
|
||||
}
|
||||
setIsComponentValid(componentViewer.shouldRender())
|
||||
|
||||
if (isLoading && !isComponentValid) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
setError(componentViewer.getError())
|
||||
setDeprecationMessage(component.deprecationMessage)
|
||||
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
reloadValidityStatus()
|
||||
}, [reloadValidityStatus])
|
||||
|
||||
const dismissDeprecationMessage = () => {
|
||||
setIsDeprecationMessageDismissed(true)
|
||||
}
|
||||
|
||||
const onVisibilityChange = useCallback(() => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return
|
||||
}
|
||||
if (hasIssueLoading) {
|
||||
requestReload?.(componentViewer)
|
||||
}
|
||||
}, [hasIssueLoading, componentViewer, requestReload])
|
||||
|
||||
const handleIframeTakingTooLongToLoad = useCallback(async () => {
|
||||
setIsLoading(false)
|
||||
setHasIssueLoading(true)
|
||||
|
||||
if (!didAttemptReload) {
|
||||
setDidAttemptReload(true)
|
||||
requestReload?.(componentViewer)
|
||||
} else {
|
||||
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
|
||||
}
|
||||
}, [didAttemptReload, onVisibilityChange, componentViewer, requestReload])
|
||||
|
||||
useMemo(() => {
|
||||
const loadTimeout = setTimeout(() => {
|
||||
handleIframeTakingTooLongToLoad().catch(console.error)
|
||||
}, MaxLoadThreshold)
|
||||
|
||||
excessiveLoadingTimeout.current = loadTimeout
|
||||
|
||||
return () => {
|
||||
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
|
||||
}
|
||||
}, [handleIframeTakingTooLongToLoad])
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current as HTMLIFrameElement
|
||||
const contentWindow = iframe.contentWindow as Window
|
||||
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
|
||||
|
||||
componentViewer.setWindow(contentWindow).catch(console.error)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
setHasIssueLoading(false)
|
||||
onLoad?.(component)
|
||||
}, MSToWaitAfterIframeLoadToAvoidFlicker)
|
||||
}, [componentViewer, onLoad, component, excessiveLoadingTimeout])
|
||||
|
||||
useEffect(() => {
|
||||
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
|
||||
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeFeaturesChangedObserver()
|
||||
}
|
||||
}, [componentViewer])
|
||||
|
||||
useEffect(() => {
|
||||
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
|
||||
switch (action) {
|
||||
case ComponentAction.KeyDown:
|
||||
application.io.handleComponentKeyDown(data.keyboardModifier)
|
||||
break
|
||||
case ComponentAction.KeyUp:
|
||||
application.io.handleComponentKeyUp(data.keyboardModifier)
|
||||
break
|
||||
case ComponentAction.Click:
|
||||
application.getAppState().notes.setContextMenuOpen(false)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
removeActionObserver()
|
||||
}
|
||||
}, [componentViewer, application])
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterDesktopObserver = application
|
||||
.getDesktopService()
|
||||
.registerUpdateObserver((updatedComponent: SNComponent) => {
|
||||
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
|
||||
requestReload?.(componentViewer)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterDesktopObserver()
|
||||
}
|
||||
}, [application, requestReload, componentViewer, component.uuid])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasIssueLoading && (
|
||||
<IssueOnLoading
|
||||
componentName={component.name}
|
||||
reloadIframe={() => {
|
||||
reloadValidityStatus(), requestReload?.(componentViewer, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{featureStatus !== FeatureStatus.Entitled && (
|
||||
<IsExpired
|
||||
expiredDate={dateToLocalizedString(component.valid_until)}
|
||||
featureStatus={featureStatus}
|
||||
componentName={component.name}
|
||||
manageSubscription={manageSubscription}
|
||||
/>
|
||||
)}
|
||||
{deprecationMessage && !isDeprecationMessageDismissed && (
|
||||
<IsDeprecated
|
||||
deprecationMessage={deprecationMessage}
|
||||
dismissDeprecationMessage={dismissDeprecationMessage}
|
||||
/>
|
||||
)}
|
||||
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
|
||||
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.name} />}
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
src={componentViewer.url || ''}
|
||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
||||
>
|
||||
Loading
|
||||
</iframe>
|
||||
)}
|
||||
{isLoading && <div className={'loading-overlay'} />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const ConfirmSignoutContainer = observer((props: Props) => {
|
||||
if (!props.appState.accountMenu.signingOut) {
|
||||
return null
|
||||
}
|
||||
return <ConfirmSignoutModal {...props} />
|
||||
})
|
||||
|
||||
export const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false)
|
||||
}
|
||||
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
|
||||
}, [appState.accountMenu.signingOut, application.bridge])
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
|
||||
Sign out workspace?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups((event.target as HTMLInputElement).checked)
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups()
|
||||
}}
|
||||
>
|
||||
View backup files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups().catch(console.error)
|
||||
} else {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
closeDialog()
|
||||
}}
|
||||
>
|
||||
{application.hasAccount() ? 'Sign Out' : 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
})
|
||||
123
app/assets/javascripts/Components/Dropdown/index.tsx
Normal file
123
app/assets/javascripts/Components/Dropdown/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ListboxArrow,
|
||||
ListboxButton,
|
||||
ListboxInput,
|
||||
ListboxList,
|
||||
ListboxOption,
|
||||
ListboxPopover,
|
||||
} from '@reach/listbox'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
export type DropdownItem = {
|
||||
icon?: IconType
|
||||
iconClassName?: string
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type DropdownProps = {
|
||||
id: string
|
||||
label: string
|
||||
items: DropdownItem[]
|
||||
value: string
|
||||
onChange: (value: string, item: DropdownItem) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ListboxButtonProps = DropdownItem & {
|
||||
isExpanded: boolean
|
||||
}
|
||||
|
||||
const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
|
||||
label,
|
||||
isExpanded,
|
||||
icon,
|
||||
iconClassName = '',
|
||||
}) => (
|
||||
<>
|
||||
<div className="sn-dropdown-button-label">
|
||||
{icon ? (
|
||||
<div className="flex mr-2">
|
||||
<Icon type={icon} className={`sn-icon--small ${iconClassName}`} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dropdown-selected-label">{label}</div>
|
||||
</div>
|
||||
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
|
||||
<Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" />
|
||||
</ListboxArrow>
|
||||
</>
|
||||
)
|
||||
|
||||
export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
id,
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
const labelId = `${id}-label`
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const selectedItem = items.find((item) => item.value === value) as DropdownItem
|
||||
|
||||
onChange(value, selectedItem)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
|
||||
<ListboxInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ListboxButton
|
||||
className="sn-dropdown-button"
|
||||
children={({ value, label, isExpanded }) => {
|
||||
const current = items.find((item) => item.value === value)
|
||||
const icon = current ? current?.icon : null
|
||||
const iconClassName = current ? current?.iconClassName : null
|
||||
return CustomDropdownButton({
|
||||
value: value ? value : label.toLowerCase(),
|
||||
label,
|
||||
isExpanded,
|
||||
...(icon ? { icon } : null),
|
||||
...(iconClassName ? { iconClassName } : null),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ListboxPopover className="sn-dropdown sn-dropdown-popover">
|
||||
<div className="sn-component">
|
||||
<ListboxList>
|
||||
{items.map((item) => (
|
||||
<ListboxOption
|
||||
className="sn-dropdown-item"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex mr-3">
|
||||
<Icon
|
||||
type={item.icon}
|
||||
className={`sn-icon--small ${item.iconClassName ?? ''}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-input">{item.label}</div>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
</div>
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
file: SNFile
|
||||
}
|
||||
|
||||
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
return (
|
||||
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon type="info" className="mr-2" />
|
||||
<div className="font-semibold">File information</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Type:</span> {file.mimeType}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Size:</span> {formatSizeToReadableString(file.size)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Last Modified:</span>{' '}
|
||||
{file.userModifiedDate.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">File ID:</span> {file.uuid}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
app/assets/javascripts/Components/Files/FilePreviewModal.tsx
Normal file
193
app/assets/javascripts/Components/Files/FilePreviewModal.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { NoPreviewIllustration } from '@standardnotes/stylekit'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
|
||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
file: SNFile
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <img src={objectUrl} />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return <video className="w-full h-full" src={objectUrl} controls />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('audio/')) {
|
||||
return <audio src={objectUrl} controls />
|
||||
}
|
||||
|
||||
return <object className="w-full h-full" data={objectUrl} />
|
||||
}
|
||||
|
||||
export const FilePreviewModal: FunctionComponent<Props> = ({ application, file, onDismiss }) => {
|
||||
const [objectUrl, setObjectUrl] = useState<string>()
|
||||
const [isFilePreviewable, setIsFilePreviewable] = useState(false)
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false)
|
||||
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const getObjectUrl = useCallback(async () => {
|
||||
setIsLoadingFile(true)
|
||||
try {
|
||||
const chunks: Uint8Array[] = []
|
||||
await application.files.downloadFile(file, async (decryptedChunk: Uint8Array) => {
|
||||
chunks.push(decryptedChunk)
|
||||
})
|
||||
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
|
||||
setObjectUrl(
|
||||
URL.createObjectURL(
|
||||
new Blob([finalDecryptedBytes], {
|
||||
type: file.mimeType,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoadingFile(false)
|
||||
}
|
||||
}, [application.files, file])
|
||||
|
||||
useEffect(() => {
|
||||
const isPreviewable = isFileTypePreviewable(file.mimeType)
|
||||
setIsFilePreviewable(isPreviewable)
|
||||
|
||||
if (!objectUrl && isPreviewable) {
|
||||
getObjectUrl().catch(console.error)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
}, [file.mimeType, getObjectUrl, objectUrl])
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className="sn-component"
|
||||
aria-label="File preview modal"
|
||||
onDismiss={onDismiss}
|
||||
initialFocusRef={closeButtonRef}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex flex-col rounded shadow-overlay"
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: '90%',
|
||||
minHeight: '90%',
|
||||
background: 'var(--sn-stylekit-background-color)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-shrink-0 justify-between items-center min-h-6 px-4 py-3 border-0 border-b-1 border-solid border-main">
|
||||
<div className="flex items-center">
|
||||
<div className="w-6 h-6">
|
||||
{getFileIconComponent(
|
||||
application.iconsController.getIconForFileType(file.mimeType),
|
||||
'w-6 h-6 flex-shrink-0',
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-3 font-medium">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex p-1.5 mr-4 bg-transparent hover:bg-contrast border-solid border-main border-1 cursor-pointer rounded"
|
||||
onClick={() => setShowFileInfoPanel((show) => !show)}
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</button>
|
||||
{objectUrl && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mr-4"
|
||||
onClick={() => {
|
||||
application.getArchiveService().downloadData(objectUrl, file.name)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onDismiss}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent hover:bg-contrast border-0 cursor-pointer rounded"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow min-h-0 overflow-auto">
|
||||
<div className="flex flex-grow items-center justify-center">
|
||||
{objectUrl ? (
|
||||
getPreviewComponentForFile(file, objectUrl)
|
||||
) : isLoadingFile ? (
|
||||
<div className="sk-spinner w-5 h-5 spinner-info"></div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<NoPreviewIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
|
||||
{isFilePreviewable ? (
|
||||
<>
|
||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
||||
There was an error loading the file. Try again, or download the file and open
|
||||
it using another application.
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mr-3"
|
||||
onClick={() => {
|
||||
getObjectUrl().catch(console.error)
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
||||
To view this file, download it and open it using another application.
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showFileInfoPanel && <FilePreviewInfoPanel file={file} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { createContext, FunctionComponent } from 'preact'
|
||||
import { useContext, useState } from 'preact/hooks'
|
||||
import { FilePreviewModal } from './FilePreviewModal'
|
||||
|
||||
type FilePreviewModalContextData = {
|
||||
activate: (file: SNFile) => void
|
||||
}
|
||||
|
||||
const FilePreviewModalContext = createContext<FilePreviewModalContextData | null>(null)
|
||||
|
||||
export const useFilePreviewModal = (): FilePreviewModalContextData => {
|
||||
const value = useContext(FilePreviewModalContext)
|
||||
|
||||
if (!value) {
|
||||
throw new Error('FilePreviewModalProvider not found.')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const FilePreviewModalProvider: FunctionComponent<{
|
||||
application: WebApplication
|
||||
}> = ({ application, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [file, setFile] = useState<SNFile>()
|
||||
|
||||
const activate = (file: SNFile) => {
|
||||
setFile(file)
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && file && (
|
||||
<FilePreviewModal application={application} file={file} onDismiss={close} />
|
||||
)}
|
||||
<FilePreviewModalContext.Provider value={{ activate }}>
|
||||
{children}
|
||||
</FilePreviewModalContext.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
app/assets/javascripts/Components/Files/isFilePreviewable.ts
Normal file
12
app/assets/javascripts/Components/Files/isFilePreviewable.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const isFileTypePreviewable = (fileType: string) => {
|
||||
const isImage = fileType.startsWith('image/')
|
||||
const isVideo = fileType.startsWith('video/')
|
||||
const isAudio = fileType.startsWith('audio/')
|
||||
const isPdf = fileType === 'application/pdf'
|
||||
|
||||
if (isImage || isVideo || isAudio || isPdf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
474
app/assets/javascripts/Components/Footer/index.tsx
Normal file
474
app/assets/javascripts/Components/Footer/index.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { preventRefreshing } from '@/Utils'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
CollectionSort,
|
||||
ApplicationDescriptor,
|
||||
} from '@standardnotes/snjs'
|
||||
import {
|
||||
STRING_NEW_UPDATE_READY,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
} from '@/Strings'
|
||||
import { alertDialog, confirmDialog } from '@/Services/AlertService'
|
||||
import { AccountMenu, AccountMenuPane } from '@/Components/AccountMenu'
|
||||
import { AppStateEvent, EventSource } from '@/UIModels/AppState'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu'
|
||||
import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu'
|
||||
import { Fragment } from 'preact'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
applicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type State = {
|
||||
outOfSync: boolean
|
||||
dataUpgradeAvailable: boolean
|
||||
hasPasscode: boolean
|
||||
descriptors: ApplicationDescriptor[]
|
||||
showBetaWarning: boolean
|
||||
showSyncResolution: boolean
|
||||
newUpdateAvailable: boolean
|
||||
showAccountMenu: boolean
|
||||
showQuickSettingsMenu: boolean
|
||||
offline: boolean
|
||||
hasError: boolean
|
||||
arbitraryStatusMessage?: string
|
||||
}
|
||||
|
||||
export class Footer extends PureComponent<Props, State> {
|
||||
public user?: unknown
|
||||
private didCheckForOffline = false
|
||||
private completedInitialSync = false
|
||||
private showingDownloadStatus = false
|
||||
private webEventListenerDestroyer: () => void
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
offline: true,
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: props.applicationGroup.getDescriptors(),
|
||||
showBetaWarning: false,
|
||||
showSyncResolution: false,
|
||||
newUpdateAvailable: false,
|
||||
showAccountMenu: false,
|
||||
showQuickSettingsMenu: false,
|
||||
}
|
||||
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => {
|
||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
||||
this.onNewUpdateAvailable()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.webEventListenerDestroyer()
|
||||
;(this.webEventListenerDestroyer as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
})
|
||||
})
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showAccountMenu: this.appState.accountMenu.show,
|
||||
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application
|
||||
.checkForSecurityUpdate()
|
||||
.then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
override async onAppLaunch() {
|
||||
super.onAppLaunch().catch(console.error)
|
||||
this.reloadPasscodeStatus().catch(console.error)
|
||||
this.reloadUser()
|
||||
this.reloadUpgradeStatus()
|
||||
this.updateOfflineStatus()
|
||||
this.findErrors()
|
||||
this.streamItems()
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
this.user = this.application.getUser()
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode()
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
})
|
||||
}
|
||||
|
||||
override onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager()
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAccountMenu()
|
||||
}
|
||||
break
|
||||
case AppStateEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…')
|
||||
break
|
||||
case AppStateEvent.EndedBackupDownload: {
|
||||
const successMessage = 'Successfully saved backup.'
|
||||
const errorMessage = 'Unable to save local backup.'
|
||||
statusService.setMessage(data.success ? successMessage : errorMessage)
|
||||
|
||||
const twoSeconds = 2000
|
||||
setTimeout(() => {
|
||||
if (statusService.message === successMessage || statusService.message === errorMessage) {
|
||||
statusService.setMessage('')
|
||||
}
|
||||
}, twoSeconds)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async onAppKeyChange() {
|
||||
super.onAppKeyChange().catch(console.error)
|
||||
this.reloadPasscodeStatus().catch(console.error)
|
||||
}
|
||||
|
||||
override onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus()
|
||||
break
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('')
|
||||
this.completedInitialSync = true
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true
|
||||
if (this.state.offline && this.application.items.getNoteCount() === 0) {
|
||||
this.appState.accountMenu.setShow(true)
|
||||
}
|
||||
}
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus()
|
||||
break
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus()
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus()
|
||||
break
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser()
|
||||
break
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync')
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`
|
||||
statusManager.setMessage(text)
|
||||
this.showingDownloadStatus = true
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false
|
||||
statusManager.setMessage('Download Complete.')
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('')
|
||||
}, 2000)
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
})
|
||||
|
||||
statusManager.setMessage(
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`,
|
||||
)
|
||||
} else {
|
||||
statusManager.setMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
const encryption = this.application.isEncryptionAvailable()
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('')
|
||||
return
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`
|
||||
const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
|
||||
statusManager.setMessage(loadingStatus)
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.setState({
|
||||
offline: this.application.noAccount(),
|
||||
})
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.setState({
|
||||
hasError: this.application.sync.getSyncStatus().hasError(),
|
||||
})
|
||||
}
|
||||
|
||||
securityUpdateClickHandler = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion()
|
||||
}).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
||||
this.appState.accountMenu.toggleShow()
|
||||
}
|
||||
|
||||
quickSettingsClickHandler = () => {
|
||||
this.appState.accountMenu.closeAccountMenu()
|
||||
this.appState.quickSettingsMenu.toggle()
|
||||
}
|
||||
|
||||
syncResolutionClickHandler = () => {
|
||||
this.setState({
|
||||
showSyncResolution: !this.state.showSyncResolution,
|
||||
})
|
||||
}
|
||||
|
||||
closeAccountMenu = () => {
|
||||
this.appState.accountMenu.setShow(false)
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}
|
||||
|
||||
lockClickHandler = () => {
|
||||
this.application.lock().catch(console.error)
|
||||
}
|
||||
|
||||
onNewUpdateAvailable = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: true,
|
||||
})
|
||||
}
|
||||
|
||||
newUpdateClickHandler = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: false,
|
||||
})
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY).catch(console.error)
|
||||
}
|
||||
|
||||
betaMessageClickHandler = () => {
|
||||
alertDialog({
|
||||
title: 'You are using a beta version of the app',
|
||||
text:
|
||||
'If you wish to go back to a stable version, make sure to sign out ' +
|
||||
'of this beta app first.',
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu = () => {
|
||||
this.appState.accountMenu.closeAccountMenu()
|
||||
}
|
||||
|
||||
clickOutsideQuickSettingsMenu = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
|
||||
<div className="left">
|
||||
<div className="sk-app-bar-item ml-0">
|
||||
<div
|
||||
onClick={this.accountMenuClickHandler}
|
||||
className={
|
||||
(this.state.showAccountMenu ? 'bg-border' : '') +
|
||||
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.state.hasError ? 'danger' : (this.user ? 'info' : 'neutral') + ' w-5 h-5'
|
||||
}
|
||||
>
|
||||
<Icon type="account-circle" className="hover:color-info w-5 h-5 max-h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showAccountMenu && (
|
||||
<AccountMenu
|
||||
onClickOutside={this.clickOutsideAccountMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-app-bar-item ml-0-important">
|
||||
<div
|
||||
onClick={this.quickSettingsClickHandler}
|
||||
className="w-8 h-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon
|
||||
type="tune"
|
||||
className={
|
||||
(this.state.showQuickSettingsMenu ? 'color-info' : '') +
|
||||
' rounded hover:color-info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showQuickSettingsMenu && (
|
||||
<QuickSettingsMenu
|
||||
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showBetaWarning && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div className="sk-app-bar-item">
|
||||
<a
|
||||
onClick={this.betaMessageClickHandler}
|
||||
className="no-decoration sk-label title"
|
||||
>
|
||||
You are using a beta version of the app
|
||||
</a>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="center">
|
||||
{this.state.arbitraryStatusMessage && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-app-bar-item-column">
|
||||
<span className="neutral sk-label">{this.state.arbitraryStatusMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="right">
|
||||
{this.state.dataUpgradeAvailable && (
|
||||
<div onClick={this.securityUpdateClickHandler} className="sk-app-bar-item">
|
||||
<span className="success sk-label">Encryption upgrade available.</span>
|
||||
</div>
|
||||
)}
|
||||
{this.state.newUpdateAvailable && (
|
||||
<div onClick={this.newUpdateClickHandler} className="sk-app-bar-item">
|
||||
<span className="info sk-label">New update available.</span>
|
||||
</div>
|
||||
)}
|
||||
{(this.state.outOfSync || this.state.showSyncResolution) && (
|
||||
<div className="sk-app-bar-item">
|
||||
{this.state.outOfSync && (
|
||||
<div onClick={this.syncResolutionClickHandler} className="sk-label warning">
|
||||
Potentially Out of Sync
|
||||
</div>
|
||||
)}
|
||||
{this.state.showSyncResolution && (
|
||||
<SyncResolutionMenu
|
||||
close={this.syncResolutionClickHandler}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{this.state.offline && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-label">Offline</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i id="footer-lock-icon" className="icon ion-locked" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
200
app/assets/javascripts/Components/Icon/index.tsx
Normal file
200
app/assets/javascripts/Components/Icon/index.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
import {
|
||||
AccessibilityIcon,
|
||||
AccountCircleIcon,
|
||||
AddIcon,
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowsSortDownIcon,
|
||||
ArrowsSortUpIcon,
|
||||
AttachmentFileIcon,
|
||||
AuthenticatorIcon,
|
||||
CheckBoldIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClearCircleFilledIcon,
|
||||
CloseIcon,
|
||||
CloudOffIcon,
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
DashboardIcon,
|
||||
DownloadIcon,
|
||||
EditorIcon,
|
||||
EmailIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileDocIcon,
|
||||
FileIcon,
|
||||
FileImageIcon,
|
||||
FileMovIcon,
|
||||
FileMusicIcon,
|
||||
FileOtherIcon,
|
||||
FilePdfIcon,
|
||||
FilePptIcon,
|
||||
FileXlsIcon,
|
||||
FileZipIcon,
|
||||
FolderIcon,
|
||||
HashtagIcon,
|
||||
HashtagOffIcon,
|
||||
HelpIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
LinkIcon,
|
||||
LinkOffIcon,
|
||||
ListBulleted,
|
||||
ListedIcon,
|
||||
LockFilledIcon,
|
||||
LockIcon,
|
||||
MarkdownIcon,
|
||||
MenuArrowDownAlt,
|
||||
MenuArrowDownIcon,
|
||||
MenuArrowRightIcon,
|
||||
MenuCloseIcon,
|
||||
MoreIcon,
|
||||
NotesIcon,
|
||||
PasswordIcon,
|
||||
PencilFilledIcon,
|
||||
PencilIcon,
|
||||
PencilOffIcon,
|
||||
PinFilledIcon,
|
||||
PinIcon,
|
||||
PlainTextIcon,
|
||||
PremiumFeatureIcon,
|
||||
RestoreIcon,
|
||||
RichTextIcon,
|
||||
SecurityIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
SignInIcon,
|
||||
SignOutIcon,
|
||||
SpreadsheetsIcon,
|
||||
StarIcon,
|
||||
SyncIcon,
|
||||
TasksIcon,
|
||||
ThemesIcon,
|
||||
TrashFilledIcon,
|
||||
TrashIcon,
|
||||
TrashSweepIcon,
|
||||
TuneIcon,
|
||||
UnarchiveIcon,
|
||||
UnpinIcon,
|
||||
UserAddIcon,
|
||||
UserIcon,
|
||||
UserSwitch,
|
||||
WarningIcon,
|
||||
WindowIcon,
|
||||
} from '@standardnotes/stylekit'
|
||||
|
||||
export const ICONS = {
|
||||
'account-circle': AccountCircleIcon,
|
||||
'arrow-left': ArrowLeftIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'attachment-file': AttachmentFileIcon,
|
||||
'check-bold': CheckBoldIcon,
|
||||
'check-circle': CheckCircleIcon,
|
||||
'chevron-down': ChevronDownIcon,
|
||||
'chevron-right': ChevronRightIcon,
|
||||
'clear-circle-filled': ClearCircleFilledIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
'file-doc': FileDocIcon,
|
||||
'file-image': FileImageIcon,
|
||||
'file-mov': FileMovIcon,
|
||||
'file-music': FileMusicIcon,
|
||||
'file-other': FileOtherIcon,
|
||||
'file-pdf': FilePdfIcon,
|
||||
'file-ppt': FilePptIcon,
|
||||
'file-xls': FileXlsIcon,
|
||||
'file-zip': FileZipIcon,
|
||||
'hashtag-off': HashtagOffIcon,
|
||||
'link-off': LinkOffIcon,
|
||||
'list-bulleted': ListBulleted,
|
||||
'lock-filled': LockFilledIcon,
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-down': MenuArrowDownIcon,
|
||||
'menu-arrow-right': MenuArrowRightIcon,
|
||||
'menu-close': MenuCloseIcon,
|
||||
'pencil-filled': PencilFilledIcon,
|
||||
'pencil-off': PencilOffIcon,
|
||||
'pin-filled': PinFilledIcon,
|
||||
'plain-text': PlainTextIcon,
|
||||
'premium-feature': PremiumFeatureIcon,
|
||||
'rich-text': RichTextIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
'trash-sweep': TrashSweepIcon,
|
||||
'user-add': UserAddIcon,
|
||||
'user-switch': UserSwitch,
|
||||
accessibility: AccessibilityIcon,
|
||||
add: AddIcon,
|
||||
archive: ArchiveIcon,
|
||||
authenticator: AuthenticatorIcon,
|
||||
check: CheckIcon,
|
||||
close: CloseIcon,
|
||||
code: CodeIcon,
|
||||
copy: CopyIcon,
|
||||
dashboard: DashboardIcon,
|
||||
download: DownloadIcon,
|
||||
editor: EditorIcon,
|
||||
email: EmailIcon,
|
||||
eye: EyeIcon,
|
||||
file: FileIcon,
|
||||
folder: FolderIcon,
|
||||
hashtag: HashtagIcon,
|
||||
help: HelpIcon,
|
||||
history: HistoryIcon,
|
||||
info: InfoIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
link: LinkIcon,
|
||||
listed: ListedIcon,
|
||||
lock: LockIcon,
|
||||
markdown: MarkdownIcon,
|
||||
more: MoreIcon,
|
||||
notes: NotesIcon,
|
||||
password: PasswordIcon,
|
||||
pencil: PencilIcon,
|
||||
pin: PinIcon,
|
||||
restore: RestoreIcon,
|
||||
security: SecurityIcon,
|
||||
server: ServerIcon,
|
||||
settings: SettingsIcon,
|
||||
signIn: SignInIcon,
|
||||
signOut: SignOutIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
star: StarIcon,
|
||||
sync: SyncIcon,
|
||||
tasks: TasksIcon,
|
||||
themes: ThemesIcon,
|
||||
trash: TrashIcon,
|
||||
tune: TuneIcon,
|
||||
unarchive: UnarchiveIcon,
|
||||
unpin: UnpinIcon,
|
||||
user: UserIcon,
|
||||
warning: WarningIcon,
|
||||
window: WindowIcon,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: IconType
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => {
|
||||
const IconComponent = ICONS[type as keyof typeof ICONS]
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<IconComponent
|
||||
className={`sn-icon ${className}`}
|
||||
role="img"
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
75
app/assets/javascripts/Components/Input/DecoratedInput.tsx
Normal file
75
app/assets/javascripts/Components/Input/DecoratedInput.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FunctionalComponent, Ref } from 'preact'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
|
||||
return {
|
||||
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
|
||||
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
|
||||
}`,
|
||||
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
|
||||
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||
disabled: 'bg-grey-5 cursor-not-allowed',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that can be decorated on the left and right side
|
||||
*/
|
||||
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
type = 'text',
|
||||
className = '',
|
||||
disabled = false,
|
||||
left,
|
||||
right,
|
||||
value,
|
||||
placeholder = '',
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
autocomplete = false,
|
||||
}: DecoratedInputProps,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const hasLeftDecorations = Boolean(left?.length)
|
||||
const hasRightDecorations = Boolean(right?.length)
|
||||
const classNames = getClassNames(hasLeftDecorations, hasRightDecorations)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}
|
||||
>
|
||||
{left && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{left.map((leftChild) => (
|
||||
<>{leftChild}</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
className={`${classNames.input} ${disabled ? classNames.disabled : ''}`}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange && onChange((e.target as HTMLInputElement).value)}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
data-lpignore={type !== 'password' ? true : false}
|
||||
autocomplete={autocomplete ? 'on' : 'off'}
|
||||
ref={ref}
|
||||
/>
|
||||
{right && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{right.map((rightChild, index) => (
|
||||
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentChild } from 'preact'
|
||||
|
||||
export type DecoratedInputProps = {
|
||||
type?: 'text' | 'email' | 'password'
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
left?: ComponentChild[]
|
||||
right?: ComponentChild[]
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange?: (text: string) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
autocomplete?: boolean
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FunctionComponent, Ref } from 'preact'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
import { StateUpdater, useState } from 'preact/hooks'
|
||||
import { DecoratedInput } from './DecoratedInput'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const Toggle: FunctionComponent<{
|
||||
isToggled: boolean
|
||||
setIsToggled: StateUpdater<boolean>
|
||||
}> = ({ isToggled, setIsToggled }) => (
|
||||
<IconButton
|
||||
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4 color-neutral"
|
||||
icon={isToggled ? 'eye-off' : 'eye'}
|
||||
iconClassName="sn-icon--small"
|
||||
title="Show/hide password"
|
||||
onClick={() => setIsToggled((isToggled) => !isToggled)}
|
||||
focusable={true}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
|
||||
*/
|
||||
export const DecoratedPasswordInput: FunctionComponent<Omit<DecoratedInputProps, 'type'>> =
|
||||
forwardRef((props, ref: Ref<HTMLInputElement>) => {
|
||||
const [isToggled, setIsToggled] = useState(false)
|
||||
|
||||
const rightSideDecorations = props.right ? [...props.right] : []
|
||||
|
||||
return (
|
||||
<DecoratedInput
|
||||
{...props}
|
||||
ref={ref}
|
||||
type={isToggled ? 'text' : 'password'}
|
||||
right={[
|
||||
...rightSideDecorations,
|
||||
<Toggle isToggled={isToggled} setIsToggled={setIsToggled} />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FunctionComponent, Ref } from 'preact'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
type: 'text' | 'email' | 'password'
|
||||
label: string
|
||||
value: string
|
||||
onChange: JSXInternal.GenericEventHandler<HTMLInputElement>
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
inputClassName?: string
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
value,
|
||||
isInvalid,
|
||||
onChange,
|
||||
className = '',
|
||||
labelClassName = '',
|
||||
inputClassName = '',
|
||||
}: Props,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
const BASE_CLASSNAME = 'relative bg-default'
|
||||
|
||||
const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
|
||||
focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
|
||||
} ${isInvalid ? 'color-dark-red' : ''} ${labelClassName}`
|
||||
|
||||
const INPUT_CLASSNAME = `w-full h-full ${
|
||||
focused || value ? 'pt-6 pb-2' : 'py-2.5'
|
||||
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
|
||||
isInvalid ? 'border-dark-red placeholder-dark-red' : ''
|
||||
} ${inputClassName}`
|
||||
|
||||
const handleFocus = () => setFocused(true)
|
||||
|
||||
const handleBlur = () => setFocused(false)
|
||||
|
||||
return (
|
||||
<div className={`${BASE_CLASSNAME} ${className}`}>
|
||||
<label htmlFor={id} className={LABEL_CLASSNAME}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={INPUT_CLASSNAME}
|
||||
placeholder={!focused ? label : ''}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
14
app/assets/javascripts/Components/Input/Input.tsx
Normal file
14
app/assets/javascripts/Components/Input/Input.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => {
|
||||
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
|
||||
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
|
||||
const classes = `${base} ${stateClasses} ${className}`
|
||||
return <input type="text" className={classes} disabled={disabled} value={text} />
|
||||
}
|
||||
118
app/assets/javascripts/Components/Menu/Menu.tsx
Normal file
118
app/assets/javascripts/Components/Menu/Menu.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
JSX,
|
||||
FunctionComponent,
|
||||
ComponentChildren,
|
||||
VNode,
|
||||
RefCallback,
|
||||
ComponentChild,
|
||||
toChildArray,
|
||||
} from 'preact'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { MenuItem, MenuItemListElement } from './MenuItem'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
||||
|
||||
type MenuProps = {
|
||||
className?: string
|
||||
style?: string | JSX.CSSProperties | undefined
|
||||
a11yLabel: string
|
||||
children: ComponentChildren
|
||||
closeMenu?: () => void
|
||||
isOpen: boolean
|
||||
initialFocus?: number
|
||||
}
|
||||
|
||||
export const Menu: FunctionComponent<MenuProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
a11yLabel,
|
||||
closeMenu,
|
||||
isOpen,
|
||||
initialFocus,
|
||||
}: MenuProps) => {
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
const menuElementRef = useRef<HTMLMenuElement>(null)
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (event) => {
|
||||
if (!menuItemRefs.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
closeMenu?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
useListKeyboardNavigation(menuElementRef, initialFocus)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && menuItemRefs.current.length > 0) {
|
||||
setTimeout(() => {
|
||||
menuElementRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => {
|
||||
if (instance && instance.children) {
|
||||
Array.from(instance.children).forEach((child) => {
|
||||
if (
|
||||
child.getAttribute('role')?.includes('menuitem') &&
|
||||
!menuItemRefs.current.includes(child as HTMLButtonElement)
|
||||
) {
|
||||
menuItemRefs.current.push(child as HTMLButtonElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mapMenuItems = (
|
||||
child: ComponentChild,
|
||||
index: number,
|
||||
array: ComponentChild[],
|
||||
): ComponentChild => {
|
||||
if (!child || (Array.isArray(child) && child.length < 1)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
return child.map(mapMenuItems)
|
||||
}
|
||||
|
||||
const _child = child as VNode<unknown>
|
||||
const isFirstMenuItem =
|
||||
index === array.findIndex((child) => (child as VNode<unknown>).type === MenuItem)
|
||||
|
||||
const hasMultipleItems = Array.isArray(_child.props.children)
|
||||
? Array.from(_child.props.children as ComponentChild[]).some(
|
||||
(child) => (child as VNode<unknown>).type === MenuItem,
|
||||
)
|
||||
: false
|
||||
|
||||
const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
|
||||
|
||||
return items.map((child) => {
|
||||
return (
|
||||
<MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
|
||||
{child}
|
||||
</MenuItemListElement>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<menu
|
||||
className={`m-0 p-0 list-style-none focus:shadow-none ${className}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={menuElementRef}
|
||||
style={style}
|
||||
aria-label={a11yLabel}
|
||||
>
|
||||
{toChildArray(children).map(mapMenuItems)}
|
||||
</menu>
|
||||
)
|
||||
}
|
||||
113
app/assets/javascripts/Components/Menu/MenuItem.tsx
Normal file
113
app/assets/javascripts/Components/Menu/MenuItem.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ComponentChildren, FunctionComponent, VNode } from 'preact'
|
||||
import { forwardRef, Ref } from 'preact/compat'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Switch, SwitchProps } from '@/Components/Switch'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
|
||||
export enum MenuItemType {
|
||||
IconButton,
|
||||
RadioButton,
|
||||
SwitchButton,
|
||||
}
|
||||
|
||||
type MenuItemProps = {
|
||||
type: MenuItemType
|
||||
children: ComponentChildren
|
||||
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>
|
||||
onChange?: SwitchProps['onChange']
|
||||
onBlur?: (event: { relatedTarget: EventTarget | null }) => void
|
||||
className?: string
|
||||
checked?: boolean
|
||||
icon?: IconType
|
||||
iconClassName?: string
|
||||
tabIndex?: number
|
||||
}
|
||||
|
||||
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onClick,
|
||||
onChange,
|
||||
onBlur,
|
||||
className = '',
|
||||
type,
|
||||
checked,
|
||||
icon,
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
}: MenuItemProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||
onClick={() => {
|
||||
onChange(!checked)
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
<span className="flex flex-grow items-center">{children}</span>
|
||||
<Switch className="px-0" checked={checked} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={ref}
|
||||
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
|
||||
onClick={onClick}
|
||||
onBlur={onBlur}
|
||||
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
|
||||
>
|
||||
{type === MenuItemType.IconButton && icon ? (
|
||||
<Icon type={icon} className={iconClassName} />
|
||||
) : null}
|
||||
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
checked ? 'pseudo-radio-btn--checked' : ''
|
||||
} mr-2 flex-shrink-0`}
|
||||
></div>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const MenuItemSeparator: FunctionComponent = () => (
|
||||
<div role="separator" className="h-1px my-2 bg-border"></div>
|
||||
)
|
||||
|
||||
type ListElementProps = {
|
||||
isFirstMenuItem: boolean
|
||||
children: ComponentChildren
|
||||
}
|
||||
|
||||
export const MenuItemListElement: FunctionComponent<ListElementProps> = forwardRef(
|
||||
({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
|
||||
const child = children as VNode<unknown>
|
||||
|
||||
return (
|
||||
<li className="list-style-none" role="none" ref={ref}>
|
||||
{{
|
||||
...child,
|
||||
props: {
|
||||
...(child.props ? { ...child.props } : {}),
|
||||
...(child.type === MenuItem
|
||||
? {
|
||||
tabIndex: isFirstMenuItem ? 0 : -1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { IlNotesIcon } from '@standardnotes/stylekit'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { PinNoteButton } from '@/Components/PinNoteButton'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<IlNotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
82
app/assets/javascripts/Components/Navigation/index.tsx
Normal file
82
app/assets/javascripts/Components/Navigation/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection'
|
||||
import { TagsSection } from '@/Components/Tags/TagsSection'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { PANEL_NAME_NAVIGATION } from '@/Constants'
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Navigation: FunctionComponent<Props> = observer(({ application }) => {
|
||||
const appState = useMemo(() => application.getAppState(), [application])
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>()
|
||||
const [panelWidth, setPanelWidth] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addEventObserver(async () => {
|
||||
const width = application.getPreference(PrefKey.TagsPanelWidth)
|
||||
if (width) {
|
||||
setPanelWidth(width)
|
||||
}
|
||||
}, ApplicationEvent.PreferencesChanged)
|
||||
|
||||
return () => {
|
||||
removeObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed)
|
||||
},
|
||||
[application, appState],
|
||||
)
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}, [appState])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section app-column app-column-first"
|
||||
data-aria-label="Navigation"
|
||||
ref={setRef}
|
||||
>
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartViewsSection appState={appState} />
|
||||
<TagsSection appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
{ref && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={ref}
|
||||
hoverable={true}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
38
app/assets/javascripts/Components/NoAccountWarning/index.tsx
Normal file
38
app/assets/javascripts/Components/NoAccountWarning/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = { appState: AppState }
|
||||
|
||||
export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
const canShow = appState.noAccountWarning.show
|
||||
if (!canShow) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
|
||||
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
|
||||
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
|
||||
<button
|
||||
className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
appState.accountMenu.setShow(true)
|
||||
}}
|
||||
>
|
||||
Open Account menu
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
appState.noAccountWarning.hide()
|
||||
}}
|
||||
title="Ignore"
|
||||
label="Ignore"
|
||||
style="height: 20px"
|
||||
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
|
||||
>
|
||||
<Icon type="close" className="block" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
57
app/assets/javascripts/Components/NoteGroupView/index.tsx
Normal file
57
app/assets/javascripts/Components/NoteGroupView/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NoteViewController } from '@standardnotes/snjs'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes'
|
||||
import { NoteView } from '@/Components/NoteView/NoteView'
|
||||
import { ElementIds } from '@/ElementIDs'
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean
|
||||
controllers: NoteViewController[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export class NoteGroupView extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
controllers: [],
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
|
||||
this.setState({
|
||||
controllers: this.application.noteControllerGroup.noteControllers,
|
||||
})
|
||||
})
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
|
||||
{this.state.showMultipleSelectedNotes && (
|
||||
<MultipleSelectedNotes application={this.application} appState={this.appState} />
|
||||
)}
|
||||
|
||||
{!this.state.showMultipleSelectedNotes && (
|
||||
<>
|
||||
{this.state.controllers.map((controller) => {
|
||||
return <NoteView application={this.application} controller={controller} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
127
app/assets/javascripts/Components/NoteTags/NoteTag.tsx
Normal file
127
app/assets/javascripts/Components/NoteTags/NoteTag.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { SNTag } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
tag: SNTag
|
||||
}
|
||||
|
||||
export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
const noteTags = appState.noteTags
|
||||
|
||||
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
|
||||
|
||||
const [showDeleteButton, setShowDeleteButton] = useState(false)
|
||||
const [tagClicked, setTagClicked] = useState(false)
|
||||
const deleteTagRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const tagRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const title = tag.title
|
||||
const prefixTitle = noteTags.getPrefixTitle(tag)
|
||||
const longTitle = noteTags.getLongTitle(tag)
|
||||
|
||||
const deleteTag = () => {
|
||||
appState.noteTags.focusPreviousTag(tag)
|
||||
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
|
||||
}
|
||||
|
||||
const onDeleteTagClick = (event: MouseEvent) => {
|
||||
event.stopImmediatePropagation()
|
||||
event.stopPropagation()
|
||||
deleteTag()
|
||||
}
|
||||
|
||||
const onTagClick = (event: MouseEvent) => {
|
||||
if (tagClicked && event.target !== deleteTagRef.current) {
|
||||
setTagClicked(false)
|
||||
appState.selectedTag = tag
|
||||
} else {
|
||||
setTagClicked(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
appState.noteTags.setFocusedTagUuid(tag.uuid)
|
||||
setShowDeleteButton(true)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const relatedTarget = event.relatedTarget as Node
|
||||
if (relatedTarget !== deleteTagRef.current) {
|
||||
appState.noteTags.setFocusedTagUuid(undefined)
|
||||
setShowDeleteButton(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTabIndex = () => {
|
||||
if (focusedTagUuid) {
|
||||
return focusedTagUuid === tag.uuid ? 0 : -1
|
||||
}
|
||||
if (autocompleteInputFocused) {
|
||||
return -1
|
||||
}
|
||||
return tags[0].uuid === tag.uuid ? 0 : -1
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const tagIndex = appState.noteTags.getTagIndex(tag, tags)
|
||||
switch (event.key) {
|
||||
case 'Backspace':
|
||||
deleteTag()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
appState.noteTags.focusPreviousTag(tag)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (tagIndex === tags.length - 1) {
|
||||
appState.noteTags.setAutocompleteInputFocused(true)
|
||||
} else {
|
||||
appState.noteTags.focusNextTag(tag)
|
||||
}
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedTagUuid === tag.uuid) {
|
||||
tagRef.current?.focus()
|
||||
}
|
||||
}, [appState.noteTags, focusedTagUuid, tag])
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={tagRef}
|
||||
className="sn-tag pl-1 pr-2 mr-2"
|
||||
onClick={onTagClick}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
tabIndex={getTabIndex()}
|
||||
title={longTitle}
|
||||
>
|
||||
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
|
||||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
|
||||
{prefixTitle && <span className="color-grey-1">{prefixTitle}</span>}
|
||||
{title}
|
||||
</span>
|
||||
{showDeleteButton && (
|
||||
<button
|
||||
ref={deleteTagRef}
|
||||
type="button"
|
||||
className="ml-2 -mr-1 border-0 p-0 bg-transparent cursor-pointer flex"
|
||||
onBlur={onBlur}
|
||||
onClick={onDeleteTagClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon type="close" className="sn-icon--small color-neutral hover:color-info" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput'
|
||||
import { NoteTag } from './NoteTag'
|
||||
import { useEffect } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const { tags, tagsContainerMaxWidth } = appState.noteTags
|
||||
|
||||
useEffect(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}, [appState.noteTags])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
|
||||
style={{
|
||||
maxWidth: tagsContainerMaxWidth,
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
185
app/assets/javascripts/Components/NoteView/NoteView.test.ts
Normal file
185
app/assets/javascripts/Components/NoteView/NoteView.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { NoteView } from './NoteView'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
} from '@standardnotes/snjs/'
|
||||
|
||||
describe('editor-view', () => {
|
||||
let ctrl: NoteView
|
||||
let setShowProtectedWarningSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
ctrl = new NoteView({} as any)
|
||||
|
||||
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay')
|
||||
|
||||
Object.defineProperties(ctrl, {
|
||||
application: {
|
||||
value: {
|
||||
getAppState: () => {
|
||||
return {
|
||||
notes: {
|
||||
setShowProtectedWarning: jest.fn(),
|
||||
},
|
||||
}
|
||||
},
|
||||
hasProtectionSources: () => true,
|
||||
authorizeNoteAccess: jest.fn(),
|
||||
},
|
||||
},
|
||||
removeComponentsObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
removeTrashKeyObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
unregisterComponent: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
editor: {
|
||||
value: {
|
||||
clearNoteChangeListener: jest.fn(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ctrl.deinit()
|
||||
})
|
||||
|
||||
describe('note is protected', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
|
||||
jest
|
||||
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
|
||||
.mockImplementation(
|
||||
() => ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction + 5,
|
||||
)
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
|
||||
const secondsElapsedSinceLastEdit =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - 3
|
||||
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
|
||||
|
||||
const secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastEdit
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000)
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
|
||||
const secondsElapsedSinceLastModification = 3
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(Date.now() - secondsElapsedSinceLastModification * 1000),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
|
||||
|
||||
let secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastModification
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
|
||||
|
||||
// A new modification has just happened
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000)
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('note is unprotected', () => {
|
||||
it('should not call any hiding logic', async () => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: false,
|
||||
},
|
||||
})
|
||||
const hideProtectedNoteIfInactiveSpy = jest.spyOn(ctrl, 'hideProtectedNoteIfInactive')
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
|
||||
|
||||
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissProtectedWarning', () => {
|
||||
describe('the note has protection sources', () => {
|
||||
it('should reveal note contents if the authorization has been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(true))
|
||||
|
||||
await ctrl.dismissProtectedWarning()
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should not reveal note contents if the authorization has not been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(false))
|
||||
|
||||
await ctrl.dismissProtectedWarning()
|
||||
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the note does not have protection sources', () => {
|
||||
it('should reveal note contents', async () => {
|
||||
jest.spyOn(ctrl['application'], 'hasProtectionSources').mockImplementation(() => false)
|
||||
|
||||
await ctrl.dismissProtectedWarning()
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1142
app/assets/javascripts/Components/NoteView/NoteView.tsx
Normal file
1142
app/assets/javascripts/Components/NoteView/NoteView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
app/assets/javascripts/Components/NotesContextMenu/index.tsx
Normal file
47
app/assets/javascripts/Components/NotesContextMenu/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NotesOptions } from '@/Components/NotesOptions/NotesOptions'
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = appState.notes
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
|
||||
appState.notes.setContextMenuOpen(open),
|
||||
)
|
||||
|
||||
useCloseOnClickOutside(contextMenuRef, () => appState.notes.setContextMenuOpen(false))
|
||||
|
||||
const reloadContextMenuLayout = useCallback(() => {
|
||||
appState.notes.reloadContextMenuLayout()
|
||||
}, [appState.notes])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', reloadContextMenuLayout)
|
||||
return () => {
|
||||
window.removeEventListener('resize', reloadContextMenuLayout)
|
||||
}
|
||||
}, [reloadContextMenuLayout])
|
||||
|
||||
return contextMenuOpen ? (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
|
||||
style={{
|
||||
...contextMenuPosition,
|
||||
maxHeight: contextMenuMaxHeight,
|
||||
}}
|
||||
>
|
||||
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
|
||||
</div>
|
||||
) : null
|
||||
})
|
||||
160
app/assets/javascripts/Components/NotesList/NotesListItem.tsx
Normal file
160
app/assets/javascripts/Components/NotesList/NotesListItem.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
CollectionSort,
|
||||
CollectionSortProperty,
|
||||
sanitizeHtmlString,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
tags: string[]
|
||||
hideDate: boolean
|
||||
hidePreview: boolean
|
||||
hideTags: boolean
|
||||
hideEditorIcon: boolean
|
||||
onClick: () => void
|
||||
onContextMenu: (e: MouseEvent) => void
|
||||
selected: boolean
|
||||
sortedBy?: CollectionSortProperty
|
||||
}
|
||||
|
||||
type NoteFlag = {
|
||||
text: string
|
||||
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
|
||||
}
|
||||
|
||||
const flagsForNote = (note: SNNote) => {
|
||||
const flags = [] as NoteFlag[]
|
||||
if (note.conflictOf) {
|
||||
flags.push({
|
||||
text: 'Conflicted Copy',
|
||||
class: 'danger',
|
||||
})
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
export const NotesListItem: FunctionComponent<Props> = ({
|
||||
application,
|
||||
hideDate,
|
||||
hidePreview,
|
||||
hideTags,
|
||||
hideEditorIcon,
|
||||
note,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
selected,
|
||||
sortedBy,
|
||||
tags,
|
||||
}) => {
|
||||
const flags = flagsForNote(note)
|
||||
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt
|
||||
const editorForNote = application.componentManager.editorForNote(note)
|
||||
const editorName = editorForNote?.name ?? 'Plain editor'
|
||||
const [icon, tint] = application.iconsController.getIconAndTintForEditor(
|
||||
editorForNote?.identifier,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note ${selected ? 'selected' : ''}`}
|
||||
id={`note-${note.uuid}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{!hideEditorIcon && (
|
||||
<div className="icon">
|
||||
<Icon
|
||||
ariaLabel={`Icon for ${editorName}`}
|
||||
type={icon}
|
||||
className={`color-accessory-tint-${tint}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
|
||||
<div className="name-container">
|
||||
{note.title.length ? <div className="name">{note.title}</div> : null}
|
||||
</div>
|
||||
{!hidePreview && !note.hidePreview && !note.protected && (
|
||||
<div className="note-preview">
|
||||
{note.preview_html && (
|
||||
<div
|
||||
className="html-preview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlString(note.preview_html),
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
{!note.preview_html && note.preview_plain && (
|
||||
<div className="plain-preview">{note.preview_plain}</div>
|
||||
)}
|
||||
{!note.preview_html && !note.preview_plain && note.text && (
|
||||
<div className="default-preview">{note.text}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hideDate || note.protected ? (
|
||||
<div className="bottom-info faded">
|
||||
{note.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
|
||||
{!hideDate && showModifiedDate && <span>Modified {note.updatedAtString || 'Now'}</span>}
|
||||
{!hideDate && !showModifiedDate && <span>{note.createdAtString || 'Now'}</span>}
|
||||
</div>
|
||||
) : null}
|
||||
{!hideTags && tags.length ? (
|
||||
<div className="tags-string">
|
||||
{tags.map((tag) => (
|
||||
<span className="tag color-foreground">
|
||||
<Icon type="hashtag" className="sn-icon--small color-grey-1 mr-1" />
|
||||
<span>{tag}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{flags.length ? (
|
||||
<div className="note-flags flex flex-wrap">
|
||||
{flags.map((flag) => (
|
||||
<div className={`flag ${flag.class}`}>
|
||||
<div className="label">{flag.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flag-icons">
|
||||
{note.locked && (
|
||||
<span title="Editing Disabled">
|
||||
<Icon
|
||||
ariaLabel="Editing Disabled"
|
||||
type="pencil-off"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.trashed && (
|
||||
<span title="Trashed">
|
||||
<Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
|
||||
</span>
|
||||
)}
|
||||
{note.archived && (
|
||||
<span title="Archived">
|
||||
<Icon
|
||||
ariaLabel="Archived"
|
||||
type="archive"
|
||||
className="sn-icon--mid color-accessory-tint-3"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.pinned && (
|
||||
<span title="Pinned">
|
||||
<Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Menu } from '@/Components/Menu/Menu'
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
closeDisplayOptionsMenu: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
|
||||
({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => {
|
||||
const [sortBy, setSortBy] = useState(() =>
|
||||
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt),
|
||||
)
|
||||
const [sortReverse, setSortReverse] = useState(() =>
|
||||
application.getPreference(PrefKey.SortNotesReverse, false),
|
||||
)
|
||||
const [hidePreview, setHidePreview] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideNotePreview, false),
|
||||
)
|
||||
const [hideDate, setHideDate] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideDate, false),
|
||||
)
|
||||
const [hideTags, setHideTags] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideTags, true),
|
||||
)
|
||||
const [hidePinned, setHidePinned] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHidePinned, false),
|
||||
)
|
||||
const [showArchived, setShowArchived] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesShowArchived, false),
|
||||
)
|
||||
const [showTrashed, setShowTrashed] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesShowTrashed, false),
|
||||
)
|
||||
const [hideProtected, setHideProtected] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideProtected, false),
|
||||
)
|
||||
const [hideEditorIcon, setHideEditorIcon] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideEditorIcon, false),
|
||||
)
|
||||
|
||||
const toggleSortReverse = () => {
|
||||
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
|
||||
setSortReverse(!sortReverse)
|
||||
}
|
||||
|
||||
const toggleSortBy = (sort: CollectionSortProperty) => {
|
||||
if (sortBy === sort) {
|
||||
toggleSortReverse()
|
||||
} else {
|
||||
setSortBy(sort)
|
||||
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSortByDateModified = () => {
|
||||
toggleSortBy(CollectionSort.UpdatedAt)
|
||||
}
|
||||
|
||||
const toggleSortByCreationDate = () => {
|
||||
toggleSortBy(CollectionSort.CreatedAt)
|
||||
}
|
||||
|
||||
const toggleSortByTitle = () => {
|
||||
toggleSortBy(CollectionSort.Title)
|
||||
}
|
||||
|
||||
const toggleHidePreview = () => {
|
||||
setHidePreview(!hidePreview)
|
||||
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideDate = () => {
|
||||
setHideDate(!hideDate)
|
||||
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideTags = () => {
|
||||
setHideTags(!hideTags)
|
||||
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHidePinned = () => {
|
||||
setHidePinned(!hidePinned)
|
||||
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleShowArchived = () => {
|
||||
setShowArchived(!showArchived)
|
||||
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleShowTrashed = () => {
|
||||
setShowTrashed(!showTrashed)
|
||||
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideProtected = () => {
|
||||
setHideProtected(!hideProtected)
|
||||
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleEditorIcon = () => {
|
||||
setHideEditorIcon(!hideEditorIcon)
|
||||
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={
|
||||
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
|
||||
border-1 border-solid border-main text-sm z-index-dropdown-menu \
|
||||
flex flex-col py-2 top-full left-2 absolute'
|
||||
}
|
||||
a11yLabel="Notes list options menu"
|
||||
closeMenu={closeDisplayOptionsMenu}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByDateModified}
|
||||
checked={sortBy === CollectionSort.UpdatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Date modified</span>
|
||||
{sortBy === CollectionSort.UpdatedAt ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByCreationDate}
|
||||
checked={sortBy === CollectionSort.CreatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Creation date</span>
|
||||
{sortBy === CollectionSort.CreatedAt ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByTitle}
|
||||
checked={sortBy === CollectionSort.Title}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Title</span>
|
||||
{sortBy === CollectionSort.Title ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItemSeparator />
|
||||
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePreview}
|
||||
onChange={toggleHidePreview}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-col max-w-3/4">Show note preview</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideDate}
|
||||
onChange={toggleHideDate}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideTags}
|
||||
onChange={toggleHideTags}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show tags
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideEditorIcon}
|
||||
onChange={toggleEditorIcon}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show editor icon
|
||||
</MenuItem>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePinned}
|
||||
onChange={toggleHidePinned}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show pinned notes
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideProtected}
|
||||
onChange={toggleHideProtected}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show protected notes
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showArchived}
|
||||
onChange={toggleShowArchived}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show archived notes
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showTrashed}
|
||||
onChange={toggleShowTrashed}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show trashed notes
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
},
|
||||
)
|
||||
104
app/assets/javascripts/Components/NotesList/index.tsx
Normal file
104
app/assets/javascripts/Components/NotesList/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { DisplayOptions } from '@/UIModels/AppState/NotesViewState'
|
||||
import { SNNote, SNTag } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { NotesListItem } from './NotesListItem'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
notes: SNNote[]
|
||||
selectedNotes: Record<string, SNNote>
|
||||
displayOptions: DisplayOptions
|
||||
paginate: () => void
|
||||
}
|
||||
|
||||
export const NotesList: FunctionComponent<Props> = observer(
|
||||
({ application, appState, notes, selectedNotes, displayOptions, paginate }) => {
|
||||
const { selectPreviousNote, selectNextNote } = appState.notesView
|
||||
const { hideTags, hideDate, hideNotePreview, hideEditorIcon, sortBy } = displayOptions
|
||||
|
||||
const tagsForNote = (note: SNNote): string[] => {
|
||||
if (hideTags) {
|
||||
return []
|
||||
}
|
||||
const selectedTag = appState.selectedTag
|
||||
if (!selectedTag) {
|
||||
return []
|
||||
}
|
||||
const tags = appState.getNoteTags(note)
|
||||
if (selectedTag instanceof SNTag && tags.length === 1) {
|
||||
return []
|
||||
}
|
||||
return tags.map((tag) => tag.title).sort()
|
||||
}
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
appState.notes.setContextMenuClickLocation({
|
||||
x: posX,
|
||||
y: posY,
|
||||
})
|
||||
appState.notes.reloadContextMenuLayout()
|
||||
appState.notes.setContextMenuOpen(true)
|
||||
}
|
||||
|
||||
const onContextMenu = (note: SNNote, posX: number, posY: number) => {
|
||||
appState.notes.selectNote(note.uuid, true).catch(console.error)
|
||||
openNoteContextMenu(posX, posY)
|
||||
}
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
const offset = NOTES_LIST_SCROLL_THRESHOLD
|
||||
const element = e.target as HTMLElement
|
||||
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
|
||||
paginate()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Up) {
|
||||
e.preventDefault()
|
||||
selectPreviousNote()
|
||||
} else if (e.key === KeyboardKey.Down) {
|
||||
e.preventDefault()
|
||||
selectNextNote()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="infinite-scroll focus:shadow-none focus:outline-none"
|
||||
id="notes-scrollable"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<NotesListItem
|
||||
application={application}
|
||||
key={note.uuid}
|
||||
note={note}
|
||||
tags={tagsForNote(note)}
|
||||
selected={!!selectedNotes[note.uuid]}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
hideEditorIcon={hideEditorIcon}
|
||||
sortedBy={sortBy}
|
||||
onClick={() => {
|
||||
appState.notes.selectNote(note.uuid, true).catch(console.error)
|
||||
}}
|
||||
onContextMenu={(e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onContextMenu(note, e.clientX, e.clientY)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
111
app/assets/javascripts/Components/NotesOptions/AddTagOption.tsx
Normal file
111
app/assets/javascripts/Components/NotesOptions/AddTagOption.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) => {
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
|
||||
|
||||
const toggleTagsMenu = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}
|
||||
|
||||
const recalculateMenuStyle = useCallback(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}, [isMenuOpen, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isMenuOpen} onChange={toggleTagsMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={menuButtonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="hashtag" className="mr-2 color-neutral" />
|
||||
Add tag
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsMenuOpen(false)
|
||||
menuButtonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown min-w-80 flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-auto"
|
||||
>
|
||||
{appState.tags.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.title}
|
||||
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
|
||||
onBlur={closeOnBlur}
|
||||
onClick={() => {
|
||||
appState.notes.isTagInSelectedNotes(tag)
|
||||
? appState.notes.removeTagFromSelectedNotes(tag).catch(console.error)
|
||||
: appState.notes.addTagToSelectedNotes(tag).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
|
||||
${appState.notes.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
|
||||
>
|
||||
{appState.noteTags.getLongTitle(tag)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { IconType, SNComponent, SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { ChangeEditorMenu } from '@/Components/ChangeEditor/ChangeEditorMenu'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type ChangeEditorOptionProps = {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
type AccordionMenuGroup<T> = {
|
||||
icon?: IconType
|
||||
iconClassName?: string
|
||||
title: string
|
||||
items: Array<T>
|
||||
}
|
||||
|
||||
export type EditorMenuItem = {
|
||||
name: string
|
||||
component?: SNComponent
|
||||
isEntitled: boolean
|
||||
}
|
||||
|
||||
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>
|
||||
|
||||
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
application,
|
||||
note,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => {
|
||||
setIsOpen(open)
|
||||
setIsVisible(open)
|
||||
})
|
||||
|
||||
const toggleChangeEditorMenu = () => {
|
||||
if (!isOpen) {
|
||||
const menuStyle = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuStyle) {
|
||||
setMenuStyle(menuStyle)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuStyle = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuStyle) {
|
||||
setMenuStyle(newMenuStyle)
|
||||
setIsVisible(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="dashboard" className="color-neutral mr-2" />
|
||||
Change editor
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setIsOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto"
|
||||
>
|
||||
{isOpen && (
|
||||
<ChangeEditorMenu
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
note={note}
|
||||
isVisible={isVisible}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs'
|
||||
import { Fragment, FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
type ListedMenuGroup = {
|
||||
name: string
|
||||
account: ListedAccount
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
type ListedMenuItemProps = {
|
||||
action: Action
|
||||
note: SNNote
|
||||
group: ListedMenuGroup
|
||||
application: WebApplication
|
||||
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>
|
||||
}
|
||||
|
||||
const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
|
||||
action,
|
||||
note,
|
||||
application,
|
||||
group,
|
||||
reloadMenuGroup,
|
||||
}) => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsRunning(true)
|
||||
|
||||
await application.actionsManager.runAction(action, note)
|
||||
|
||||
setIsRunning(false)
|
||||
|
||||
reloadMenuGroup(group).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.url}
|
||||
onClick={handleClick}
|
||||
className="sn-dropdown-item flex justify-between py-2 text-input focus:bg-info-backdrop focus:shadow-none"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold">{action.label}</div>
|
||||
{action.access_type && (
|
||||
<div className="text-xs mt-0.5 color-grey-0">
|
||||
{'Uses '}
|
||||
<strong>{action.access_type}</strong>
|
||||
{' access to this note.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isRunning && <div className="sk-spinner spinner-info w-3 h-3" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type ListedActionsMenuProps = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
recalculateMenuStyle: () => void
|
||||
}
|
||||
|
||||
const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
|
||||
application,
|
||||
note,
|
||||
recalculateMenuStyle,
|
||||
}) => {
|
||||
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
|
||||
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true)
|
||||
|
||||
const reloadMenuGroup = async (group: ListedMenuGroup) => {
|
||||
const updatedAccountInfo = await application.getListedAccountInfo(group.account, note.uuid)
|
||||
|
||||
if (!updatedAccountInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedGroup: ListedMenuGroup = {
|
||||
name: updatedAccountInfo.display_name,
|
||||
account: group.account,
|
||||
actions: updatedAccountInfo.actions as Action[],
|
||||
}
|
||||
|
||||
const updatedGroups = menuGroups.map((group) => {
|
||||
if (updatedGroup.account.authorId === group.account.authorId) {
|
||||
return updatedGroup
|
||||
} else {
|
||||
return group
|
||||
}
|
||||
})
|
||||
|
||||
setMenuGroups(updatedGroups)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchListedAccounts = async () => {
|
||||
if (!application.hasAccount()) {
|
||||
setIsFetchingAccounts(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const listedAccountEntries = await application.getListedAccounts()
|
||||
|
||||
if (!listedAccountEntries.length) {
|
||||
throw new Error('No Listed accounts found')
|
||||
}
|
||||
|
||||
const menuGroups: ListedMenuGroup[] = []
|
||||
|
||||
await Promise.all(
|
||||
listedAccountEntries.map(async (account) => {
|
||||
const accountInfo = await application.getListedAccountInfo(account, note.uuid)
|
||||
|
||||
if (accountInfo) {
|
||||
menuGroups.push({
|
||||
name: accountInfo.display_name,
|
||||
account,
|
||||
actions: accountInfo.actions as Action[],
|
||||
})
|
||||
} else {
|
||||
menuGroups.push({
|
||||
name: account.authorId,
|
||||
account,
|
||||
actions: [],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setMenuGroups(
|
||||
menuGroups.sort((a, b) => {
|
||||
return a.name.toString().toLowerCase() < b.name.toString().toLowerCase() ? -1 : 1
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsFetchingAccounts(false)
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void fetchListedAccounts()
|
||||
}, [application, note.uuid, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFetchingAccounts && (
|
||||
<div className="w-full flex items-center justify-center p-4">
|
||||
<div className="sk-spinner w-5 h-5 spinner-info" />
|
||||
</div>
|
||||
)}
|
||||
{!isFetchingAccounts && menuGroups.length ? (
|
||||
<>
|
||||
{menuGroups.map((group, index) => (
|
||||
<Fragment key={group.account.authorId}>
|
||||
<div
|
||||
className={`w-full flex items-center px-2.5 py-2 text-input font-semibold color-text border-0 border-y-1px border-solid border-main ${
|
||||
index === 0 ? 'border-t-0 mb-1' : 'my-1'
|
||||
}`}
|
||||
>
|
||||
<Icon type="notes" className="mr-2 color-info" /> {group.name}
|
||||
</div>
|
||||
{group.actions.length ? (
|
||||
group.actions.map((action) => (
|
||||
<ListedMenuItem
|
||||
action={action}
|
||||
note={note}
|
||||
key={action.url}
|
||||
group={group}
|
||||
application={application}
|
||||
reloadMenuGroup={reloadMenuGroup}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 color-grey-0 select-none">No actions available</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{!isFetchingAccounts && !menuGroups.length ? (
|
||||
<div className="w-full flex items-center justify-center px-4 py-6">
|
||||
<div className="color-grey-0 select-none">No Listed accounts found</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => {
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
|
||||
|
||||
const toggleListedMenu = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}
|
||||
|
||||
const recalculateMenuStyle = useCallback(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}, [isMenuOpen, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isMenuOpen} onChange={toggleListedMenu}>
|
||||
<DisclosureButton
|
||||
ref={menuButtonRef}
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="listed" className="color-neutral mr-2" />
|
||||
Listed actions
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-68 pb-1 fixed overflow-y-auto"
|
||||
>
|
||||
{isMenuOpen && (
|
||||
<ListedActionsMenu
|
||||
application={application}
|
||||
note={note}
|
||||
recalculateMenuStyle={recalculateMenuStyle}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
450
app/assets/javascripts/Components/NotesOptions/NotesOptions.tsx
Normal file
450
app/assets/javascripts/Components/NotesOptions/NotesOptions.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState, useEffect, useMemo } from 'preact/hooks'
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { KeyboardModifier } from '@/Services/IOService'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { ChangeEditorOption } from './ChangeEditorOption'
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
|
||||
import { ListedActionsOption } from './ListedActionsOption'
|
||||
import { AddTagOption } from './AddTagOption'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||
|
||||
export type NotesOptionsProps = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
}
|
||||
|
||||
type DeletePermanentlyButtonProps = {
|
||||
closeOnBlur: NotesOptionsProps['closeOnBlur']
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const DeletePermanentlyButton = ({ closeOnBlur, onClick }: DeletePermanentlyButtonProps) => (
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}>
|
||||
<Icon type="close" className="color-danger mr-2" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
const iconClass = 'color-neutral mr-2'
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0
|
||||
}
|
||||
return text.split(/\s+/).length
|
||||
}
|
||||
|
||||
const getParagraphCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0
|
||||
}
|
||||
return text.replace(/\n$/gm, '').split(/\n/).length
|
||||
}
|
||||
|
||||
const countNoteAttributes = (text: string) => {
|
||||
try {
|
||||
JSON.parse(text)
|
||||
return {
|
||||
characters: 'N/A',
|
||||
words: 'N/A',
|
||||
paragraphs: 'N/A',
|
||||
}
|
||||
} catch {
|
||||
const characters = text.length
|
||||
const words = getWordCount(text)
|
||||
const paragraphs = getParagraphCount(text)
|
||||
|
||||
return {
|
||||
characters,
|
||||
words,
|
||||
paragraphs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calculateReadTime = (words: number) => {
|
||||
const timeToRead = Math.round(words / 200)
|
||||
if (timeToRead === 0) {
|
||||
return '< 1 minute'
|
||||
} else {
|
||||
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) {
|
||||
return
|
||||
}
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`
|
||||
}
|
||||
|
||||
const NoteAttributes: FunctionComponent<{
|
||||
application: SNApplication
|
||||
note: SNNote
|
||||
}> = ({ application, note }) => {
|
||||
const { words, characters, paragraphs } = useMemo(
|
||||
() => countNoteAttributes(note.text),
|
||||
[note.text],
|
||||
)
|
||||
|
||||
const readTime = useMemo(
|
||||
() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'),
|
||||
[words],
|
||||
)
|
||||
|
||||
const dateLastModified = useMemo(() => formatDate(note.userModifiedDate), [note.userModifiedDate])
|
||||
|
||||
const dateCreated = useMemo(() => formatDate(note.created_at), [note.created_at])
|
||||
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium">
|
||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||
<>
|
||||
<div className="mb-1">
|
||||
{words} words · {characters} characters · {paragraphs} paragraphs
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Read time:</span> {readTime}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Last modified:</span> {dateLastModified}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Created:</span> {dateCreated}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SpellcheckOptions: FunctionComponent<{
|
||||
appState: AppState
|
||||
note: SNNote
|
||||
}> = ({ appState, note }) => {
|
||||
const editor = appState.application.componentManager.editorForNote(note)
|
||||
const spellcheckControllable = Boolean(!editor || editor.package_info.spellcheckControl)
|
||||
const noteSpellcheck = !spellcheckControllable
|
||||
? true
|
||||
: note
|
||||
? appState.notes.getSpellcheckStateForNote(note)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="sn-dropdown-item justify-between px-3 py-1"
|
||||
onClick={() => {
|
||||
appState.notes.toggleGlobalSpellcheckForNote(note).catch(console.error)
|
||||
}}
|
||||
disabled={!spellcheckControllable}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="notes" className={iconClass} />
|
||||
Spellcheck
|
||||
</span>
|
||||
<Switch className="px-0" checked={noteSpellcheck} disabled={!spellcheckControllable} />
|
||||
</button>
|
||||
{!spellcheckControllable && (
|
||||
<p className="text-xs px-3 py-1.5">Spellcheck cannot be controlled for this editor.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
||||
|
||||
const NoteSizeWarning: FunctionComponent<{
|
||||
note: SNNote
|
||||
}> = ({ note }) =>
|
||||
(new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
||||
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
|
||||
<Icon type="warning" className="color-accessory-tint-3 flex-shrink-0 mr-3" />
|
||||
<div className="color-grey-0 select-none leading-140% max-w-80%">
|
||||
This note may have trouble syncing to the mobile application due to its size.
|
||||
</div>
|
||||
</div>
|
||||
) : null)
|
||||
|
||||
export const NotesOptions = observer(
|
||||
({ application, appState, closeOnBlur }: NotesOptionsProps) => {
|
||||
const [altKeyDown, setAltKeyDown] = useState(false)
|
||||
|
||||
const toggleOn = (condition: (note: SNNote) => boolean) => {
|
||||
const notesMatchingAttribute = notes.filter(condition)
|
||||
const notesNotMatchingAttribute = notes.filter((note) => !condition(note))
|
||||
return notesMatchingAttribute.length > notesNotMatchingAttribute.length
|
||||
}
|
||||
|
||||
const notes = Object.values(appState.notes.selectedNotes)
|
||||
const hidePreviews = toggleOn((note) => note.hidePreview)
|
||||
const locked = toggleOn((note) => note.locked)
|
||||
const protect = toggleOn((note) => note.protected)
|
||||
const archived = notes.some((note) => note.archived)
|
||||
const unarchived = notes.some((note) => !note.archived)
|
||||
const trashed = notes.some((note) => note.trashed)
|
||||
const notTrashed = notes.some((note) => !note.trashed)
|
||||
const pinned = notes.some((note) => note.pinned)
|
||||
const unpinned = notes.some((note) => !note.pinned)
|
||||
|
||||
useEffect(() => {
|
||||
const removeAltKeyObserver = application.io.addKeyObserver({
|
||||
modifiers: [KeyboardModifier.Alt],
|
||||
onKeyDown: () => {
|
||||
setAltKeyDown(true)
|
||||
},
|
||||
onKeyUp: () => {
|
||||
setAltKeyDown(false)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAltKeyObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const getNoteFileName = (note: SNNote): string => {
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
return `${note.title}.${format}`
|
||||
}
|
||||
|
||||
const downloadSelectedItems = async () => {
|
||||
if (notes.length === 1) {
|
||||
application
|
||||
.getArchiveService()
|
||||
.downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]))
|
||||
return
|
||||
}
|
||||
|
||||
if (notes.length > 1) {
|
||||
const loadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Exporting ${notes.length} notes...`,
|
||||
})
|
||||
await application.getArchiveService().downloadDataAsZip(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
filename: getNoteFileName(note),
|
||||
content: new Blob([note.text]),
|
||||
}
|
||||
}),
|
||||
)
|
||||
dismissToast(loadingToastId)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${notes.length} notes`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
application.mutator.duplicateItem(note).catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
const openRevisionHistoryModal = () => {
|
||||
appState.notes.setShowRevisionHistoryModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{notes.length === 1 && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={openRevisionHistoryModal}
|
||||
>
|
||||
<Icon type="history" className={iconClass} />
|
||||
Note history
|
||||
</button>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setLockSelectedNotes(!locked)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="pencil-off" className={iconClass} />
|
||||
Prevent editing
|
||||
</span>
|
||||
<Switch className="px-0" checked={locked} />
|
||||
</button>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="rich-text" className={iconClass} />
|
||||
Show preview
|
||||
</span>
|
||||
<Switch className="px-0" checked={!hidePreviews} />
|
||||
</button>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setProtectSelectedNotes(!protect).catch(console.error)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className={iconClass} />
|
||||
Protect
|
||||
</span>
|
||||
<Switch className="px-0" checked={protect} />
|
||||
</button>
|
||||
{notes.length === 1 && (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<ChangeEditorOption appState={appState} application={application} note={notes[0]} />
|
||||
</>
|
||||
)}
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
{appState.tags.tagsCount > 0 && <AddTagOption appState={appState} />}
|
||||
{unpinned && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="pin" className={iconClass} />
|
||||
Pin to top
|
||||
</button>
|
||||
)}
|
||||
{pinned && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(false)
|
||||
}}
|
||||
>
|
||||
<Icon type="unpin" className={iconClass} />
|
||||
Unpin
|
||||
</button>
|
||||
)}
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={downloadSelectedItems}>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</button>
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={duplicateSelectedItems}>
|
||||
<Icon type="copy" className={iconClass} />
|
||||
Duplicate
|
||||
</button>
|
||||
{unarchived && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(true).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="archive" className={iconClass} />
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{archived && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(false).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="unarchive" className={iconClass} />
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
{notTrashed &&
|
||||
(altKeyDown ? (
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className={iconClass} />
|
||||
Move to trash
|
||||
</button>
|
||||
))}
|
||||
{trashed && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(false)
|
||||
}}
|
||||
>
|
||||
<Icon type="restore" className={iconClass} />
|
||||
Restore
|
||||
</button>
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.emptyTrash()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<Icon type="trash-sweep" className="color-danger mr-2" />
|
||||
<div className="flex-row">
|
||||
<div className="color-danger">Empty Trash</div>
|
||||
<div className="text-xs">{appState.notes.trashedNotesCount} notes in Trash</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{notes.length === 1 ? (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<ListedActionsOption application={application} note={notes[0]} />
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<SpellcheckOptions appState={appState} note={notes[0]} />
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<NoteAttributes application={application} note={notes[0]} />
|
||||
<NoteSizeWarning note={notes[0]} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { useRef, useState } from 'preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NotesOptions } from './NotesOptions'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const NotesOptionsPanel = observer(
|
||||
({ application, appState, onClickPreprocessing }: Props) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
open={open}
|
||||
onChange={async () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2)
|
||||
}
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
const newOpenState = !open
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
setOpen(newOpenState)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button border-contrast"
|
||||
>
|
||||
<VisuallyHidden>Actions</VisuallyHidden>
|
||||
<Icon type="more" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{open && (
|
||||
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
)
|
||||
},
|
||||
)
|
||||
260
app/assets/javascripts/Components/NotesView/index.tsx
Normal file
260
app/assets/javascripts/Components/NotesView/index.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { PANEL_NAME_NOTES } from '@/Constants'
|
||||
import { PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { NoAccountWarning } from '@/Components/NoAccountWarning'
|
||||
import { NotesList } from '@/Components/NotesList'
|
||||
import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu'
|
||||
import { SearchOptions } from '@/Components/SearchOptions'
|
||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }) => {
|
||||
const notesViewPanelRef = useRef<HTMLDivElement>(null)
|
||||
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
completedFullSync,
|
||||
createNewNote,
|
||||
displayOptions,
|
||||
noteFilterText,
|
||||
optionsSubtitle,
|
||||
panelTitle,
|
||||
renderedNotes,
|
||||
selectedNotes,
|
||||
setNoteFilterText,
|
||||
searchBarElement,
|
||||
selectNextNote,
|
||||
selectPreviousNote,
|
||||
onFilterEnter,
|
||||
handleFilterTextChanged,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
} = appState.notesView
|
||||
|
||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
|
||||
const [focusedSearch, setFocusedSearch] = useState(false)
|
||||
|
||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
||||
displayOptionsMenuRef,
|
||||
setShowDisplayOptionsMenu,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterTextChanged()
|
||||
}, [noteFilterText, handleFilterTextChanged])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
const newNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault()
|
||||
createNewNote().catch(console.error)
|
||||
},
|
||||
})
|
||||
|
||||
const nextNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [document.body, ...(searchBarElement ? [searchBarElement] : [])],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement === document.activeElement) {
|
||||
searchBarElement?.blur()
|
||||
}
|
||||
selectNextNote()
|
||||
},
|
||||
})
|
||||
|
||||
const previousNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: () => {
|
||||
selectPreviousNote()
|
||||
},
|
||||
})
|
||||
|
||||
const searchKeyObserver = application.io.addKeyObserver({
|
||||
key: 'f',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement) {
|
||||
searchBarElement.focus()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
newNoteKeyObserver()
|
||||
nextNoteKeyObserver()
|
||||
previousNoteKeyObserver()
|
||||
searchKeyObserver()
|
||||
}
|
||||
}, [application.io, createNewNote, searchBarElement, selectNextNote, selectPreviousNote])
|
||||
|
||||
const onNoteFilterTextChange = (e: Event) => {
|
||||
setNoteFilterText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const onSearchFocused = () => setFocusedSearch(true)
|
||||
const onSearchBlurred = () => setFocusedSearch(false)
|
||||
|
||||
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Enter) {
|
||||
onFilterEnter()
|
||||
}
|
||||
}
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
width,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed,
|
||||
) => {
|
||||
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed)
|
||||
}
|
||||
|
||||
const panelWidthEventCallback = () => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}
|
||||
|
||||
const toggleDisplayOptionsMenu = () => {
|
||||
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notes-column"
|
||||
className="sn-component section notes app-column app-column-second"
|
||||
aria-label="Notes"
|
||||
ref={notesViewPanelRef}
|
||||
>
|
||||
<div className="content">
|
||||
<div id="notes-title-bar" className="section-title-bar">
|
||||
<div id="notes-title-bar-container">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||
<button
|
||||
className="sk-button contrast wide"
|
||||
title="Create a new note in the selected tag"
|
||||
aria-label="Create new note"
|
||||
onClick={() => createNewNote()}
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i className="ion-plus add-button" aria-hidden></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-section" role="search">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onFocus={onSearchFocused}
|
||||
onBlur={onSearchBlurred}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{noteFilterText && (
|
||||
<button onClick={clearFilterText} aria-role="button" id="search-clear-button">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(focusedSearch || noteFilterText) && (
|
||||
<div className="animate-fade-from-top">
|
||||
<SearchOptions application={application} appState={appState} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NoAccountWarning appState={appState} />
|
||||
</div>
|
||||
<div id="notes-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
|
||||
<div className="sk-app-bar no-edges">
|
||||
<div className="left">
|
||||
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
|
||||
<DisclosureButton
|
||||
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onBlur={closeDisplayOptMenuOnBlur}
|
||||
>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-label">Options</div>
|
||||
</div>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-sublabel">{optionsSubtitle}</div>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
||||
{showDisplayOptionsMenu && (
|
||||
<NotesListOptionsMenu
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||
isOpen={showDisplayOptionsMenu}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">No notes.</p>
|
||||
) : null}
|
||||
{!completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">Loading notes...</p>
|
||||
) : null}
|
||||
{renderedNotes.length ? (
|
||||
<NotesList
|
||||
notes={renderedNotes}
|
||||
selectedNotes={selectedNotes}
|
||||
application={application}
|
||||
appState={appState}
|
||||
displayOptions={displayOptions}
|
||||
paginate={paginate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{notesViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
hoverable={true}
|
||||
defaultWidth={300}
|
||||
panel={notesViewPanelRef.current}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from 'preact/hooks'
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const OtherSessionsSignOutContainer = observer((props: Props) => {
|
||||
if (!props.appState.accountMenu.otherSessionsSignOut) {
|
||||
return null
|
||||
}
|
||||
return <ConfirmOtherSessionsSignOut {...props} />
|
||||
})
|
||||
|
||||
const ConfirmOtherSessionsSignOut = observer(({ application, appState }: Props) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setOtherSessionsSignOut(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End all other sessions?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
This action will sign out all other devices signed into your account, and remove
|
||||
your data from those devices when they next regain connection to the internet.
|
||||
You may sign back in on those devices at any time.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
application.revokeAllOtherSessions().catch(console.error)
|
||||
closeDialog()
|
||||
application.alertService
|
||||
.alert(
|
||||
'You have successfully revoked your sessions from other devices.',
|
||||
undefined,
|
||||
'Finish',
|
||||
)
|
||||
.catch(console.error)
|
||||
}}
|
||||
>
|
||||
End Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
})
|
||||
326
app/assets/javascripts/Components/PanelResizer/index.tsx
Normal file
326
app/assets/javascripts/Components/PanelResizer/index.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { Component, createRef } from 'preact'
|
||||
import { debounce } from '@/Utils'
|
||||
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean,
|
||||
) => void
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
|
||||
export enum PanelResizeType {
|
||||
WidthOnly = 'WidthOnly',
|
||||
OffsetAndWidth = 'OffsetAndWidth',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
width: number
|
||||
left: number
|
||||
alwaysVisible?: boolean
|
||||
collapsable?: boolean
|
||||
defaultWidth?: number
|
||||
hoverable?: boolean
|
||||
minWidth?: number
|
||||
panel: HTMLDivElement
|
||||
side: PanelSide
|
||||
type: PanelResizeType
|
||||
resizeFinishCallback?: ResizeFinishCallback
|
||||
widthEventCallback?: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
pressed: boolean
|
||||
}
|
||||
|
||||
export class PanelResizer extends Component<Props, State> {
|
||||
private overlay?: HTMLDivElement
|
||||
private resizerElementRef = createRef<HTMLDivElement>()
|
||||
private debouncedResizeHandler: () => void
|
||||
private startLeft: number
|
||||
private startWidth: number
|
||||
private lastDownX: number
|
||||
private lastLeft: number
|
||||
private lastWidth: number
|
||||
private widthBeforeLastDblClick: number
|
||||
private minWidth: number
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
pressed: false,
|
||||
}
|
||||
|
||||
this.minWidth = props.minWidth || 5
|
||||
this.startLeft = props.panel.offsetLeft
|
||||
this.startWidth = props.panel.scrollWidth
|
||||
this.lastDownX = 0
|
||||
this.lastLeft = props.panel.offsetLeft
|
||||
this.lastWidth = props.panel.scrollWidth
|
||||
this.widthBeforeLastDblClick = 0
|
||||
|
||||
this.setWidth(this.props.width)
|
||||
this.setLeft(this.props.left)
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp)
|
||||
document.addEventListener('mousemove', this.onMouseMove)
|
||||
this.debouncedResizeHandler = debounce(this.handleResize, 250)
|
||||
if (this.props.type === PanelResizeType.OffsetAndWidth) {
|
||||
window.addEventListener('resize', this.debouncedResizeHandler)
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.width != prevProps.width) {
|
||||
this.setWidth(this.props.width)
|
||||
}
|
||||
if (this.props.left !== prevProps.left) {
|
||||
this.setLeft(this.props.left)
|
||||
this.setWidth(this.props.width)
|
||||
}
|
||||
|
||||
const isCollapsed = this.isCollapsed()
|
||||
if (isCollapsed !== this.state.collapsed) {
|
||||
this.setState({ collapsed: isCollapsed })
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount() {
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
document.removeEventListener('mousemove', this.onMouseMove)
|
||||
window.removeEventListener('resize', this.debouncedResizeHandler)
|
||||
}
|
||||
|
||||
get appFrame() {
|
||||
return document.getElementById('app')?.getBoundingClientRect() as DOMRect
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect()
|
||||
}
|
||||
|
||||
isAtMaxWidth = () => {
|
||||
const marginOfError = 5
|
||||
const difference = Math.abs(
|
||||
Math.round(this.lastWidth + this.lastLeft) - Math.round(this.getParentRect().width),
|
||||
)
|
||||
return difference < marginOfError
|
||||
}
|
||||
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.minWidth
|
||||
}
|
||||
|
||||
finishSettingWidth = () => {
|
||||
if (!this.props.collapsable) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
collapsed: this.isCollapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
setWidth = (width: number, finish = false): void => {
|
||||
if (width === 0) {
|
||||
width = this.computeMaxWidth()
|
||||
}
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth
|
||||
}
|
||||
|
||||
const parentRect = this.getParentRect()
|
||||
if (width > parentRect.width) {
|
||||
width = parentRect.width
|
||||
}
|
||||
|
||||
const maxWidth = this.appFrame.width - this.props.panel.getBoundingClientRect().x
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
const isFullWidth = Math.round(width + this.lastLeft) === Math.round(parentRect.width)
|
||||
if (isFullWidth) {
|
||||
if (this.props.type === PanelResizeType.WidthOnly) {
|
||||
this.props.panel.style.removeProperty('width')
|
||||
} else {
|
||||
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`
|
||||
}
|
||||
} else {
|
||||
this.props.panel.style.width = width + 'px'
|
||||
}
|
||||
this.lastWidth = width
|
||||
if (finish) {
|
||||
this.finishSettingWidth()
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLeft = (left: number) => {
|
||||
this.props.panel.style.left = left + 'px'
|
||||
this.lastLeft = left
|
||||
}
|
||||
|
||||
onDblClick = () => {
|
||||
const collapsed = this.isCollapsed()
|
||||
if (collapsed) {
|
||||
this.setWidth(this.widthBeforeLastDblClick || this.props.defaultWidth || 0)
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth
|
||||
this.setWidth(this.minWidth)
|
||||
}
|
||||
this.finishSettingWidth()
|
||||
|
||||
this.props.resizeFinishCallback?.(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed(),
|
||||
)
|
||||
}
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.props.widthEventCallback) {
|
||||
this.props.widthEventCallback()
|
||||
}
|
||||
|
||||
let x
|
||||
if (event) {
|
||||
x = event.clientX
|
||||
} else {
|
||||
/** Coming from resize event */
|
||||
x = 0
|
||||
this.lastDownX = 0
|
||||
}
|
||||
const deltaX = x - this.lastDownX
|
||||
const newWidth = this.startWidth + deltaX
|
||||
this.setWidth(newWidth, false)
|
||||
}
|
||||
|
||||
handleLeftEvent(event: MouseEvent) {
|
||||
const panelRect = this.props.panel.getBoundingClientRect()
|
||||
const x = event.clientX || panelRect.x
|
||||
let deltaX = x - this.lastDownX
|
||||
let newLeft = this.startLeft + deltaX
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0
|
||||
deltaX = -this.startLeft
|
||||
}
|
||||
const parentRect = this.getParentRect()
|
||||
let newWidth = this.startWidth - deltaX
|
||||
if (newWidth < this.minWidth) {
|
||||
newWidth = this.minWidth
|
||||
}
|
||||
if (newWidth > parentRect.width) {
|
||||
newWidth = parentRect.width
|
||||
}
|
||||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth
|
||||
}
|
||||
this.setLeft(newLeft)
|
||||
this.setWidth(newWidth, false)
|
||||
}
|
||||
|
||||
computeMaxWidth(): number {
|
||||
const parentRect = this.getParentRect()
|
||||
let width = parentRect.width - this.props.left
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
const startWidth = this.isAtMaxWidth() ? this.computeMaxWidth() : this.props.panel.scrollWidth
|
||||
|
||||
this.startWidth = startWidth
|
||||
this.lastWidth = startWidth
|
||||
|
||||
this.handleWidthEvent()
|
||||
this.finishSettingWidth()
|
||||
}
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.addInvisibleOverlay()
|
||||
this.lastDownX = event.clientX
|
||||
this.startWidth = this.props.panel.scrollWidth
|
||||
this.startLeft = this.props.panel.offsetLeft
|
||||
this.setState({
|
||||
pressed: true,
|
||||
})
|
||||
}
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay()
|
||||
if (!this.state.pressed) {
|
||||
return
|
||||
}
|
||||
this.setState({ pressed: false })
|
||||
const isMaxWidth = this.isAtMaxWidth()
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(this.lastWidth, this.lastLeft, isMaxWidth, this.isCollapsed())
|
||||
}
|
||||
this.finishSettingWidth()
|
||||
}
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (!this.state.pressed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.props.side === PanelSide.Left) {
|
||||
this.handleLeftEvent(event)
|
||||
} else {
|
||||
this.handleWidthEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
return
|
||||
}
|
||||
const overlayElement = document.createElement('div')
|
||||
overlayElement.id = 'resizer-overlay'
|
||||
this.overlay = overlayElement
|
||||
document.body.prepend(this.overlay)
|
||||
}
|
||||
|
||||
removeInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove()
|
||||
this.overlay = undefined
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className={`panel-resizer ${this.props.side} ${this.props.hoverable ? 'hoverable' : ''} ${
|
||||
this.props.alwaysVisible ? 'alwaysVisible' : ''
|
||||
} ${this.state.pressed ? 'dragging' : ''} ${this.state.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onDblClick={this.onDblClick}
|
||||
ref={this.resizerElementRef}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
}
|
||||
333
app/assets/javascripts/Components/PasswordWizard/index.tsx
Normal file
333
app/assets/javascripts/Components/PasswordWizard/index.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { createRef, JSX } from 'preact'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
|
||||
interface Props {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
type State = {
|
||||
continueTitle: string
|
||||
formData: FormData
|
||||
isContinuing?: boolean
|
||||
lockContinue?: boolean
|
||||
processing?: boolean
|
||||
showSpinner?: boolean
|
||||
step: Steps
|
||||
title: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = 'Continue'
|
||||
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2,
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string
|
||||
newPassword?: string
|
||||
newPasswordConfirmation?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export class PasswordWizard extends PureComponent<Props, State> {
|
||||
private currentPasswordInput = createRef<HTMLInputElement>()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.registerWindowUnloadStopper()
|
||||
this.state = {
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: 'Change Password',
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.currentPasswordInput.current?.focus()
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
super.componentWillUnmount()
|
||||
window.onbeforeunload = null
|
||||
}
|
||||
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
isContinuing: false,
|
||||
})
|
||||
}
|
||||
|
||||
nextStep = async () => {
|
||||
if (this.state.lockContinue || this.state.isContinuing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: true,
|
||||
showSpinner: true,
|
||||
continueTitle: 'Generating Keys...',
|
||||
})
|
||||
|
||||
const valid = await this.validateCurrentPassword()
|
||||
if (!valid) {
|
||||
this.resetContinueState()
|
||||
return
|
||||
}
|
||||
|
||||
const success = await this.processPasswordChange()
|
||||
if (!success) {
|
||||
this.resetContinueState()
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: false,
|
||||
showSpinner: false,
|
||||
continueTitle: 'Finish',
|
||||
step: Steps.FinishStep,
|
||||
})
|
||||
}
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword
|
||||
const newPass = this.state.formData.newPassword
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService
|
||||
.alert('Please enter your current password.')
|
||||
.catch(console.error)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService.alert('Please enter a new password.').catch(console.error)
|
||||
return false
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService
|
||||
.alert('Your new password does not match its confirmation.')
|
||||
.catch(console.error)
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
}).catch(console.error)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService
|
||||
.alert(
|
||||
"We don't have your email stored. Please sign out then log back in to fix this issue.",
|
||||
)
|
||||
.catch(console.error)
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
}).catch(console.error)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword as string,
|
||||
)
|
||||
if (!success) {
|
||||
this.application.alertService
|
||||
.alert('The current password you entered is not correct. Please try again.')
|
||||
.catch(console.error)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.application.downloadBackup()
|
||||
|
||||
this.setState({
|
||||
lockContinue: true,
|
||||
processing: true,
|
||||
})
|
||||
|
||||
await this.setFormDataState({
|
||||
status: 'Processing encryption keys…',
|
||||
})
|
||||
|
||||
const newPassword = this.state.formData.newPassword
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword as string,
|
||||
newPassword as string,
|
||||
)
|
||||
|
||||
const success = !response.error
|
||||
this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: 'Unable to process your password. Please try again.',
|
||||
}).catch(console.error)
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: 'Successfully changed password.',
|
||||
},
|
||||
})
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService
|
||||
.alert('Cannot close window until pending tasks are complete.')
|
||||
.catch(console.error)
|
||||
} else {
|
||||
this.dismissModal()
|
||||
}
|
||||
}
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
handleCurrentPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
currentPassword: currentTarget.value,
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
handleNewPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPassword: currentTarget.value,
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
handleNewPasswordConfirmationInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPasswordConfirmation: currentTarget.value,
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="password-wizard" className="sk-modal small auto-height">
|
||||
<div className="sk-modal-background" />
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">{this.state.title}</div>
|
||||
<a onClick={this.dismiss} className="sk-a info close-button">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
{this.state.step === Steps.PasswordStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-panel-column stretch">
|
||||
<form className="sk-panel-form">
|
||||
<label htmlFor="password-wiz-current-password" className="block mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
ref={this.currentPasswordInput}
|
||||
id="password-wiz-current-password"
|
||||
value={this.state.formData.currentPassword}
|
||||
onChange={this.handleCurrentPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label htmlFor="password-wiz-new-password" className="block mb-1">
|
||||
New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-new-password"
|
||||
value={this.state.formData.newPassword}
|
||||
onChange={this.handleNewPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label
|
||||
htmlFor="password-wiz-confirm-new-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-confirm-new-password"
|
||||
value={this.state.formData.newPasswordConfirmation}
|
||||
onChange={this.handleNewPasswordConfirmationInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.step === Steps.FinishStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-label sk-bold info">
|
||||
Your password has been successfully changed.
|
||||
</div>
|
||||
<p className="sk-p">
|
||||
Please ensure you are running the latest version of Standard Notes on all
|
||||
platforms to ensure maximum compatibility.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.nextStep}
|
||||
disabled={this.state.lockContinue}
|
||||
className="sn-button min-w-20 info"
|
||||
>
|
||||
{this.state.continueTitle}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
93
app/assets/javascripts/Components/PermissionsModal/index.tsx
Normal file
93
app/assets/javascripts/Components/PermissionsModal/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SNComponent } from '@standardnotes/snjs'
|
||||
import { Component } from 'preact'
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
|
||||
|
||||
interface Props {
|
||||
application: WebApplication
|
||||
callback: (approved: boolean) => void
|
||||
component: SNComponent
|
||||
permissionsString: string
|
||||
}
|
||||
|
||||
export class PermissionsModal extends Component<Props> {
|
||||
getElement(): Element | null {
|
||||
return findDOMNode(this)
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
const elem = this.getElement()
|
||||
if (!elem) {
|
||||
return
|
||||
}
|
||||
|
||||
const parent = elem.parentElement
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
parent.remove()
|
||||
unmountComponentAtNode(parent)
|
||||
}
|
||||
|
||||
accept = () => {
|
||||
this.props.callback(true)
|
||||
this.dismiss()
|
||||
}
|
||||
|
||||
deny = () => {
|
||||
this.props.callback(false)
|
||||
this.dismiss()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.deny} className="sk-modal-background" />
|
||||
<div id="permissions-modal" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Activate Component</div>
|
||||
<a onClick={this.deny} className="sk-a info close-button">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h2">
|
||||
<strong>{this.props.component.name}</strong>
|
||||
{' would like to interact with your '}
|
||||
{this.props.permissionsString}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<p className="sk-p">
|
||||
Components use an offline messaging system to communicate. Learn more at{' '}
|
||||
<a
|
||||
href="https://standardnotes.com/permissions"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="sk-a info"
|
||||
>
|
||||
https://standardnotes.com/permissions.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.accept}
|
||||
className="sn-button info block w-full text-base py-3"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/assets/javascripts/Components/PinNoteButton/index.tsx
Normal file
39
app/assets/javascripts/Components/PinNoteButton/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
className?: string
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const PinNoteButton: FunctionComponent<Props> = observer(
|
||||
({ appState, className = '', onClickPreprocessing }) => {
|
||||
const notes = Object.values(appState.notes.selectedNotes)
|
||||
const pinned = notes.some((note) => note.pinned)
|
||||
|
||||
const togglePinned = async () => {
|
||||
if (onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
if (!pinned) {
|
||||
appState.notes.setPinSelectedNotes(true)
|
||||
} else {
|
||||
appState.notes.setPinSelectedNotes(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sn-icon-button border-contrast ${pinned ? 'toggled' : ''} ${className}`}
|
||||
onClick={togglePinned}
|
||||
>
|
||||
<VisuallyHidden>Pin selected notes</VisuallyHidden>
|
||||
<Icon type="pin" className="block" />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { PreferencesGroup, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { OfflineSubscription } from '@/Components/Preferences/Panes/Account/OfflineSubscription'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Extensions } from '@/Components/Preferences/Panes/Extensions'
|
||||
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
|
||||
import { AccordionItem } from '@/Components/Shared/AccordionItem'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
extensionsLatestVersions: ExtensionsLatestVersions
|
||||
}
|
||||
|
||||
export const Advanced: FunctionalComponent<IProps> = observer(
|
||||
({ application, appState, extensionsLatestVersions }) => {
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Advanced Settings'}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<OfflineSubscription application={application} appState={appState} />
|
||||
<Extensions
|
||||
className={'mt-3'}
|
||||
application={application}
|
||||
extensionsLatestVersions={extensionsLatestVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AccountMenuPane } from '@/Components/AccountMenu'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { AccountIllustration } from '@standardnotes/stylekit'
|
||||
|
||||
export const Authentication: FunctionComponent<{
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}> = observer(({ appState }) => {
|
||||
const clickSignIn = () => {
|
||||
appState.preferences.closePreferences()
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn)
|
||||
appState.accountMenu.setShow(true)
|
||||
}
|
||||
|
||||
const clickRegister = () => {
|
||||
appState.preferences.closePreferences()
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.Register)
|
||||
appState.accountMenu.setShow(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-col items-center px-12">
|
||||
<AccountIllustration className="mb-3" />
|
||||
<Title>You're not signed in</Title>
|
||||
<Text className="text-center mb-3">
|
||||
Sign in to sync your notes and preferences across all your devices and enable end-to-end
|
||||
encryption.
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
label="Create free account"
|
||||
onClick={clickRegister}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="text-input">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
className="border-0 p-0 bg-default color-info underline cursor-pointer"
|
||||
onClick={clickSignIn}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { StateUpdater } from 'preact/hooks'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
type Props = {
|
||||
setNewEmail: StateUpdater<string>
|
||||
setCurrentPassword: StateUpdater<string>
|
||||
}
|
||||
|
||||
const labelClassName = 'block mb-1'
|
||||
|
||||
const inputClassName = 'sk-input contrast'
|
||||
|
||||
export const ChangeEmailForm: FunctionalComponent<Props> = ({
|
||||
setNewEmail,
|
||||
setCurrentPassword,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="mt-2 mb-3">
|
||||
<label className={labelClassName} htmlFor="change-email-email-input">
|
||||
New Email:
|
||||
</label>
|
||||
<input
|
||||
id="change-email-email-input"
|
||||
className={inputClassName}
|
||||
type="email"
|
||||
onChange={({ target }) => {
|
||||
setNewEmail((target as HTMLInputElement).value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className={labelClassName} htmlFor="change-email-password-input">
|
||||
Current Password:
|
||||
</label>
|
||||
<input
|
||||
id="change-email-password-input"
|
||||
className={inputClassName}
|
||||
type="password"
|
||||
onChange={({ target }) => {
|
||||
setCurrentPassword((target as HTMLInputElement).value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
export const ChangeEmailSuccess: FunctionalComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={'sk-label sk-bold info mt-2'}>Your email has been successfully changed.</div>
|
||||
<p className={'sk-p'}>
|
||||
Please ensure you are running the latest version of Standard Notes on all platforms to
|
||||
ensure maximum compatibility.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from '@node_modules/preact/hooks'
|
||||
import {
|
||||
ModalDialog,
|
||||
ModalDialogButtons,
|
||||
ModalDialogDescription,
|
||||
ModalDialogLabel,
|
||||
} from '@/Components/Shared/ModalDialog'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
|
||||
import { ChangeEmailForm } from './ChangeEmailForm'
|
||||
import { ChangeEmailSuccess } from './ChangeEmailSuccess'
|
||||
import { isEmailValid } from '@/Utils'
|
||||
|
||||
enum SubmitButtonTitles {
|
||||
Default = 'Continue',
|
||||
GeneratingKeys = 'Generating Keys...',
|
||||
Finish = 'Finish',
|
||||
}
|
||||
|
||||
enum Steps {
|
||||
InitialStep,
|
||||
FinishStep,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onCloseDialog: () => void
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const ChangeEmail: FunctionalComponent<Props> = ({ onCloseDialog, application }) => {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [isContinuing, setIsContinuing] = useState(false)
|
||||
const [lockContinue, setLockContinue] = useState(false)
|
||||
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
|
||||
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
|
||||
|
||||
useBeforeUnload()
|
||||
|
||||
const applicationAlertService = application.alertService
|
||||
|
||||
const validateCurrentPassword = async () => {
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
applicationAlertService.alert('Please enter your current password.').catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const success = await application.validateAccountPassword(currentPassword)
|
||||
if (!success) {
|
||||
applicationAlertService
|
||||
.alert('The current password you entered is not correct. Please try again.')
|
||||
.catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
const validateNewEmail = async () => {
|
||||
if (!isEmailValid(newEmail)) {
|
||||
applicationAlertService
|
||||
.alert(
|
||||
'The email you entered has an invalid format. Please review your input and try again.',
|
||||
)
|
||||
.catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resetProgressState = () => {
|
||||
setSubmitButtonTitle(SubmitButtonTitles.Default)
|
||||
setIsContinuing(false)
|
||||
}
|
||||
|
||||
const processEmailChange = async () => {
|
||||
await application.downloadBackup()
|
||||
|
||||
setLockContinue(true)
|
||||
|
||||
const response = await application.changeEmail(newEmail, currentPassword)
|
||||
|
||||
const success = !response.error
|
||||
|
||||
setLockContinue(false)
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (lockContinue) {
|
||||
applicationAlertService
|
||||
.alert('Cannot close window until pending tasks are complete.')
|
||||
.catch(console.error)
|
||||
} else {
|
||||
onCloseDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (lockContinue || isContinuing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep === Steps.FinishStep) {
|
||||
dismiss()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsContinuing(true)
|
||||
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys)
|
||||
|
||||
const valid = (await validateCurrentPassword()) && (await validateNewEmail())
|
||||
|
||||
if (!valid) {
|
||||
resetProgressState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const success = await processEmailChange()
|
||||
if (!success) {
|
||||
resetProgressState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsContinuing(false)
|
||||
setSubmitButtonTitle(SubmitButtonTitles.Finish)
|
||||
setCurrentStep(Steps.FinishStep)
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
if (lockContinue) {
|
||||
applicationAlertService
|
||||
.alert('Cannot close window until pending tasks are complete.')
|
||||
.catch(console.error)
|
||||
} else {
|
||||
onCloseDialog()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModalDialog>
|
||||
<ModalDialogLabel closeDialog={handleDialogClose}>Change Email</ModalDialogLabel>
|
||||
<ModalDialogDescription className="px-4.5">
|
||||
{currentStep === Steps.InitialStep && (
|
||||
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
|
||||
)}
|
||||
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
|
||||
</ModalDialogDescription>
|
||||
<ModalDialogButtons className="px-4.5">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="primary"
|
||||
label={submitButtonTitle}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</ModalDialogButtons>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from '@node_modules/mobx-react-lite'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { dateToLocalizedString } from '@standardnotes/snjs'
|
||||
import { useCallback, useState } from 'preact/hooks'
|
||||
import { ChangeEmail } from '@/Components/Preferences/Panes/Account/ChangeEmail'
|
||||
import { FunctionComponent, render } from 'preact'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { PasswordWizard } from '@/Components/PasswordWizard'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const Credentials: FunctionComponent<Props> = observer(({ application }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false)
|
||||
|
||||
const user = application.getUser()
|
||||
|
||||
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp)
|
||||
|
||||
const presentPasswordWizard = useCallback(() => {
|
||||
render(
|
||||
<PasswordWizard application={application} />,
|
||||
document.body.appendChild(document.createElement('div')),
|
||||
)
|
||||
}, [application])
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<Subtitle>Email</Subtitle>
|
||||
<Text>
|
||||
You're signed in as <span className="font-bold wrap">{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Change email"
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<Subtitle>Password</Subtitle>
|
||||
<Text>
|
||||
Current password was set on <span className="font-bold">{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Change password"
|
||||
onClick={presentPasswordWizard}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { Subtitle } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/Strings'
|
||||
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const OfflineSubscription: FunctionalComponent<IProps> = observer(({ 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: Event) => {
|
||||
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="flex flex-col mt-3 w-full">
|
||||
<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={'mb-3'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
|
||||
<div className={'mt-3 mb-3 info'}>
|
||||
Your offline subscription code has been successfully{' '}
|
||||
{isSuccessfullyActivated ? 'activated' : 'removed'}.
|
||||
</div>
|
||||
)}
|
||||
{hasUserPreviouslyStoredCode && (
|
||||
<Button
|
||||
dangerStyle={true}
|
||||
label="Remove offline key"
|
||||
onClick={() => {
|
||||
handleRemoveClick().catch(console.error)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
|
||||
<Button
|
||||
label={'Submit'}
|
||||
variant="primary"
|
||||
disabled={activationCode === ''}
|
||||
onClick={(event) => handleSubscriptionCodeSubmit(event)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-8 mb-5" />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { OtherSessionsSignOutContainer } from '@/Components/OtherSessionsSignOut'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
const SignOutView: FunctionComponent<{
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}> = observer(({ application, appState }) => {
|
||||
return (
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Sign out</Title>
|
||||
<Subtitle>Other devices</Subtitle>
|
||||
<Text>Want to sign out on all devices except this one?</Text>
|
||||
<div className="min-h-3" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="mr-3"
|
||||
variant="normal"
|
||||
label="Sign out other sessions"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setOtherSessionsSignOut(true)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="normal"
|
||||
label="Manage sessions"
|
||||
onClick={() => appState.openSessionsModal()}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>This workspace</Subtitle>
|
||||
<Text>Remove all data related to the current workspace from the application.</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
dangerStyle={true}
|
||||
label="Sign out workspace"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<OtherSessionsSignOutContainer appState={appState} application={application} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const ClearSessionDataView: FunctionComponent<{
|
||||
appState: AppState
|
||||
}> = observer(({ appState }) => {
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Clear workspace</Title>
|
||||
<Text>Remove all data related to the current workspace from the application.</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
dangerStyle={true}
|
||||
label="Clear workspace"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
|
||||
export const SignOutWrapper: FunctionComponent<{
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}> = observer(({ application, appState }) => {
|
||||
if (!application.hasAccount()) {
|
||||
return <ClearSessionDataView appState={appState} />
|
||||
}
|
||||
return <SignOutView appState={appState} application={application} />
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
|
||||
export const NoSubscription: FunctionalComponent<{
|
||||
application: WebApplication
|
||||
}> = ({ application }) => {
|
||||
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
|
||||
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
|
||||
|
||||
const onPurchaseClick = async () => {
|
||||
const errorMessage =
|
||||
'There was an error when attempting to redirect you to the subscription page.'
|
||||
setIsLoadingPurchaseFlow(true)
|
||||
try {
|
||||
if (!(await loadPurchaseFlowUrl(application))) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
}
|
||||
} catch (e) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
} finally {
|
||||
setIsLoadingPurchaseFlow(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>You don't have a Standard Notes subscription yet.</Text>
|
||||
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
|
||||
{purchaseFlowError && <Text className="color-danger">{purchaseFlowError}</Text>}
|
||||
<div className="flex">
|
||||
<LinkButton
|
||||
className="min-w-20 mt-3 mr-3"
|
||||
label="Learn More"
|
||||
link={window.plansUrl as string}
|
||||
/>
|
||||
{application.hasAccount() && (
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="primary"
|
||||
label="Subscribe"
|
||||
onClick={onPurchaseClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PreferencesGroup, PreferencesSegment, Title } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SubscriptionInformation } from './SubscriptionInformation'
|
||||
import { NoSubscription } from './NoSubscription'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const Subscription: FunctionComponent<Props> = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const subscriptionState = appState.subscription
|
||||
const { userSubscription } = subscriptionState
|
||||
|
||||
const now = new Date().getTime()
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<Title>Subscription</Title>
|
||||
{userSubscription && userSubscription.endsAt > now ? (
|
||||
<SubscriptionInformation
|
||||
subscriptionState={subscriptionState}
|
||||
application={application}
|
||||
/>
|
||||
) : (
|
||||
<NoSubscription application={application} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { SubscriptionState } from '@/UIModels/AppState/SubscriptionState'
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
|
||||
type Props = {
|
||||
subscriptionState: SubscriptionState
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const StatusText = observer(
|
||||
({ subscriptionState }: { subscriptionState: Props['subscriptionState'] }) => {
|
||||
const {
|
||||
userSubscriptionName,
|
||||
userSubscriptionExpirationDate,
|
||||
isUserSubscriptionExpired,
|
||||
isUserSubscriptionCanceled,
|
||||
} = subscriptionState
|
||||
const expirationDateString = userSubscriptionExpirationDate?.toLocaleString()
|
||||
|
||||
if (isUserSubscriptionCanceled) {
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription has been canceled{' '}
|
||||
{isUserSubscriptionExpired ? (
|
||||
<span className="font-bold">and expired on {expirationDateString}</span>
|
||||
) : (
|
||||
<span className="font-bold">but will remain valid until {expirationDateString}</span>
|
||||
)}
|
||||
. You may resubscribe below if you wish.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUserSubscriptionExpired) {
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription <span className="font-bold">expired on {expirationDateString}</span>. You may
|
||||
resubscribe below if you wish.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription will be <span className="font-bold">renewed on {expirationDateString}</span>.
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const SubscriptionInformation = observer(({ subscriptionState, application }: Props) => {
|
||||
const manageSubscription = async () => {
|
||||
openSubscriptionDashboard(application)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusText subscriptionState={subscriptionState} />
|
||||
<Button
|
||||
className="min-w-20 mt-3 mr-3"
|
||||
variant="normal"
|
||||
label="Manage subscription"
|
||||
onClick={manageSubscription}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
|
||||
import { useState } from '@node_modules/preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const formatLastSyncDate = (lastUpdatedDate: Date) => {
|
||||
return dateToLocalizedString(lastUpdatedDate)
|
||||
}
|
||||
|
||||
export const Sync: FunctionComponent<Props> = observer(({ application }: Props) => {
|
||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||
const [lastSyncDate, setLastSyncDate] = useState(
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date),
|
||||
)
|
||||
|
||||
const doSynchronization = async () => {
|
||||
setIsSyncingInProgress(true)
|
||||
|
||||
const response = await application.sync.sync({
|
||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||
checkIntegrity: true,
|
||||
})
|
||||
setIsSyncingInProgress(false)
|
||||
if (response && (response as any).error) {
|
||||
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
||||
} else {
|
||||
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<Title>Sync</Title>
|
||||
<Text>
|
||||
Last synced <span className="font-bold">on {lastSyncDate}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Sync now"
|
||||
disabled={isSyncingInProgress}
|
||||
onClick={doSynchronization}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Authentication } from './Authentication'
|
||||
import { Credentials } from './Credentials'
|
||||
import { Sync } from './Sync'
|
||||
import { Subscription } from './Subscription/Subscription'
|
||||
import { SignOutWrapper } from './SignOutView'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const AccountPreferences = observer(({ application, appState }: Props) => (
|
||||
<PreferencesPane>
|
||||
{!application.hasAccount() ? (
|
||||
<Authentication application={application} appState={appState} />
|
||||
) : (
|
||||
<>
|
||||
<Credentials application={application} appState={appState} />
|
||||
<Sync application={application} />
|
||||
</>
|
||||
)}
|
||||
<Subscription application={application} appState={appState} />
|
||||
<SignOutWrapper application={application} appState={appState} />
|
||||
</PreferencesPane>
|
||||
))
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
PrefKey,
|
||||
GetFeatures,
|
||||
SNTheme,
|
||||
} from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Title,
|
||||
Text,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { sortThemes } from '@/Utils/SortThemes'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Appearance: FunctionComponent<Props> = observer(({ application }) => {
|
||||
const premiumModal = usePremiumModal()
|
||||
const isEntitledToMidnightTheme =
|
||||
application.features.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
|
||||
FeatureStatus.Entitled
|
||||
|
||||
const [themeItems, setThemeItems] = useState<DropdownItem[]>([])
|
||||
const [autoLightTheme, setAutoLightTheme] = useState<string>(
|
||||
() => application.getPreference(PrefKey.AutoLightThemeIdentifier, 'Default') as string,
|
||||
)
|
||||
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default',
|
||||
) as string,
|
||||
)
|
||||
const [useDeviceSettings, setUseDeviceSettings] = useState(
|
||||
() => application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const themesAsItems: DropdownItem[] = application.items
|
||||
.getDisplayableItems<SNTheme>(ContentType.Theme)
|
||||
.filter((theme) => !theme.isLayerable())
|
||||
.sort(sortThemes)
|
||||
.map((theme) => {
|
||||
return {
|
||||
label: theme.name,
|
||||
value: theme.identifier as string,
|
||||
}
|
||||
})
|
||||
|
||||
GetFeatures()
|
||||
.filter((feature) => feature.content_type === ContentType.Theme && !feature.layerable)
|
||||
.forEach((theme) => {
|
||||
if (themesAsItems.findIndex((item) => item.value === theme.identifier) === -1) {
|
||||
themesAsItems.push({
|
||||
label: theme.name as string,
|
||||
value: theme.identifier,
|
||||
icon: 'premium-feature',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
themesAsItems.unshift({
|
||||
label: 'Default',
|
||||
value: 'Default',
|
||||
})
|
||||
|
||||
setThemeItems(themesAsItems)
|
||||
}, [application])
|
||||
|
||||
const toggleUseDeviceSettings = () => {
|
||||
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings).catch(console.error)
|
||||
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
|
||||
application
|
||||
.setPreference(PrefKey.AutoLightThemeIdentifier, autoLightTheme as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
}
|
||||
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
|
||||
application
|
||||
.setPreference(PrefKey.AutoDarkThemeIdentifier, autoDarkTheme as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
}
|
||||
setUseDeviceSettings(!useDeviceSettings)
|
||||
}
|
||||
|
||||
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`)
|
||||
} else {
|
||||
application
|
||||
.setPreference(PrefKey.AutoLightThemeIdentifier, value as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
setAutoLightTheme(value)
|
||||
}
|
||||
}
|
||||
|
||||
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`)
|
||||
} else {
|
||||
application
|
||||
.setPreference(PrefKey.AutoDarkThemeIdentifier, value as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
setAutoDarkTheme(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Themes</Title>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Use system color scheme</Subtitle>
|
||||
<Text>Automatically change active theme based on your system settings.</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleUseDeviceSettings} checked={useDeviceSettings} />
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Light Theme</Subtitle>
|
||||
<Text>Theme to be used for system light mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-light-theme-dropdown"
|
||||
label="Select the automatic light theme"
|
||||
items={themeItems}
|
||||
value={autoLightTheme}
|
||||
onChange={changeAutoLightTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Dark Theme</Subtitle>
|
||||
<Text>Theme to be used for system dark mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-dark-theme-dropdown"
|
||||
label="Select the automatic dark theme"
|
||||
items={themeItems}
|
||||
value={autoDarkTheme}
|
||||
onChange={changeAutoDarkTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import {
|
||||
ButtonType,
|
||||
SettingName,
|
||||
CloudProvider,
|
||||
DropboxBackupFrequency,
|
||||
GoogleDriveBackupFrequency,
|
||||
OneDriveBackupFrequency,
|
||||
} from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { isDev, openInNewTab } from '@/Utils'
|
||||
import { Subtitle } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
providerName: CloudProvider
|
||||
isEntitledToCloudBackups: boolean
|
||||
}
|
||||
|
||||
export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
application,
|
||||
providerName,
|
||||
isEntitledToCloudBackups,
|
||||
}) => {
|
||||
const [authBegan, setAuthBegan] = useState(false)
|
||||
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false)
|
||||
const [backupFrequency, setBackupFrequency] = useState<string | undefined>(undefined)
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
|
||||
const disable = async (event: Event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const shouldDisable = await application.alertService.confirm(
|
||||
'Are you sure you want to disable this integration?',
|
||||
'Disable?',
|
||||
'Disable',
|
||||
ButtonType.Danger,
|
||||
'Cancel',
|
||||
)
|
||||
if (shouldDisable) {
|
||||
await application.settings.deleteSetting(backupFrequencySettingName)
|
||||
await application.settings.deleteSetting(backupTokenSettingName)
|
||||
|
||||
setBackupFrequency(undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
application.alertService.alert(error as string).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
const installIntegration = (event: Event) => {
|
||||
if (!isEntitledToCloudBackups) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
|
||||
const authUrl = application.getCloudProviderIntegrationUrl(providerName, isDev)
|
||||
openInNewTab(authUrl)
|
||||
setAuthBegan(true)
|
||||
}
|
||||
|
||||
const performBackupNow = async () => {
|
||||
// A backup is performed anytime the setting is updated with the integration token, so just update it here
|
||||
try {
|
||||
await application.settings.updateSetting(
|
||||
backupFrequencySettingName,
|
||||
backupFrequency as string,
|
||||
)
|
||||
void application.alertService.alert(
|
||||
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.',
|
||||
)
|
||||
} catch (err) {
|
||||
application.alertService
|
||||
.alert(
|
||||
'There was an error while trying to trigger a backup for this provider. Please try again.',
|
||||
)
|
||||
.catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
const backupSettingsData = {
|
||||
[CloudProvider.Dropbox]: {
|
||||
backupTokenSettingName: SettingName.DropboxBackupToken,
|
||||
backupFrequencySettingName: SettingName.DropboxBackupFrequency,
|
||||
defaultBackupFrequency: DropboxBackupFrequency.Daily,
|
||||
},
|
||||
[CloudProvider.Google]: {
|
||||
backupTokenSettingName: SettingName.GoogleDriveBackupToken,
|
||||
backupFrequencySettingName: SettingName.GoogleDriveBackupFrequency,
|
||||
defaultBackupFrequency: GoogleDriveBackupFrequency.Daily,
|
||||
},
|
||||
[CloudProvider.OneDrive]: {
|
||||
backupTokenSettingName: SettingName.OneDriveBackupToken,
|
||||
backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
|
||||
defaultBackupFrequency: OneDriveBackupFrequency.Daily,
|
||||
},
|
||||
}
|
||||
const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } =
|
||||
backupSettingsData[providerName]
|
||||
|
||||
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
|
||||
const urlSearchParams = new URLSearchParams(url.search)
|
||||
let integrationTokenKeyInUrl = ''
|
||||
|
||||
switch (providerName) {
|
||||
case CloudProvider.Dropbox:
|
||||
integrationTokenKeyInUrl = 'dbt'
|
||||
break
|
||||
case CloudProvider.Google:
|
||||
integrationTokenKeyInUrl = 'key'
|
||||
break
|
||||
case CloudProvider.OneDrive:
|
||||
integrationTokenKeyInUrl = 'key'
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid Cloud Provider name')
|
||||
}
|
||||
return urlSearchParams.get(integrationTokenKeyInUrl)
|
||||
}
|
||||
|
||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
try {
|
||||
const decryptedCode = atob(confirmation)
|
||||
const urlFromDecryptedCode = new URL(decryptedCode)
|
||||
const cloudProviderToken = getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode)
|
||||
|
||||
if (!cloudProviderToken) {
|
||||
throw new Error()
|
||||
}
|
||||
await application.settings.updateSetting(backupTokenSettingName, cloudProviderToken)
|
||||
await application.settings.updateSetting(backupFrequencySettingName, defaultBackupFrequency)
|
||||
|
||||
setBackupFrequency(defaultBackupFrequency)
|
||||
|
||||
setAuthBegan(false)
|
||||
setSuccessfullyInstalled(true)
|
||||
setConfirmation('')
|
||||
|
||||
await application.alertService.alert(
|
||||
`${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`,
|
||||
)
|
||||
} catch (e) {
|
||||
await application.alertService.alert('Invalid code. Please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
setConfirmation((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const getIntegrationStatus = useCallback(async () => {
|
||||
if (!application.getUser()) {
|
||||
return
|
||||
}
|
||||
const frequency = await application.settings.getSetting(backupFrequencySettingName)
|
||||
setBackupFrequency(frequency)
|
||||
}, [application, backupFrequencySettingName])
|
||||
|
||||
useEffect(() => {
|
||||
getIntegrationStatus().catch(console.error)
|
||||
}, [getIntegrationStatus])
|
||||
|
||||
const isExpanded = authBegan || successfullyInstalled
|
||||
const shouldShowEnableButton = !backupFrequency && !authBegan
|
||||
const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${
|
||||
shouldShowEnableButton || backupFrequency ? 'flex justify-between items-center' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<Subtitle className={additionalClass}>{providerName}</Subtitle>
|
||||
|
||||
{successfullyInstalled && <p>{providerName} has been successfully enabled.</p>}
|
||||
</div>
|
||||
{authBegan && (
|
||||
<div>
|
||||
<p className="sk-panel-row">
|
||||
Complete authentication from the newly opened window. Upon completion, a confirmation
|
||||
code will be displayed. Enter this code below:
|
||||
</p>
|
||||
<div className={'mt-1'}>
|
||||
<input
|
||||
className="sk-input sk-base center-text"
|
||||
placeholder="Enter confirmation code"
|
||||
value={confirmation}
|
||||
onKeyPress={handleKeyPress}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowEnableButton && (
|
||||
<div>
|
||||
<Button
|
||||
variant="normal"
|
||||
label="Enable"
|
||||
className={`px-1 text-xs min-w-40 ${additionalClass}`}
|
||||
onClick={installIntegration}
|
||||
disabled={!isEntitledToCloudBackups}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupFrequency && (
|
||||
<div className={'flex flex-col items-end'}>
|
||||
<Button
|
||||
className={`min-w-40 mb-2 ${additionalClass}`}
|
||||
variant="normal"
|
||||
label="Perform Backup"
|
||||
onClick={performBackupNow}
|
||||
/>
|
||||
<Button className="min-w-40" variant="normal" label="Disable" onClick={disable} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { CloudBackupProvider } from './CloudBackupProvider'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import {
|
||||
FeatureStatus,
|
||||
FeatureIdentifier,
|
||||
CloudProvider,
|
||||
MuteFailedCloudBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { convertStringifiedBooleanToBoolean } from '@/Utils'
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Strings'
|
||||
|
||||
const providerData = [
|
||||
{ name: CloudProvider.Dropbox },
|
||||
{ name: CloudProvider.Google },
|
||||
{ name: CloudProvider.OneDrive },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
const [isEntitledToCloudBackups, setIsEntitledToCloudBackups] = useState(false)
|
||||
const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
|
||||
|
||||
const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
|
||||
if (!application.getUser()) {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const userSettings = await application.settings.listSettings()
|
||||
setIsFailedCloudBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings.getSettingValue(
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
MuteFailedCloudBackupsEmailsOption.NotMuted,
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [application])
|
||||
|
||||
useEffect(() => {
|
||||
const dailyDropboxBackupStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyDropboxBackup,
|
||||
)
|
||||
const dailyGdriveBackupStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyGDriveBackup,
|
||||
)
|
||||
const dailyOneDriveBackupStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyOneDriveBackup,
|
||||
)
|
||||
const isCloudBackupsAllowed = [
|
||||
dailyDropboxBackupStatus,
|
||||
dailyGdriveBackupStatus,
|
||||
dailyOneDriveBackupStatus,
|
||||
].every((status) => status === FeatureStatus.Entitled)
|
||||
|
||||
setIsEntitledToCloudBackups(isCloudBackupsAllowed)
|
||||
loadIsFailedCloudBackupEmailMutedSetting().catch(console.error)
|
||||
}, [application, loadIsFailedCloudBackupEmailMutedSetting])
|
||||
|
||||
const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
|
||||
try {
|
||||
await application.settings.updateSetting(settingName, payload)
|
||||
return true
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMuteFailedCloudBackupEmails = async () => {
|
||||
if (!isEntitledToCloudBackups) {
|
||||
return
|
||||
}
|
||||
const previousValue = isFailedCloudBackupEmailMuted
|
||||
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted)
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
`${!isFailedCloudBackupEmailMuted}`,
|
||||
)
|
||||
if (!updateResult) {
|
||||
setIsFailedCloudBackupEmailMuted(previousValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Cloud Backups</Title>
|
||||
{!isEntitledToCloudBackups && (
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is required to enable Cloud
|
||||
Backups.{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/features">
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
<HorizontalSeparator classes="mt-3 mb-3" />
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<Text className={additionalClass}>
|
||||
Configure the integrations below to enable automatic daily backups of your encrypted
|
||||
data set to your third-party cloud provider.
|
||||
</Text>
|
||||
<div>
|
||||
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
|
||||
<div>
|
||||
{providerData.map(({ name }) => (
|
||||
<>
|
||||
<CloudBackupProvider
|
||||
application={application}
|
||||
providerName={name}
|
||||
isEntitledToCloudBackups={isEntitledToCloudBackups}
|
||||
/>
|
||||
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={additionalClass}>
|
||||
<Subtitle>Email preferences</Subtitle>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className="flex flex-col">
|
||||
<Text>Receive a notification email if a cloud backup fails.</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleMuteFailedCloudBackupEmails}
|
||||
checked={!isFailedCloudBackupEmailMuted}
|
||||
disabled={!isEntitledToCloudBackups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
import { alertDialog } from '@/Services/AlertService'
|
||||
import {
|
||||
STRING_IMPORT_SUCCESS,
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
STRING_IMPORTING_ZIP_FILE,
|
||||
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
|
||||
StringImportError,
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
} from '@/Strings'
|
||||
import { BackupFile } from '@standardnotes/snjs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import TargetedEvent = JSXInternal.TargetedEvent
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PreferencesGroup, PreferencesSegment, Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isImportDataLoading, setIsImportDataLoading] = useState(false)
|
||||
const {
|
||||
isBackupEncrypted,
|
||||
isEncryptionEnabled,
|
||||
setIsBackupEncrypted,
|
||||
setIsEncryptionEnabled,
|
||||
setEncryptionStatusString,
|
||||
} = appState.accountMenu
|
||||
|
||||
const refreshEncryptionStatus = useCallback(() => {
|
||||
const hasUser = application.hasAccount()
|
||||
const hasPasscode = application.hasPasscode()
|
||||
|
||||
const encryptionEnabled = hasUser || hasPasscode
|
||||
|
||||
const encryptionStatusString = hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED
|
||||
|
||||
setEncryptionStatusString(encryptionStatusString)
|
||||
setIsEncryptionEnabled(encryptionEnabled)
|
||||
setIsBackupEncrypted(encryptionEnabled)
|
||||
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
refreshEncryptionStatus()
|
||||
}, [refreshEncryptionStatus])
|
||||
|
||||
const downloadDataArchive = () => {
|
||||
application.getArchiveService().downloadBackup(isBackupEncrypted).catch(console.error)
|
||||
}
|
||||
|
||||
const readFile = async (file: File): Promise<any> => {
|
||||
if (file.type === 'application/zip') {
|
||||
application.alertService.alert(STRING_IMPORTING_ZIP_FILE).catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string)
|
||||
resolve(data)
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_INVALID_IMPORT_FILE).catch(console.error)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
const performImport = async (data: BackupFile) => {
|
||||
setIsImportDataLoading(true)
|
||||
|
||||
const result = await application.mutator.importData(data)
|
||||
|
||||
setIsImportDataLoading(false)
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
let statusText = STRING_IMPORT_SUCCESS
|
||||
if ('error' in result) {
|
||||
statusText = result.error.text
|
||||
} else if (result.errorCount) {
|
||||
statusText = StringImportError(result.errorCount)
|
||||
}
|
||||
void alertDialog({
|
||||
text: statusText,
|
||||
})
|
||||
}
|
||||
|
||||
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
|
||||
const { files } = event.target as HTMLInputElement
|
||||
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const data = await readFile(file)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version
|
||||
if (!version) {
|
||||
await performImport(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (application.protocolService.supportedVersions().includes(version)) {
|
||||
await performImport(data)
|
||||
} else {
|
||||
setIsImportDataLoading(false)
|
||||
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION })
|
||||
}
|
||||
}
|
||||
|
||||
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
|
||||
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const { code } = event
|
||||
|
||||
// Process only when "Enter" or "Space" keys are pressed
|
||||
if (code !== 'Enter' && code !== 'Space') {
|
||||
return
|
||||
}
|
||||
// Don't proceed the event's default action
|
||||
// (like scrolling in case the "space" key is pressed)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
;(fileInputRef.current as HTMLInputElement).click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Data Backups</Title>
|
||||
|
||||
{!isDesktopApplication() && (
|
||||
<Text className="mb-3">
|
||||
Backups are automatically created on desktop and can be managed via the "Backups"
|
||||
top-level menu.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Subtitle>Download a backup of all your data</Subtitle>
|
||||
|
||||
{isEncryptionEnabled && (
|
||||
<form className="sk-panel-form sk-panel-row">
|
||||
<div className="sk-input-group">
|
||||
<label className="sk-horizontal-group tight">
|
||||
<input
|
||||
type="radio"
|
||||
onChange={() => setIsBackupEncrypted(true)}
|
||||
checked={isBackupEncrypted}
|
||||
/>
|
||||
<Subtitle>Encrypted</Subtitle>
|
||||
</label>
|
||||
<label className="sk-horizontal-group tight">
|
||||
<input
|
||||
type="radio"
|
||||
onChange={() => setIsBackupEncrypted(false)}
|
||||
checked={!isBackupEncrypted}
|
||||
/>
|
||||
<Subtitle>Decrypted</Subtitle>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={downloadDataArchive}
|
||||
label="Download backup"
|
||||
className="mt-2"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Import a previously saved backup file</Subtitle>
|
||||
|
||||
<div class="flex flex-row items-center mt-3">
|
||||
<Button variant="normal" label="Import backup" onClick={handleImportFile} />
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={importFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
{isImportDataLoading && <div className="sk-spinner normal info ml-4" />}
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/Utils'
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Strings'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PreferencesGroup, PreferencesSegment, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import {
|
||||
FeatureStatus,
|
||||
FeatureIdentifier,
|
||||
EmailBackupFrequency,
|
||||
MuteFailedBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const EmailBackups = observer(({ application }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>(
|
||||
EmailBackupFrequency.Disabled,
|
||||
)
|
||||
const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<DropdownItem[]>([])
|
||||
const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] = useState(true)
|
||||
const [isEntitledToEmailBackups, setIsEntitledToEmailBackups] = useState(false)
|
||||
|
||||
const loadEmailFrequencySetting = useCallback(async () => {
|
||||
if (!application.getUser()) {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const userSettings = await application.settings.listSettings()
|
||||
setEmailFrequency(
|
||||
userSettings.getSettingValue<EmailBackupFrequency>(
|
||||
SettingName.EmailBackupFrequency,
|
||||
EmailBackupFrequency.Disabled,
|
||||
),
|
||||
)
|
||||
setIsFailedBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings.getSettingValue<MuteFailedBackupsEmailsOption>(
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
MuteFailedBackupsEmailsOption.NotMuted,
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [application])
|
||||
|
||||
useEffect(() => {
|
||||
const emailBackupsFeatureStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyEmailBackup,
|
||||
)
|
||||
setIsEntitledToEmailBackups(emailBackupsFeatureStatus === FeatureStatus.Entitled)
|
||||
|
||||
const frequencyOptions = []
|
||||
for (const frequency in EmailBackupFrequency) {
|
||||
const frequencyValue = EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency]
|
||||
frequencyOptions.push({
|
||||
value: frequencyValue,
|
||||
label: application.settings.getEmailBackupFrequencyOptionLabel(frequencyValue),
|
||||
})
|
||||
}
|
||||
setEmailFrequencyOptions(frequencyOptions)
|
||||
|
||||
loadEmailFrequencySetting().catch(console.error)
|
||||
}, [application, loadEmailFrequencySetting])
|
||||
|
||||
const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
|
||||
try {
|
||||
await application.settings.updateSetting(settingName, payload, false)
|
||||
return true
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
|
||||
const previousFrequency = emailFrequency
|
||||
setEmailFrequency(frequency)
|
||||
|
||||
const updateResult = await updateSetting(SettingName.EmailBackupFrequency, frequency)
|
||||
if (!updateResult) {
|
||||
setEmailFrequency(previousFrequency)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMuteFailedBackupEmails = async () => {
|
||||
if (!isEntitledToEmailBackups) {
|
||||
return
|
||||
}
|
||||
const previousValue = isFailedBackupEmailMuted
|
||||
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted)
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
`${!isFailedBackupEmailMuted}`,
|
||||
)
|
||||
if (!updateResult) {
|
||||
setIsFailedBackupEmailMuted(previousValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailFrequencyChange = (item: string) => {
|
||||
if (!isEntitledToEmailBackups) {
|
||||
return
|
||||
}
|
||||
updateEmailFrequency(item as EmailBackupFrequency).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Email Backups</Title>
|
||||
{!isEntitledToEmailBackups && (
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is required to enable Email
|
||||
Backups.{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/features">
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
<HorizontalSeparator classes="mt-3 mb-3" />
|
||||
</>
|
||||
)}
|
||||
<div className={isEntitledToEmailBackups ? '' : 'faded cursor-default pointer-events-none'}>
|
||||
{!isDesktopApplication() && (
|
||||
<Text className="mb-3">
|
||||
Daily encrypted email backups of your entire data set delivered directly to your
|
||||
inbox.
|
||||
</Text>
|
||||
)}
|
||||
<Subtitle>Email frequency</Subtitle>
|
||||
<Text>How often to receive backups.</Text>
|
||||
<div className="mt-2">
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Dropdown
|
||||
id="def-editor-dropdown"
|
||||
label="Select email frequency"
|
||||
items={emailFrequencyOptions}
|
||||
value={emailFrequency}
|
||||
onChange={handleEmailFrequencyChange}
|
||||
disabled={!isEntitledToEmailBackups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-4" />
|
||||
<Subtitle>Email preferences</Subtitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Text>Receive a notification email if an email backup fails.</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleMuteFailedBackupEmails}
|
||||
checked={!isFailedBackupEmailMuted}
|
||||
disabled={!isEntitledToEmailBackups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { CloudLink } from './CloudBackups'
|
||||
import { DataBackups } from './DataBackups'
|
||||
import { EmailBackups } from './EmailBackups'
|
||||
|
||||
interface Props {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Backups: FunctionComponent<Props> = ({ application, appState }) => {
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<DataBackups application={application} appState={appState} />
|
||||
<EmailBackups application={application} />
|
||||
<CloudLink application={application} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import {
|
||||
Title,
|
||||
Subtitle,
|
||||
Text,
|
||||
LinkButton,
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
|
||||
export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Frequently asked questions</Title>
|
||||
<div className="h-2 w-full" />
|
||||
<Subtitle>Who can read my private notes?</Subtitle>
|
||||
<Text>
|
||||
Quite simply: no one but you. Not us, not your ISP, not a hacker, and not a government
|
||||
agency. As long as you keep your password safe, and your password is reasonably strong,
|
||||
then you are the only person in the world with the ability to decrypt your notes. For more
|
||||
on how we handle your privacy and security, check out our easy to read{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/privacy">
|
||||
Privacy Manifesto.
|
||||
</a>
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I collaborate with others on a note?</Subtitle>
|
||||
<Text>
|
||||
Because of our encrypted architecture, Standard Notes does not currently provide a
|
||||
real-time collaboration solution. Multiple users can share the same account however, but
|
||||
editing at the same time may result in sync conflicts, which may result in the duplication
|
||||
of notes.
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
|
||||
<Text>
|
||||
Standard Notes can be used totally offline without an account, and without an internet
|
||||
connection. You can find{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
|
||||
>
|
||||
more details here.
|
||||
</a>
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can’t find your question here?</Subtitle>
|
||||
<LinkButton className="mt-3" label="Open FAQ" link="https://standardnotes.com/help" />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Community forum</Title>
|
||||
<Text>
|
||||
If you have an issue, found a bug or want to suggest a feature, you can browse or post to
|
||||
the forum. It’s recommended for non-account related issues. Please read our{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/longevity/">
|
||||
Longevity statement
|
||||
</a>{' '}
|
||||
before advocating for a feature request.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
label="Go to the forum"
|
||||
link="https://forum.standardnotes.org/"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Community groups</Title>
|
||||
<Text>
|
||||
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your
|
||||
feedback with us? Join the Standard Notes community groups for discussions on security,
|
||||
themes, editors and more.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="https://standardnotes.com/slack"
|
||||
label="Join our Slack"
|
||||
/>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="https://standardnotes.com/discord"
|
||||
label="Join our Discord"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Account related issue?</Title>
|
||||
<Text>Send an email to help@standardnotes.com and we’ll sort it out.</Text>
|
||||
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DisplayStringForContentType, SNComponent } from '@standardnotes/snjs'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Title, Text, Subtitle, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
|
||||
|
||||
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.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 (
|
||||
<>
|
||||
<Subtitle>{field.label}</Subtitle>
|
||||
<Text className={'wrap'}>{field.value}</Text>
|
||||
<div className="min-h-2" />
|
||||
</>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="min-h-3" />
|
||||
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="normal"
|
||||
label="Cancel"
|
||||
onClick={() => callback(false)}
|
||||
/>
|
||||
|
||||
<div className="min-w-3" />
|
||||
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="normal"
|
||||
label="Install"
|
||||
onClick={() => callback(true)}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { SNComponent } from '@standardnotes/snjs'
|
||||
import { PreferencesSegment, SubtitleLight, Title } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { RenameExtension } from './RenameExtension'
|
||||
|
||||
const UseHosted: FunctionComponent<{
|
||||
offlineOnly: boolean
|
||||
toggleOfllineOnly: () => void
|
||||
}> = ({ offlineOnly, toggleOfllineOnly }) => (
|
||||
<div className="flex flex-row">
|
||||
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
|
||||
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface ExtensionItemProps {
|
||||
application: WebApplication
|
||||
extension: SNComponent
|
||||
first: boolean
|
||||
latestVersion: string | undefined
|
||||
uninstall: (extension: SNComponent) => void
|
||||
toggleActivate?: (extension: SNComponent) => void
|
||||
}
|
||||
|
||||
export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
|
||||
application,
|
||||
extension,
|
||||
first,
|
||||
uninstall,
|
||||
}) => {
|
||||
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false)
|
||||
const [extensionName, setExtensionName] = useState(extension.name)
|
||||
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly
|
||||
setOfflineOnly(newOfflineOnly)
|
||||
application.mutator
|
||||
.changeAndSaveItem(extension, (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.mutator
|
||||
.changeAndSaveItem(extension, (m: any) => {
|
||||
if (m.content == undefined) {
|
||||
m.content = {}
|
||||
}
|
||||
m.content.name = newName
|
||||
})
|
||||
.then((item) => {
|
||||
const component = item as SNComponent
|
||||
setExtensionName(component.name)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const localInstallable = extension.package_info.download_url
|
||||
const isThirParty = application.features.isThirdPartyFeature(extension.identifier)
|
||||
|
||||
return (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
{first && (
|
||||
<>
|
||||
<Title>Extensions</Title>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
|
||||
<div className="min-h-2" />
|
||||
|
||||
{isThirParty && localInstallable && (
|
||||
<UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />
|
||||
)}
|
||||
|
||||
<>
|
||||
<div className="min-h-2" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="normal"
|
||||
label="Uninstall"
|
||||
onClick={() => uninstall(extension)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PreferencesSegment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SNComponent, ClientDisplayableError, FeatureDescription } from '@standardnotes/snjs'
|
||||
import { makeAutoObservable, observable } from 'mobx'
|
||||
|
||||
export class ExtensionsLatestVersions {
|
||||
static async load(application: WebApplication): Promise<ExtensionsLatestVersions | undefined> {
|
||||
const response = await application.getAvailableSubscriptions()
|
||||
|
||||
if (response instanceof ClientDisplayableError) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const versionMap: Map<string, string> = new Map()
|
||||
collectFeatures(response.CORE_PLAN?.features as FeatureDescription[], versionMap)
|
||||
collectFeatures(response.PLUS_PLAN?.features as FeatureDescription[], versionMap)
|
||||
collectFeatures(response.PRO_PLAN?.features as FeatureDescription[], versionMap)
|
||||
|
||||
return new ExtensionsLatestVersions(versionMap)
|
||||
}
|
||||
|
||||
constructor(private readonly latestVersionsMap: Map<string, string>) {
|
||||
makeAutoObservable<ExtensionsLatestVersions, 'latestVersionsMap'>(this, {
|
||||
latestVersionsMap: observable.ref,
|
||||
})
|
||||
}
|
||||
|
||||
getVersion(extension: SNComponent): 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, feature.version as string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useState, useRef, useEffect } from 'preact/hooks'
|
||||
|
||||
export 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) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ButtonType, ContentType, SNComponent } from '@standardnotes/snjs'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Title, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ExtensionsLatestVersions } from './ExtensionsLatestVersions'
|
||||
import { ExtensionItem } from './ExtensionItem'
|
||||
import { ConfirmCustomExtension } from './ConfirmCustomExtension'
|
||||
|
||||
const loadExtensions = (application: WebApplication) =>
|
||||
application.items.getItems([
|
||||
ContentType.ActionsExtension,
|
||||
ContentType.Component,
|
||||
ContentType.Theme,
|
||||
]) as SNComponent[]
|
||||
|
||||
export const Extensions: FunctionComponent<{
|
||||
application: WebApplication
|
||||
extensionsLatestVersions: ExtensionsLatestVersions
|
||||
className?: string
|
||||
}> = observer(({ application, extensionsLatestVersions, className = '' }) => {
|
||||
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) => {
|
||||
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 SNComponent)
|
||||
application.sync.sync().catch(console.error)
|
||||
setExtensions(loadExtensions(application))
|
||||
}
|
||||
|
||||
const visibleExtensions = extensions.filter((extension) => {
|
||||
return extension.package_info != undefined && !['modal', 'rooms'].includes(extension.area)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{visibleExtensions.length > 0 && (
|
||||
<div>
|
||||
{visibleExtensions
|
||||
.sort((e1, e2) => e1.name?.toLowerCase().localeCompare(e2.name?.toLowerCase()))
|
||||
.map((extension, i) => (
|
||||
<ExtensionItem
|
||||
key={extension.uuid}
|
||||
application={application}
|
||||
extension={extension}
|
||||
latestVersion={extensionsLatestVersions.getVersion(extension)}
|
||||
first={i === 0}
|
||||
uninstall={uninstallExtension}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{!confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<Title>Install Custom Extension</Title>
|
||||
<DecoratedInput
|
||||
placeholder={'Enter Extension URL'}
|
||||
value={customUrl}
|
||||
onChange={(value) => {
|
||||
setCustomUrl(value)
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-2" />
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="normal"
|
||||
label="Install"
|
||||
onClick={() => submitExtensionUrl(customUrl)}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
{confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<ConfirmCustomExtension
|
||||
component={confirmableExtension}
|
||||
callback={handleConfirmExtensionSubmit}
|
||||
/>
|
||||
<div ref={confirmableEnd} />
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
|
||||
import {
|
||||
FeatureIdentifier,
|
||||
PrefKey,
|
||||
ComponentArea,
|
||||
ComponentMutator,
|
||||
SNComponent,
|
||||
} from '@standardnotes/snjs'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
type EditorOption = DropdownItem & {
|
||||
value: FeatureIdentifier | 'plain-editor'
|
||||
}
|
||||
|
||||
const makeEditorDefault = (
|
||||
application: WebApplication,
|
||||
component: SNComponent,
|
||||
currentDefault: SNComponent,
|
||||
) => {
|
||||
if (currentDefault) {
|
||||
removeEditorDefault(application, currentDefault)
|
||||
}
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.defaultEditor = true
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const removeEditorDefault = (application: WebApplication, component: SNComponent) => {
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.defaultEditor = false
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const getDefaultEditor = (application: WebApplication) => {
|
||||
return application.componentManager
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.filter((e) => e.isDefaultEditor())[0]
|
||||
}
|
||||
|
||||
export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
|
||||
const [defaultEditorValue, setDefaultEditorValue] = useState(
|
||||
() => getDefaultEditor(application)?.package_info?.identifier || 'plain-editor',
|
||||
)
|
||||
|
||||
const [spellcheck, setSpellcheck] = useState(() =>
|
||||
application.getPreference(PrefKey.EditorSpellcheck, true),
|
||||
)
|
||||
|
||||
const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() =>
|
||||
application.getPreference(PrefKey.NoteAddToParentFolders, true),
|
||||
)
|
||||
|
||||
const toggleSpellcheck = () => {
|
||||
setSpellcheck(!spellcheck)
|
||||
application.getAppState().toggleGlobalSpellcheck().catch(console.error)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const editors = application.componentManager
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.map((editor): EditorOption => {
|
||||
const identifier = editor.package_info.identifier
|
||||
const [iconType, tint] = application.iconsController.getIconAndTintForEditor(identifier)
|
||||
|
||||
return {
|
||||
label: editor.name,
|
||||
value: identifier,
|
||||
...(iconType ? { icon: iconType } : null),
|
||||
...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null),
|
||||
}
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
label: 'Plain Editor',
|
||||
value: 'plain-editor',
|
||||
},
|
||||
])
|
||||
.sort((a, b) => {
|
||||
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1
|
||||
})
|
||||
|
||||
setEditorItems(editors)
|
||||
}, [application])
|
||||
|
||||
const setDefaultEditor = (value: string) => {
|
||||
setDefaultEditorValue(value as FeatureIdentifier)
|
||||
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
|
||||
const currentDefault = getDefaultEditor(application)
|
||||
|
||||
if (value !== 'plain-editor') {
|
||||
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
|
||||
makeEditorDefault(application, editorComponent, currentDefault)
|
||||
} else {
|
||||
removeEditorDefault(application, currentDefault)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Defaults</Title>
|
||||
<div>
|
||||
<Subtitle>Default Editor</Subtitle>
|
||||
<Text>New notes will be created using this editor.</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="def-editor-dropdown"
|
||||
label="Select the default editor"
|
||||
items={editorItems}
|
||||
value={defaultEditorValue}
|
||||
onChange={setDefaultEditor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Spellcheck</Subtitle>
|
||||
<Text>
|
||||
The default spellcheck value for new notes. Spellcheck can be configured per note from
|
||||
the note context menu. Spellcheck may degrade overall typing performance with long
|
||||
notes.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Add all parent tags when adding a nested tag to a note</Subtitle>
|
||||
<Text>
|
||||
When enabled, adding a nested tag to a note will automatically add all associated
|
||||
parent tags.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={() => {
|
||||
application
|
||||
.setPreference(PrefKey.NoteAddToParentFolders, !addNoteToParentFolders)
|
||||
.catch(console.error)
|
||||
setAddNoteToParentFolders(!addNoteToParentFolders)
|
||||
}}
|
||||
checked={addNoteToParentFolders}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FeatureIdentifier, FeatureStatus, FindNativeFeature } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
|
||||
type ExperimentalFeatureItem = {
|
||||
identifier: FeatureIdentifier
|
||||
name: string
|
||||
description: string
|
||||
isEnabled: boolean
|
||||
isEntitled: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const LabsPane: FunctionComponent<Props> = ({ application }) => {
|
||||
const [experimentalFeatures, setExperimentalFeatures] = useState<ExperimentalFeatureItem[]>([])
|
||||
|
||||
const reloadExperimentalFeatures = useCallback(() => {
|
||||
const experimentalFeatures = application.features
|
||||
.getExperimentalFeatures()
|
||||
.map((featureIdentifier) => {
|
||||
const feature = FindNativeFeature(featureIdentifier)
|
||||
return {
|
||||
identifier: featureIdentifier,
|
||||
name: feature?.name ?? featureIdentifier,
|
||||
description: feature?.description ?? '',
|
||||
isEnabled: application.features.isExperimentalFeatureEnabled(featureIdentifier),
|
||||
isEntitled:
|
||||
application.features.getFeatureStatus(featureIdentifier) === FeatureStatus.Entitled,
|
||||
}
|
||||
})
|
||||
setExperimentalFeatures(experimentalFeatures)
|
||||
}, [application.features])
|
||||
|
||||
useEffect(() => {
|
||||
reloadExperimentalFeatures()
|
||||
}, [reloadExperimentalFeatures])
|
||||
|
||||
const premiumModal = usePremiumModal()
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Labs</Title>
|
||||
<div>
|
||||
{experimentalFeatures.map(
|
||||
({ identifier, name, description, isEnabled, isEntitled }, index: number) => {
|
||||
const toggleFeature = () => {
|
||||
if (!isEntitled) {
|
||||
premiumModal.activate(name)
|
||||
return
|
||||
}
|
||||
|
||||
application.features.toggleExperimentalFeature(identifier)
|
||||
reloadExperimentalFeatures()
|
||||
}
|
||||
|
||||
const showHorizontalSeparator =
|
||||
experimentalFeatures.length > 1 && index !== experimentalFeatures.length - 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>{name}</Subtitle>
|
||||
<Text>{description}</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleFeature} checked={isEnabled} />
|
||||
</div>
|
||||
{showHorizontalSeparator && <HorizontalSeparator classes="mt-5 mb-3" />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)}
|
||||
{experimentalFeatures.length === 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Text>No experimental features available.</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Tools: FunctionalComponent<Props> = observer(({ application }: Props) => {
|
||||
const [monospaceFont, setMonospaceFont] = useState(() =>
|
||||
application.getPreference(PrefKey.EditorMonospaceEnabled, true),
|
||||
)
|
||||
const [marginResizers, setMarginResizers] = useState(() =>
|
||||
application.getPreference(PrefKey.EditorResizersEnabled, true),
|
||||
)
|
||||
|
||||
const toggleMonospaceFont = () => {
|
||||
setMonospaceFont(!monospaceFont)
|
||||
application.setPreference(PrefKey.EditorMonospaceEnabled, !monospaceFont).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleMarginResizers = () => {
|
||||
setMarginResizers(!marginResizers)
|
||||
application.setPreference(PrefKey.EditorResizersEnabled, !marginResizers).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Tools</Title>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Monospace Font</Subtitle>
|
||||
<Text>Toggles the font style in the Plain Text editor.</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleMonospaceFont} checked={monospaceFont} />
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Margin Resizers</Subtitle>
|
||||
<Text>Allows left and right editor margins to be resized.</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleMarginResizers} checked={marginResizers} />
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user