refactor: replace 'preact' with 'react' (#1048)

This commit is contained in:
Aman Harwara
2022-05-30 12:42:52 +05:30
committed by GitHub
parent e74b4953ea
commit 8c368dd96b
231 changed files with 4794 additions and 4302 deletions

View File

@@ -22,14 +22,13 @@ declare global {
import { IsWebPlatform, WebAppVersion } from '@/Version' import { IsWebPlatform, WebAppVersion } from '@/Version'
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs' import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
import { render } from 'preact' import ApplicationGroupView from './Components/ApplicationGroupView/ApplicationGroupView'
import { ApplicationGroupView } from './Components/ApplicationGroupView/ApplicationGroupView'
import { WebDevice } from './Device/WebDevice' import { WebDevice } from './Device/WebDevice'
import { StartApplication } from './Device/StartApplication' import { StartApplication } from './Device/StartApplication'
import { ApplicationGroup } from './UIModels/ApplicationGroup' import { ApplicationGroup } from './UIModels/ApplicationGroup'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
import { WebApplication } from './UIModels/Application' import { WebApplication } from './UIModels/Application'
import { unmountComponentAtRoot } from './Utils/PreactUtils' import { createRoot } from 'react-dom/client'
let keyCount = 0 let keyCount = 0
const getKey = () => { const getKey = () => {
@@ -38,6 +37,11 @@ const getKey = () => {
const RootId = 'app-group-root' const RootId = 'app-group-root'
const rootElement = document.createElement('div')
rootElement.id = RootId
const appendedRootNode = document.body.appendChild(rootElement)
const root = createRoot(appendedRootNode)
const startApplication: StartApplication = async function startApplication( const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string, defaultSyncServerHost: string,
device: WebOrDesktopDevice, device: WebOrDesktopDevice,
@@ -48,19 +52,14 @@ const startApplication: StartApplication = async function startApplication(
SNLog.onError = console.error SNLog.onError = console.error
const onDestroy = () => { const onDestroy = () => {
const root = document.getElementById(RootId) as HTMLElement const rootElement = document.getElementById(RootId) as HTMLElement
unmountComponentAtRoot(root) root.unmount()
root.remove() rootElement.remove()
renderApp() renderApp()
} }
const renderApp = () => { const renderApp = () => {
const root = document.createElement('div') root.render(
root.id = RootId
const parentNode = document.body.appendChild(root)
render(
<ApplicationGroupView <ApplicationGroupView
key={getKey()} key={getKey()}
server={defaultSyncServerHost} server={defaultSyncServerHost}
@@ -69,7 +68,6 @@ const startApplication: StartApplication = async function startApplication(
websocketUrl={webSocketUrl} websocketUrl={webSocketUrl}
onDestroy={onDestroy} onDestroy={onDestroy}
/>, />,
parentNode,
) )
} }

View File

@@ -2,9 +2,7 @@ import { ApplicationEvent } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState, AppStateEvent } from '@/UIModels/AppState' import { AppState, AppStateEvent } from '@/UIModels/AppState'
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx' import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
import { Component } from 'preact' import { Component } from 'react'
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
export type PureComponentState = Partial<Record<string, any>> export type PureComponentState = Partial<Record<string, any>>
export type PureComponentProps = Partial<Record<string, any>> export type PureComponentProps = Partial<Record<string, any>>
@@ -36,20 +34,6 @@ export abstract class PureComponent<P = PureComponentProps, S = PureComponentSta
;(this.state as unknown) = undefined ;(this.state 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 { override componentWillUnmount(): void {
this.deinit() this.deinit()
} }
@@ -58,10 +42,6 @@ export abstract class PureComponent<P = PureComponentProps, S = PureComponentSta
return this.application.getAppState() return this.application.getAppState()
} }
protected getElement(): Element | null {
return findDOMNode(this)
}
autorun(view: (r: IReactionPublic) => void): void { autorun(view: (r: IReactionPublic) => void): void {
this.reactionDisposers.push(autorun(view)) this.reactionDisposers.push(autorun(view))
} }

View File

@@ -2,15 +2,10 @@ import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { useCallback, useRef, useState } from 'preact/hooks' import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react'
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' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AccountMenuPane } from './AccountMenuPane' import { AccountMenuPane } from './AccountMenuPane'
import MenuPaneSelector from './MenuPaneSelector'
type Props = { type Props = {
appState: AppState appState: AppState
@@ -19,114 +14,61 @@ type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
} }
type PaneSelectorProps = { const AccountMenu: FunctionComponent<Props> = ({ application, appState, onClickOutside, mainApplicationGroup }) => {
appState: AppState const { currentPane, shouldAnimateCloseMenu } = appState.accountMenu
application: WebApplication
mainApplicationGroup: ApplicationGroup const closeAccountMenu = useCallback(() => {
menuPane: AccountMenuPane appState.accountMenu.closeAccountMenu()
setMenuPane: (pane: AccountMenuPane) => void }, [appState])
closeMenu: () => void
const setCurrentPane = useCallback(
(pane: AccountMenuPane) => {
appState.accountMenu.setCurrentPane(pane)
},
[appState],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register)
} else {
setCurrentPane(AccountMenuPane.GeneralMenu)
}
break
}
},
[closeAccountMenu, currentPane, setCurrentPane],
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`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>
)
} }
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer( export default observer(AccountMenu)
({ 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, shouldAnimateCloseMenu } = appState.accountMenu
const closeAccountMenu = useCallback(() => {
appState.accountMenu.closeAccountMenu()
}, [appState])
const setCurrentPane = useCallback(
(pane: AccountMenuPane) => {
appState.accountMenu.setCurrentPane(pane)
},
[appState],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register)
} else {
setCurrentPane(AccountMenuPane.GeneralMenu)
}
break
}
},
[closeAccountMenu, currentPane, setCurrentPane],
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`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

@@ -1,11 +1,10 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Checkbox from '@/Components/Checkbox/Checkbox'
import { Checkbox } from '@/Components/Checkbox/Checkbox' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -15,170 +14,177 @@ type Props = {
onStrictSignInChange?: (isStrictSignIn: boolean) => void onStrictSignInChange?: (isStrictSignIn: boolean) => void
} }
export const AdvancedOptions: FunctionComponent<Props> = observer( const AdvancedOptions: FunctionComponent<Props> = ({
({ appState, application, disabled = false, onPrivateWorkspaceChange, onStrictSignInChange, children }) => { appState,
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu application,
const [showAdvanced, setShowAdvanced] = useState(false) disabled = false,
onPrivateWorkspaceChange,
onStrictSignInChange,
children,
}) => {
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu
const [showAdvanced, setShowAdvanced] = useState(false)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
const [privateWorkspaceName, setPrivateWorkspaceName] = useState('') const [privateWorkspaceName, setPrivateWorkspaceName] = useState('')
const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('') const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('')
const [isStrictSignin, setIsStrictSignin] = useState(false) const [isStrictSignin, setIsStrictSignin] = useState(false)
useEffect(() => { useEffect(() => {
const recomputePrivateWorkspaceIdentifier = async () => { const recomputePrivateWorkspaceIdentifier = async () => {
const identifier = await application.computePrivateWorkspaceIdentifier( const identifier = await application.computePrivateWorkspaceIdentifier(
privateWorkspaceName, privateWorkspaceName,
privateWorkspaceUserphrase, privateWorkspaceUserphrase,
) )
if (!identifier) { if (!identifier) {
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) { if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
application.alertService.alert('Unable to compute private workspace name.').catch(console.error) application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
}
return
} }
onPrivateWorkspaceChange?.(true, identifier) return
} }
onPrivateWorkspaceChange?.(true, identifier)
}
if (privateWorkspaceName && privateWorkspaceUserphrase) { if (privateWorkspaceName && privateWorkspaceUserphrase) {
recomputePrivateWorkspaceIdentifier().catch(console.error) recomputePrivateWorkspaceIdentifier().catch(console.error)
}
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
useEffect(() => {
onPrivateWorkspaceChange?.(isPrivateWorkspace)
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
const handleIsPrivateWorkspaceChange = useCallback(() => {
setIsPrivateWorkspace(!isPrivateWorkspace)
}, [isPrivateWorkspace])
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
setPrivateWorkspaceName(name)
}, [])
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
setPrivateWorkspaceUserphrase(userphrase)
}, [])
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (e.target instanceof HTMLInputElement) {
setEnableServerOption(e.target.checked)
} }
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange]) },
[setEnableServerOption],
)
useEffect(() => { const handleSyncServerChange = useCallback(
onPrivateWorkspaceChange?.(isPrivateWorkspace) (server: string) => {
}, [isPrivateWorkspace, onPrivateWorkspaceChange]) setServer(server)
application.setCustomHost(server).catch(console.error)
},
[application, setServer],
)
const handleIsPrivateWorkspaceChange = useCallback(() => { const handleStrictSigninChange = useCallback(() => {
setIsPrivateWorkspace(!isPrivateWorkspace) const newValue = !isStrictSignin
}, [isPrivateWorkspace]) setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue)
}, [isStrictSignin, onStrictSignInChange])
const handlePrivateWorkspaceNameChange = useCallback((name: string) => { const toggleShowAdvanced = useCallback(() => {
setPrivateWorkspaceName(name) setShowAdvanced(!showAdvanced)
}, []) }, [showAdvanced])
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => { return (
setPrivateWorkspaceUserphrase(userphrase) <>
}, []) <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-passive-1 ml-1" />
</div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
const handleServerOptionChange = useCallback( <div className="flex justify-between items-center mb-1">
(e: Event) => { <Checkbox
if (e.target instanceof HTMLInputElement) { name="private-workspace"
setEnableServerOption(e.target.checked) label="Private workspace"
} checked={isPrivateWorkspace}
}, disabled={disabled}
[setEnableServerOption], onChange={handleIsPrivateWorkspaceChange}
) />
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
const handleSyncServerChange = useCallback( <Icon type="info" className="color-neutral" />
(server: string) => { </a>
setServer(server)
application.setCustomHost(server).catch(console.error)
},
[application, setServer],
)
const handleStrictSigninChange = useCallback(() => {
const newValue = !isStrictSignin
setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue)
}, [isStrictSignin, onStrictSignInChange])
const toggleShowAdvanced = useCallback(() => {
setShowAdvanced(!showAdvanced)
}, [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-passive-1 ml-1" />
</div> </div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
{isPrivateWorkspace && (
<>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="server" className="color-neutral" />]}
type="text"
placeholder="Userphrase"
value={privateWorkspaceUserphrase}
onChange={handlePrivateWorkspaceUserphraseChange}
disabled={disabled}
/>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="folder" className="color-neutral" />]}
type="text"
placeholder="Name"
value={privateWorkspaceName}
onChange={handlePrivateWorkspaceNameChange}
disabled={disabled}
/>
</>
)}
{onStrictSignInChange && (
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<Checkbox <Checkbox
name="private-workspace" name="use-strict-signin"
label="Private workspace" label="Use strict sign-in"
checked={isPrivateWorkspace} checked={isStrictSignin}
disabled={disabled} disabled={disabled}
onChange={handleIsPrivateWorkspaceChange} onChange={handleStrictSigninChange}
/> />
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more"> <a
href="https://standardnotes.com/help/security"
target="_blank"
rel="noopener noreferrer"
title="Learn more"
>
<Icon type="info" className="color-neutral" /> <Icon type="info" className="color-neutral" />
</a> </a>
</div> </div>
)}
{isPrivateWorkspace && ( <Checkbox
<> name="custom-sync-server"
<DecoratedInput label="Custom sync server"
className={'mb-2'} checked={enableServerOption}
left={[<Icon type="server" className="color-neutral" />]} onChange={handleServerOptionChange}
type="text" disabled={disabled}
placeholder="Userphrase" />
value={privateWorkspaceUserphrase} <DecoratedInput
onChange={handlePrivateWorkspaceUserphraseChange} type="text"
disabled={disabled} left={[<Icon type="server" className="color-neutral" />]}
/> placeholder="https://api.standardnotes.com"
<DecoratedInput value={server}
className={'mb-2'} onChange={handleSyncServerChange}
left={[<Icon type="folder" className="color-neutral" />]} disabled={!enableServerOption && !disabled}
type="text" />
placeholder="Name" </div>
value={privateWorkspaceName} ) : null}
onChange={handlePrivateWorkspaceNameChange} </>
disabled={disabled} )
/> }
</>
)}
{onStrictSignInChange && ( export default observer(AdvancedOptions)
<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

@@ -2,14 +2,13 @@ import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from './AccountMenuPane' import { AccountMenuPane } from './AccountMenuPane'
import { Button } from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { Checkbox } from '@/Components/Checkbox/Checkbox' import Checkbox from '@/Components/Checkbox/Checkbox'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconButton } from '@/Components/Button/IconButton' import IconButton from '@/Components/Button/IconButton'
type Props = { type Props = {
appState: AppState appState: AppState
@@ -19,140 +18,140 @@ type Props = {
password: string password: string
} }
export const ConfirmPassword: FunctionComponent<Props> = observer( const ConfirmPassword: FunctionComponent<Props> = ({ application, appState, setMenuPane, email, password }) => {
({ application, appState, setMenuPane, email, password }) => { const { notesAndTagsCount } = appState.accountMenu
const { notesAndTagsCount } = appState.accountMenu const [confirmPassword, setConfirmPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [isRegistering, setIsRegistering] = useState(false)
const [isRegistering, setIsRegistering] = useState(false) const [isEphemeral, setIsEphemeral] = useState(false)
const [isEphemeral, setIsEphemeral] = useState(false) const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true) const [error, setError] = useState('')
const [error, setError] = useState('')
const passwordInputRef = useRef<HTMLInputElement>(null) const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
}, []) }, [])
const handlePasswordChange = useCallback((text: string) => { const handlePasswordChange = useCallback((text: string) => {
setConfirmPassword(text) setConfirmPassword(text)
}, []) }, [])
const handleEphemeralChange = useCallback(() => { const handleEphemeralChange = useCallback(() => {
setIsEphemeral(!isEphemeral) setIsEphemeral(!isEphemeral)
}, [isEphemeral]) }, [isEphemeral])
const handleShouldMergeChange = useCallback(() => { const handleShouldMergeChange = useCallback(() => {
setShouldMergeLocal(!shouldMergeLocal) setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal]) }, [shouldMergeLocal])
const handleConfirmFormSubmit = useCallback( const handleConfirmFormSubmit = useCallback(
(e: Event) => { (e) => {
e.preventDefault() e.preventDefault()
if (!password) { if (!password) {
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
return return
} }
if (password === confirmPassword) { if (password === confirmPassword) {
setIsRegistering(true) setIsRegistering(true)
application application
.register(email, password, isEphemeral, shouldMergeLocal) .register(email, password, isEphemeral, shouldMergeLocal)
.then((res) => { .then((res) => {
if (res.error) { if (res.error) {
throw new Error(res.error.message) throw new Error(res.error.message)
} }
appState.accountMenu.closeAccountMenu() appState.accountMenu.closeAccountMenu()
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu) appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
}) })
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)
setError(err.message) setError(err.message)
}) })
.finally(() => { .finally(() => {
setIsRegistering(false) setIsRegistering(false)
}) })
} else { } else {
setError(STRING_NON_MATCHING_PASSWORDS) setError(STRING_NON_MATCHING_PASSWORDS)
setConfirmPassword('') setConfirmPassword('')
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
} }
}, },
[appState, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal], [appState, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
) )
const handleKeyDown = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (error.length) { if (error.length) {
setError('') setError('')
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleConfirmFormSubmit(e) handleConfirmFormSubmit(e)
} }
}, },
[handleConfirmFormSubmit, error], [handleConfirmFormSubmit, error],
) )
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
setMenuPane(AccountMenuPane.Register) setMenuPane(AccountMenuPane.Register)
}, [setMenuPane]) }, [setMenuPane])
return ( return (
<> <>
<div className="flex items-center px-3 mt-1 mb-3"> <div className="flex items-center px-3 mt-1 mb-3">
<IconButton <IconButton
icon="arrow-left" icon="arrow-left"
title="Go back" title="Go back"
className="flex mr-2 color-neutral p-0" className="flex mr-2 color-neutral p-0"
onClick={handleGoBack} onClick={handleGoBack}
focusable={true} focusable={true}
disabled={isRegistering} disabled={isRegistering}
/> />
<div className="sn-account-menu-headline">Confirm password</div> <div className="sn-account-menu-headline">Confirm password</div>
</div> </div>
<div className="px-3 mb-3 text-sm"> <div className="px-3 mb-3 text-sm">
Because your notes are encrypted using your password,{' '} Because your notes are encrypted using your password,{' '}
<span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your <span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your
password, you will permanently lose access to your data. password, you will permanently lose access to your data.
</div> </div>
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1"> <form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
<DecoratedPasswordInput <DecoratedPasswordInput
className="mb-2" className="mb-2"
disabled={isRegistering} disabled={isRegistering}
left={[<Icon type="password" className="color-neutral" />]} left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange} onChange={handlePasswordChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Confirm password" placeholder="Confirm password"
ref={passwordInputRef} ref={passwordInputRef}
value={confirmPassword} value={confirmPassword}
/> />
{error ? <div className="color-danger my-2">{error}</div> : null} {error ? <div className="color-danger my-2">{error}</div> : null}
<Button <Button
className="btn-w-full mt-1 mb-3" className="btn-w-full mt-1 mb-3"
label={isRegistering ? 'Creating account...' : 'Create account & sign in'} label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
variant="primary" variant="primary"
onClick={handleConfirmFormSubmit} onClick={handleConfirmFormSubmit}
disabled={isRegistering} disabled={isRegistering}
/> />
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
onChange={handleEphemeralChange}
disabled={isRegistering}
/>
{notesAndTagsCount > 0 ? (
<Checkbox <Checkbox
name="is-ephemeral" name="should-merge-local"
label="Stay signed in" label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={!isEphemeral} checked={shouldMergeLocal}
onChange={handleEphemeralChange} onChange={handleShouldMergeChange}
disabled={isRegistering} disabled={isRegistering}
/> />
{notesAndTagsCount > 0 ? ( ) : null}
<Checkbox </form>
name="should-merge-local" </>
label={`Merge local data (${notesAndTagsCount} notes and tags)`} )
checked={shouldMergeLocal} }
onChange={handleShouldMergeChange}
disabled={isRegistering} export default observer(ConfirmPassword)
/>
) : null}
</form>
</>
)
},
)

View File

@@ -1,140 +1,147 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from './AccountMenuPane' import { AccountMenuPane } from './AccountMenuPane'
import { Button } from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconButton } from '@/Components/Button/IconButton' import IconButton from '@/Components/Button/IconButton'
import { AdvancedOptions } from './AdvancedOptions' import AdvancedOptions from './AdvancedOptions'
type Props = { type Props = {
appState: AppState appState: AppState
application: WebApplication application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
email: string email: string
setEmail: StateUpdater<string> setEmail: React.Dispatch<React.SetStateAction<string>>
password: string password: string
setPassword: StateUpdater<string> setPassword: React.Dispatch<React.SetStateAction<string>>
} }
export const CreateAccount: FunctionComponent<Props> = observer( const CreateAccount: FunctionComponent<Props> = ({
({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => { appState,
const emailInputRef = useRef<HTMLInputElement>(null) application,
const passwordInputRef = useRef<HTMLInputElement>(null) setMenuPane,
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) email,
setEmail,
password,
setPassword,
}) => {
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
useEffect(() => { useEffect(() => {
if (emailInputRef.current) { if (emailInputRef.current) {
emailInputRef.current?.focus()
}
}, [])
const handleEmailChange = useCallback(
(text: string) => {
setEmail(text)
},
[setEmail],
)
const handlePasswordChange = useCallback(
(text: string) => {
setPassword(text)
},
[setPassword],
)
const handleRegisterFormSubmit = useCallback(
(e) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef.current?.focus() emailInputRef.current?.focus()
return
} }
}, [])
const handleEmailChange = useCallback( if (!password || password.length === 0) {
(text: string) => { passwordInputRef.current?.focus()
setEmail(text) return
}, }
[setEmail],
)
const handlePasswordChange = useCallback( setEmail(email)
(text: string) => { setPassword(password)
setPassword(text) setMenuPane(AccountMenuPane.ConfirmPassword)
}, },
[setPassword], [email, password, setPassword, setMenuPane, setEmail],
) )
const handleRegisterFormSubmit = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: Event) => { (e) => {
e.preventDefault() if (e.key === 'Enter') {
handleRegisterFormSubmit(e)
}
},
[handleRegisterFormSubmit],
)
if (!email || email.length === 0) { const handleClose = useCallback(() => {
emailInputRef.current?.focus() setMenuPane(AccountMenuPane.GeneralMenu)
return setEmail('')
} setPassword('')
}, [setEmail, setMenuPane, setPassword])
if (!password || password.length === 0) { const onPrivateWorkspaceChange = useCallback(
passwordInputRef.current?.focus() (isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
return setIsPrivateWorkspace(isPrivateWorkspace)
} if (isPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
setEmail(email) return (
setPassword(password) <>
setMenuPane(AccountMenuPane.ConfirmPassword) <div className="flex items-center px-3 mt-1 mb-3">
}, <IconButton
[email, password, setPassword, setMenuPane, setEmail], icon="arrow-left"
) title="Go back"
className="flex mr-2 color-neutral p-0"
const handleKeyDown = useCallback( onClick={handleClose}
(e: KeyboardEvent) => { focusable={true}
if (e.key === 'Enter') {
handleRegisterFormSubmit(e)
}
},
[handleRegisterFormSubmit],
)
const handleClose = useCallback(() => {
setMenuPane(AccountMenuPane.GeneralMenu)
setEmail('')
setPassword('')
}, [setEmail, setMenuPane, setPassword])
const onPrivateWorkspaceChange = useCallback(
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
setIsPrivateWorkspace(isPrivateWorkspace)
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
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 p-0"
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={isPrivateWorkspace}
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}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
/> />
</> <div className="sn-account-menu-headline">Create account</div>
) </div>
}, <form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
) <DecoratedInput
className="mb-2"
disabled={isPrivateWorkspace}
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}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
/>
</>
)
}
export default observer(CreateAccount)

