refactor: format and lint codebase (#971)

This commit is contained in:
Aman Harwara
2022-04-13 22:02:34 +05:30
committed by GitHub
parent dc9c1ea0fc
commit 8e467f9e6d
367 changed files with 13778 additions and 16093 deletions

View 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 */
}
}

View File

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

View File

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

View 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}
/>
</>
)
},
)

View File

@@ -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">
Youre 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 &amp; 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>
</>
)
},
)

View 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}
/>
</>
)
},
)

View 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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'} />}
</>
)
},
)

View File

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

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

View File

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

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

View File

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

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

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

View 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 } : {})}
/>
)
}

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

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

View File

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

View File

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

View 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}
</>
)
},
)

View File

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>Cant 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. Its 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 well sort it out.</Text>
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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