View File

@@ -1,17 +1,18 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { formatLastSyncDate } from '@/Components/Preferences/Panes/Account/Sync'
import { SyncQueueStrategy } from '@standardnotes/snjs' import { SyncQueueStrategy } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings' import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
import { useCallback, useMemo, useState } from 'preact/hooks' import { useCallback, useMemo, useState, FunctionComponent } from 'react'
import { AccountMenuPane } from './AccountMenuPane' import { AccountMenuPane } from './AccountMenuPane'
import { FunctionComponent } from 'preact' import Menu from '@/Components/Menu/Menu'
import { Menu } from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption' import { MenuItemType } from '@/Components/Menu/MenuItemType'
import WorkspaceSwitcherOption from './WorkspaceSwitcher/WorkspaceSwitcherOption'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
type Props = { type Props = {
appState: AppState appState: AppState
@@ -23,156 +24,162 @@ type Props = {
const iconClassName = 'color-neutral mr-2' const iconClassName = 'color-neutral mr-2'
export const GeneralAccountMenu: FunctionComponent<Props> = observer( const GeneralAccountMenu: FunctionComponent<Props> = ({
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => { application,
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false) appState,
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date)) setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
const doSynchronization = useCallback(async () => { const doSynchronization = useCallback(async () => {
setIsSyncingInProgress(true) setIsSyncingInProgress(true)
application.sync application.sync
.sync({ .sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew, queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true, checkIntegrity: true,
}) })
.then((res) => { .then((res) => {
if (res && (res as any).error) { if (res && (res as any).error) {
throw new Error() throw new Error()
} else { } else {
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date)) setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
} }
}) })
.catch(() => { .catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error) application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
}) })
.finally(() => { .finally(() => {
setIsSyncingInProgress(false) setIsSyncingInProgress(false)
}) })
}, [application]) }, [application])
const user = useMemo(() => application.getUser(), [application]) const user = useMemo(() => application.getUser(), [application])
const openPreferences = useCallback(() => { const openPreferences = useCallback(() => {
appState.accountMenu.closeAccountMenu() appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('account') appState.preferences.setCurrentPane('account')
appState.preferences.openPreferences() appState.preferences.openPreferences()
}, [appState]) }, [appState])
const openHelp = useCallback(() => { const openHelp = useCallback(() => {
appState.accountMenu.closeAccountMenu() appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('help-feedback') appState.preferences.setCurrentPane('help-feedback')
appState.preferences.openPreferences() appState.preferences.openPreferences()
}, [appState]) }, [appState])
const signOut = useCallback(() => { const signOut = useCallback(() => {
appState.accountMenu.setSigningOut(true) appState.accountMenu.setSigningOut(true)
}, [appState]) }, [appState])
const activateRegisterPane = useCallback(() => { const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register) setMenuPane(AccountMenuPane.Register)
}, [setMenuPane]) }, [setMenuPane])
const activateSignInPane = useCallback(() => { const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn) setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane]) }, [setMenuPane])
const CREATE_ACCOUNT_INDEX = 1 const CREATE_ACCOUNT_INDEX = 1
const SWITCHER_INDEX = 0 const SWITCHER_INDEX = 0
return ( return (
<> <>
<div className="flex items-center justify-between px-3 mt-1 mb-1"> <div className="flex items-center justify-between px-3 mt-1 mb-1">
<div className="sn-account-menu-headline">Account</div> <div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}> <div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-neutral" /> <Icon type="close" className="color-neutral" />
</div>
</div> </div>
{user ? ( </div>
<> {user ? (
<div className="px-3 mb-3 color-foreground text-sm"> <>
<div>You're signed in as:</div> <div className="px-3 mb-3 color-foreground text-sm">
<div className="my-0.5 font-bold wrap">{user.email}</div> <div>You're signed in as:</div>
<span className="color-neutral">{application.getHost()}</span> <div className="my-0.5 font-bold wrap">{user.email}</div>
</div> <span className="color-neutral">{application.getHost()}</span>
<div className="flex items-start justify-between px-3 mb-3"> </div>
{isSyncingInProgress ? ( <div className="flex items-start justify-between px-3 mb-3">
<div className="flex items-center color-info font-semibold"> {isSyncingInProgress ? (
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div> <div className="flex items-center color-info font-semibold">
Syncing... <div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
</div> Syncing...
) : (
<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-passive-1" onClick={doSynchronization}>
<Icon type="sync" />
</div> </div>
) : (
<div className="flex items-start">
<Icon type="check-circle" className="mr-2 success" />
<div>
<div className="font-semibold success">Last synced:</div>
<div className="color-text">{lastSyncDate}</div>
</div>
</div>
)}
<div className="flex cursor-pointer color-passive-1" onClick={doSynchronization}>
<Icon type="sync" />
</div> </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-passive-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={openPreferences}>
<Icon type="user" className={iconClassName} />
Account settings
</MenuItem>
) : ( ) : (
<> <>
<div className="px-3 mb-1"> <MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
<div className="mb-3 color-foreground"> <Icon type="user" className={iconClassName} />
Youre offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end Create free account
encryption. </MenuItem>
</div> <MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<div className="flex items-center color-passive-1"> <Icon type="signIn" className={iconClassName} />
<Icon type="cloud-off" className="mr-2" /> Sign in
<span className="font-semibold">Offline</span> </MenuItem>
</div>
</div>
</> </>
)} )}
<Menu <MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
isOpen={appState.accountMenu.show} <div className="flex items-center">
a11yLabel="General account menu" <Icon type="help" className={iconClassName} />
closeMenu={closeMenu} Help &amp; feedback
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX} </div>
> <span className="color-neutral">v{appState.version}</span>
<MenuItemSeparator /> </MenuItem>
<WorkspaceSwitcherOption mainApplicationGroup={mainApplicationGroup} appState={appState} /> {user ? (
<MenuItemSeparator /> <>
{user ? ( <MenuItemSeparator />
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}> <MenuItem type={MenuItemType.IconButton} onClick={signOut}>
<Icon type="user" className={iconClassName} /> <Icon type="signOut" className={iconClassName} />
Account settings Sign out workspace
</MenuItem> </MenuItem>
) : ( </>
<> ) : null}
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}> </Menu>
<Icon type="user" className={iconClassName} /> </>
Create free account )
</MenuItem> }
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<Icon type="signIn" className={iconClassName} /> export default observer(GeneralAccountMenu)
Sign in
</MenuItem>
</>
)}
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
<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={signOut}>
<Icon type="signOut" className={iconClassName} />
Sign out workspace
</MenuItem>
</>
) : null}
</Menu>
</>
)
},
)

View File

@@ -0,0 +1,70 @@
import { WebApplication } from '@/UIModels/Application'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import ConfirmPassword from './ConfirmPassword'
import CreateAccount from './CreateAccount'
import GeneralAccountMenu from './GeneralAccountMenu'
import SignInPane from './SignIn'
type Props = {
appState: AppState
application: WebApplication
mainApplicationGroup: ApplicationGroup
menuPane: AccountMenuPane
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const MenuPaneSelector: FunctionComponent<Props> = ({
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 default observer(MenuPaneSelector)

View File

@@ -2,16 +2,15 @@ import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { isDev } from '@/Utils' import { isDev } from '@/Utils'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import React, { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from './AccountMenuPane' import { AccountMenuPane } from './AccountMenuPane'
import { Button } from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { Checkbox } from '@/Components/Checkbox/Checkbox' import Checkbox from '@/Components/Checkbox/Checkbox'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconButton } from '@/Components/Button/IconButton' import IconButton from '@/Components/Button/IconButton'
import { AdvancedOptions } from './AdvancedOptions' import AdvancedOptions from './AdvancedOptions'
type Props = { type Props = {
appState: AppState appState: AppState
@@ -19,7 +18,7 @@ type Props = {
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
} }
export const SignInPane: FunctionComponent<Props> = observer(({ application, appState, setMenuPane }) => { const SignInPane: FunctionComponent<Props> = ({ application, appState, setMenuPane }) => {
const { notesAndTagsCount } = appState.accountMenu const { notesAndTagsCount } = appState.accountMenu
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -111,7 +110,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
) )
const handleSignInFormSubmit = useCallback( const handleSignInFormSubmit = useCallback(
(e: Event) => { (e: React.SyntheticEvent) => {
e.preventDefault() e.preventDefault()
if (!email || email.length === 0) { if (!email || email.length === 0) {
@@ -129,8 +128,8 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
[email, password, signIn], [email, password, signIn],
) )
const handleKeyDown = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSignInFormSubmit(e) handleSignInFormSubmit(e)
} }
@@ -210,4 +209,6 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
/> />
</> </>
) )
}) }
export default observer(SignInPane)

View File

@@ -8,7 +8,7 @@ type Props = {
application: WebApplication application: WebApplication
} }
const User = observer(({ appState, application }: Props) => { const User = ({ appState, application }: Props) => {
const { server } = appState.accountMenu const { server } = appState.accountMenu
const user = application.getUser() as UserType const user = application.getUser() as UserType
@@ -39,6 +39,6 @@ const User = observer(({ appState, application }: Props) => {
<div className="sk-panel-row" /> <div className="sk-panel-row" />
</div> </div>
) )
}) }
export default User export default observer(User)

View File

@@ -1,9 +1,17 @@
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { ApplicationDescriptor } from '@standardnotes/snjs' import { ApplicationDescriptor } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import {
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' FocusEventHandler,
FunctionComponent,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Props = { type Props = {
descriptor: ApplicationDescriptor descriptor: ApplicationDescriptor
@@ -13,7 +21,7 @@ type Props = {
hideOptions: boolean hideOptions: boolean
} }
export const WorkspaceMenuItem: FunctionComponent<Props> = ({ const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor, descriptor,
onClick, onClick,
onDelete, onDelete,
@@ -29,15 +37,15 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
} }
}, [isRenaming]) }, [isRenaming])
const handleInputKeyDown = useCallback((event: KeyboardEvent) => { const handleInputKeyDown: KeyboardEventHandler = useCallback((event) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur() inputRef.current?.blur()
} }
}, []) }, [])
const handleInputBlur = useCallback( const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(event: FocusEvent) => { (event) => {
const name = (event.target as HTMLInputElement).value const name = event.target.value
renameDescriptor(name) renameDescriptor(name)
setIsRenaming(false) setIsRenaming(false)
}, },
@@ -65,7 +73,8 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
)} )}
{descriptor.primary && !hideOptions && ( {descriptor.primary && !hideOptions && (
<div> <div>
<button <a
role="button"
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer" className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -73,8 +82,9 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
}} }}
> >
<Icon type="pencil" className="sn-icon--mid color-neutral" /> <Icon type="pencil" className="sn-icon--mid color-neutral" />
</button> </a>
<button <a
role="button"
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer" className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -82,10 +92,12 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
}} }}
> >
<Icon type="trash" className="sn-icon--mid color-danger" /> <Icon type="trash" className="sn-icon--mid color-danger" />
</button> </a>
</div> </div>
)} )}
</div> </div>
</MenuItem> </MenuItem>
) )
} }
export default WorkspaceMenuItem

View File

@@ -2,12 +2,13 @@ import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs' import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import Menu from '@/Components/Menu/Menu'
import { Menu } from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { WorkspaceMenuItem } from './WorkspaceMenuItem' import { MenuItemType } from '@/Components/Menu/MenuItemType'
import WorkspaceMenuItem from './WorkspaceMenuItem'
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
@@ -16,74 +17,79 @@ type Props = {
hideWorkspaceOptions?: boolean hideWorkspaceOptions?: boolean
} }
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer( const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
({ mainApplicationGroup, appState, isOpen, hideWorkspaceOptions = false }: Props) => { mainApplicationGroup,
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([]) appState,
isOpen,
hideWorkspaceOptions = false,
}: Props) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
useEffect(() => { useEffect(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors() const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors) setApplicationDescriptors(applicationDescriptors)
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => { const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
if (event === ApplicationGroupEvent.DescriptorsDataChanged) { if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
const applicationDescriptors = mainApplicationGroup.getDescriptors() const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors) setApplicationDescriptors(applicationDescriptors)
}
})
return () => {
removeAppGroupObserver()
} }
}, [mainApplicationGroup]) })
const signoutAll = useCallback(async () => { return () => {
const confirmed = await appState.application.alertService.confirm( removeAppGroupObserver()
'Are you sure you want to sign out of all workspaces on this device?', }
undefined, }, [mainApplicationGroup])
'Sign out all',
ButtonType.Danger,
)
if (!confirmed) {
return
}
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
}, [mainApplicationGroup, appState])
const destroyWorkspace = useCallback(() => { const signoutAll = useCallback(async () => {
appState.accountMenu.setSigningOut(true) const confirmed = await appState.application.alertService.confirm(
}, [appState]) 'Are you sure you want to sign out of all workspaces on this device?',
undefined,
return ( 'Sign out all',
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}> ButtonType.Danger,
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
key={descriptor.identifier}
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
{!hideWorkspaceOptions && (
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
<Icon type="signOut" className="color-neutral mr-2" />
Sign out all workspaces
</MenuItem>
)}
</Menu>
) )
}, if (!confirmed) {
) return
}
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
}, [mainApplicationGroup, appState])
const destroyWorkspace = useCallback(() => {
appState.accountMenu.setSigningOut(true)
}, [appState])
return (
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
key={descriptor.identifier}
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
{!hideWorkspaceOptions && (
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
<Icon type="signOut" className="color-neutral mr-2" />
Sign out all workspaces
</MenuItem>
)}
</Menu>
)
}
export default observer(WorkspaceSwitcherMenu)

View File

@@ -3,17 +3,16 @@ import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
appState: AppState appState: AppState
} }
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mainApplicationGroup, appState }) => { const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -64,4 +63,6 @@ export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mai
)} )}
</> </>
) )
}) }
export default observer(WorkspaceSwitcherOption)

View File

@@ -1,12 +1,12 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { Component } from 'preact' import { Component } from 'react'
import { ApplicationView } from '@/Components/ApplicationView/ApplicationView' import ApplicationView from '@/Components/ApplicationView/ApplicationView'
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice'
import { ApplicationGroupEvent, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs' import { ApplicationGroupEvent, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs'
import { unmountComponentAtNode, findDOMNode } from 'preact/compat'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { isDesktopApplication } from '@/Utils' import { isDesktopApplication } from '@/Utils'
import DeallocateHandler from '../DeallocateHandler/DeallocateHandler'
type Props = { type Props = {
server: string server: string
@@ -23,7 +23,7 @@ type State = {
deviceDestroyed?: boolean deviceDestroyed?: boolean
} }
export class ApplicationGroupView extends Component<Props, State> { class ApplicationGroupView extends Component<Props, State> {
applicationObserverRemover?: () => void applicationObserverRemover?: () => void
private group?: ApplicationGroup private group?: ApplicationGroup
private application?: WebApplication private application?: WebApplication
@@ -74,17 +74,15 @@ export class ApplicationGroupView extends Component<Props, State> {
const onDestroy = this.props.onDestroy const onDestroy = this.props.onDestroy
const node = findDOMNode(this) as Element
unmountComponentAtNode(node)
onDestroy() onDestroy()
} }
render() { override render() {
const renderDialog = (message: string) => { const renderDialog = (message: string) => {
return ( return (
<DialogOverlay className={'sn-component challenge-modal-overlay'}> <DialogOverlay className={'sn-component challenge-modal-overlay'}>
<DialogContent <DialogContent
aria-label="Switching workspace"
className={ className={
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main' 'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
} }
@@ -116,12 +114,16 @@ export class ApplicationGroupView extends Component<Props, State> {
return ( return (
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}> <div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
<ApplicationView <DeallocateHandler application={this.state.activeApplication}>
key={this.state.activeApplication.ephemeralIdentifier} <ApplicationView
mainApplicationGroup={this.group} key={this.state.activeApplication.ephemeralIdentifier}
application={this.state.activeApplication} mainApplicationGroup={this.group}
/> application={this.state.activeApplication}
/>
</DeallocateHandler>
</div> </div>
) )
} }
} }
export default ApplicationGroupView

View File

@@ -1,56 +1,44 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { getPlatformString, getWindowUrlParams } from '@/Utils' import { getPlatformString, getWindowUrlParams } from '@/Utils'
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState' import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState'
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs' import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs'
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants' import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
import { alertDialog } from '@/Services/AlertService' import { alertDialog } from '@/Services/AlertService'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { Navigation } from '@/Components/Navigation/Navigation' import Navigation from '@/Components/Navigation/Navigation'
import { NoteGroupView } from '@/Components/NoteGroupView/NoteGroupView' import NoteGroupView from '@/Components/NoteGroupView/NoteGroupView'
import { Footer } from '@/Components/Footer/Footer' import Footer from '@/Components/Footer/Footer'
import { SessionsModal } from '@/Components/SessionsModal/SessionsModal' import SessionsModal from '@/Components/SessionsModal/SessionsModal'
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper' import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrapper'
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal' import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
import { NotesContextMenu } from '@/Components/NotesContextMenu/NotesContextMenu' import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper' import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { render, FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { PermissionsModal } from '@/Components/PermissionsModal/PermissionsModal' import RevisionHistoryModalWrapper from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper' import PremiumModalProvider from '@/Hooks/usePremiumModal'
import { PremiumModalProvider } from '@/Hooks/usePremiumModal' import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal' import TagsContextMenuWrapper from '@/Components/Tags/TagContextMenu'
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
import { ToastContainer } from '@standardnotes/stylekit' import { ToastContainer } from '@standardnotes/stylekit'
import { FilePreviewModalWrapper } from '@/Components/Files/FilePreviewModal' import FilePreviewModalWrapper from '@/Components/Files/FilePreviewModal'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' import ContentListView from '@/Components/ContentListView/ContentListView'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState' import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu'
import { ContentListView } from '@/Components/ContentListView/ContentListView' import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper'
import { FileContextMenu } from '@/Components/FileContextMenu/FileContextMenu'
type Props = { type Props = {
application: WebApplication application: WebApplication
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
} }
export const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => { const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
const platformString = getPlatformString() const platformString = getPlatformString()
const [appClass, setAppClass] = useState('') const [appClass, setAppClass] = useState('')
const [launched, setLaunched] = useState(false) const [launched, setLaunched] = useState(false)
const [needsUnlock, setNeedsUnlock] = useState(true) const [needsUnlock, setNeedsUnlock] = useState(true)
const [challenges, setChallenges] = useState<Challenge[]>([]) const [challenges, setChallenges] = useState<Challenge[]>([])
const [dealloced, setDealloced] = useState(false)
const componentManager = application.componentManager
const appState = application.getAppState() const appState = application.getAppState()
useEffect(() => { useEffect(() => {
setDealloced(application.dealloced)
}, [application.dealloced])
useEffect(() => {
if (dealloced) {
return
}
const desktopService = application.getDesktopService() const desktopService = application.getDesktopService()
if (desktopService) { if (desktopService) {
@@ -70,7 +58,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
}) })
.catch(console.error) .catch(console.error)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [application, dealloced]) }, [application])
const removeChallenge = useCallback( const removeChallenge = useCallback(
(challenge: Challenge) => { (challenge: Challenge) => {
@@ -81,29 +69,9 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
[challenges], [challenges],
) )
const presentPermissionsDialog = useCallback(
(dialog: PermissionDialog) => {
render(
<PermissionsModal
application={application}
callback={dialog.callback}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>,
document.body.appendChild(document.createElement('div')),
)
},
[application],
)
const onAppStart = useCallback(() => { const onAppStart = useCallback(() => {
setNeedsUnlock(application.hasPasscode()) setNeedsUnlock(application.hasPasscode())
componentManager.presentPermissionsDialog = presentPermissionsDialog }, [application])
return () => {
;(componentManager.presentPermissionsDialog as unknown) = undefined
}
}, [application, componentManager, presentPermissionsDialog])
const handleDemoSignInFromParams = useCallback(() => { const handleDemoSignInFromParams = useCallback(() => {
const token = getWindowUrlParams().get('demo-token') const token = getWindowUrlParams().get('demo-token')
@@ -183,7 +151,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
<> <>
{challenges.map((challenge) => { {challenges.map((challenge) => {
return ( return (
<div className="sk-modal"> <div className="sk-modal" key={`${challenge.id}${application.ephemeralIdentifier}`}>
<ChallengeModal <ChallengeModal
key={`${challenge.id}${application.ephemeralIdentifier}`} key={`${challenge.id}${application.ephemeralIdentifier}`}
application={application} application={application}
@@ -199,10 +167,6 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
) )
}, [appState, challenges, mainApplicationGroup, removeChallenge, application]) }, [appState, challenges, mainApplicationGroup, removeChallenge, application])
if (dealloced || isStateDealloced(appState)) {
return null
}
if (!renderAppContents) { if (!renderAppContents) {
return renderChallenges() return renderChallenges()
} }
@@ -227,8 +191,8 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
<> <>
<NotesContextMenu application={application} appState={appState} /> <NotesContextMenu application={application} appState={appState} />
<TagsContextMenu appState={appState} /> <TagsContextMenuWrapper appState={appState} />
<FileContextMenu appState={appState} /> <FileContextMenuWrapper appState={appState} />
<PurchaseFlowWrapper application={application} appState={appState} /> <PurchaseFlowWrapper application={application} appState={appState} />
<ConfirmSignoutContainer <ConfirmSignoutContainer
applicationGroup={mainApplicationGroup} applicationGroup={mainApplicationGroup}
@@ -237,8 +201,11 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
/> />
<ToastContainer /> <ToastContainer />
<FilePreviewModalWrapper application={application} appState={appState} /> <FilePreviewModalWrapper application={application} appState={appState} />
<PermissionsModalWrapper application={application} />
</> </>
</div> </div>
</PremiumModalProvider> </PremiumModalProvider>
) )
} }
export default ApplicationView

View File

@@ -4,20 +4,18 @@ import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { ChallengeReason, ContentType, FileItem, SNNote } from '@standardnotes/snjs' import { ChallengeReason, ContentType, FileItem, SNNote } from '@standardnotes/snjs'
import { confirmDialog } from '@/Services/AlertService' import { confirmDialog } from '@/Services/AlertService'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit' import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
import { StreamingFileReader } from '@standardnotes/filepicker' import { StreamingFileReader } from '@standardnotes/filepicker'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { AttachedFilesPopover } from './AttachedFilesPopover' import AttachedFilesPopover from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs' import { PopoverTabs } from './PopoverTabs'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck' import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -25,128 +23,109 @@ type Props = {
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const AttachedFilesButton: FunctionComponent<Props> = observer( const AttachedFilesButton: FunctionComponent<Props> = ({ application, appState, onClickPreprocessing }: Props) => {
({ application, appState, onClickPreprocessing }: Props) => { const premiumModal = usePremiumModal()
if (isStateDealloced(appState)) { const note: SNNote | undefined = appState.notes.firstSelectedNote
return null
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)
useEffect(() => {
if (appState.filePreviewModal.isOpen) {
keepMenuOpen(true)
} else {
keepMenuOpen(false)
} }
}, [appState.filePreviewModal.isOpen, keepMenuOpen])
const premiumModal = usePremiumModal() const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
const note: SNNote | undefined = appState.notes.firstSelectedNote const [allFiles, setAllFiles] = useState<FileItem[]>([])
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
const attachedFilesCount = attachedFiles.length
const [open, setOpen] = useState(false) useEffect(() => {
const [position, setPosition] = useState({ const unregisterFileStream = application.streamItems(ContentType.File, () => {
top: 0, setAllFiles(application.items.getDisplayableFiles())
right: 0, if (note) {
setAttachedFiles(application.items.getFilesForNote(note))
}
}) })
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)
useEffect(() => { return () => {
if (appState.filePreviewModal.isOpen) { unregisterFileStream()
keepMenuOpen(true) }
} else { }, [application, note])
keepMenuOpen(false)
const toggleAttachedFilesMenu = useCallback(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)
} }
}, [appState.filePreviewModal.isOpen, keepMenuOpen])
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles) setPosition({
const [allFiles, setAllFiles] = useState<FileItem[]>([]) top: rect.bottom,
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([]) right: document.body.clientWidth - rect.right,
const attachedFilesCount = attachedFiles.length
useEffect(() => {
const unregisterFileStream = application.streamItems(ContentType.File, () => {
setAllFiles(application.items.getDisplayableFiles())
if (note) {
setAttachedFiles(application.items.getFilesForNote(note))
}
}) })
return () => { const newOpenState = !open
unregisterFileStream() if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
} }
}, [application, note])
const toggleAttachedFilesMenu = useCallback(async () => { setOpen(newOpenState)
const rect = buttonRef.current?.getBoundingClientRect() }
if (rect) { }, [onClickPreprocessing, open])
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) { const prospectivelyShowFilesPremiumModal = useCallback(() => {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) if (!appState.features.hasFiles) {
} premiumModal.activate('Files')
}
}, [appState.features.hasFiles, premiumModal])
setPosition({ const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
top: rect.bottom, prospectivelyShowFilesPremiumModal()
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open await toggleAttachedFilesMenu()
if (newOpenState && onClickPreprocessing) { }, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
await onClickPreprocessing()
}
setOpen(newOpenState) const deleteFile = async (file: FileItem) => {
} const shouldDelete = await confirmDialog({
}, [onClickPreprocessing, open]) text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
const prospectivelyShowFilesPremiumModal = useCallback(() => { })
if (!appState.features.hasFiles) { if (shouldDelete) {
premiumModal.activate('Files') const deletingToastId = addToast({
} type: ToastType.Loading,
}, [appState.features.hasFiles, premiumModal]) message: `Deleting file "${file.name}"...`,
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
prospectivelyShowFilesPremiumModal()
await toggleAttachedFilesMenu()
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
const deleteFile = async (file: FileItem) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
}) })
if (shouldDelete) { await application.files.deleteFile(file)
const deletingToastId = addToast({ addToast({
type: ToastType.Loading, type: ToastType.Success,
message: `Deleting file "${file.name}"...`, message: `Deleted file "${file.name}"`,
}) })
await application.files.deleteFile(file) dismissToast(deletingToastId)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
})
dismissToast(deletingToastId)
}
} }
}
const downloadFile = async (file: FileItem) => { const downloadFile = async (file: FileItem) => {
appState.files.downloadFile(file).catch(console.error) appState.files.downloadFile(file).catch(console.error)
} }
const attachFileToNote = useCallback( const attachFileToNote = useCallback(
async (file: FileItem) => { async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: FileItem) => {
if (!note) { if (!note) {
addToast({ addToast({
type: ToastType.Error, type: ToastType.Error,
@@ -154,268 +133,283 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
}) })
return return
} }
await application.items.disassociateFileWithNote(file, note)
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.disassociateFileWithNote(file, note)
}
const toggleFileProtection = async (file: FileItem) => {
let result: FileItem | 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: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
const renameFile = async (file: FileItem, 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()
} }
const toggleFileProtection = async (file: FileItem) => { if (!isAuthorizedForAction) {
let result: FileItem | undefined return false
if (file.protected) { }
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
case PopoverFileItemActionType.PreviewFile: {
keepMenuOpen(true) keepMenuOpen(true)
result = await application.mutator.unprotectFile(file) const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
keepMenuOpen(false) appState.filePreviewModal.activate(
buttonRef.current?.focus() file,
} else { otherFiles.filter((file) => !file.protected),
result = await application.mutator.protectFile(file) )
break
} }
const isProtected = result ? result.protected : file.protected
return isProtected
} }
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => { if (
const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason) action.type !== PopoverFileItemActionType.DownloadFile &&
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file) action.type !== PopoverFileItemActionType.PreviewFile
return isAuthorized ) {
application.sync.sync().catch(console.error)
} }
const renameFile = async (file: FileItem, fileName: string) => { return true
await application.items.renameFile(file, fileName) }
}
const handleFileAction = async (action: PopoverFileItemAction) => { const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file const dragCounter = useRef(0)
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
case PopoverFileItemActionType.PreviewFile: {
keepMenuOpen(true)
const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
appState.filePreviewModal.activate(
file,
otherFiles.filter((file) => !file.protected),
)
break
}
}
if (
action.type !== PopoverFileItemActionType.DownloadFile &&
action.type !== PopoverFileItemActionType.PreviewFile
) {
application.sync.sync().catch(console.error)
}
return true
}
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const dragCounter = useRef(0)
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
event.preventDefault()
event.stopPropagation()
}
},
[application],
)
const handleDragIn = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}
},
[application],
)
switch ((event.target as HTMLElement).id) { const handleDragIn = useCallback(
case PopoverTabs.AllFiles: (event: DragEvent) => {
setCurrentTab(PopoverTabs.AllFiles) if (!isHandlingFileDrag(event, application)) {
break return
case PopoverTabs.AttachedFiles: }
setCurrentTab(PopoverTabs.AttachedFiles)
break event.preventDefault()
event.stopPropagation()
switch ((event.target as HTMLElement).id) {
case PopoverTabs.AllFiles:
setCurrentTab(PopoverTabs.AllFiles)
break
case PopoverTabs.AttachedFiles:
setCurrentTab(PopoverTabs.AttachedFiles)
break
}
dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true)
if (!open) {
toggleAttachedFilesMenu().catch(console.error)
} }
}
},
[open, toggleAttachedFilesMenu, application],
)
dragCounter.current = dragCounter.current + 1 const handleDragOut = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
if (event.dataTransfer?.items.length) { event.preventDefault()
setIsDraggingFiles(true) event.stopPropagation()
if (!open) {
toggleAttachedFilesMenu().catch(console.error) dragCounter.current = dragCounter.current - 1
if (dragCounter.current > 0) {
return
}
setIsDraggingFiles(false)
},
[application],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
if (!appState.features.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
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
} }
}
},
[open, toggleAttachedFilesMenu, application],
)
const handleDragOut = useCallback( const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault() if (!uploadedFiles) {
event.stopPropagation() return
}
dragCounter.current = dragCounter.current - 1 if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
})
if (dragCounter.current > 0) { event.dataTransfer.clearData()
return dragCounter.current = 0
}
setIsDraggingFiles(false)
},
[application],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
if (!appState.features.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
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,
appState.features.hasFiles,
attachFileToNote,
currentTab,
application,
prospectivelyShowFilesPremiumModal,
],
)
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, handleDrag, handleDragOut]) },
[
appState.files,
appState.features.hasFiles,
attachFileToNote,
currentTab,
application,
prospectivelyShowFilesPremiumModal,
],
)
return ( useEffect(() => {
<div ref={containerRef}> window.addEventListener('dragenter', handleDragIn)
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}> window.addEventListener('dragleave', handleDragOut)
<DisclosureButton window.addEventListener('dragover', handleDrag)
onKeyDown={(event) => { window.addEventListener('drop', handleDrop)
if (event.key === 'Escape') {
setOpen(false) return () => {
} window.removeEventListener('dragenter', handleDragIn)
}} window.removeEventListener('dragleave', handleDragOut)
ref={buttonRef} window.removeEventListener('dragover', handleDrag)
className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`} window.removeEventListener('drop', handleDrop)
onBlur={closeOnBlur} }
> }, [handleDragIn, handleDrop, handleDrag, handleDragOut])
<VisuallyHidden>Attached files</VisuallyHidden>
<Icon type="attachment-file" className="block" /> return (
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>} <div ref={containerRef}>
</DisclosureButton> <Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
<DisclosurePanel <DisclosureButton
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false) setOpen(false)
buttonRef.current?.focus() }
} }}
}} ref={buttonRef}
ref={panelRef} className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`}
style={{ onBlur={closeOnBlur}
...position, >
maxHeight, <VisuallyHidden>Attached files</VisuallyHidden>
}} <Icon type="attachment-file" className="block" />
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed" {attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
onBlur={closeOnBlur} </DisclosureButton>
> <DisclosurePanel
{open && ( onKeyDown={(event) => {
<AttachedFilesPopover if (event.key === 'Escape') {
application={application} setOpen(false)
appState={appState} buttonRef.current?.focus()
attachedFiles={attachedFiles} }
allFiles={allFiles} }}
closeOnBlur={closeOnBlur} ref={panelRef}
currentTab={currentTab} style={{
handleFileAction={handleFileAction} ...position,
isDraggingFiles={isDraggingFiles} maxHeight,
setCurrentTab={setCurrentTab} }}
/> className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
)} onBlur={closeOnBlur}
</DisclosurePanel> >
</Disclosure> {open && (
</div> <AttachedFilesPopover
) application={application}
}, appState={appState}
) attachedFiles={attachedFiles}
allFiles={allFiles}
closeOnBlur={closeOnBlur}
currentTab={currentTab}
handleFileAction={handleFileAction}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
}
export default observer(AttachedFilesButton)

View File

@@ -4,11 +4,10 @@ import { AppState } from '@/UIModels/AppState'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FilesIllustration } from '@standardnotes/icons' import { FilesIllustration } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { Dispatch, FunctionComponent, SetStateAction, useRef, useState } from 'react'
import { StateUpdater, useRef, useState } from 'preact/hooks' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import PopoverFileItem from './PopoverFileItem'
import { PopoverFileItem } from './PopoverFileItem'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverTabs } from './PopoverTabs' import { PopoverTabs } from './PopoverTabs'
@@ -21,153 +20,153 @@ type Props = {
currentTab: PopoverTabs currentTab: PopoverTabs
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean> handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
isDraggingFiles: boolean isDraggingFiles: boolean
setCurrentTab: StateUpdater<PopoverTabs> setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
} }
export const AttachedFilesPopover: FunctionComponent<Props> = observer( const AttachedFilesPopover: FunctionComponent<Props> = ({
({ application,
application, appState,
appState, allFiles,
allFiles, attachedFiles,
attachedFiles, closeOnBlur,
closeOnBlur, currentTab,
currentTab, handleFileAction,
handleFileAction, isDraggingFiles,
isDraggingFiles, setCurrentTab,
setCurrentTab, }) => {
}) => { const [searchQuery, setSearchQuery] = useState('')
const [searchQuery, setSearchQuery] = useState('') const searchInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
const filteredList = const filteredList =
searchQuery.length > 0 searchQuery.length > 0
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1) ? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
: filesList : filesList
const handleAttachFilesClick = async () => { const handleAttachFilesClick = async () => {
const uploadedFiles = await appState.files.uploadNewFile() const uploadedFiles = await appState.files.uploadNewFile()
if (!uploadedFiles) { if (!uploadedFiles) {
return return
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
}).catch(console.error)
})
}
} }
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
}).catch(console.error)
})
}
}
return ( return (
<div <div
className="flex flex-col" className="flex flex-col"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
style={{ style={{
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '', border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
}} }}
> >
<div className="flex border-0 border-b-1 border-solid border-main"> <div className="flex border-0 border-b-1 border-solid border-main">
<button <button
id={PopoverTabs.AttachedFiles} id={PopoverTabs.AttachedFiles}
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${ 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' currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles) setCurrentTab(PopoverTabs.AttachedFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
Attached Attached
</button> </button>
<button <button
id={PopoverTabs.AllFiles} id={PopoverTabs.AllFiles}
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${ 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' currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AllFiles) setCurrentTab(PopoverTabs.AllFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
All files All files
</button> </button>
</div> </div>
<div className="min-h-0 max-h-110 overflow-y-auto"> <div className="min-h-0 max-h-110 overflow-y-auto">
{filteredList.length > 0 || searchQuery.length > 0 ? ( {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="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main" className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
placeholder="Search files..." placeholder="Search files..."
value={searchQuery} value={searchQuery}
onInput={(e) => { onInput={(e) => {
setSearchQuery((e.target as HTMLInputElement).value) 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} onBlur={closeOnBlur}
ref={searchInputRef} >
/> <Icon type="clear-circle-filled" className="color-neutral" />
{searchQuery.length > 0 && ( </button>
<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> </div>
) : null} </div>
{filteredList.length > 0 ? ( ) : null}
filteredList.map((file: FileItem) => { {filteredList.length > 0 ? (
return ( filteredList.map((file: FileItem) => {
<PopoverFileItem return (
key={file.uuid} <PopoverFileItem
file={file} key={file.uuid}
isAttachedToNote={attachedFiles.includes(file)} file={file}
handleFileAction={handleFileAction} isAttachedToNote={attachedFiles.includes(file)}
getIconType={application.iconsController.getIconForFileType} handleFileAction={handleFileAction}
closeOnBlur={closeOnBlur} 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"> <div className="flex flex-col items-center justify-center w-full py-8">
<FilesIllustration /> <div className="w-18 h-18 mb-2">
</div> <FilesIllustration />
<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-passive-0 mt-3">Or drop your files here</div>
</div> </div>
)} <div className="text-sm font-medium mb-3">
</div> {searchQuery.length > 0
{filteredList.length > 0 && ( ? 'No result found'
<button : currentTab === PopoverTabs.AttachedFiles
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop" ? 'No files attached to this note'
onClick={handleAttachFilesClick} : 'No files found in this account'}
onBlur={closeOnBlur} </div>
> <Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
<Icon type="add" className="mr-2 color-neutral" /> {currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files </Button>
</button> <div className="text-xs color-passive-0 mt-3">Or drop your files here</div>
</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>
)
}
export default observer(AttachedFilesPopover)

View File

@@ -1,28 +1,15 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { formatSizeToReadableString } from '@standardnotes/filepicker' import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { IconType, FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FormEventHandler, FunctionComponent, KeyboardEventHandler, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon, ICONS } from '@/Components/Icon/Icon' import { PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import PopoverFileSubmenu from './PopoverFileSubmenu'
import { PopoverFileSubmenu } from './PopoverFileSubmenu' import { getFileIconComponent } from './getFileIconComponent'
import { PopoverFileItemProps } from './PopoverFileItemProps'
export const getFileIconComponent = (iconType: string, className: string) => { const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />
}
export type PopoverFileItemProps = {
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file, file,
isAttachedToNote, isAttachedToNote,
handleFileAction, handleFileAction,
@@ -51,11 +38,11 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
setIsRenamingFile(false) setIsRenamingFile(false)
} }
const handleFileNameInput = (event: Event) => { const handleFileNameInput: FormEventHandler<HTMLInputElement> = (event) => {
setFileName((event.target as HTMLInputElement).value) setFileName((event.target as HTMLInputElement).value)
} }
const handleFileNameInputKeyDown = (event: KeyboardEvent) => { const handleFileNameInputKeyDown: KeyboardEventHandler = (event) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
itemRef.current?.focus() itemRef.current?.focus()
} }
@@ -115,3 +102,5 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
</div> </div>
) )
} }
export default PopoverFileItem

View File

@@ -0,0 +1,10 @@
import { IconType, FileItem } from '@standardnotes/snjs'
import { PopoverFileItemAction } from './PopoverFileItemAction'
export type PopoverFileItemProps = {
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}

View File

@@ -1,20 +1,19 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { FunctionComponent } from 'preact' import { Dispatch, FunctionComponent, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import Switch from '@/Components/Switch/Switch'
import { Switch } from '@/Components/Switch/Switch'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { PopoverFileItemProps } from './PopoverFileItem' import { PopoverFileItemProps } from './PopoverFileItemProps'
import { PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemActionType } from './PopoverFileItemAction'
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & { type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
setIsRenamingFile: StateUpdater<boolean> setIsRenamingFile: Dispatch<SetStateAction<boolean>>
previewHandler: () => void previewHandler: () => void
} }
export const PopoverFileSubmenu: FunctionComponent<Props> = ({ const PopoverFileSubmenu: FunctionComponent<Props> = ({
file, file,
isAttachedToNote, isAttachedToNote,
handleFileAction, handleFileAction,
@@ -197,3 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
</div> </div>
) )
} }
export default PopoverFileSubmenu

View File

@@ -0,0 +1,7 @@
import { ICONS } from '@/Components/Icon/Icon'
export const getFileIconComponent = (iconType: string, className: string) => {
const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />
}

View File

@@ -1,4 +1,6 @@
interface BubbleProperties { import { FunctionComponent } from 'react'
type Props = {
label: string label: string
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
@@ -10,7 +12,7 @@ const styles = {
selected: 'border-info bg-info color-neutral-contrast', selected: 'border-info bg-info color-neutral-contrast',
} }
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => ( const Bubble: FunctionComponent<Props> = ({ label, selected, onSelect }) => (
<span <span
role="tab" role="tab"
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`} className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}

View File

@@ -1,6 +1,4 @@
import { JSXInternal } from 'preact/src/jsx' import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
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' const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
@@ -32,15 +30,15 @@ const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}` return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
} }
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & { interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
children?: ComponentChildren children?: ReactNode
className?: string className?: string
variant?: ButtonVariant variant?: ButtonVariant
dangerStyle?: boolean dangerStyle?: boolean
label?: string label?: string
} }
export const Button: FunctionComponent<ButtonProps> = forwardRef( const Button = forwardRef(
( (
{ {
variant = 'normal', variant = 'normal',
@@ -66,3 +64,5 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
) )
}, },
) )
export default Button

View File

@@ -1,34 +1,18 @@
import { FunctionComponent } from 'preact' import { FunctionComponent, MouseEventHandler } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
interface Props { type Props = {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void onClick: () => void
className?: string className?: string
icon: IconType icon: IconType
iconClassName?: string iconClassName?: string
/**
* Button tooltip
*/
title: string title: string
focusable: boolean focusable: boolean
disabled?: boolean disabled?: boolean
} }
/** const IconButton: FunctionComponent<Props> = ({
* IconButton component with an icon
* preventDefault is already handled within the component
*/
export const IconButton: FunctionComponent<Props> = ({
onClick, onClick,
className = '', className = '',
icon, icon,
@@ -37,7 +21,7 @@ export const IconButton: FunctionComponent<Props> = ({
iconClassName = '', iconClassName = '',
disabled = false, disabled = false,
}) => { }) => {
const click = (e: MouseEvent) => { const click: MouseEventHandler = (e) => {
e.preventDefault() e.preventDefault()
onClick() onClick()
} }
@@ -55,3 +39,5 @@ export const IconButton: FunctionComponent<Props> = ({
</button> </button>
) )
} }
export default IconButton

View File

@@ -1,28 +1,18 @@
import { FunctionComponent } from 'preact' import { FunctionComponent, MouseEventHandler } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
type ButtonType = 'normal' | 'primary' type ButtonType = 'normal' | 'primary'
interface Props { type Props = {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void onClick: () => void
type: ButtonType type: ButtonType
className?: string className?: string
icon: IconType icon: IconType
} }
/** const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
* IconButton component with an icon const click: MouseEventHandler = (e) => {
* preventDefault is already handled within the component
*/
export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
const click = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
onClick() onClick()
} }
@@ -33,3 +23,5 @@ export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, class
</button> </button>
) )
} }
export default RoundIconButton

View File

@@ -9,22 +9,14 @@ import {
removeFromArray, removeFromArray,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { ProtectedIllustration } from '@standardnotes/icons' import { ProtectedIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import ChallengeModalPrompt from './ChallengePrompt'
import { ChallengeModalPrompt } from './ChallengePrompt' import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
import { LockscreenWorkspaceSwitcher } from './LockscreenWorkspaceSwitcher'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { ChallengeModalValues } from './ChallengeModalValues'
type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
invalid: boolean
}
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -50,7 +42,7 @@ const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]
return undefined return undefined
} }
export const ChallengeModal: FunctionComponent<Props> = ({ const ChallengeModal: FunctionComponent<Props> = ({
application, application,
appState, appState,
mainApplicationGroup, mainApplicationGroup,
@@ -191,6 +183,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
key={challenge.id} key={challenge.id}
> >
<DialogContent <DialogContent
aria-label="Challenge modal"
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${ className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
challenge.reason !== ChallengeReason.ApplicationUnlock challenge.reason !== ChallengeReason.ApplicationUnlock
? 'shadow-overlay-light border-1 border-solid border-main' ? 'shadow-overlay-light border-1 border-solid border-main'
@@ -268,3 +261,5 @@ export const ChallengeModal: FunctionComponent<Props> = ({
</DialogOverlay> </DialogOverlay>
) )
} }
export default ChallengeModal

View File

@@ -0,0 +1,4 @@
import { ChallengePrompt } from '@standardnotes/snjs'
import { InputValue } from './InputValue'
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>

View File

@@ -1,9 +1,8 @@
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs' import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent, useEffect, useRef } from 'react'
import { useEffect, useRef } from 'preact/hooks' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import { ChallengeModalValues } from './ChallengeModalValues'
import { ChallengeModalValues } from './ChallengeModal'
type Props = { type Props = {
prompt: ChallengePrompt prompt: ChallengePrompt
@@ -13,7 +12,7 @@ type Props = {
isInvalid: boolean isInvalid: boolean
} }
export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => { const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@@ -38,6 +37,7 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
const selected = option.valueInSeconds === values[prompt.id].value const selected = option.valueInSeconds === values[prompt.id].value
return ( return (
<label <label
key={option.label}
className={`cursor-pointer px-2 py-1.5 rounded ${ className={`cursor-pointer px-2 py-1.5 rounded ${
selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3' selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3'
}`} }`}
@@ -80,3 +80,5 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
</div> </div>
) )
} }
export default ChallengeModalPrompt

View File

@@ -0,0 +1,7 @@
import { ChallengePrompt } from '@standardnotes/snjs'
export type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
invalid: boolean
}

View File

@@ -1,11 +1,10 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
import { WorkspaceSwitcherMenu } from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
type Props = { type Props = {
@@ -13,7 +12,7 @@ type Props = {
appState: AppState appState: AppState
} }
export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => { const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -65,3 +64,5 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainAppl
</div> </div>
) )
} }
export default LockscreenWorkspaceSwitcher

View File

@@ -4,12 +4,10 @@ import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useRef, useState } from 'react'
import { useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import ChangeEditorMenu from './ChangeEditorMenu'
import { ChangeEditorMenu } from './ChangeEditorMenu'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -17,98 +15,94 @@ type Props = {
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const ChangeEditorButton: FunctionComponent<Props> = observer( const ChangeEditorButton: FunctionComponent<Props> = ({ application, appState, onClickPreprocessing }: Props) => {
({ application, appState, onClickPreprocessing }: Props) => { const note = appState.notes.firstSelectedNote
if (isStateDealloced(appState)) { const [isOpen, setIsOpen] = useState(false)
return null 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 note = appState.notes.firstSelectedNote const toggleChangeEditorMenu = async () => {
const [isOpen, setIsOpen] = useState(false) const rect = buttonRef.current?.getBoundingClientRect()
const [isVisible, setIsVisible] = useState(false) if (rect) {
const [position, setPosition] = useState({ const { clientHeight } = document.documentElement
top: 0, const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
right: 0, const footerHeightInPx = footerElementRect?.height
})
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 () => { if (footerHeightInPx) {
const rect = buttonRef.current?.getBoundingClientRect() setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
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 ( setPosition({
<div ref={containerRef}> top: rect.bottom,
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}> right: document.body.clientWidth - rect.right,
<DisclosureButton })
onKeyDown={(event) => {
if (event.key === 'Escape') { 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 note type</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) setIsOpen(false)
} }}
}} />
onBlur={closeOnBlur} )}
ref={buttonRef} </DisclosurePanel>
className="sn-icon-button border-contrast" </Disclosure>
> </div>
<VisuallyHidden>Change note type</VisuallyHidden> )
<Icon type="dashboard" className="block" /> }
</DisclosureButton>
<DisclosurePanel export default observer(ChangeEditorButton)
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

@@ -1,6 +1,7 @@
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { Menu } from '@/Components/Menu/Menu' import Menu from '@/Components/Menu/Menu'
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
@@ -13,9 +14,9 @@ import {
SNNote, SNNote,
TransactionalMutation, TransactionalMutation,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact' import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { createEditorMenuGroups } from './createEditorMenuGroups' import { createEditorMenuGroups } from './createEditorMenuGroups'
import { PLAIN_EDITOR_NAME } from '@/Constants' import { PLAIN_EDITOR_NAME } from '@/Constants'
import { import {
@@ -34,7 +35,7 @@ type ChangeEditorMenuProps = {
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-') const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
application, application,
closeOnBlur, closeOnBlur,
closeMenu, closeMenu,
@@ -189,6 +190,7 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
} }
return ( return (
<MenuItem <MenuItem
key={item.name}
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={onClickEditorItem} onClick={onClickEditorItem}
className={ className={
@@ -214,3 +216,5 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
</Menu> </Menu>
) )
} }
export default ChangeEditorMenu

View File

@@ -8,7 +8,8 @@ import {
GetFeatures, GetFeatures,
NoteType, NoteType,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { PLAIN_EDITOR_NAME } from '@/Constants' import { PLAIN_EDITOR_NAME } from '@/Constants'
type EditorGroup = NoteType | 'plain' | 'others' type EditorGroup = NoteType | 'plain' | 'others'

View File

@@ -1,14 +1,14 @@
import { FunctionComponent } from 'preact' import { ChangeEventHandler, FunctionComponent } from 'react'
type CheckboxProps = { type CheckboxProps = {
name: string name: string
checked: boolean checked: boolean
onChange: (e: Event) => void onChange: ChangeEventHandler<HTMLInputElement>
disabled?: boolean disabled?: boolean
label: string label: string
} }
export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => { const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
return ( return (
<label htmlFor={name} className="flex items-center fit-content mb-2"> <label htmlFor={name} className="flex items-center fit-content mb-2">
<input <input
@@ -24,3 +24,5 @@ export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onCh
</label> </label>
) )
} }
export default Checkbox

View File

@@ -8,14 +8,13 @@ import {
ComponentViewerError, ComponentViewerError,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { FunctionalComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted' import OfflineRestricted from '@/Components/ComponentView/OfflineRestricted'
import { UrlMissing } from '@/Components/ComponentView/UrlMissing' import UrlMissing from '@/Components/ComponentView/UrlMissing'
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated' import IsDeprecated from '@/Components/ComponentView/IsDeprecated'
import { IsExpired } from '@/Components/ComponentView/IsExpired' import IsExpired from '@/Components/ComponentView/IsExpired'
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading' import IssueOnLoading from '@/Components/ComponentView/IssueOnLoading'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription' import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
@@ -35,187 +34,187 @@ const MaxLoadThreshold = 4000
const VisibilityChangeKey = 'visibilitychange' const VisibilityChangeKey = 'visibilitychange'
const MSToWaitAfterIframeLoadToAvoidFlicker = 35 const MSToWaitAfterIframeLoadToAvoidFlicker = 35
export const ComponentView: FunctionalComponent<IProps> = observer( const ComponentView: FunctionComponent<IProps> = ({ application, onLoad, componentViewer, requestReload }) => {
({ application, onLoad, componentViewer, requestReload }) => { const iframeRef = useRef<HTMLIFrameElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null) const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
const [hasIssueLoading, setHasIssueLoading] = useState(false) const [hasIssueLoading, setHasIssueLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus()) const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
const [isComponentValid, setIsComponentValid] = useState(true) const [isComponentValid, setIsComponentValid] = useState(true)
const [error, setError] = useState<ComponentViewerError | undefined>(undefined) const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined) const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false) const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
const [didAttemptReload, setDidAttemptReload] = useState(false) const [didAttemptReload, setDidAttemptReload] = useState(false)
const component: SNComponent = componentViewer.component const component: SNComponent = componentViewer.component
const manageSubscription = useCallback(() => { const manageSubscription = useCallback(() => {
openSubscriptionDashboard(application) openSubscriptionDashboard(application)
}, [application]) }, [application])
const reloadValidityStatus = useCallback(() => { const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus()) setFeatureStatus(componentViewer.getFeatureStatus())
if (!componentViewer.lockReadonly) { if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled) componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
} }
setIsComponentValid(componentViewer.shouldRender()) setIsComponentValid(componentViewer.shouldRender())
if (isLoading && !isComponentValid) { if (isLoading && !isComponentValid) {
setIsLoading(false) setIsLoading(false)
}
setError(componentViewer.getError())
setDeprecationMessage(component.deprecationMessage)
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
useEffect(() => {
reloadValidityStatus()
}, [reloadValidityStatus])
const dismissDeprecationMessage = () => {
setIsDeprecationMessageDismissed(true)
} }
const onVisibilityChange = useCallback(() => { setError(componentViewer.getError())
if (document.visibilityState === 'hidden') { setDeprecationMessage(component.deprecationMessage)
return }, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
}
if (hasIssueLoading) { useEffect(() => {
reloadValidityStatus()
}, [reloadValidityStatus])
const dismissDeprecationMessage = () => {
setIsDeprecationMessageDismissed(true)
}
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return
}
if (hasIssueLoading) {
requestReload?.(componentViewer)
}
}, [hasIssueLoading, componentViewer, requestReload])
useEffect(() => {
const loadTimeout = setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer) requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
} }
}, [hasIssueLoading, componentViewer, requestReload]) }, MaxLoadThreshold)
useEffect(() => { setLoadTimeout(loadTimeout)
const loadTimeout = setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
}
}, MaxLoadThreshold)
setLoadTimeout(loadTimeout)
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentViewer])
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement
const contentWindow = iframe.contentWindow as Window
return () => {
if (loadTimeout) { if (loadTimeout) {
clearTimeout(loadTimeout) clearTimeout(loadTimeout)
} }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentViewer])
try { const onIframeLoad = useCallback(() => {
componentViewer.setWindow(contentWindow) const iframe = iframeRef.current as HTMLIFrameElement
} catch (error) { const contentWindow = iframe.contentWindow as Window
console.error(error)
if (loadTimeout) {
clearTimeout(loadTimeout)
}
try {
componentViewer.setWindow(contentWindow)
} catch (error) {
console.error(error)
}
setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(false)
onLoad?.(component)
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, loadTimeout])
useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus())
} }
})
setTimeout(() => { return () => {
setIsLoading(false) removeFeaturesChangedObserver()
setHasIssueLoading(false) }
onLoad?.(component) }, [componentViewer])
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, loadTimeout])
useEffect(() => { useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => { const removeActionObserver = componentViewer.addActionObserver((action, data) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) { switch (action) {
setFeatureStatus(componentViewer.getFeatureStatus()) 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 () => { return () => {
removeFeaturesChangedObserver() unregisterDesktopObserver?.()
} }
}, [componentViewer]) }, [application, requestReload, componentViewer, component.uuid])
useEffect(() => { return (
const removeActionObserver = componentViewer.addActionObserver((action, data) => { <>
switch (action) { {hasIssueLoading && (
case ComponentAction.KeyDown: <IssueOnLoading
application.io.handleComponentKeyDown(data.keyboardModifier) componentName={component.displayName}
break reloadIframe={() => {
case ComponentAction.KeyUp: reloadValidityStatus(), requestReload?.(componentViewer, true)
application.io.handleComponentKeyUp(data.keyboardModifier) }}
break />
case ComponentAction.Click: )}
application.getAppState().notes.setContextMenuOpen(false)
break
default:
return
}
})
return () => {
removeActionObserver()
}
}, [componentViewer, application])
useEffect(() => { {featureStatus !== FeatureStatus.Entitled && (
const unregisterDesktopObserver = application <IsExpired
.getDesktopService() expiredDate={dateToLocalizedString(component.valid_until)}
?.registerUpdateObserver((updatedComponent: SNComponent) => { featureStatus={featureStatus}
if (updatedComponent.uuid === component.uuid && updatedComponent.active) { componentName={component.displayName}
requestReload?.(componentViewer) manageSubscription={manageSubscription}
} />
}) )}
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
)}
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
{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'} />}
</>
)
}
return () => { export default observer(ComponentView)
unregisterDesktopObserver?.()
}
}, [application, requestReload, componentViewer, component.uuid])
return (
<>
{hasIssueLoading && (
<IssueOnLoading
componentName={component.displayName}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer, true)
}}
/>
)}
{featureStatus !== FeatureStatus.Entitled && (
<IsExpired
expiredDate={dateToLocalizedString(component.valid_until)}
featureStatus={featureStatus}
componentName={component.displayName}
manageSubscription={manageSubscription}
/>
)}
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
)}
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
{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

@@ -1,11 +1,11 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
deprecationMessage: string | undefined deprecationMessage: string | undefined
dismissDeprecationMessage: () => void dismissDeprecationMessage: () => void
} }
export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage, dismissDeprecationMessage }) => { const IsDeprecated: FunctionComponent<Props> = ({ deprecationMessage, dismissDeprecationMessage }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -23,3 +23,5 @@ export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage,
</div> </div>
) )
} }
export default IsDeprecated

View File

@@ -1,7 +1,7 @@
import { FeatureStatus } from '@standardnotes/snjs' import { FeatureStatus } from '@standardnotes/snjs'
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
expiredDate: string expiredDate: string
componentName: string componentName: string
featureStatus: FeatureStatus featureStatus: FeatureStatus
@@ -21,12 +21,7 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone
} }
} }
export const IsExpired: FunctionalComponent<IProps> = ({ const IsExpired: FunctionComponent<Props> = ({ expiredDate, featureStatus, componentName, manageSubscription }) => {
expiredDate,
featureStatus,
componentName,
manageSubscription,
}) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -52,3 +47,5 @@ export const IsExpired: FunctionalComponent<IProps> = ({
</div> </div>
) )
} }
export default IsExpired

View File

@@ -1,11 +1,11 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
componentName: string componentName: string
reloadIframe: () => void reloadIframe: () => void
} }
export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => { const IssueOnLoading: FunctionComponent<Props> = ({ componentName, reloadIframe }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -23,3 +23,5 @@ export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, rel
</div> </div>
) )
} }
export default IssueOnLoading

View File

@@ -1,6 +1,6 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
export const OfflineRestricted: FunctionalComponent = () => { const OfflineRestricted: FunctionComponent = () => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-panel static'}> <div className={'sk-panel static'}>
@@ -29,3 +29,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
</div> </div>
) )
} }
export default OfflineRestricted

View File

@@ -1,10 +1,10 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
componentName: string componentName: string
} }
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => { const UrlMissing: FunctionComponent<Props> = ({ componentName }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-panel static'}> <div className={'sk-panel static'}>
@@ -20,3 +20,5 @@ export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
</div> </div>
) )
} }
export default UrlMissing

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'preact/hooks' import { FunctionComponent, useEffect, useRef, useState } from 'react'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog' import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings' import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
@@ -13,14 +13,7 @@ type Props = {
applicationGroup: ApplicationGroup applicationGroup: ApplicationGroup
} }
export const ConfirmSignoutContainer = observer((props: Props) => { const ConfirmSignoutModal: FunctionComponent<Props> = ({ application, appState, applicationGroup }) => {
if (!props.appState.accountMenu.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
})
export const ConfirmSignoutModal = observer(({ application, appState, applicationGroup }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
const cancelRef = useRef<HTMLButtonElement>(null) const cancelRef = useRef<HTMLButtonElement>(null)
@@ -114,4 +107,15 @@ export const ConfirmSignoutModal = observer(({ application, appState, applicatio
</div> </div>
</AlertDialog> </AlertDialog>
) )
}) }
ConfirmSignoutModal.displayName = 'ConfirmSignoutModal'
const ConfirmSignoutContainer = (props: Props) => {
if (!props.appState.accountMenu.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
}
export default observer(ConfirmSignoutContainer)

View File

@@ -3,11 +3,10 @@ import { KeyboardKey } from '@/Services/IOService'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { UuidString } from '@standardnotes/snjs' import { UuidString } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react'
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants'
import { ListableContentItem } from './Types/ListableContentItem' import { ListableContentItem } from './Types/ListableContentItem'
import { ContentListItem } from './ContentListItem' import ContentListItem from './ContentListItem'
import { useCallback } from 'preact/hooks'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -17,59 +16,59 @@ type Props = {
paginate: () => void paginate: () => void
} }
export const ContentList: FunctionComponent<Props> = observer( const ContentList: FunctionComponent<Props> = ({ application, appState, items, selectedItems, paginate }) => {
({ application, appState, items, selectedItems, paginate }) => { const { selectPreviousItem, selectNextItem } = appState.contentListView
const { selectPreviousItem, selectNextItem } = appState.contentListView const { hideTags, hideDate, hideNotePreview, hideEditorIcon } = appState.contentListView.webDisplayOptions
const { hideTags, hideDate, hideNotePreview, hideEditorIcon } = appState.contentListView.webDisplayOptions const { sortBy } = appState.contentListView.displayOptions
const { sortBy } = appState.contentListView.displayOptions
const onScroll = useCallback( const onScroll: UIEventHandler = useCallback(
(e: Event) => { (e) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD const offset = NOTES_LIST_SCROLL_THRESHOLD
const element = e.target as HTMLElement const element = e.target as HTMLElement
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) { if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
paginate() paginate()
} }
}, },
[paginate], [paginate],
) )
const onKeyDown = useCallback( const onKeyDown: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (e.key === KeyboardKey.Up) { if (e.key === KeyboardKey.Up) {
e.preventDefault() e.preventDefault()
selectPreviousItem() selectPreviousItem()
} else if (e.key === KeyboardKey.Down) { } else if (e.key === KeyboardKey.Down) {
e.preventDefault() e.preventDefault()
selectNextItem() selectNextItem()
} }
}, },
[selectNextItem, selectPreviousItem], [selectNextItem, selectPreviousItem],
) )
return ( return (
<div <div
className="infinite-scroll focus:shadow-none focus:outline-none" className="infinite-scroll focus:shadow-none focus:outline-none"
id="notes-scrollable" id="notes-scrollable"
onScroll={onScroll} onScroll={onScroll}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
> >
{items.map((item) => ( {items.map((item) => (
<ContentListItem <ContentListItem
key={item.uuid} key={item.uuid}
application={application} application={application}
appState={appState} appState={appState}
item={item} item={item}
selected={!!selectedItems[item.uuid]} selected={!!selectedItems[item.uuid]}
hideDate={hideDate} hideDate={hideDate}
hidePreview={hideNotePreview} hidePreview={hideNotePreview}
hideTags={hideTags} hideTags={hideTags}
hideIcon={hideEditorIcon} hideIcon={hideEditorIcon}
sortBy={sortBy} sortBy={sortBy}
/> />
))} ))}
</div> </div>
) )
}, }
)
export default observer(ContentList)

View File

@@ -1,10 +1,10 @@
import { ContentType, SNTag } from '@standardnotes/snjs' import { ContentType, SNTag } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { FileListItem } from './FileListItem' import FileListItem from './FileListItem'
import { NoteListItem } from './NoteListItem' import NoteListItem from './NoteListItem'
import { AbstractListItemProps } from './Types/AbstractListItemProps' import { AbstractListItemProps } from './Types/AbstractListItemProps'
export const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => { const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
const getTags = () => { const getTags = () => {
if (props.hideTags) { if (props.hideTags) {
return [] return []
@@ -34,3 +34,5 @@ export const ContentListItem: FunctionComponent<AbstractListItemProps> = (props)
return null return null
} }
} }
export default ContentListItem

View File

@@ -4,27 +4,29 @@ import { AppState } from '@/UIModels/AppState'
import { PANEL_NAME_NOTES } from '@/Constants' import { PANEL_NAME_NOTES } from '@/Constants'
import { PrefKey } from '@standardnotes/snjs' import { PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import {
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' ChangeEventHandler,
import { ContentList } from '@/Components/ContentListView/ContentList' FunctionComponent,
import { NotesListOptionsMenu } from '@/Components/ContentListView/NotesListOptionsMenu' KeyboardEventHandler,
import { NoAccountWarning } from '@/Components/NoAccountWarning/NoAccountWarning' useCallback,
import { SearchOptions } from '@/Components/SearchOptions/SearchOptions' useEffect,
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer/PanelResizer' useRef,
useState,
} from 'react'
import ContentList from '@/Components/ContentListView/ContentList'
import NotesListOptionsMenu from '@/Components/ContentListView/NotesListOptionsMenu'
import NoAccountWarningWrapper from '@/Components/NoAccountWarning/NoAccountWarning'
import SearchOptions from '@/Components/SearchOptions/SearchOptions'
import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState appState: AppState
} }
export const ContentListView: FunctionComponent<Props> = observer(({ application, appState }) => { const ContentListView: FunctionComponent<Props> = ({ application, appState }) => {
if (isStateDealloced(appState)) {
return null
}
const itemsViewPanelRef = useRef<HTMLDivElement>(null) const itemsViewPanelRef = useRef<HTMLDivElement>(null)
const displayOptionsMenuRef = useRef<HTMLDivElement>(null) const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
@@ -104,9 +106,9 @@ export const ContentListView: FunctionComponent<Props> = observer(({ application
} }
}, [application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem]) }, [application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem])
const onNoteFilterTextChange = useCallback( const onNoteFilterTextChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e: Event) => { (e) => {
setNoteFilterText((e.target as HTMLInputElement).value) setNoteFilterText(e.target.value)
}, },
[setNoteFilterText], [setNoteFilterText],
) )
@@ -114,8 +116,8 @@ export const ContentListView: FunctionComponent<Props> = observer(({ application
const onSearchFocused = useCallback(() => setFocusedSearch(true), []) const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
const onSearchBlurred = useCallback(() => setFocusedSearch(false), []) const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
const onNoteFilterKeyUp = useCallback( const onNoteFilterKeyUp: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (e.key === KeyboardKey.Enter) { if (e.key === KeyboardKey.Enter) {
onFilterEnter() onFilterEnter()
} }
@@ -176,10 +178,10 @@ export const ContentListView: FunctionComponent<Props> = observer(({ application
onKeyUp={onNoteFilterKeyUp} onKeyUp={onNoteFilterKeyUp}
onFocus={onSearchFocused} onFocus={onSearchFocused}
onBlur={onSearchBlurred} onBlur={onSearchBlurred}
autocomplete="off" autoComplete="off"
/> />
{noteFilterText && ( {noteFilterText && (
<button onClick={clearFilterText} aria-role="button" id="search-clear-button"> <button onClick={clearFilterText} id="search-clear-button">
</button> </button>
)} )}
@@ -191,7 +193,7 @@ export const ContentListView: FunctionComponent<Props> = observer(({ application
</div> </div>
)} )}
</div> </div>
<NoAccountWarning appState={appState} /> <NoAccountWarningWrapper appState={appState} />
</div> </div>
<div id="items-menu-bar" className="sn-component" ref={displayOptionsMenuRef}> <div id="items-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
<div className="sk-app-bar no-edges"> <div className="sk-app-bar no-edges">
@@ -253,4 +255,6 @@ export const ContentListView: FunctionComponent<Props> = observer(({ application
)} )}
</div> </div>
) )
}) }
export default observer(ContentListView)

View File

@@ -1,81 +1,89 @@
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback } from 'react'
import { useCallback } from 'preact/hooks' import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent'
import { getFileIconComponent } from '../AttachedFilesPopover/PopoverFileItem' import ListItemConflictIndicator from './ListItemConflictIndicator'
import { ListItemConflictIndicator } from './ListItemConflictIndicator' import ListItemFlagIcons from './ListItemFlagIcons'
import { ListItemFlagIcons } from './ListItemFlagIcons' import ListItemTags from './ListItemTags'
import { ListItemTags } from './ListItemTags' import ListItemMetadata from './ListItemMetadata'
import { ListItemMetadata } from './ListItemMetadata'
import { DisplayableListItemProps } from './Types/DisplayableListItemProps' import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
export const FileListItem: FunctionComponent<DisplayableListItemProps> = observer( const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
({ application, appState, hideDate, hideIcon, hideTags, item, selected, sortBy, tags }) => { application,
const openFileContextMenu = useCallback( appState,
(posX: number, posY: number) => { hideDate,
appState.files.setFileContextMenuLocation({ hideIcon,
x: posX, hideTags,
y: posY, item,
}) selected,
appState.files.setShowFileContextMenu(true) sortBy,
}, tags,
[appState.files], }) => {
) const openFileContextMenu = useCallback(
(posX: number, posY: number) => {
const openContextMenu = useCallback( appState.files.setFileContextMenuLocation({
(posX: number, posY: number) => { x: posX,
void appState.contentListView.selectItemWithScrollHandling(item, { y: posY,
userTriggered: true,
scrollIntoView: false,
})
openFileContextMenu(posX, posY)
},
[appState.contentListView, item, openFileContextMenu],
)
const onClick = useCallback(() => {
void appState.selectedItems.selectItem(item.uuid, true).then(({ didSelect }) => {
if (didSelect && appState.selectedItems.selectedItemsCount < 2) {
appState.filePreviewModal.activate(item as FileItem, appState.files.allFiles)
}
}) })
}, [appState.filePreviewModal, appState.files.allFiles, appState.selectedItems, item]) appState.files.setShowFileContextMenu(true)
},
[appState.files],
)
const IconComponent = () => const openContextMenu = useCallback(
getFileIconComponent( async (posX: number, posY: number) => {
application.iconsController.getIconForFileType((item as FileItem).mimeType), const { didSelect } = await appState.selectedItems.selectItem(item.uuid)
'w-5 h-5 flex-shrink-0', if (didSelect) {
) openFileContextMenu(posX, posY)
}
},
[appState.selectedItems, item.uuid, openFileContextMenu],
)
return ( const onClick = useCallback(() => {
<div void appState.selectedItems.selectItem(item.uuid, true).then(({ didSelect }) => {
className={`content-list-item flex items-stretch w-full cursor-pointer ${ if (didSelect && appState.selectedItems.selectedItemsCount < 2) {
selected && 'selected border-0 border-l-2px border-solid border-info' appState.filePreviewModal.activate(item as FileItem, appState.files.allFiles)
}`} }
id={item.uuid} })
onClick={onClick} }, [appState.filePreviewModal, appState.files.allFiles, appState.selectedItems, item])
onContextMenu={(event) => {
event.preventDefault() const IconComponent = () =>
openContextMenu(event.clientX, event.clientY) getFileIconComponent(
}} application.iconsController.getIconForFileType((item as FileItem).mimeType),
> 'w-5 h-5 flex-shrink-0',
{!hideIcon ? (
<div className="flex flex-col items-center justify-between p-4.5 pr-3 mr-0">
<IconComponent />
</div>
) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div>
<ListItemFlagIcons item={item} />
</div>
) )
},
) return (
<div
className={`content-list-item flex items-stretch w-full cursor-pointer ${
selected && 'selected border-0 border-l-2px border-solid border-info'
}`}
id={item.uuid}
onClick={onClick}
onContextMenu={(event) => {
event.preventDefault()
void openContextMenu(event.clientX, event.clientY)
}}
>
{!hideIcon ? (
<div className="flex flex-col items-center justify-between p-4.5 pr-3 mr-0">
<IconComponent />
</div>
) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div>
<ListItemFlagIcons item={item} />
</div>
)
}
export default observer(FileListItem)

View File

@@ -1,11 +1,13 @@
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem' import { ListableContentItem } from './Types/ListableContentItem'
export const ListItemConflictIndicator: FunctionComponent<{ type Props = {
item: { item: {
conflictOf?: ListableContentItem['conflictOf'] conflictOf?: ListableContentItem['conflictOf']
} }
}> = ({ item }) => { }
const ListItemConflictIndicator: FunctionComponent<Props> = ({ item }) => {
return item.conflictOf ? ( return item.conflictOf ? (
<div className="flex flex-wrap items-center mt-0.5"> <div className="flex flex-wrap items-center mt-0.5">
<div className={'py-1 px-1.5 rounded mr-1 mt-2 bg-danger color-danger-contrast'}> <div className={'py-1 px-1.5 rounded mr-1 mt-2 bg-danger color-danger-contrast'}>
@@ -14,3 +16,5 @@ export const ListItemConflictIndicator: FunctionComponent<{
</div> </div>
) : null ) : null
} }
export default ListItemConflictIndicator

View File

@@ -1,5 +1,5 @@
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { ListableContentItem } from './Types/ListableContentItem' import { ListableContentItem } from './Types/ListableContentItem'
type Props = { type Props = {
@@ -12,7 +12,7 @@ type Props = {
hasFiles?: boolean hasFiles?: boolean
} }
export const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false }) => { const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false }) => {
return ( return (
<div className="flex items-start p-4 pl-0 border-0 border-b-1 border-solid border-main"> <div className="flex items-start p-4 pl-0 border-0 border-b-1 border-solid border-main">
{item.locked && ( {item.locked && (
@@ -43,3 +43,5 @@ export const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = f
</div> </div>
) )
} }
export default ListItemFlagIcons

View File

@@ -1,5 +1,5 @@
import { CollectionSort, SortableItem } from '@standardnotes/snjs' import { CollectionSort, SortableItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem' import { ListableContentItem } from './Types/ListableContentItem'
type Props = { type Props = {
@@ -12,7 +12,7 @@ type Props = {
sortBy: keyof SortableItem | undefined sortBy: keyof SortableItem | undefined
} }
export const ListItemMetadata: FunctionComponent<Props> = ({ item, hideDate, sortBy }) => { const ListItemMetadata: FunctionComponent<Props> = ({ item, hideDate, sortBy }) => {
const showModifiedDate = sortBy === CollectionSort.UpdatedAt const showModifiedDate = sortBy === CollectionSort.UpdatedAt
if (hideDate && !item.protected) { if (hideDate && !item.protected) {
@@ -27,3 +27,5 @@ export const ListItemMetadata: FunctionComponent<Props> = ({ item, hideDate, sor
</div> </div>
) )
} }
export default ListItemMetadata

View File

@@ -1,10 +1,12 @@
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
export const ListItemTags: FunctionComponent<{ type Props = {
hideTags: boolean hideTags: boolean
tags: string[] tags: string[]
}> = ({ hideTags, tags }) => { }
const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
if (hideTags || !tags.length) { if (hideTags || !tags.length) {
return null return null
} }
@@ -12,7 +14,10 @@ export const ListItemTags: FunctionComponent<{
return ( return (
<div className="flex flex-wrap mt-1.5 text-xs gap-2"> <div className="flex flex-wrap mt-1.5 text-xs gap-2">
{tags.map((tag) => ( {tags.map((tag) => (
<span className="inline-flex items-center py-1 px-1.5 bg-passive-4-opacity-variant color-foreground rounded-0.5"> <span
className="inline-flex items-center py-1 px-1.5 bg-passive-4-opacity-variant color-foreground rounded-0.5"
key={tag}
>
<Icon type="hashtag" className="sn-icon--small color-passive-1 mr-1" /> <Icon type="hashtag" className="sn-icon--small color-passive-1 mr-1" />
<span>{tag}</span> <span>{tag}</span>
</span> </span>
@@ -20,3 +25,5 @@ export const ListItemTags: FunctionComponent<{
</div> </div>
) )
} }
export default ListItemTags

View File

@@ -1,84 +1,97 @@
import { PLAIN_EDITOR_NAME } from '@/Constants' import { PLAIN_EDITOR_NAME } from '@/Constants'
import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs' import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { ListItemConflictIndicator } from './ListItemConflictIndicator' import ListItemConflictIndicator from './ListItemConflictIndicator'
import { ListItemFlagIcons } from './ListItemFlagIcons' import ListItemFlagIcons from './ListItemFlagIcons'
import { ListItemTags } from './ListItemTags' import ListItemTags from './ListItemTags'
import { ListItemMetadata } from './ListItemMetadata' import ListItemMetadata from './ListItemMetadata'
import { DisplayableListItemProps } from './Types/DisplayableListItemProps' import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
export const NoteListItem: FunctionComponent<DisplayableListItemProps> = observer( const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
({ application, appState, hideDate, hideIcon, hideTags, hidePreview, item, selected, sortBy, tags }) => { application,
const editorForNote = application.componentManager.editorForNote(item as SNNote) appState,
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME hideDate,
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type) hideIcon,
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0 hideTags,
hidePreview,
item,
selected,
sortBy,
tags,
}) => {
const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0
const openNoteContextMenu = (posX: number, posY: number) => { const openNoteContextMenu = (posX: number, posY: number) => {
appState.notes.setContextMenuClickLocation({ appState.notes.setContextMenuClickLocation({
x: posX, x: posX,
y: posY, y: posY,
}) })
appState.notes.reloadContextMenuLayout() appState.notes.reloadContextMenuLayout()
appState.notes.setContextMenuOpen(true) appState.notes.setContextMenuOpen(true)
} }
const openContextMenu = (posX: number, posY: number) => { const openContextMenu = async (posX: number, posY: number) => {
void appState.selectedItems.selectItem(item.uuid, true) const { didSelect } = await appState.selectedItems.selectItem(item.uuid, true)
if (didSelect) {
openNoteContextMenu(posX, posY) openNoteContextMenu(posX, posY)
} }
}
return ( return (
<div <div
className={`content-list-item flex items-stretch w-full cursor-pointer ${ className={`content-list-item flex items-stretch w-full cursor-pointer ${
selected && 'selected border-0 border-l-2px border-solid border-info' selected && 'selected border-0 border-l-2px border-solid border-info'
}`} }`}
id={item.uuid} id={item.uuid}
onClick={() => { onClick={() => {
void appState.selectedItems.selectItem(item.uuid, true) void appState.selectedItems.selectItem(item.uuid, true)
}} }}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
openContextMenu(event.clientX, event.clientY) void openContextMenu(event.clientX, event.clientY)
}} }}
> >
{!hideIcon ? ( {!hideIcon ? (
<div className="flex flex-col items-center justify-between p-4 pr-3 mr-0"> <div className="flex flex-col items-center justify-between p-4 pr-3 mr-0">
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} /> <Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} />
</div>
) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
{!hidePreview && !item.hidePreview && !item.protected && (
<div className="overflow-hidden overflow-ellipsis text-sm">
{item.preview_html && (
<div
className="my-1"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(item.preview_html),
}}
></div>
)}
{!item.preview_html && item.preview_plain && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.text}</div>
)}
</div>
)}
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div> </div>
<ListItemFlagIcons item={item} hasFiles={hasFiles} /> ) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
{!hidePreview && !item.hidePreview && !item.protected && (
<div className="overflow-hidden overflow-ellipsis text-sm">
{item.preview_html && (
<div
className="my-1"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(item.preview_html),
}}
></div>
)}
{!item.preview_html && item.preview_plain && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.text}</div>
)}
</div>
)}
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div> </div>
) <ListItemFlagIcons item={item} hasFiles={hasFiles} />
}, </div>
) )
}
export default observer(NoteListItem)

View File

@@ -1,11 +1,12 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs' import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useState } from 'react'
import { useCallback, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import Menu from '@/Components/Menu/Menu'
import { Menu } from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -14,235 +15,238 @@ type Props = {
isOpen: boolean isOpen: boolean
} }
export const NotesListOptionsMenu: FunctionComponent<Props> = observer( const NotesListOptionsMenu: FunctionComponent<Props> = ({
({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => { closeDisplayOptionsMenu,
const [sortBy, setSortBy] = useState(() => application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)) closeOnBlur,
const [sortReverse, setSortReverse] = useState(() => application.getPreference(PrefKey.SortNotesReverse, false)) application,
const [hidePreview, setHidePreview] = useState(() => application.getPreference(PrefKey.NotesHideNotePreview, false)) isOpen,
const [hideDate, setHideDate] = useState(() => application.getPreference(PrefKey.NotesHideDate, false)) }) => {
const [hideTags, setHideTags] = useState(() => application.getPreference(PrefKey.NotesHideTags, true)) const [sortBy, setSortBy] = useState(() => application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt))
const [hidePinned, setHidePinned] = useState(() => application.getPreference(PrefKey.NotesHidePinned, false)) const [sortReverse, setSortReverse] = useState(() => application.getPreference(PrefKey.SortNotesReverse, false))
const [showArchived, setShowArchived] = useState(() => application.getPreference(PrefKey.NotesShowArchived, false)) const [hidePreview, setHidePreview] = useState(() => application.getPreference(PrefKey.NotesHideNotePreview, false))
const [showTrashed, setShowTrashed] = useState(() => application.getPreference(PrefKey.NotesShowTrashed, false)) const [hideDate, setHideDate] = useState(() => application.getPreference(PrefKey.NotesHideDate, false))
const [hideProtected, setHideProtected] = useState(() => const [hideTags, setHideTags] = useState(() => application.getPreference(PrefKey.NotesHideTags, true))
application.getPreference(PrefKey.NotesHideProtected, false), const [hidePinned, setHidePinned] = useState(() => application.getPreference(PrefKey.NotesHidePinned, false))
) const [showArchived, setShowArchived] = useState(() => application.getPreference(PrefKey.NotesShowArchived, false))
const [hideEditorIcon, setHideEditorIcon] = useState(() => const [showTrashed, setShowTrashed] = useState(() => application.getPreference(PrefKey.NotesShowTrashed, false))
application.getPreference(PrefKey.NotesHideEditorIcon, false), const [hideProtected, setHideProtected] = useState(() => application.getPreference(PrefKey.NotesHideProtected, false))
) const [hideEditorIcon, setHideEditorIcon] = useState(() =>
application.getPreference(PrefKey.NotesHideEditorIcon, false),
)
const toggleSortReverse = useCallback(() => { const toggleSortReverse = useCallback(() => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error) application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
setSortReverse(!sortReverse) setSortReverse(!sortReverse)
}, [application, sortReverse]) }, [application, sortReverse])
const toggleSortBy = useCallback( const toggleSortBy = useCallback(
(sort: CollectionSortProperty) => { (sort: CollectionSortProperty) => {
if (sortBy === sort) { if (sortBy === sort) {
toggleSortReverse() toggleSortReverse()
} else { } else {
setSortBy(sort) setSortBy(sort)
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error) application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
} }
}, },
[application, sortBy, toggleSortReverse], [application, sortBy, toggleSortReverse],
) )
const toggleSortByDateModified = useCallback(() => { const toggleSortByDateModified = useCallback(() => {
toggleSortBy(CollectionSort.UpdatedAt) toggleSortBy(CollectionSort.UpdatedAt)
}, [toggleSortBy]) }, [toggleSortBy])
const toggleSortByCreationDate = useCallback(() => { const toggleSortByCreationDate = useCallback(() => {
toggleSortBy(CollectionSort.CreatedAt) toggleSortBy(CollectionSort.CreatedAt)
}, [toggleSortBy]) }, [toggleSortBy])
const toggleSortByTitle = useCallback(() => { const toggleSortByTitle = useCallback(() => {
toggleSortBy(CollectionSort.Title) toggleSortBy(CollectionSort.Title)
}, [toggleSortBy]) }, [toggleSortBy])
const toggleHidePreview = useCallback(() => { const toggleHidePreview = useCallback(() => {
setHidePreview(!hidePreview) setHidePreview(!hidePreview)
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error) application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
}, [application, hidePreview]) }, [application, hidePreview])
const toggleHideDate = useCallback(() => { const toggleHideDate = useCallback(() => {
setHideDate(!hideDate) setHideDate(!hideDate)
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error) application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
}, [application, hideDate]) }, [application, hideDate])
const toggleHideTags = useCallback(() => { const toggleHideTags = useCallback(() => {
setHideTags(!hideTags) setHideTags(!hideTags)
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error) application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
}, [application, hideTags]) }, [application, hideTags])
const toggleHidePinned = useCallback(() => { const toggleHidePinned = useCallback(() => {
setHidePinned(!hidePinned) setHidePinned(!hidePinned)
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error) application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
}, [application, hidePinned]) }, [application, hidePinned])
const toggleShowArchived = useCallback(() => { const toggleShowArchived = useCallback(() => {
setShowArchived(!showArchived) setShowArchived(!showArchived)
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error) application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
}, [application, showArchived]) }, [application, showArchived])
const toggleShowTrashed = useCallback(() => { const toggleShowTrashed = useCallback(() => {
setShowTrashed(!showTrashed) setShowTrashed(!showTrashed)
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error) application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
}, [application, showTrashed]) }, [application, showTrashed])
const toggleHideProtected = useCallback(() => { const toggleHideProtected = useCallback(() => {
setHideProtected(!hideProtected) setHideProtected(!hideProtected)
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error) application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
}, [application, hideProtected]) }, [application, hideProtected])
const toggleEditorIcon = useCallback(() => { const toggleEditorIcon = useCallback(() => {
setHideEditorIcon(!hideEditorIcon) setHideEditorIcon(!hideEditorIcon)
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error) application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
}, [application, hideEditorIcon]) }, [application, hideEditorIcon])
return ( return (
<Menu <Menu
className={ className={
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \ 'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \ border-1 border-solid border-main text-sm z-index-dropdown-menu \
flex flex-col py-2 top-full left-2 absolute' flex flex-col py-2 top-full left-2 absolute'
} }
a11yLabel="Notes list options menu" a11yLabel="Notes list options menu"
closeMenu={closeDisplayOptionsMenu} closeMenu={closeDisplayOptionsMenu}
isOpen={isOpen} 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="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div> <div className="flex flex-grow items-center justify-between ml-2">
<MenuItem <span>Date modified</span>
className="py-2" {sortBy === CollectionSort.UpdatedAt ? (
type={MenuItemType.RadioButton} sortReverse ? (
onClick={toggleSortByDateModified} <Icon type="arrows-sort-up" className="color-neutral" />
checked={sortBy === CollectionSort.UpdatedAt} ) : (
onBlur={closeOnBlur} <Icon type="arrows-sort-down" className="color-neutral" />
> )
<div className="flex flex-grow items-center justify-between ml-2"> ) : null}
<span>Date modified</span> </div>
{sortBy === CollectionSort.UpdatedAt ? ( </MenuItem>
sortReverse ? ( <MenuItem
<Icon type="arrows-sort-up" className="color-neutral" /> className="py-2"
) : ( type={MenuItemType.RadioButton}
<Icon type="arrows-sort-down" className="color-neutral" /> onClick={toggleSortByCreationDate}
) checked={sortBy === CollectionSort.CreatedAt}
) : null} onBlur={closeOnBlur}
</div> >
</MenuItem> <div className="flex flex-grow items-center justify-between ml-2">
<MenuItem <span>Creation date</span>
className="py-2" {sortBy === CollectionSort.CreatedAt ? (
type={MenuItemType.RadioButton} sortReverse ? (
onClick={toggleSortByCreationDate} <Icon type="arrows-sort-up" className="color-neutral" />
checked={sortBy === CollectionSort.CreatedAt} ) : (
onBlur={closeOnBlur} <Icon type="arrows-sort-down" className="color-neutral" />
> )
<div className="flex flex-grow items-center justify-between ml-2"> ) : null}
<span>Creation date</span> </div>
{sortBy === CollectionSort.CreatedAt ? ( </MenuItem>
sortReverse ? ( <MenuItem
<Icon type="arrows-sort-up" className="color-neutral" /> className="py-2"
) : ( type={MenuItemType.RadioButton}
<Icon type="arrows-sort-down" className="color-neutral" /> onClick={toggleSortByTitle}
) checked={sortBy === CollectionSort.Title}
) : null} onBlur={closeOnBlur}
</div> >
</MenuItem> <div className="flex flex-grow items-center justify-between ml-2">
<MenuItem <span>Title</span>
className="py-2" {sortBy === CollectionSort.Title ? (
type={MenuItemType.RadioButton} sortReverse ? (
onClick={toggleSortByTitle} <Icon type="arrows-sort-up" className="color-neutral" />
checked={sortBy === CollectionSort.Title} ) : (
onBlur={closeOnBlur} <Icon type="arrows-sort-down" className="color-neutral" />
> )
<div className="flex flex-grow items-center justify-between ml-2"> ) : null}
<span>Title</span> </div>
{sortBy === CollectionSort.Title ? ( </MenuItem>
sortReverse ? ( <MenuItemSeparator />
<Icon type="arrows-sort-up" className="color-neutral" /> <div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
) : ( <MenuItem
<Icon type="arrows-sort-down" className="color-neutral" /> type={MenuItemType.SwitchButton}
) className="py-1 hover:bg-contrast focus:bg-info-backdrop"
) : null} checked={!hidePreview}
</div> onChange={toggleHidePreview}
</MenuItem> onBlur={closeOnBlur}
<MenuItemSeparator /> >
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div> <div className="flex flex-col max-w-3/4">Show note preview</div>
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={!hidePreview} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleHidePreview} checked={!hideDate}
onBlur={closeOnBlur} onChange={toggleHideDate}
> onBlur={closeOnBlur}
<div className="flex flex-col max-w-3/4">Show note preview</div> >
</MenuItem> Show date
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={!hideDate} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleHideDate} checked={!hideTags}
onBlur={closeOnBlur} onChange={toggleHideTags}
> onBlur={closeOnBlur}
Show date >
</MenuItem> Show tags
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={!hideTags} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleHideTags} checked={!hideEditorIcon}
onBlur={closeOnBlur} onChange={toggleEditorIcon}
> onBlur={closeOnBlur}
Show tags >
</MenuItem> Show editor icon
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <div className="h-1px my-2 bg-border"></div>
className="py-1 hover:bg-contrast focus:bg-info-backdrop" <div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
checked={!hideEditorIcon} <MenuItem
onChange={toggleEditorIcon} type={MenuItemType.SwitchButton}
onBlur={closeOnBlur} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
> checked={!hidePinned}
Show editor icon onChange={toggleHidePinned}
</MenuItem> onBlur={closeOnBlur}
<div className="h-1px my-2 bg-border"></div> >
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div> Show pinned notes
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={!hidePinned} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleHidePinned} checked={!hideProtected}
onBlur={closeOnBlur} onChange={toggleHideProtected}
> onBlur={closeOnBlur}
Show pinned notes >
</MenuItem> Show protected notes
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={!hideProtected} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleHideProtected} checked={showArchived}
onBlur={closeOnBlur} onChange={toggleShowArchived}
> onBlur={closeOnBlur}
Show protected notes >
</MenuItem> Show archived notes
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} <MenuItem
className="py-1 hover:bg-contrast focus:bg-info-backdrop" type={MenuItemType.SwitchButton}
checked={showArchived} className="py-1 hover:bg-contrast focus:bg-info-backdrop"
onChange={toggleShowArchived} checked={showTrashed}
onBlur={closeOnBlur} onChange={toggleShowTrashed}
> onBlur={closeOnBlur}
Show archived notes >
</MenuItem> Show trashed notes
<MenuItem </MenuItem>
type={MenuItemType.SwitchButton} </Menu>
className="py-1 hover:bg-contrast focus:bg-info-backdrop" )
checked={showTrashed} }
onChange={toggleShowTrashed}
onBlur={closeOnBlur} export default observer(NotesListOptionsMenu)
>
Show trashed notes
</MenuItem>
</Menu>
)
},
)

View File

@@ -0,0 +1,17 @@
import { WebApplication } from '@/UIModels/Application'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
type Props = {
application: WebApplication
}
const DeallocateHandler: FunctionComponent<Props> = ({ application, children }) => {
if (application.dealloced) {
return null
}
return <>{children}</>
}
export default observer(DeallocateHandler)

View File

@@ -1,16 +1,8 @@
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox' import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { DropdownItem } from './DropdownItem'
export type DropdownItem = {
icon?: IconType
iconClassName?: string
label: string
value: string
disabled?: boolean
}
type DropdownProps = { type DropdownProps = {
id: string id: string
@@ -46,7 +38,7 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
</> </>
) )
export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => { const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
const labelId = `${id}-label` const labelId = `${id}-label`
const handleChange = (value: string) => { const handleChange = (value: string) => {
@@ -79,6 +71,7 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
<ListboxList> <ListboxList>
{items.map((item) => ( {items.map((item) => (
<ListboxOption <ListboxOption
key={item.value}
className="sn-dropdown-item" className="sn-dropdown-item"
value={item.value} value={item.value}
label={item.label} label={item.label}
@@ -99,3 +92,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
</> </>
) )
} }
export default Dropdown

View File

@@ -0,0 +1,9 @@
import { IconType } from '@standardnotes/snjs'
export type DropdownItem = {
icon?: IconType
iconClassName?: string
label: string
value: string
disabled?: boolean
}

View File

@@ -3,18 +3,16 @@ import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import React from 'react'
import { PopoverFileItemAction } from '../AttachedFilesPopover/PopoverFileItemAction' import { PopoverFileItemAction } from '../AttachedFilesPopover/PopoverFileItemAction'
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs' import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs'
import { FileMenuOptions } from './FileMenuOptions' import FileMenuOptions from './FileMenuOptions'
type Props = { type Props = {
appState: AppState appState: AppState
} }
export const FileContextMenu: FunctionComponent<Props> = observer(({ appState }) => { const FileContextMenu: FunctionComponent<Props> = observer(({ appState }) => {
const { selectedFiles, showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = appState.files const { selectedFiles, showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = appState.files
const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({ const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({
@@ -28,9 +26,6 @@ export const FileContextMenu: FunctionComponent<Props> = observer(({ appState })
useCloseOnClickOutside(contextMenuRef, () => appState.files.setShowFileContextMenu(false)) useCloseOnClickOutside(contextMenuRef, () => appState.files.setShowFileContextMenu(false))
const selectedFile = selectedFiles[0] const selectedFile = selectedFiles[0]
if (!showFileContextMenu || !selectedFile) {
return null
}
const reloadContextMenuLayout = useCallback(() => { const reloadContextMenuLayout = useCallback(() => {
const { clientHeight } = document.documentElement const { clientHeight } = document.documentElement
@@ -118,3 +113,19 @@ export const FileContextMenu: FunctionComponent<Props> = observer(({ appState })
</div> </div>
) )
}) })
FileContextMenu.displayName = 'FileContextMenu'
const FileContextMenuWrapper: FunctionComponent<Props> = ({ appState }) => {
const { selectedFiles, showFileContextMenu } = appState.files
const selectedFile = selectedFiles[0]
if (!showFileContextMenu || !selectedFile) {
return null
}
return <FileContextMenu appState={appState} />
}
export default observer(FileContextMenuWrapper)

View File

@@ -1,9 +1,9 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { PopoverFileItemAction, PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction' import { PopoverFileItemAction, PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { Switch } from '@/Components/Switch/Switch' import Switch from '@/Components/Switch/Switch'
type Props = { type Props = {
closeMenu: () => void closeMenu: () => void
@@ -17,7 +17,7 @@ type Props = {
shouldShowAttachOption: boolean shouldShowAttachOption: boolean
} }
export const FileMenuOptions: FunctionComponent<Props> = ({ const FileMenuOptions: FunctionComponent<Props> = ({
closeMenu, closeMenu,
closeOnBlur, closeOnBlur,
file, file,
@@ -139,3 +139,5 @@ export const FileMenuOptions: FunctionComponent<Props> = ({
</> </>
) )
} }
export default FileMenuOptions

View File

@@ -1,13 +1,13 @@
import { formatSizeToReadableString } from '@standardnotes/filepicker' import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
type Props = { type Props = {
file: FileItem file: FileItem
} }
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => { const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
return ( return (
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main"> <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"> <div className="flex items-center mb-4">
@@ -35,3 +35,5 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
</div> </div>
) )
} }
export default FilePreviewInfoPanel

View File

@@ -3,14 +3,13 @@ import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { addToast, ToastType } from '@standardnotes/stylekit' import { addToast, ToastType } from '@standardnotes/stylekit'
import { NoPreviewIllustration } from '@standardnotes/icons' import { NoPreviewIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import FilePreviewInfoPanel from './FilePreviewInfoPanel'
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
import { isFileTypePreviewable } from './isFilePreviewable' import { isFileTypePreviewable } from './isFilePreviewable'
import { PreviewComponent } from './PreviewComponent' import PreviewComponent from './PreviewComponent'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
@@ -21,10 +20,6 @@ type Props = {
appState: AppState appState: AppState
} }
export const FilePreviewModalWrapper: FunctionComponent<Props> = observer(({ application, appState }) => {
return appState.filePreviewModal.isOpen ? <FilePreviewModal application={application} appState={appState} /> : null
})
const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appState }) => { const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appState }) => {
const { currentFile, setCurrentFile, otherFiles, dismiss } = appState.filePreviewModal const { currentFile, setCurrentFile, otherFiles, dismiss } = appState.filePreviewModal
@@ -91,8 +86,8 @@ const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appS
} }
}, [currentFile, getObjectUrl, objectUrl]) }, [currentFile, getObjectUrl, objectUrl])
const keyDownHandler = useCallback( const keyDownHandler: KeyboardEventHandler = useCallback(
(event: KeyboardEvent) => { (event) => {
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) { if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
return return
} }
@@ -141,6 +136,7 @@ const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appS
dangerouslyBypassScrollLock dangerouslyBypassScrollLock
> >
<DialogContent <DialogContent
aria-label="File preview modal"
className="flex flex-col rounded shadow-overlay" className="flex flex-col rounded shadow-overlay"
style={{ style={{
width: '90%', width: '90%',
@@ -256,3 +252,11 @@ const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appS
</DialogOverlay> </DialogOverlay>
) )
}) })
FilePreviewModal.displayName = 'FilePreviewModal'
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, appState }) => {
return appState.filePreviewModal.isOpen ? <FilePreviewModal application={application} appState={appState} /> : null
}
export default observer(FilePreviewModalWrapper)

View File

@@ -1,13 +1,12 @@
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent, useRef, useState } from 'react'
import { useRef, useState } from 'preact/hooks' import IconButton from '../Button/IconButton'
import { IconButton } from '../Button/IconButton'
type Props = { type Props = {
objectUrl: string objectUrl: string
} }
export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => { const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
const initialImgHeightRef = useRef<number>() const initialImgHeightRef = useRef<number>()
const [imageZoomPercent, setImageZoomPercent] = useState(100) const [imageZoomPercent, setImageZoomPercent] = useState(100)
@@ -21,8 +20,8 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
height: `${imageZoomPercent}%`, height: `${imageZoomPercent}%`,
...(imageZoomPercent <= 100 ...(imageZoomPercent <= 100
? { ? {
'min-width': '100%', minWidth: '100%',
'object-fit': 'contain', objectFit: 'contain',
} }
: { : {
position: 'absolute', position: 'absolute',
@@ -69,3 +68,5 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
</div> </div>
) )
} }
export default ImagePreview

View File

@@ -1,13 +1,13 @@
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { ImagePreview } from './ImagePreview' import ImagePreview from './ImagePreview'
type Props = { type Props = {
file: FileItem file: FileItem
objectUrl: string objectUrl: string
} }
export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => { const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
if (file.mimeType.startsWith('image/')) { if (file.mimeType.startsWith('image/')) {
return <ImagePreview objectUrl={objectUrl} /> return <ImagePreview objectUrl={objectUrl} />
} }
@@ -22,3 +22,5 @@ export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl })
return <object className="w-full h-full" data={objectUrl} /> return <object className="w-full h-full" data={objectUrl} />
} }
export default PreviewComponent

View File

@@ -11,12 +11,12 @@ import {
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON, STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/Strings' } from '@/Strings'
import { alertDialog, confirmDialog } from '@/Services/AlertService' import { alertDialog, confirmDialog } from '@/Services/AlertService'
import { AccountMenu } from '@/Components/AccountMenu/AccountMenu' import AccountMenu from '@/Components/AccountMenu/AccountMenu'
import { AppStateEvent, EventSource } from '@/UIModels/AppState' import { AppStateEvent, EventSource } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu/QuickSettingsMenu' import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu'
import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu/SyncResolutionMenu' import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu'
import { Fragment } from 'preact' import { Fragment } from 'react'
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane' import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
type Props = { type Props = {
@@ -39,7 +39,7 @@ type State = {
arbitraryStatusMessage?: string arbitraryStatusMessage?: string
} }
export class Footer extends PureComponent<Props, State> { class Footer extends PureComponent<Props, State> {
public user?: unknown public user?: unknown
private didCheckForOffline = false private didCheckForOffline = false
private completedInitialSync = false private completedInitialSync = false
@@ -455,3 +455,5 @@ export class Footer extends PureComponent<Props, State> {
) )
} }
} }
export default Footer

View File

@@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { import {
@@ -187,7 +187,7 @@ type Props = {
ariaLabel?: string ariaLabel?: string
} }
export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => { const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel }) => {
const IconComponent = ICONS[type as keyof typeof ICONS] const IconComponent = ICONS[type as keyof typeof ICONS]
if (!IconComponent) { if (!IconComponent) {
return null return null
@@ -200,3 +200,5 @@ export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLab
/> />
) )
} }
export default Icon

View File

@@ -1,5 +1,4 @@
import { FunctionalComponent, Ref } from 'preact' import { forwardRef, Fragment, Ref } from 'react'
import { forwardRef } from 'preact/compat'
import { DecoratedInputProps } from './DecoratedInputProps' import { DecoratedInputProps } from './DecoratedInputProps'
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => { const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
@@ -17,7 +16,7 @@ const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean
/** /**
* Input that can be decorated on the left and right side * Input that can be decorated on the left and right side
*/ */
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef( const DecoratedInput = forwardRef(
( (
{ {
type = 'text', type = 'text',
@@ -42,8 +41,8 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
<div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}> <div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}>
{left && ( {left && (
<div className="flex items-center px-2 py-1.5"> <div className="flex items-center px-2 py-1.5">
{left.map((leftChild) => ( {left.map((leftChild, index) => (
<>{leftChild}</> <Fragment key={index}>{leftChild}</Fragment>
))} ))}
</div> </div>
)} )}
@@ -58,14 +57,16 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
onFocus={onFocus} onFocus={onFocus}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
data-lpignore={type !== 'password' ? true : false} data-lpignore={type !== 'password' ? true : false}
autocomplete={autocomplete ? 'on' : 'off'} autoComplete={autocomplete ? 'on' : 'off'}
ref={ref} ref={ref}
/> />
{right && ( {right && (
<div className="flex items-center px-2 py-1.5"> <div className="flex items-center px-2 py-1.5">
{right.map((rightChild, index) => ( {right.map((rightChild, index) => (
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div> <div className={index > 0 ? 'ml-3' : ''} key={index}>
{rightChild}
</div>
))} ))}
</div> </div>
)} )}
@@ -73,3 +74,5 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
) )
}, },
) )
export default DecoratedInput

View File

@@ -1,15 +1,15 @@
import { ComponentChild } from 'preact' import { FocusEventHandler, KeyboardEventHandler, ReactNode } from 'react'
export type DecoratedInputProps = { export type DecoratedInputProps = {
type?: 'text' | 'email' | 'password' type?: 'text' | 'email' | 'password'
className?: string className?: string
disabled?: boolean disabled?: boolean
left?: ComponentChild[] left?: ReactNode[]
right?: ComponentChild[] right?: ReactNode[]
value?: string value?: string
placeholder?: string placeholder?: string
onChange?: (text: string) => void onChange?: (text: string) => void
onFocus?: (event: FocusEvent) => void onFocus?: FocusEventHandler
onKeyDown?: (event: KeyboardEvent) => void onKeyDown?: KeyboardEventHandler
autocomplete?: boolean autocomplete?: boolean
} }

View File

@@ -1,13 +1,11 @@
import { FunctionComponent, Ref } from 'preact' import { Dispatch, FunctionComponent, Ref, SetStateAction, forwardRef, useState } from 'react'
import { forwardRef } from 'preact/compat' import DecoratedInput from './DecoratedInput'
import { StateUpdater, useState } from 'preact/hooks' import IconButton from '@/Components/Button/IconButton'
import { DecoratedInput } from './DecoratedInput'
import { IconButton } from '@/Components/Button/IconButton'
import { DecoratedInputProps } from './DecoratedInputProps' import { DecoratedInputProps } from './DecoratedInputProps'
const Toggle: FunctionComponent<{ const Toggle: FunctionComponent<{
isToggled: boolean isToggled: boolean
setIsToggled: StateUpdater<boolean> setIsToggled: Dispatch<SetStateAction<boolean>>
}> = ({ isToggled, setIsToggled }) => ( }> = ({ isToggled, setIsToggled }) => (
<IconButton <IconButton
className="w-5 h-5 p-0 justify-center sk-circle hover:bg-passive-4 color-neutral" className="w-5 h-5 p-0 justify-center sk-circle hover:bg-passive-4 color-neutral"
@@ -22,19 +20,19 @@ const Toggle: FunctionComponent<{
/** /**
* Password input that has a toggle to show/hide password and can be decorated on the left and right side * 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( const DecoratedPasswordInput = forwardRef((props: DecoratedInputProps, ref: Ref<HTMLInputElement>) => {
(props, ref: Ref<HTMLInputElement>) => { const [isToggled, setIsToggled] = useState(false)
const [isToggled, setIsToggled] = useState(false)
const rightSideDecorations = props.right ? [...props.right] : [] const rightSideDecorations = props.right ? [...props.right] : []
return ( return (
<DecoratedInput <DecoratedInput
{...props} {...props}
ref={ref} ref={ref}
type={isToggled ? 'text' : 'password'} type={isToggled ? 'text' : 'password'}
right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]} right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]}
/> />
) )
}, })
)
export default DecoratedPasswordInput

View File

@@ -1,14 +1,11 @@
import { FunctionComponent, Ref } from 'preact' import { ChangeEventHandler, Ref, forwardRef, useState } from 'react'
import { JSXInternal } from 'preact/src/jsx'
import { forwardRef } from 'preact/compat'
import { useState } from 'preact/hooks'
type Props = { type Props = {
id: string id: string
type: 'text' | 'email' | 'password' type: 'text' | 'email' | 'password'
label: string label: string
value: string value: string
onChange: JSXInternal.GenericEventHandler<HTMLInputElement> onChange: ChangeEventHandler<HTMLInputElement>
disabled?: boolean disabled?: boolean
className?: string className?: string
labelClassName?: string labelClassName?: string
@@ -16,7 +13,7 @@ type Props = {
isInvalid?: boolean isInvalid?: boolean
} }
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef( const FloatingLabelInput = forwardRef(
( (
{ {
id, id,
@@ -71,3 +68,5 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
) )
}, },
) )
export default FloatingLabelInput

View File

@@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface Props { interface Props {
text?: string text?: string
@@ -6,9 +6,11 @@ interface Props {
className?: string className?: string
} }
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => { const Input: FunctionComponent<Props> = ({ className = '', disabled = false, text }) => {
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast' 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 stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
const classes = `${base} ${stateClasses} ${className}` const classes = `${base} ${stateClasses} ${className}`
return <input type="text" className={classes} disabled={disabled} value={text} /> return <input type="text" className={classes} disabled={disabled} value={text} />
} }
export default Input

View File

@@ -1,21 +1,26 @@
import { JSX, FunctionComponent, ComponentChildren, VNode, RefCallback, ComponentChild, toChildArray } from 'preact' import {
import { useCallback, useEffect, useRef } from 'preact/hooks' CSSProperties,
import { JSXInternal } from 'preact/src/jsx' FunctionComponent,
import { MenuItem, MenuItemListElement } from './MenuItem' KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type MenuProps = { type MenuProps = {
className?: string className?: string
style?: string | JSX.CSSProperties | undefined style?: CSSProperties | undefined
a11yLabel: string a11yLabel: string
children: ComponentChildren children: ReactNode
closeMenu?: () => void closeMenu?: () => void
isOpen: boolean isOpen: boolean
initialFocus?: number initialFocus?: number
} }
export const Menu: FunctionComponent<MenuProps> = ({ const Menu: FunctionComponent<MenuProps> = ({
children, children,
className = '', className = '',
style, style,
@@ -24,16 +29,10 @@ export const Menu: FunctionComponent<MenuProps> = ({
isOpen, isOpen,
initialFocus, initialFocus,
}: MenuProps) => { }: MenuProps) => {
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const menuElementRef = useRef<HTMLMenuElement>(null) const menuElementRef = useRef<HTMLMenuElement>(null)
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = useCallback( const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => { (event) => {
if (!menuItemRefs.current) {
return
}
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
closeMenu?.() closeMenu?.()
return return
@@ -45,58 +44,13 @@ export const Menu: FunctionComponent<MenuProps> = ({
useListKeyboardNavigation(menuElementRef, initialFocus) useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => { useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) { if (isOpen) {
setTimeout(() => { setTimeout(() => {
menuElementRef.current?.focus() menuElementRef.current?.focus()
}) })
} }
}, [isOpen]) }, [isOpen])
const pushRefToArray: RefCallback<HTMLLIElement> = useCallback((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 = useCallback(
(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>
)
})
},
[pushRefToArray],
)
return ( return (
<menu <menu
className={`m-0 p-0 list-style-none focus:shadow-none ${className}`} className={`m-0 p-0 list-style-none focus:shadow-none ${className}`}
@@ -105,7 +59,9 @@ export const Menu: FunctionComponent<MenuProps> = ({
style={style} style={style}
aria-label={a11yLabel} aria-label={a11yLabel}
> >
{toChildArray(children).map(mapMenuItems)} {children}
</menu> </menu>
) )
} }
export default Menu

View File

@@ -1,21 +1,15 @@
import { ComponentChildren, FunctionComponent, VNode } from 'preact' import { forwardRef, MouseEventHandler, ReactNode, Ref } from 'react'
import { forwardRef, Ref } from 'preact/compat' import Icon from '@/Components/Icon/Icon'
import { JSXInternal } from 'preact/src/jsx' import Switch from '@/Components/Switch/Switch'
import { Icon } from '@/Components/Icon/Icon' import { SwitchProps } from '@/Components/Switch/SwitchProps'
import { Switch, SwitchProps } from '@/Components/Switch/Switch'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { MenuItemType } from './MenuItemType'
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}
type MenuItemProps = { type MenuItemProps = {
type: MenuItemType type: MenuItemType
children: ComponentChildren children: ReactNode
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement> onClick?: MouseEventHandler<HTMLButtonElement>
onChange?: SwitchProps['onChange'] onChange?: SwitchProps['onChange']
onBlur?: (event: { relatedTarget: EventTarget | null }) => void onBlur?: (event: { relatedTarget: EventTarget | null }) => void
className?: string className?: string
@@ -25,7 +19,7 @@ type MenuItemProps = {
tabIndex?: number tabIndex?: number
} }
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef( const MenuItem = forwardRef(
( (
{ {
children, children,
@@ -42,63 +36,42 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
ref: Ref<HTMLButtonElement>, ref: Ref<HTMLButtonElement>,
) => { ) => {
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? ( return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
<button <li className="list-style-none" role="none">
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between" <button
onClick={() => { ref={ref}
onChange(!checked) className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
}} onClick={() => {
onBlur={onBlur} onChange(!checked)
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} }}
role="menuitemcheckbox" onBlur={onBlur}
aria-checked={checked} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
> role="menuitemcheckbox"
<span className="flex flex-grow items-center">{children}</span> aria-checked={checked}
<Switch className="px-0" checked={checked} /> >
</button> <span className="flex flex-grow items-center">{children}</span>
<Switch className="px-0" checked={checked} />
</button>
</li>
) : ( ) : (
<button <li className="list-style-none" role="none">
ref={ref} <button
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'} ref={ref}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
onClick={onClick} className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onBlur={onBlur} onClick={onClick}
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})} onBlur={onBlur}
> {...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null} >
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? ( {type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} flex-shrink-0`}></div> {type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
) : null} <div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} flex-shrink-0`}></div>
{children} ) : null}
</button> {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> </li>
) )
}, },
) )
export default MenuItem

View File

@@ -0,0 +1,9 @@
import { FunctionComponent } from 'react'
const MenuItemSeparator: FunctionComponent = () => (
<li className="list-style-none" role="none">
<div role="separator" className="h-1px my-2 bg-border" />
</li>
)
export default MenuItemSeparator

View File

@@ -0,0 +1,5 @@
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}

View File

@@ -1,18 +1,18 @@
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { IlNotesIcon } from '@standardnotes/icons' import { IlNotesIcon } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel' import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { PinNoteButton } from '@/Components/PinNoteButton/PinNoteButton' import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import { Button } from '../Button/Button' import Button from '../Button/Button'
import { useCallback } from 'preact/hooks' import { useCallback } from 'react'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState appState: AppState
} }
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => { const MultipleSelectedNotes = ({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount const count = appState.notes.selectedNotesCount
const cancelMultipleSelection = useCallback(() => { const cancelMultipleSelection = useCallback(() => {
@@ -40,4 +40,6 @@ export const MultipleSelectedNotes = observer(({ application, appState }: Props)
</div> </div>
</div> </div>
) )
}) }
export default observer(MultipleSelectedNotes)

View File

@@ -1,18 +1,17 @@
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection' import SmartViewsSection from '@/Components/Tags/SmartViewsSection'
import { TagsSection } from '@/Components/Tags/TagsSection' import TagsSection from '@/Components/Tags/TagsSection'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { PANEL_NAME_NAVIGATION } from '@/Constants' import { PANEL_NAME_NAVIGATION } from '@/Constants'
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs' import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
type Props = { type Props = {
application: WebApplication application: WebApplication
} }
export const Navigation: FunctionComponent<Props> = observer(({ application }) => { const Navigation: FunctionComponent<Props> = ({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]) const appState = useMemo(() => application.getAppState(), [application])
const [ref, setRef] = useState<HTMLDivElement | null>() const [ref, setRef] = useState<HTMLDivElement | null>()
const [panelWidth, setPanelWidth] = useState<number>(0) const [panelWidth, setPanelWidth] = useState<number>(0)
@@ -79,4 +78,6 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
)} )}
</div> </div>
) )
}) }
export default observer(Navigation)

View File

@@ -1,18 +1,13 @@
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { useCallback } from 'preact/hooks' import { MouseEventHandler, useCallback } from 'react'
type Props = { appState: AppState } type Props = { appState: AppState }
export const NoAccountWarning = observer(({ appState }: Props) => { const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show const showAccountMenu: MouseEventHandler = useCallback(
if (!canShow) { (event) => {
return null
}
const showAccountMenu = useCallback(
(event: Event) => {
event.stopPropagation() event.stopPropagation()
appState.accountMenu.setShow(true) appState.accountMenu.setShow(true)
}, },
@@ -32,9 +27,9 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
</button> </button>
<button <button
onClick={hideWarning} onClick={hideWarning}
title="Ignore" title="Ignore warning"
label="Ignore" aria-label="Ignore warning"
style="height: 20px" 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" 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" /> <Icon type="close" className="block" />
@@ -42,3 +37,13 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
</div> </div>
) )
}) })
NoAccountWarning.displayName = 'NoAccountWarning'
const NoAccountWarningWrapper = ({ appState }: Props) => {
const canShow = appState.noAccountWarning.show
return canShow ? <NoAccountWarning appState={appState} /> : null
}
export default observer(NoAccountWarningWrapper)

View File

@@ -1,8 +1,8 @@
import { NoteViewController } from '@standardnotes/snjs' import { NoteViewController } from '@standardnotes/snjs'
import { PureComponent } from '@/Components/Abstract/PureComponent' import { PureComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes' import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import { NoteView } from '@/Components/NoteView/NoteView' import NoteView from '@/Components/NoteView/NoteView'
import { ElementIds } from '@/ElementIDs' import { ElementIds } from '@/ElementIDs'
type State = { type State = {
@@ -14,7 +14,7 @@ type Props = {
application: WebApplication application: WebApplication
} }
export class NoteGroupView extends PureComponent<Props, State> { class NoteGroupView extends PureComponent<Props, State> {
private removeChangeObserver!: () => void private removeChangeObserver!: () => void
constructor(props: Props) { constructor(props: Props) {
@@ -70,3 +70,5 @@ export class NoteGroupView extends PureComponent<Props, State> {
) )
} }
} }
export default NoteGroupView

View File

@@ -1,5 +1,13 @@
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { SNTag } from '@standardnotes/snjs' import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
@@ -9,7 +17,7 @@ type Props = {
tag: SNTag tag: SNTag
} }
export const NoteTag = observer(({ appState, tag }: Props) => { const NoteTag = ({ appState, tag }: Props) => {
const noteTags = appState.noteTags const noteTags = appState.noteTags
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
@@ -29,17 +37,16 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error) appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
}, [appState, tag]) }, [appState, tag])
const onDeleteTagClick = useCallback( const onDeleteTagClick: MouseEventHandler = useCallback(
(event: MouseEvent) => { (event) => {
event.stopImmediatePropagation()
event.stopPropagation() event.stopPropagation()
deleteTag() deleteTag()
}, },
[deleteTag], [deleteTag],
) )
const onTagClick = useCallback( const onTagClick: MouseEventHandler = useCallback(
(event: MouseEvent) => { (event) => {
if (tagClicked && event.target !== deleteTagRef.current) { if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false) setTagClicked(false)
appState.tags.selected = tag appState.tags.selected = tag
@@ -55,8 +62,8 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
setShowDeleteButton(true) setShowDeleteButton(true)
}, [appState, tag]) }, [appState, tag])
const onBlur = useCallback( const onBlur: FocusEventHandler = useCallback(
(event: FocusEvent) => { (event) => {
const relatedTarget = event.relatedTarget as Node const relatedTarget = event.relatedTarget as Node
if (relatedTarget !== deleteTagRef.current) { if (relatedTarget !== deleteTagRef.current) {
appState.noteTags.setFocusedTagUuid(undefined) appState.noteTags.setFocusedTagUuid(undefined)
@@ -76,8 +83,8 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
return tags[0].uuid === tag.uuid ? 0 : -1 return tags[0].uuid === tag.uuid ? 0 : -1
}, [autocompleteInputFocused, tags, tag, focusedTagUuid]) }, [autocompleteInputFocused, tags, tag, focusedTagUuid])
const onKeyDown = useCallback( const onKeyDown: KeyboardEventHandler = useCallback(
(event: KeyboardEvent) => { (event) => {
const tagIndex = appState.noteTags.getTagIndex(tag, tags) const tagIndex = appState.noteTags.getTagIndex(tag, tags)
switch (event.key) { switch (event.key) {
case 'Backspace': case 'Backspace':
@@ -136,4 +143,6 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
)} )}
</button> </button>
) )
}) }
export default observer(NoteTag)

View File

@@ -1,19 +1,14 @@
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput' import AutocompleteTagInput from '@/Components/TagAutocomplete/AutocompleteTagInput'
import { NoteTag } from './NoteTag' import NoteTag from './NoteTag'
import { useEffect } from 'preact/hooks' import { useEffect } from 'react'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
appState: AppState appState: AppState
} }
export const NoteTagsContainer = observer(({ appState }: Props) => { const NoteTagsContainer = ({ appState }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const { tags, tagsContainerMaxWidth } = appState.noteTags const { tags, tagsContainerMaxWidth } = appState.noteTags
useEffect(() => { useEffect(() => {
@@ -33,4 +28,6 @@ export const NoteTagsContainer = observer(({ appState }: Props) => {
<AutocompleteTagInput appState={appState} /> <AutocompleteTagInput appState={appState} />
</div> </div>
) )
}) }
export default observer(NoteTagsContainer)

View File

@@ -1,5 +1,5 @@
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '../Icon/Icon' import Icon from '../Icon/Icon'
type Props = { type Props = {
onMouseLeave: () => void onMouseLeave: () => void
@@ -9,7 +9,7 @@ type Props = {
lockText: string lockText: string
} }
export const EditingDisabledBanner: FunctionComponent<Props> = ({ const EditingDisabledBanner: FunctionComponent<Props> = ({
onMouseLeave, onMouseLeave,
onMouseOver, onMouseOver,
onClick, onClick,
@@ -36,3 +36,5 @@ export const EditingDisabledBanner: FunctionComponent<Props> = ({
</div> </div>
) )
} }
export default EditingDisabledBanner

View File

@@ -12,7 +12,7 @@ import {
SNNote, SNNote,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { NoteView } from './NoteView' import NoteView from './NoteView'
describe('NoteView', () => { describe('NoteView', () => {
let noteViewController: NoteViewController let noteViewController: NoteViewController

View File

@@ -1,4 +1,4 @@
import { createRef, JSX, RefObject } from 'preact' import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { import {
ApplicationEvent, ApplicationEvent,
isPayloadSourceRetrieved, isPayloadSourceRetrieved,
@@ -19,16 +19,16 @@ import { KeyboardModifier, KeyboardKey } from '@/Services/IOService'
import { STRING_DELETE_PLACEHOLDER_ATTEMPT, STRING_DELETE_LOCKED_ATTEMPT, StringDeleteNote } from '@/Strings' import { STRING_DELETE_PLACEHOLDER_ATTEMPT, STRING_DELETE_LOCKED_ATTEMPT, StringDeleteNote } from '@/Strings'
import { confirmDialog } from '@/Services/AlertService' import { confirmDialog } from '@/Services/AlertService'
import { PureComponent } from '@/Components/Abstract/PureComponent' import { PureComponent } from '@/Components/Abstract/PureComponent'
import { ProtectedNoteOverlay } from '@/Components/ProtectedNoteOverlay/ProtectedNoteOverlay' import ProtectedNoteOverlay from '@/Components/ProtectedNoteOverlay/ProtectedNoteOverlay'
import { PinNoteButton } from '@/Components/PinNoteButton/PinNoteButton' import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel' import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import { NoteTagsContainer } from '@/Components/NoteTags/NoteTagsContainer' import NoteTagsContainer from '@/Components/NoteTags/NoteTagsContainer'
import { ComponentView } from '@/Components/ComponentView/ComponentView' import ComponentView from '@/Components/ComponentView/ComponentView'
import { PanelSide, PanelResizer, PanelResizeType } from '@/Components/PanelResizer/PanelResizer' import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { ElementIds } from '@/ElementIDs' import { ElementIds } from '@/ElementIDs'
import { ChangeEditorButton } from '@/Components/ChangeEditor/ChangeEditorButton' import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
import { AttachedFilesButton } from '@/Components/AttachedFilesPopover/AttachedFilesButton' import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
import { EditingDisabledBanner } from './EditingDisabledBanner' import EditingDisabledBanner from './EditingDisabledBanner'
import { import {
transactionForAssociateComponentWithCurrentNote, transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote, transactionForDisassociateComponentWithCurrentNote,
@@ -78,7 +78,7 @@ type State = {
rightResizerOffset: number rightResizerOffset: number
} }
export class NoteView extends PureComponent<NoteViewProps, State> { class NoteView extends PureComponent<NoteViewProps, State> {
readonly controller!: NoteViewController readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout private statusTimeout?: NodeJS.Timeout
@@ -528,7 +528,7 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
} }
} }
onTextAreaChange = ({ currentTarget }: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => { onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value const text = currentTarget.value
this.setState({ this.setState({
editorText: text, editorText: text,
@@ -548,12 +548,16 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
.catch(console.error) .catch(console.error)
} }
onTitleEnter = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => { onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
if (key !== KeyboardKey.Enter) {
return
}
currentTarget.blur() currentTarget.blur()
this.focusEditor() this.focusEditor()
} }
onTitleChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => { onTitleChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
const title = currentTarget.value const title = currentTarget.value
this.setState({ this.setState({
editorTitle: title, editorTitle: title,
@@ -911,12 +915,12 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
id={ElementIds.NoteTitleEditor} id={ElementIds.NoteTitleEditor}
onChange={this.onTitleChange} onChange={this.onTitleChange}
onFocus={(event) => { onFocus={(event) => {
;(event.target as HTMLTextAreaElement).select() event.target.select()
}} }}
onKeyUp={(event) => event.keyCode == 13 && this.onTitleEnter(event)} onKeyUp={this.onTitleEnter}
spellcheck={false} spellCheck={false}
value={this.state.editorTitle} value={this.state.editorTitle}
autocomplete="off" autoComplete="off"
/> />
</div> </div>
</div> </div>
@@ -996,15 +1000,15 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
{this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading && ( {this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading && (
<textarea <textarea
autocomplete="off" autoComplete="off"
className="editable font-editor" className="editable font-editor"
dir="auto" dir="auto"
id={ElementIds.NoteTextEditor} id={ElementIds.NoteTextEditor}
onChange={this.onTextAreaChange} onChange={this.onTextAreaChange}
value={this.state.editorText} value={this.state.editorText}
readonly={this.state.noteLocked} readOnly={this.state.noteLocked}
onFocus={this.onContentFocus} onFocus={this.onContentFocus}
spellcheck={this.state.spellcheck} spellCheck={this.state.spellcheck}
ref={(ref) => ref && this.onSystemEditorLoad(ref)} ref={(ref) => ref && this.onSystemEditorLoad(ref)}
></textarea> ></textarea>
)} )}
@@ -1059,7 +1063,7 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
<div className="sn-component"> <div className="sn-component">
{this.state.stackComponentViewers.map((viewer) => { {this.state.stackComponentViewers.map((viewer) => {
return ( return (
<div className="component-view component-stack-item"> <div className="component-view component-stack-item" key={viewer.identifier}>
<ComponentView <ComponentView
key={viewer.identifier} key={viewer.identifier}
componentViewer={viewer} componentViewer={viewer}
@@ -1076,3 +1080,5 @@ export class NoteView extends PureComponent<NoteViewProps, State> {
) )
} }
} }
export default NoteView

View File

@@ -2,8 +2,8 @@ import { AppState } from '@/UIModels/AppState'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { NotesOptions } from '@/Components/NotesOptions/NotesOptions' import NotesOptions from '@/Components/NotesOptions/NotesOptions'
import { useCallback, useEffect, useRef } from 'preact/hooks' import { useCallback, useEffect, useRef } from 'react'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
type Props = { type Props = {
@@ -11,7 +11,7 @@ type Props = {
appState: AppState appState: AppState
} }
export const NotesContextMenu = observer(({ application, appState }: Props) => { const NotesContextMenu = ({ application, appState }: Props) => {
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = appState.notes const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = appState.notes
const contextMenuRef = useRef<HTMLDivElement>(null) const contextMenuRef = useRef<HTMLDivElement>(null)
@@ -42,4 +42,6 @@ export const NotesContextMenu = observer(({ application, appState }: Props) => {
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} /> <NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
</div> </div>
) : null ) : null
}) }
export default observer(NotesContextMenu)

View File

@@ -0,0 +1,8 @@
import { IconType } from '@standardnotes/snjs'
export type AccordionMenuGroup<T> = {
icon?: IconType
iconClassName?: string
title: string
items: Array<T>
}

View File

@@ -2,16 +2,15 @@ import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = { type Props = {
appState: AppState appState: AppState
} }
export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) => { const AddTagOption: FunctionComponent<Props> = ({ appState }) => {
const menuContainerRef = useRef<HTMLDivElement>(null) const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null) const menuButtonRef = useRef<HTMLButtonElement>(null)
@@ -87,7 +86,7 @@ export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) =>
> >
{appState.tags.tags.map((tag) => ( {appState.tags.tags.map((tag) => (
<button <button
key={tag.title} key={tag.uuid}
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80" className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
onBlur={closeOnBlur} onBlur={closeOnBlur}
onClick={() => { onClick={() => {
@@ -108,4 +107,6 @@ export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) =>
</Disclosure> </Disclosure>
</div> </div>
) )
}) }
export default observer(AddTagOption)

View File

@@ -2,11 +2,10 @@ import { KeyboardKey } from '@/Services/IOService'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { IconType, SNComponent, SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon' import ChangeEditorMenu from '@/Components/ChangeEditor/ChangeEditorMenu'
import { ChangeEditorMenu } from '@/Components/ChangeEditor/ChangeEditorMenu'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
@@ -16,22 +15,7 @@ type ChangeEditorOptionProps = {
note: SNNote note: SNNote
} }
type AccordionMenuGroup<T> = { const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ application, note }) => {
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 [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({ const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
@@ -121,3 +105,5 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
</div> </div>
) )
} }
export default ChangeEditorOption

View File

@@ -0,0 +1,4 @@
import { EditorMenuItem } from './EditorMenuItem'
import { AccordionMenuGroup } from './AccordionMenuGroup'
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>

View File

@@ -0,0 +1,7 @@
import { SNComponent } from '@standardnotes/snjs'
export type EditorMenuItem = {
name: string
component?: SNComponent
isEntitled: boolean
}

View File

@@ -2,9 +2,8 @@ import { WebApplication } from '@/UIModels/Application'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs' import { Action, ListedAccount, SNNote } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact' import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = { type Props = {
@@ -206,7 +205,7 @@ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({ applicat
) )
} }
export const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => { const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => {
const menuContainerRef = useRef<HTMLDivElement>(null) const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null) const menuButtonRef = useRef<HTMLButtonElement>(null)
@@ -273,3 +272,5 @@ export const ListedActionsOption: FunctionComponent<Props> = ({ application, not
</div> </div>
) )
} }
export default ListedActionsOption

View File

@@ -1,23 +1,16 @@
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { Switch } from '@/Components/Switch/Switch' import Switch from '@/Components/Switch/Switch'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback } from 'preact/hooks' import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
import { SNApplication, SNNote } from '@standardnotes/snjs' import { SNApplication, SNNote } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { KeyboardModifier } from '@/Services/IOService' import { KeyboardModifier } from '@/Services/IOService'
import { FunctionComponent } from 'preact' import ChangeEditorOption from './ChangeEditorOption'
import { ChangeEditorOption } from './ChangeEditorOption'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants' import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
import { ListedActionsOption } from './ListedActionsOption' import ListedActionsOption from './ListedActionsOption'
import { AddTagOption } from './AddTagOption' import AddTagOption from './AddTagOption'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit' import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
import { NotesOptionsProps } from './NotesOptionsProps'
export type NotesOptionsProps = {
application: WebApplication
appState: AppState
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
type DeletePermanentlyButtonProps = { type DeletePermanentlyButtonProps = {
closeOnBlur: NotesOptionsProps['closeOnBlur'] closeOnBlur: NotesOptionsProps['closeOnBlur']
@@ -176,7 +169,7 @@ const NoteSizeWarning: FunctionComponent<{
) : null ) : null
} }
export const NotesOptions = observer(({ application, appState, closeOnBlur }: NotesOptionsProps) => { const NotesOptions = ({ application, appState, closeOnBlur }: NotesOptionsProps) => {
const [altKeyDown, setAltKeyDown] = useState(false) const [altKeyDown, setAltKeyDown] = useState(false)
const toggleOn = (condition: (note: SNNote) => boolean) => { const toggleOn = (condition: (note: SNNote) => boolean) => {
@@ -440,4 +433,6 @@ export const NotesOptions = observer(({ application, appState, closeOnBlur }: No
) : null} ) : null}
</> </>
) )
}) }
export default observer(NotesOptions)

View File

@@ -1,11 +1,11 @@
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useRef, useState } from 'preact/hooks' import { useRef, useState } from 'react'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { NotesOptions } from './NotesOptions' import NotesOptions from './NotesOptions'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
@@ -15,7 +15,7 @@ type Props = {
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const NotesOptionsPanel = observer(({ application, appState, onClickPreprocessing }: Props) => { const NotesOptionsPanel = ({ application, appState, onClickPreprocessing }: Props) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ const [position, setPosition] = useState({
top: 0, top: 0,
@@ -83,4 +83,6 @@ export const NotesOptionsPanel = observer(({ application, appState, onClickPrepr
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
) )
}) }
export default observer(NotesOptionsPanel)

View File

@@ -0,0 +1,8 @@
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
export type NotesOptionsProps = {
application: WebApplication
appState: AppState
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'preact/hooks' import { useCallback, useRef } from 'react'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog' import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
@@ -9,13 +9,6 @@ type Props = {
appState: AppState 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 ConfirmOtherSessionsSignOut = observer(({ application, appState }: Props) => {
const cancelRef = useRef<HTMLButtonElement>(null) const cancelRef = useRef<HTMLButtonElement>(null)
@@ -65,3 +58,14 @@ const ConfirmOtherSessionsSignOut = observer(({ application, appState }: Props)
</AlertDialog> </AlertDialog>
) )
}) })
ConfirmOtherSessionsSignOut.displayName = 'ConfirmOtherSessionsSignOut'
const OtherSessionsSignOutContainer = (props: Props) => {
if (!props.appState.accountMenu.otherSessionsSignOut) {
return null
}
return <ConfirmOtherSessionsSignOut {...props} />
}
export default observer(OtherSessionsSignOutContainer)

View File

@@ -1,4 +1,4 @@
import { Component, createRef } from 'preact' import { Component, createRef, MouseEventHandler } from 'react'
import { debounce } from '@/Utils' import { debounce } from '@/Utils'
export type ResizeFinishCallback = ( export type ResizeFinishCallback = (
@@ -38,7 +38,7 @@ type State = {
pressed: boolean pressed: boolean
} }
export class PanelResizer extends Component<Props, State> { class PanelResizer extends Component<Props, State> {
private overlay?: HTMLDivElement private overlay?: HTMLDivElement
private resizerElementRef = createRef<HTMLDivElement>() private resizerElementRef = createRef<HTMLDivElement>()
private debouncedResizeHandler: () => void private debouncedResizeHandler: () => void
@@ -76,6 +76,10 @@ export class PanelResizer extends Component<Props, State> {
} }
} }
override componentDidMount() {
this.resizerElementRef.current?.addEventListener('dblclick', this.onDblClick)
}
override componentDidUpdate(prevProps: Props) { override componentDidUpdate(prevProps: Props) {
if (this.props.width != prevProps.width) { if (this.props.width != prevProps.width) {
this.setWidth(this.props.width) this.setWidth(this.props.width)
@@ -92,6 +96,7 @@ export class PanelResizer extends Component<Props, State> {
} }
override componentWillUnmount() { override componentWillUnmount() {
this.resizerElementRef.current?.removeEventListener('dblclick', this.onDblClick)
document.removeEventListener('mouseup', this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp)
document.removeEventListener('mousemove', this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('resize', this.debouncedResizeHandler) window.removeEventListener('resize', this.debouncedResizeHandler)
@@ -241,7 +246,7 @@ export class PanelResizer extends Component<Props, State> {
this.finishSettingWidth() this.finishSettingWidth()
} }
onMouseDown = (event: MouseEvent) => { onMouseDown: MouseEventHandler = (event) => {
this.addInvisibleOverlay() this.addInvisibleOverlay()
this.lastDownX = event.clientX this.lastDownX = event.clientX
this.startWidth = this.props.panel.scrollWidth this.startWidth = this.props.panel.scrollWidth
@@ -299,16 +304,17 @@ export class PanelResizer extends Component<Props, State> {
} }
} }
render() { override render() {
return ( return (
<div <div
className={`panel-resizer ${this.props.side} ${this.props.hoverable ? 'hoverable' : ''} ${ className={`panel-resizer ${this.props.side} ${this.props.hoverable ? 'hoverable' : ''} ${
this.props.alwaysVisible ? 'alwaysVisible' : '' this.props.alwaysVisible ? 'alwaysVisible' : ''
} ${this.state.pressed ? 'dragging' : ''} ${this.state.collapsed ? 'collapsed' : ''}`} } ${this.state.pressed ? 'dragging' : ''} ${this.state.collapsed ? 'collapsed' : ''}`}
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
onDblClick={this.onDblClick}
ref={this.resizerElementRef} ref={this.resizerElementRef}
></div> ></div>
) )
} }
} }
export default PanelResizer

View File

@@ -1,9 +1,10 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { createRef, JSX } from 'preact' import { ChangeEventHandler, createRef } from 'react'
import { PureComponent } from '@/Components/Abstract/PureComponent' import { PureComponent } from '@/Components/Abstract/PureComponent'
interface Props { interface Props {
application: WebApplication application: WebApplication
dismissModal: () => void
} }
type State = { type State = {
@@ -31,7 +32,7 @@ type FormData = {
status?: string status?: string
} }
export class PasswordWizard extends PureComponent<Props, State> { class PasswordWizard extends PureComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>() private currentPasswordInput = createRef<HTMLInputElement>()
constructor(props: Props) { constructor(props: Props) {
@@ -188,7 +189,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
if (this.state.lockContinue) { if (this.state.lockContinue) {
this.application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error) this.application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else { } else {
this.dismissModal() this.props.dismissModal()
} }
} }
@@ -201,19 +202,19 @@ export class PasswordWizard extends PureComponent<Props, State> {
}) })
} }
handleCurrentPasswordInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => { handleCurrentPasswordInputChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
this.setFormDataState({ this.setFormDataState({
currentPassword: currentTarget.value, currentPassword: currentTarget.value,
}).catch(console.error) }).catch(console.error)
} }
handleNewPasswordInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => { handleNewPasswordInputChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
this.setFormDataState({ this.setFormDataState({
newPassword: currentTarget.value, newPassword: currentTarget.value,
}).catch(console.error) }).catch(console.error)
} }
handleNewPasswordConfirmationInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => { handleNewPasswordConfirmationInputChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
this.setFormDataState({ this.setFormDataState({
newPasswordConfirmation: currentTarget.value, newPasswordConfirmation: currentTarget.value,
}).catch(console.error) }).catch(console.error)
@@ -310,3 +311,5 @@ export class PasswordWizard extends PureComponent<Props, State> {
) )
} }
} }
export default PasswordWizard

View File

@@ -1,45 +1,27 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { SNComponent } from '@standardnotes/snjs' import { SNComponent } from '@standardnotes/snjs'
import { Component } from 'preact' import { Component } from 'react'
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
interface Props { interface Props {
application: WebApplication application: WebApplication
callback: (approved: boolean) => void callback: (approved: boolean) => void
dismiss: () => void
component: SNComponent component: SNComponent
permissionsString: string permissionsString: string
} }
export class PermissionsModal extends Component<Props> { 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 = () => { accept = () => {
this.props.callback(true) this.props.callback(true)
this.dismiss() this.props.dismiss()
} }
deny = () => { deny = () => {
this.props.callback(false) this.props.callback(false)
this.dismiss() this.props.dismiss()
} }
render() { override render() {
return ( return (
<div className="sk-modal"> <div className="sk-modal">
<div onClick={this.deny} className="sk-modal-background" /> <div onClick={this.deny} className="sk-modal-background" />
@@ -88,3 +70,5 @@ export class PermissionsModal extends Component<Props> {
) )
} }
} }
export default PermissionsModal

View File

@@ -0,0 +1,56 @@
import { WebApplication } from '@/UIModels/Application'
import { ApplicationEvent, PermissionDialog } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import PermissionsModal from './PermissionsModal'
type Props = {
application: WebApplication
}
const PermissionsModalWrapper: FunctionComponent<Props> = ({ application }) => {
const [dialog, setDialog] = useState<PermissionDialog>()
const presentPermissionsDialog = useCallback((permissionDialog: PermissionDialog) => {
setDialog(permissionDialog)
}, [])
const dismissPermissionsDialog = useCallback(() => {
setDialog(undefined)
}, [])
const onAppStart = useCallback(() => {
application.componentManager.presentPermissionsDialog = presentPermissionsDialog
return () => {
;(application.componentManager.presentPermissionsDialog as unknown) = undefined
}
}, [application, presentPermissionsDialog])
useEffect(() => {
if (application.isStarted()) {
onAppStart()
}
const removeAppObserver = application.addEventObserver(async (eventName) => {
if (eventName === ApplicationEvent.Started) {
onAppStart()
}
})
return () => {
removeAppObserver()
}
}, [application, onAppStart])
return dialog ? (
<PermissionsModal
application={application}
callback={dialog.callback}
dismiss={dismissPermissionsDialog}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>
) : null
}
export default PermissionsModalWrapper

View File

@@ -1,10 +1,8 @@
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback } from 'react'
import { Icon } from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { useCallback } from 'preact/hooks'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
appState: AppState appState: AppState
@@ -12,34 +10,27 @@ type Props = {
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const PinNoteButton: FunctionComponent<Props> = observer( const PinNoteButton: FunctionComponent<Props> = ({ appState, className = '', onClickPreprocessing }: Props) => {
({ appState, className = '', onClickPreprocessing }: Props) => { const notes = appState.notes.selectedNotes
if (isStateDealloced(appState)) { const pinned = notes.some((note) => note.pinned)
return null
const togglePinned = useCallback(async () => {
if (onClickPreprocessing) {
await onClickPreprocessing()
} }
if (!pinned) {
appState.notes.setPinSelectedNotes(true)
} else {
appState.notes.setPinSelectedNotes(false)
}
}, [appState, onClickPreprocessing, pinned])
const notes = appState.notes.selectedNotes return (
const pinned = notes.some((note) => note.pinned) <button className={`sn-icon-button border-contrast ${pinned ? 'toggled' : ''} ${className}`} onClick={togglePinned}>
<VisuallyHidden>Pin selected notes</VisuallyHidden>
<Icon type="pin" className="block" />
</button>
)
}
const togglePinned = useCallback(async () => { export default observer(PinNoteButton)
if (onClickPreprocessing) {
await onClickPreprocessing()
}
if (!pinned) {
appState.notes.setPinSelectedNotes(true)
} else {
appState.notes.setPinSelectedNotes(false)
}
}, [appState, onClickPreprocessing, pinned])
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,60 @@
import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import Backups from '@/Components/Preferences/Panes/Backups/Backups'
import Appearance from './Panes/Appearance'
import General from './Panes/General/General'
import AccountPreferences from './Panes/Account/AccountPreferences'
import Security from './Panes/Security/Security'
import Listed from './Panes/Listed/Listed'
import HelpAndFeedback from './Panes/HelpFeedback'
import { PreferencesProps } from './PreferencesProps'
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
menu,
appState,
application,
mfaProvider,
userProvider,
}) => {
switch (menu.selectedPaneId) {
case 'general':
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
)
case 'account':
return <AccountPreferences application={application} appState={appState} />
case 'appearance':
return <Appearance application={application} />
case 'security':
return (
<Security mfaProvider={mfaProvider} userProvider={userProvider} appState={appState} application={application} />
)
case 'backups':
return <Backups application={application} appState={appState} />
case 'listed':
return <Listed application={application} />
case 'shortcuts':
return null
case 'accessibility':
return null
case 'get-free-month':
return null
case 'help-feedback':
return <HelpAndFeedback />
default:
return (
<General
appState={appState}
application={application}
extensionsLatestVersions={menu.extensionsLatestVersions}
/>
)
}
}
export default observer(PaneSelector)

View File

@@ -1,20 +1,20 @@
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { Authentication } from './Authentication' import Authentication from './Authentication'
import { Credentials } from './Credentials' import Credentials from './Credentials'
import { Sync } from './Sync' import Sync from './Sync'
import { Subscription } from './Subscription/Subscription' import Subscription from './Subscription/Subscription'
import { SignOutWrapper } from './SignOutView' import SignOutWrapper from './SignOutView'
import { FilesSection } from './Files' import FilesSection from './Files'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState appState: AppState
} }
export const AccountPreferences = observer(({ application, appState }: Props) => ( const AccountPreferences = ({ application, appState }: Props) => (
<PreferencesPane> <PreferencesPane>
{!application.hasAccount() ? ( {!application.hasAccount() ? (
<Authentication application={application} appState={appState} /> <Authentication application={application} appState={appState} />
@@ -28,4 +28,6 @@ export const AccountPreferences = observer(({ application, appState }: Props) =>
{application.hasAccount() && appState.features.hasFiles && <FilesSection application={application} />} {application.hasAccount() && appState.features.hasFiles && <FilesSection application={application} />}
<SignOutWrapper application={application} appState={appState} /> <SignOutWrapper application={application} appState={appState} />
</PreferencesPane> </PreferencesPane>
)) )
export default observer(AccountPreferences)

View File

@@ -1,20 +1,21 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
import { PreferencesGroup, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents' import OfflineSubscription from '@/Components/Preferences/Panes/Account/OfflineSubscription'
import { OfflineSubscription } from '@/Components/Preferences/Panes/Account/OfflineSubscription'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { Extensions } from '@/Components/Preferences/Panes/Extensions/Extensions' import Extensions from '@/Components/Preferences/Panes/Extensions/Extensions'
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions' import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
import { AccordionItem } from '@/Components/Shared/AccordionItem' import AccordionItem from '@/Components/Shared/AccordionItem'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
interface IProps { type Props = {
application: WebApplication application: WebApplication
appState: AppState appState: AppState
extensionsLatestVersions: ExtensionsLatestVersions extensionsLatestVersions: ExtensionsLatestVersions
} }
export const Advanced: FunctionalComponent<IProps> = observer(({ application, appState, extensionsLatestVersions }) => { const Advanced: FunctionComponent<Props> = ({ application, appState, extensionsLatestVersions }) => {
return ( return (
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
@@ -33,4 +34,6 @@ export const Advanced: FunctionalComponent<IProps> = observer(({ application, ap
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
) )
}) }
export default observer(Advanced)

View File

@@ -1,16 +1,20 @@
import { Button } from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/Components/Preferences/PreferencesComponents' import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState' import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { AccountIllustration } from '@standardnotes/icons' import { AccountIllustration } from '@standardnotes/icons'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
export const Authentication: FunctionComponent<{ type Props = {
application: WebApplication application: WebApplication
appState: AppState appState: AppState
}> = observer(({ appState }) => { }
const Authentication: FunctionComponent<Props> = ({ appState }) => {
const clickSignIn = () => { const clickSignIn = () => {
appState.preferences.closePreferences() appState.preferences.closePreferences()
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn) appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn)
@@ -43,4 +47,6 @@ export const Authentication: FunctionComponent<{
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
) )
}) }
export default observer(Authentication)

Some files were not shown because too many files have changed in this diff Show More