refactor: format and lint codebase (#971)

This commit is contained in:
Aman Harwara 2022-04-13 22:02:34 +05:30 committed by GitHub
parent dc9c1ea0fc
commit 8e467f9e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
367 changed files with 13778 additions and 16093 deletions

View File

@ -5,7 +5,8 @@
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"prettier", "prettier",
"plugin:react-hooks/recommended" "plugin:react-hooks/recommended",
"./node_modules/@standardnotes/config/src/.eslintrc"
], ],
"plugins": ["@typescript-eslint", "react", "react-hooks"], "plugins": ["@typescript-eslint", "react", "react-hooks"],
"parserOptions": { "parserOptions": {
@ -23,7 +24,9 @@
"react-hooks/exhaustive-deps": "error", "react-hooks/exhaustive-deps": "error",
"eol-last": "error", "eol-last": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error" "no-trailing-spaces": "error",
"@typescript-eslint/no-explicit-any": "warn",
"no-invalid-this": "warn"
}, },
"env": { "env": {
"browser": true "browser": true

View File

@ -1,3 +1,6 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
} }

View File

@ -1,3 +1,3 @@
declare module '*.svg' { declare module '*.svg' {
export default function SvgComponent(props: React.SVGProps<SVGSVGElement>): JSX.Element; export default function SvgComponent(props: React.SVGProps<SVGSVGElement>): JSX.Element
} }

View File

@ -1 +1 @@
declare module 'qrcode.react'; declare module 'qrcode.react'

View File

@ -0,0 +1,79 @@
'use strict'
declare global {
interface Window {
dashboardUrl?: string
defaultSyncServer: string
devAccountEmail?: string
devAccountPassword?: string
devAccountServer?: string
enabledUnfinishedFeatures: boolean
plansUrl?: string
purchaseUrl?: string
startApplication?: StartApplication
websocketUrl: string
electronAppVersion?: string
}
}
import { IsWebPlatform, WebAppVersion } from '@/Version'
import { Runtime, SNLog } from '@standardnotes/snjs'
import { render } from 'preact'
import { ApplicationGroupView } from './Components/ApplicationGroupView'
import { Bridge } from './Services/Bridge'
import { BrowserBridge } from './Services/BrowserBridge'
import { StartApplication } from './StartApplication'
import { ApplicationGroup } from './UIModels/ApplicationGroup'
import { isDev } from './Utils'
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
bridge: Bridge,
enableUnfinishedFeatures: boolean,
webSocketUrl: string,
) {
SNLog.onLog = console.log
SNLog.onError = console.error
const mainApplicationGroup = new ApplicationGroup(
defaultSyncServerHost,
bridge,
enableUnfinishedFeatures ? Runtime.Dev : Runtime.Prod,
webSocketUrl,
)
if (isDev) {
Object.defineProperties(window, {
application: {
get: () => mainApplicationGroup.primaryApplication,
},
})
}
const renderApp = () => {
render(
<ApplicationGroupView mainApplicationGroup={mainApplicationGroup} />,
document.body.appendChild(document.createElement('div')),
)
}
const domReady = document.readyState === 'complete' || document.readyState === 'interactive'
if (domReady) {
renderApp()
} else {
window.addEventListener('DOMContentLoaded', () => {
renderApp()
})
}
}
if (IsWebPlatform) {
startApplication(
window.defaultSyncServer,
new BrowserBridge(WebAppVersion),
window.enabledUnfinishedFeatures,
window.websocketUrl,
).catch(console.error)
} else {
window.startApplication = startApplication
}

View File

@ -0,0 +1,135 @@
import { ApplicationEvent } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { AppState, AppStateEvent } from '@/UIModels/AppState'
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
import { Component } from 'preact'
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
export type PureComponentState = Partial<Record<string, any>>
export type PureComponentProps = Partial<Record<string, any>>
export abstract class PureComponent<
P = PureComponentProps,
S = PureComponentState,
> extends Component<P, S> {
private unsubApp!: () => void
private unsubState!: () => void
private reactionDisposers: IReactionDisposer[] = []
constructor(props: P, protected application: WebApplication) {
super(props)
}
override componentDidMount() {
this.addAppEventObserver()
this.addAppStateObserver()
}
deinit(): void {
this.unsubApp?.()
this.unsubState?.()
for (const disposer of this.reactionDisposers) {
disposer()
}
this.reactionDisposers.length = 0
;(this.unsubApp as unknown) = undefined
;(this.unsubState as unknown) = undefined
}
protected dismissModal(): void {
const elem = this.getElement()
if (!elem) {
return
}
const parent = elem.parentElement
if (!parent) {
return
}
parent.remove()
unmountComponentAtNode(parent)
}
override componentWillUnmount(): void {
this.deinit()
}
render() {
return <div>Must override</div>
}
public get appState(): AppState {
return this.application.getAppState()
}
protected getElement(): Element | null {
return findDOMNode(this)
}
autorun(view: (r: IReactionPublic) => void): void {
this.reactionDisposers.push(autorun(view))
}
addAppStateObserver() {
this.unsubState = this.application.getAppState().addObserver(async (eventName, data) => {
this.onAppStateEvent(eventName, data)
})
}
onAppStateEvent(_eventName: AppStateEvent, _data: unknown) {
/** Optional override */
}
addAppEventObserver() {
if (this.application.isStarted()) {
this.onAppStart().catch(console.error)
}
if (this.application.isLaunched()) {
this.onAppLaunch().catch(console.error)
}
this.unsubApp = this.application.addEventObserver(async (eventName, data: unknown) => {
this.onAppEvent(eventName, data)
if (eventName === ApplicationEvent.Started) {
await this.onAppStart()
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch()
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync()
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync()
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange().catch(console.error)
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded()
}
})
}
onAppEvent(_eventName: ApplicationEvent, _data?: unknown) {
/** Optional override */
}
async onAppStart() {
/** Optional override */
}
onLocalDataLoaded() {
/** Optional override */
}
async onAppLaunch() {
/** Optional override */
}
async onAppKeyChange() {
/** Optional override */
}
onAppIncrementalSync() {
/** Optional override */
}
onAppFullSync() {
/** Optional override */
}
}

View File

@ -1,96 +1,85 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks'
import { Checkbox } from '../Checkbox'; import { Checkbox } from '@/Components/Checkbox'
import { DecoratedInput } from '../DecoratedInput'; import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
disabled?: boolean; disabled?: boolean
onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void; onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void
onStrictSignInChange?: (isStrictSignIn: boolean) => void; onStrictSignInChange?: (isStrictSignIn: boolean) => void
}; }
export const AdvancedOptions: FunctionComponent<Props> = observer( export const AdvancedOptions: FunctionComponent<Props> = observer(
({ ({ appState, application, disabled = false, onVaultChange, onStrictSignInChange, children }) => {
appState, const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu
application, const [showAdvanced, setShowAdvanced] = useState(false)
disabled = false,
onVaultChange,
onStrictSignInChange,
children,
}) => {
const { server, setServer, enableServerOption, setEnableServerOption } =
appState.accountMenu;
const [showAdvanced, setShowAdvanced] = useState(false);
const [isVault, setIsVault] = useState(false); const [isVault, setIsVault] = useState(false)
const [vaultName, setVaultName] = useState(''); const [vaultName, setVaultName] = useState('')
const [vaultUserphrase, setVaultUserphrase] = useState(''); const [vaultUserphrase, setVaultUserphrase] = useState('')
const [isStrictSignin, setIsStrictSignin] = useState(false); const [isStrictSignin, setIsStrictSignin] = useState(false)
useEffect(() => { useEffect(() => {
const recomputeVaultedEmail = async () => { const recomputeVaultedEmail = async () => {
const vaultedEmail = await application.vaultToEmail( const vaultedEmail = await application.vaultToEmail(vaultName, vaultUserphrase)
vaultName,
vaultUserphrase
);
if (!vaultedEmail) { if (!vaultedEmail) {
if (vaultName?.length > 0 && vaultUserphrase?.length > 0) { if (vaultName?.length > 0 && vaultUserphrase?.length > 0) {
application.alertService.alert('Unable to compute vault name.'); application.alertService.alert('Unable to compute vault name.').catch(console.error)
} }
return; return
} }
onVaultChange?.(true, vaultedEmail); onVaultChange?.(true, vaultedEmail)
}; }
if (vaultName && vaultUserphrase) { if (vaultName && vaultUserphrase) {
recomputeVaultedEmail(); recomputeVaultedEmail().catch(console.error)
} }
}, [vaultName, vaultUserphrase, application, onVaultChange]); }, [vaultName, vaultUserphrase, application, onVaultChange])
useEffect(() => { useEffect(() => {
onVaultChange?.(isVault); onVaultChange?.(isVault)
}, [isVault, onVaultChange]); }, [isVault, onVaultChange])
const handleIsVaultChange = () => { const handleIsVaultChange = () => {
setIsVault(!isVault); setIsVault(!isVault)
}; }
const handleVaultNameChange = (name: string) => { const handleVaultNameChange = (name: string) => {
setVaultName(name); setVaultName(name)
}; }
const handleVaultUserphraseChange = (userphrase: string) => { const handleVaultUserphraseChange = (userphrase: string) => {
setVaultUserphrase(userphrase); setVaultUserphrase(userphrase)
}; }
const handleServerOptionChange = (e: Event) => { const handleServerOptionChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) { if (e.target instanceof HTMLInputElement) {
setEnableServerOption(e.target.checked); setEnableServerOption(e.target.checked)
} }
}; }
const handleSyncServerChange = (server: string) => { const handleSyncServerChange = (server: string) => {
setServer(server); setServer(server)
application.setCustomHost(server); application.setCustomHost(server).catch(console.error)
}; }
const handleStrictSigninChange = () => { const handleStrictSigninChange = () => {
const newValue = !isStrictSignin; const newValue = !isStrictSignin
setIsStrictSignin(newValue); setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue); onStrictSignInChange?.(newValue)
}; }
const toggleShowAdvanced = () => { const toggleShowAdvanced = () => {
setShowAdvanced(!showAdvanced); setShowAdvanced(!showAdvanced)
}; }
return ( return (
<> <>
@ -130,7 +119,7 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
{appState.enableUnfinishedFeatures && isVault && ( {appState.enableUnfinishedFeatures && isVault && (
<> <>
<DecoratedInput <DecoratedInput
className={`mb-2`} className={'mb-2'}
left={[<Icon type="folder" className="color-neutral" />]} left={[<Icon type="folder" className="color-neutral" />]}
type="text" type="text"
placeholder="Vault name" placeholder="Vault name"
@ -139,7 +128,7 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
disabled={disabled} disabled={disabled}
/> />
<DecoratedInput <DecoratedInput
className={`mb-2`} className={'mb-2'}
left={[<Icon type="server" className="color-neutral" />]} left={[<Icon type="server" className="color-neutral" />]}
type="text" type="text"
placeholder="Vault userphrase" placeholder="Vault userphrase"
@ -188,6 +177,6 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
</div> </div>
) : null} ) : null}
</> </>
); )
} },
); )

View File

@ -1,96 +1,96 @@
import { STRING_NON_MATCHING_PASSWORDS } from '@/strings'; import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from '.'; import { AccountMenuPane } from '.'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { Checkbox } from '../Checkbox'; import { Checkbox } from '@/Components/Checkbox'
import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { IconButton } from '../IconButton'; import { IconButton } from '@/Components/Button/IconButton'
type Props = { type Props = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void; setMenuPane: (pane: AccountMenuPane) => void
email: string; email: string
password: string; password: string
}; }
export const ConfirmPassword: FunctionComponent<Props> = observer( export const ConfirmPassword: FunctionComponent<Props> = observer(
({ 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 = (text: string) => { const handlePasswordChange = (text: string) => {
setConfirmPassword(text); setConfirmPassword(text)
}; }
const handleEphemeralChange = () => { const handleEphemeralChange = () => {
setIsEphemeral(!isEphemeral); setIsEphemeral(!isEphemeral)
}; }
const handleShouldMergeChange = () => { const handleShouldMergeChange = () => {
setShouldMergeLocal(!shouldMergeLocal); setShouldMergeLocal(!shouldMergeLocal)
}; }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (error.length) { if (error.length) {
setError(''); setError('')
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleConfirmFormSubmit(e); handleConfirmFormSubmit(e)
} }
}; }
const handleConfirmFormSubmit = (e: Event) => { const handleConfirmFormSubmit = (e: Event) => {
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()
} }
}; }
const handleGoBack = () => { const handleGoBack = () => {
setMenuPane(AccountMenuPane.Register); setMenuPane(AccountMenuPane.Register)
}; }
return ( return (
<> <>
@ -110,8 +110,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
<span className="color-dark-red"> <span className="color-dark-red">
Standard Notes does not have a password reset option Standard Notes does not have a password reset option
</span> </span>
. If you forget your password, you will permanently lose access to . If you forget your password, you will permanently lose access to your data.
your data.
</div> </div>
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1"> <form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
<DecoratedPasswordInput <DecoratedPasswordInput
@ -127,9 +126,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
{error ? <div className="color-dark-red my-2">{error}</div> : null} {error ? <div className="color-dark-red my-2">{error}</div> : null}
<Button <Button
className="btn-w-full mt-1 mb-3" className="btn-w-full mt-1 mb-3"
label={ label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
isRegistering ? 'Creating account...' : 'Create account & sign in'
}
variant="primary" variant="primary"
onClick={handleConfirmFormSubmit} onClick={handleConfirmFormSubmit}
disabled={isRegistering} disabled={isRegistering}
@ -152,6 +149,6 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
) : null} ) : null}
</form> </form>
</> </>
); )
} },
); )

View File

@ -1,90 +1,82 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'; import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from '.'; import { AccountMenuPane } from '.'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { DecoratedInput } from '../DecoratedInput'; import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { IconButton } from '../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: StateUpdater<string>
password: string; password: string
setPassword: StateUpdater<string>; setPassword: StateUpdater<string>
}; }
export const CreateAccount: FunctionComponent<Props> = observer( export const CreateAccount: FunctionComponent<Props> = observer(
({ ({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => {
appState, const emailInputRef = useRef<HTMLInputElement>(null)
application, const passwordInputRef = useRef<HTMLInputElement>(null)
setMenuPane, const [isVault, setIsVault] = useState(false)
email,
setEmail,
password,
setPassword,
}) => {
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
const [isVault, setIsVault] = useState(false);
useEffect(() => { useEffect(() => {
if (emailInputRef.current) { if (emailInputRef.current) {
emailInputRef.current?.focus(); emailInputRef.current?.focus()
} }
}, []); }, [])
const handleEmailChange = (text: string) => { const handleEmailChange = (text: string) => {
setEmail(text); setEmail(text)
}; }
const handlePasswordChange = (text: string) => { const handlePasswordChange = (text: string) => {
setPassword(text); setPassword(text)
}; }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleRegisterFormSubmit(e); handleRegisterFormSubmit(e)
} }
}; }
const handleRegisterFormSubmit = (e: Event) => { const handleRegisterFormSubmit = (e: Event) => {
e.preventDefault(); e.preventDefault()
if (!email || email.length === 0) { if (!email || email.length === 0) {
emailInputRef.current?.focus(); emailInputRef.current?.focus()
return; return
} }
if (!password || password.length === 0) { if (!password || password.length === 0) {
passwordInputRef.current?.focus(); passwordInputRef.current?.focus()
return; return
} }
setEmail(email); setEmail(email)
setPassword(password); setPassword(password)
setMenuPane(AccountMenuPane.ConfirmPassword); setMenuPane(AccountMenuPane.ConfirmPassword)
}; }
const handleClose = () => { const handleClose = () => {
setMenuPane(AccountMenuPane.GeneralMenu); setMenuPane(AccountMenuPane.GeneralMenu)
setEmail(''); setEmail('')
setPassword(''); setPassword('')
}; }
const onVaultChange = (isVault: boolean, vaultedEmail?: string) => { const onVaultChange = (isVault: boolean, vaultedEmail?: string) => {
setIsVault(isVault); setIsVault(isVault)
if (isVault && vaultedEmail) { if (isVault && vaultedEmail) {
setEmail(vaultedEmail); setEmail(vaultedEmail)
} }
}; }
return ( return (
<> <>
@ -133,6 +125,6 @@ export const CreateAccount: FunctionComponent<Props> = observer(
onVaultChange={onVaultChange} onVaultChange={onVaultChange}
/> />
</> </>
); )
} },
); )

View File

@ -1,37 +1,37 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { formatLastSyncDate } from '@/components/Preferences/panes/account/Sync'; 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 { useState } from 'preact/hooks'; import { useState } from 'preact/hooks'
import { AccountMenuPane } from '.'; import { AccountMenuPane } from '.'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Menu } from '../Menu/Menu'; import { Menu } from '@/Components/Menu/Menu'
import { MenuItem, MenuItemSeparator, MenuItemType } from '../Menu/MenuItem'; import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption'; import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption'
import { ApplicationGroup } from '@/ui_models/application_group'; import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
type Props = { type Props = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
mainApplicationGroup: ApplicationGroup; mainApplicationGroup: ApplicationGroup
setMenuPane: (pane: AccountMenuPane) => void; setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void; closeMenu: () => void
}; }
const iconClassName = 'color-neutral mr-2'; const iconClassName = 'color-neutral mr-2'
export const GeneralAccountMenu: FunctionComponent<Props> = observer( export const GeneralAccountMenu: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => { ({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false); const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState( const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.sync.getLastSyncDate() as Date) formatLastSyncDate(application.sync.getLastSyncDate() as Date),
); )
const doSynchronization = async () => { const doSynchronization = async () => {
setIsSyncingInProgress(true); setIsSyncingInProgress(true)
application.sync application.sync
.sync({ .sync({
@ -40,25 +40,23 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
}) })
.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( setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
);
} }
}) })
.catch(() => { .catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR); application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
}) })
.finally(() => { .finally(() => {
setIsSyncingInProgress(false); setIsSyncingInProgress(false)
}); })
}; }
const user = application.getUser(); const user = application.getUser()
const CREATE_ACCOUNT_INDEX = 1; const CREATE_ACCOUNT_INDEX = 1
const SWITCHER_INDEX = 0; const SWITCHER_INDEX = 0
return ( return (
<> <>
@ -90,10 +88,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
</div> </div>
</div> </div>
)} )}
<div <div className="flex cursor-pointer color-grey-1" onClick={doSynchronization}>
className="flex cursor-pointer color-grey-1"
onClick={doSynchronization}
>
<Icon type="sync" /> <Icon type="sync" />
</div> </div>
</div> </div>
@ -102,8 +97,8 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<> <>
<div className="px-3 mb-1"> <div className="px-3 mb-1">
<div className="mb-3 color-foreground"> <div className="mb-3 color-foreground">
Youre offline. Sign in to sync your notes and preferences Youre offline. Sign in to sync your notes and preferences across all your devices
across all your devices and enable end-to-end encryption. and enable end-to-end encryption.
</div> </div>
<div className="flex items-center color-grey-1"> <div className="flex items-center color-grey-1">
<Icon type="cloud-off" className="mr-2" /> <Icon type="cloud-off" className="mr-2" />
@ -116,9 +111,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
isOpen={appState.accountMenu.show} isOpen={appState.accountMenu.show}
a11yLabel="General account menu" a11yLabel="General account menu"
closeMenu={closeMenu} closeMenu={closeMenu}
initialFocus={ initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX
}
> >
<MenuItemSeparator /> <MenuItemSeparator />
<WorkspaceSwitcherOption <WorkspaceSwitcherOption
@ -130,9 +123,9 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
appState.accountMenu.closeAccountMenu(); appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('account'); appState.preferences.setCurrentPane('account')
appState.preferences.openPreferences(); appState.preferences.openPreferences()
}} }}
> >
<Icon type="user" className={iconClassName} /> <Icon type="user" className={iconClassName} />
@ -143,7 +136,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
setMenuPane(AccountMenuPane.Register); setMenuPane(AccountMenuPane.Register)
}} }}
> >
<Icon type="user" className={iconClassName} /> <Icon type="user" className={iconClassName} />
@ -152,7 +145,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
setMenuPane(AccountMenuPane.SignIn); setMenuPane(AccountMenuPane.SignIn)
}} }}
> >
<Icon type="signIn" className={iconClassName} /> <Icon type="signIn" className={iconClassName} />
@ -164,9 +157,9 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
className="justify-between" className="justify-between"
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
appState.accountMenu.closeAccountMenu(); appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('help-feedback'); appState.preferences.setCurrentPane('help-feedback')
appState.preferences.openPreferences(); appState.preferences.openPreferences()
}} }}
> >
<div className="flex items-center"> <div className="flex items-center">
@ -181,7 +174,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
appState.accountMenu.setSigningOut(true); appState.accountMenu.setSigningOut(true)
}} }}
> >
<Icon type="signOut" className={iconClassName} /> <Icon type="signOut" className={iconClassName} />
@ -191,6 +184,6 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
) : null} ) : null}
</Menu> </Menu>
</> </>
); )
} },
); )

View File

@ -1,134 +1,134 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; 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 { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from '.'; import { AccountMenuPane } from '.'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { Checkbox } from '../Checkbox'; import { Checkbox } from '@/Components/Checkbox'
import { DecoratedInput } from '../DecoratedInput'; import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { IconButton } from '../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
}; }
export const SignInPane: FunctionComponent<Props> = observer( export const SignInPane: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane }) => { ({ 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('')
const [error, setError] = useState(''); const [error, setError] = useState('')
const [isEphemeral, setIsEphemeral] = useState(false); const [isEphemeral, setIsEphemeral] = useState(false)
const [isStrictSignin, setIsStrictSignin] = useState(false); const [isStrictSignin, setIsStrictSignin] = useState(false)
const [isSigningIn, setIsSigningIn] = useState(false); const [isSigningIn, setIsSigningIn] = useState(false)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true); const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [isVault, setIsVault] = useState(false); const [isVault, setIsVault] = useState(false)
const emailInputRef = useRef<HTMLInputElement>(null); const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null); const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (emailInputRef?.current) { if (emailInputRef?.current) {
emailInputRef.current?.focus(); emailInputRef.current?.focus()
} }
if (isDev && window.devAccountEmail) { if (isDev && window.devAccountEmail) {
setEmail(window.devAccountEmail); setEmail(window.devAccountEmail)
setPassword(window.devAccountPassword as string); setPassword(window.devAccountPassword as string)
} }
}, []); }, [])
const resetInvalid = () => { const resetInvalid = () => {
if (error.length) { if (error.length) {
setError(''); setError('')
} }
}; }
const handleEmailChange = (text: string) => { const handleEmailChange = (text: string) => {
setEmail(text); setEmail(text)
}; }
const handlePasswordChange = (text: string) => { const handlePasswordChange = (text: string) => {
if (error.length) { if (error.length) {
setError(''); setError('')
} }
setPassword(text); setPassword(text)
}; }
const handleEphemeralChange = () => { const handleEphemeralChange = () => {
setIsEphemeral(!isEphemeral); setIsEphemeral(!isEphemeral)
}; }
const handleStrictSigninChange = () => { const handleStrictSigninChange = () => {
setIsStrictSignin(!isStrictSignin); setIsStrictSignin(!isStrictSignin)
}; }
const handleShouldMergeChange = () => { const handleShouldMergeChange = () => {
setShouldMergeLocal(!shouldMergeLocal); setShouldMergeLocal(!shouldMergeLocal)
}; }
const signIn = () => { const signIn = () => {
setIsSigningIn(true); setIsSigningIn(true)
emailInputRef?.current?.blur(); emailInputRef?.current?.blur()
passwordInputRef?.current?.blur(); passwordInputRef?.current?.blur()
application application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal) .signIn(email, password, isStrictSignin, 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()
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err)
setError(err.message ?? err.toString()); setError(err.message ?? err.toString())
setPassword(''); setPassword('')
passwordInputRef?.current?.blur(); passwordInputRef?.current?.blur()
}) })
.finally(() => { .finally(() => {
setIsSigningIn(false); setIsSigningIn(false)
}); })
}; }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSignInFormSubmit(e); handleSignInFormSubmit(e)
} }
}; }
const onVaultChange = useCallback( const onVaultChange = useCallback(
(newIsVault: boolean, vaultedEmail?: string) => { (newIsVault: boolean, vaultedEmail?: string) => {
setIsVault(newIsVault); setIsVault(newIsVault)
if (newIsVault && vaultedEmail) { if (newIsVault && vaultedEmail) {
setEmail(vaultedEmail); setEmail(vaultedEmail)
} }
}, },
[setEmail] [setEmail],
); )
const handleSignInFormSubmit = (e: Event) => { const handleSignInFormSubmit = (e: Event) => {
e.preventDefault(); e.preventDefault()
if (!email || email.length === 0) { if (!email || email.length === 0) {
emailInputRef?.current?.focus(); emailInputRef?.current?.focus()
return; return
} }
if (!password || password.length === 0) { if (!password || password.length === 0) {
passwordInputRef?.current?.focus(); passwordInputRef?.current?.focus()
return; return
} }
signIn(); signIn()
}; }
return ( return (
<> <>
@ -201,6 +201,6 @@ export const SignInPane: FunctionComponent<Props> = observer(
onStrictSignInChange={handleStrictSigninChange} onStrictSignInChange={handleStrictSigninChange}
/> />
</> </>
); )
} },
); )

View File

@ -1,16 +1,16 @@
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { User as UserType } from '@standardnotes/snjs'; import { User as UserType } from '@standardnotes/snjs'
type Props = { type Props = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
}; }
const User = observer(({ appState, application }: Props) => { const User = observer(({ appState, application }: Props) => {
const { server } = appState.accountMenu; const { server } = appState.accountMenu
const user = application.getUser() as UserType; const user = application.getUser() as UserType
return ( return (
<div className="sk-panel-section"> <div className="sk-panel-section">
@ -18,8 +18,7 @@ const User = observer(({ appState, application }: Props) => {
<div className="sk-notification danger"> <div className="sk-notification danger">
<div className="sk-notification-title">Sync Unreachable</div> <div className="sk-notification-title">Sync Unreachable</div>
<div className="sk-notification-text"> <div className="sk-notification-text">
Hmm...we can't seem to sync your account. The reason:{' '} Hmm...we can't seem to sync your account. The reason: {appState.sync.errorMessage}
{appState.sync.errorMessage}
</div> </div>
<a <a
className="sk-a info-contrast sk-bold sk-panel-row" className="sk-a info-contrast sk-bold sk-panel-row"
@ -39,7 +38,7 @@ const User = observer(({ appState, application }: Props) => {
</div> </div>
<div className="sk-panel-row" /> <div className="sk-panel-row" />
</div> </div>
); )
}); })
export default User; export default User

View File

@ -1,16 +1,16 @@
import { Icon } from '@/components/Icon'; import { Icon } from '@/Components/Icon'
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem'; import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
import { KeyboardKey } from '@/services/ioService'; import { KeyboardKey } from '@/Services/IOService'
import { ApplicationDescriptor } from '@standardnotes/snjs/dist/@types'; import { ApplicationDescriptor } from '@standardnotes/snjs/dist/@types'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks'
type Props = { type Props = {
descriptor: ApplicationDescriptor; descriptor: ApplicationDescriptor
onClick: () => void; onClick: () => void
onDelete: () => void; onDelete: () => void
renameDescriptor: (label: string) => void; renameDescriptor: (label: string) => void
}; }
export const WorkspaceMenuItem: FunctionComponent<Props> = ({ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor, descriptor,
@ -18,26 +18,26 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
onDelete, onDelete,
renameDescriptor, renameDescriptor,
}) => { }) => {
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false)
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (isRenaming) { if (isRenaming) {
inputRef.current?.focus(); inputRef.current?.focus()
} }
}, [isRenaming]); }, [isRenaming])
const handleInputKeyDown = (event: KeyboardEvent) => { const handleInputKeyDown = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur(); inputRef.current?.blur()
} }
}; }
const handleInputBlur = (event: FocusEvent) => { const handleInputBlur = (event: FocusEvent) => {
const name = (event.target as HTMLInputElement).value; const name = (event.target as HTMLInputElement).value
renameDescriptor(name); renameDescriptor(name)
setIsRenaming(false); setIsRenaming(false)
}; }
return ( return (
<MenuItem <MenuItem
@ -63,7 +63,7 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
<button <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={() => { onClick={() => {
setIsRenaming((isRenaming) => !isRenaming); setIsRenaming((isRenaming) => !isRenaming)
}} }}
> >
<Icon type="pencil" className="sn-icon--mid color-neutral" /> <Icon type="pencil" className="sn-icon--mid color-neutral" />
@ -78,5 +78,5 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
)} )}
</div> </div>
</MenuItem> </MenuItem>
); )
}; }

View File

@ -0,0 +1,64 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState'
import { ApplicationDescriptor } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { Menu } from '@/Components/Menu/Menu'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
import { WorkspaceMenuItem } from './WorkspaceMenuItem'
type Props = {
mainApplicationGroup: ApplicationGroup
appState: AppState
isOpen: boolean
}
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer(
({ mainApplicationGroup, appState, isOpen }) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>(
[],
)
useEffect(() => {
const removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors)
})
return () => {
removeAppGroupObserver()
}
}, [mainApplicationGroup])
return (
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
descriptor={descriptor}
onDelete={() => {
appState.accountMenu.setSigningOut(true)
}}
onClick={() => {
mainApplicationGroup.loadApplicationForDescriptor(descriptor).catch(console.error)
}}
renameDescriptor={(label: string) =>
mainApplicationGroup.renameDescriptor(descriptor, label)
}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
mainApplicationGroup.addNewApplication().catch(console.error)
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
</Menu>
)
},
)

View File

@ -1,53 +1,47 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants'; import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { ApplicationGroup } from '@/ui_models/application_group'; import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
calculateSubmenuStyle, import { observer } from 'mobx-react-lite'
SubmenuStyle, import { FunctionComponent } from 'preact'
} from '@/utils/calculateSubmenuStyle'; import { useEffect, useRef, useState } from 'preact/hooks'
import { observer } from 'mobx-react-lite'; import { Icon } from '@/Components/Icon'
import { FunctionComponent } from 'preact'; import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../../Icon';
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu';
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup; mainApplicationGroup: ApplicationGroup
appState: AppState; appState: AppState
}; }
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer( export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
({ mainApplicationGroup, appState }) => { ({ 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)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>(); const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
const toggleMenu = () => { const toggleMenu = () => {
if (!isOpen) { if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current); const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) { if (menuPosition) {
setMenuStyle(menuPosition); setMenuStyle(menuPosition)
} }
} }
setIsOpen(!isOpen); setIsOpen(!isOpen)
}; }
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setTimeout(() => { setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle( const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
buttonRef.current,
menuRef.current
);
if (newMenuPosition) { if (newMenuPosition) {
setMenuStyle(newMenuPosition); setMenuStyle(newMenuPosition)
} }
}); })
} }
}, [isOpen]); }, [isOpen])
return ( return (
<> <>
@ -78,6 +72,6 @@ export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
</div> </div>
)} )}
</> </>
); )
} },
); )

View File

@ -1,15 +1,15 @@
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/components/utils'; import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks'
import { GeneralAccountMenu } from './GeneralAccountMenu'; import { GeneralAccountMenu } from './GeneralAccountMenu'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { SignInPane } from './SignIn'; import { SignInPane } from './SignIn'
import { CreateAccount } from './CreateAccount'; import { CreateAccount } from './CreateAccount'
import { ConfirmPassword } from './ConfirmPassword'; import { ConfirmPassword } from './ConfirmPassword'
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import { ApplicationGroup } from '@/ui_models/application_group'; import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
export enum AccountMenuPane { export enum AccountMenuPane {
GeneralMenu, GeneralMenu,
@ -19,32 +19,25 @@ export enum AccountMenuPane {
} }
type Props = { type Props = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
onClickOutside: () => void; onClickOutside: () => void
mainApplicationGroup: ApplicationGroup; mainApplicationGroup: ApplicationGroup
}; }
type PaneSelectorProps = { type PaneSelectorProps = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
mainApplicationGroup: ApplicationGroup; mainApplicationGroup: ApplicationGroup
menuPane: AccountMenuPane; menuPane: AccountMenuPane
setMenuPane: (pane: AccountMenuPane) => void; setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void; closeMenu: () => void
}; }
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer( const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
({ ({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => {
application, const [email, setEmail] = useState('')
appState, const [password, setPassword] = useState('')
menuPane,
setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
switch (menuPane) { switch (menuPane) {
case AccountMenuPane.GeneralMenu: case AccountMenuPane.GeneralMenu:
@ -56,15 +49,11 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
setMenuPane={setMenuPane} setMenuPane={setMenuPane}
closeMenu={closeMenu} closeMenu={closeMenu}
/> />
); )
case AccountMenuPane.SignIn: case AccountMenuPane.SignIn:
return ( return (
<SignInPane <SignInPane appState={appState} application={application} setMenuPane={setMenuPane} />
appState={appState} )
application={application}
setMenuPane={setMenuPane}
/>
);
case AccountMenuPane.Register: case AccountMenuPane.Register:
return ( return (
<CreateAccount <CreateAccount
@ -76,7 +65,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
password={password} password={password}
setPassword={setPassword} setPassword={setPassword}
/> />
); )
case AccountMenuPane.ConfirmPassword: case AccountMenuPane.ConfirmPassword:
return ( return (
<ConfirmPassword <ConfirmPassword
@ -86,48 +75,40 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
email={email} email={email}
password={password} password={password}
/> />
); )
} }
} },
); )
export const AccountMenu: FunctionComponent<Props> = observer( export const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState, onClickOutside, mainApplicationGroup }) => { ({ application, appState, onClickOutside, mainApplicationGroup }) => {
const { const { currentPane, setCurrentPane, shouldAnimateCloseMenu, closeAccountMenu } =
currentPane, appState.accountMenu
setCurrentPane,
shouldAnimateCloseMenu,
closeAccountMenu,
} = appState.accountMenu;
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => { useCloseOnClickOutside(ref, () => {
onClickOutside(); onClickOutside()
}); })
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = ( const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (event) => {
event
) => {
switch (event.key) { switch (event.key) {
case 'Escape': case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) { if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu(); closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) { } else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register); setCurrentPane(AccountMenuPane.Register)
} else { } else {
setCurrentPane(AccountMenuPane.GeneralMenu); setCurrentPane(AccountMenuPane.GeneralMenu)
} }
break; break
} }
}; }
return ( return (
<div ref={ref} id="account-menu" className="sn-component"> <div ref={ref} id="account-menu" className="sn-component">
<div <div
className={`sn-menu-border sn-account-menu sn-dropdown ${ className={`sn-menu-border sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
? 'slide-up-animation'
: 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`} } min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
@ -141,6 +122,6 @@ export const AccountMenu: FunctionComponent<Props> = observer(
/> />
</div> </div>
</div> </div>
); )
} },
); )

View File

@ -1,27 +1,26 @@
import { ApplicationGroup } from '@/ui_models/application_group'; import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { Component } from 'preact'; import { Component } from 'preact'
import { ApplicationView } from './ApplicationView'; import { ApplicationView } from '@/Components/ApplicationView'
type State = { type State = {
activeApplication?: WebApplication; activeApplication?: WebApplication
}; }
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup; mainApplicationGroup: ApplicationGroup
}; }
export class ApplicationGroupView extends Component<Props, State> { export class ApplicationGroupView extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props)
props.mainApplicationGroup.addApplicationChangeObserver(() => { props.mainApplicationGroup.addApplicationChangeObserver(() => {
const activeApplication = props.mainApplicationGroup const activeApplication = props.mainApplicationGroup.primaryApplication as WebApplication
.primaryApplication as WebApplication; this.setState({ activeApplication })
this.setState({ activeApplication }); })
});
props.mainApplicationGroup.initialize(); props.mainApplicationGroup.initialize().catch(console.error)
} }
render() { render() {
@ -37,6 +36,6 @@ export class ApplicationGroupView extends Component<Props, State> {
</div> </div>
)} )}
</> </>
); )
} }
} }

View File

@ -0,0 +1,216 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { getPlatformString, getWindowUrlParams } from '@/Utils'
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState'
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs'
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
import { alertDialog } from '@/Services/AlertService'
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { Navigation } from '@/Components/Navigation'
import { NotesView } from '@/Components/NotesView'
import { NoteGroupView } from '@/Components/NoteGroupView'
import { Footer } from '@/Components/Footer'
import { SessionsModal } from '@/Components/SessionsModal'
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper'
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal'
import { NotesContextMenu } from '@/Components/NotesContextMenu'
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { render } from 'preact'
import { PermissionsModal } from '@/Components/PermissionsModal'
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
import { PremiumModalProvider } from '@/Hooks/usePremiumModal'
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal'
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
import { ToastContainer } from '@standardnotes/stylekit'
import { FilePreviewModalProvider } from '@/Components/Files/FilePreviewModalProvider'
type Props = {
application: WebApplication
mainApplicationGroup: ApplicationGroup
}
type State = {
started?: boolean
launched?: boolean
needsUnlock?: boolean
appClass: string
challenges: Challenge[]
}
export class ApplicationView extends PureComponent<Props, State> {
public readonly platformString = getPlatformString()
constructor(props: Props) {
super(props, props.application)
this.state = {
appClass: '',
challenges: [],
}
}
override deinit() {
;(this.application as unknown) = undefined
super.deinit()
}
override componentDidMount(): void {
super.componentDidMount()
this.loadApplication().catch(console.error)
}
async loadApplication() {
this.application.componentManager.setDesktopManager(this.application.getDesktopService())
await this.application.prepareForLaunch({
receiveChallenge: async (challenge) => {
const challenges = this.state.challenges.slice()
challenges.push(challenge)
this.setState({ challenges: challenges })
},
})
await this.application.launch()
}
public removeChallenge = async (challenge: Challenge) => {
const challenges = this.state.challenges.slice()
removeFromArray(challenges, challenge)
this.setState({ challenges: challenges })
}
override async onAppStart() {
super.onAppStart().catch(console.error)
this.setState({
started: true,
needsUnlock: this.application.hasPasscode(),
})
this.application.componentManager.presentPermissionsDialog = this.presentPermissionsDialog
}
override async onAppLaunch() {
super.onAppLaunch().catch(console.error)
this.setState({
launched: true,
needsUnlock: false,
})
this.handleDemoSignInFromParams().catch(console.error)
}
onUpdateAvailable() {
this.application.notifyWebEvent(WebAppEvent.NewUpdateAvailable)
}
override async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName)
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
}).catch(console.error)
break
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
}).catch(console.error)
break
}
}
override async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
if (eventName === AppStateEvent.PanelResized) {
const { panel, collapsed } = data as PanelResizedData
let appClass = ''
if (panel === PANEL_NAME_NOTES && collapsed) {
appClass += 'collapsed-notes'
}
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
appClass += ' collapsed-navigation'
}
this.setState({ appClass })
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await this.application.isLocked())) {
this.application.sync.sync().catch(console.error)
}
}
}
async handleDemoSignInFromParams() {
const token = getWindowUrlParams().get('demo-token')
if (!token || this.application.hasAccount()) {
return
}
await this.application.sessions.populateSessionFromDemoShareToken(token)
}
presentPermissionsDialog = (dialog: PermissionDialog) => {
render(
<PermissionsModal
application={this.application}
callback={dialog.callback}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>,
document.body.appendChild(document.createElement('div')),
)
}
override render() {
if (this.application['dealloced'] === true) {
console.error('Attempting to render dealloced application')
return <div></div>
}
const renderAppContents = !this.state.needsUnlock && this.state.launched
return (
<FilePreviewModalProvider application={this.application}>
<PremiumModalProvider application={this.application} appState={this.appState}>
<div className={this.platformString + ' main-ui-view sn-component'}>
{renderAppContents && (
<div id="app" className={this.state.appClass + ' app app-column-container'}>
<Navigation application={this.application} />
<NotesView application={this.application} appState={this.appState} />
<NoteGroupView application={this.application} />
</div>
)}
{renderAppContents && (
<>
<Footer
application={this.application}
applicationGroup={this.props.mainApplicationGroup}
/>
<SessionsModal application={this.application} appState={this.appState} />
<PreferencesViewWrapper appState={this.appState} application={this.application} />
<RevisionHistoryModalWrapper
application={this.application}
appState={this.appState}
/>
</>
)}
{this.state.challenges.map((challenge) => {
return (
<div className="sk-modal">
<ChallengeModal
key={challenge.id}
application={this.application}
challenge={challenge}
onDismiss={this.removeChallenge}
/>
</div>
)
})}
{renderAppContents && (
<>
<NotesContextMenu application={this.application} appState={this.appState} />
<TagsContextMenu appState={this.appState} />
<PurchaseFlowWrapper application={this.application} appState={this.appState} />
<ConfirmSignoutContainer appState={this.appState} application={this.application} />
<ToastContainer />
</>
)}
</div>
</PremiumModalProvider>
</FilePreviewModalProvider>
)
}
}

View File

@ -0,0 +1,368 @@
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import {
ChallengeReason,
ContentType,
FeatureIdentifier,
FeatureStatus,
SNFile,
} from '@standardnotes/snjs'
import { confirmDialog } from '@/Services/AlertService'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
import { StreamingFileReader } from '@standardnotes/filepicker'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { AttachedFilesPopover, PopoverTabs } from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
type Props = {
application: WebApplication
appState: AppState
onClickPreprocessing?: () => Promise<void>
}
const createDragOverlay = () => {
if (document.getElementById('drag-overlay')) {
return
}
const overlayElementTemplate =
'<div class="sn-component" id="drag-overlay"><div class="absolute top-0 left-0 w-full h-full z-index-1001"></div></div>'
const overlayFragment = document.createRange().createContextualFragment(overlayElementTemplate)
document.body.appendChild(overlayFragment)
}
const removeDragOverlay = () => {
document.getElementById('drag-overlay')?.remove()
}
export const AttachedFilesButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }) => {
const premiumModal = usePremiumModal()
const note = Object.values(appState.notes.selectedNotes)[0]
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
const [attachedFilesCount, setAttachedFilesCount] = useState(
note ? application.items.getFilesForNote(note).length : 0,
)
const reloadAttachedFilesCount = useCallback(() => {
setAttachedFilesCount(note ? application.items.getFilesForNote(note).length : 0)
}, [application.items, note])
useEffect(() => {
const unregisterFileStream = application.streamItems(ContentType.File, () => {
reloadAttachedFilesCount()
})
return () => {
unregisterFileStream()
}
}, [application, reloadAttachedFilesCount])
const toggleAttachedFilesMenu = useCallback(async () => {
if (
application.features.getFeatureStatus(FeatureIdentifier.Files) !== FeatureStatus.Entitled
) {
premiumModal.activate('Files')
return
}
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setOpen(newOpenState)
}
}, [application.features, onClickPreprocessing, open, premiumModal])
const deleteFile = async (file: SNFile) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
const deletingToastId = addToast({
type: ToastType.Loading,
message: `Deleting file "${file.name}"...`,
})
await application.files.deleteFile(file)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
})
dismissToast(deletingToastId)
}
}
const downloadFile = async (file: SNFile) => {
appState.files.downloadFile(file).catch(console.error)
}
const attachFileToNote = useCallback(
async (file: SNFile) => {
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: SNFile) => {
await application.items.disassociateFileWithNote(file, note)
}
const toggleFileProtection = async (file: SNFile) => {
let result: SNFile | undefined
if (file.protected) {
keepMenuOpen(true)
result = await application.mutator.unprotectFile(file)
keepMenuOpen(false)
buttonRef.current?.focus()
} else {
result = await application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
}
const authorizeProtectedActionForFile = async (
file: SNFile,
challengeReason: ChallengeReason,
) => {
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles(
[file],
challengeReason,
)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
const renameFile = async (file: SNFile, fileName: string) => {
await application.items.renameFile(file, fileName)
}
const handleFileAction = async (action: PopoverFileItemAction) => {
const file =
action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
let isAuthorizedForAction = true
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
keepMenuOpen(true)
isAuthorizedForAction = await authorizeProtectedActionForFile(
file,
ChallengeReason.AccessProtectedFile,
)
keepMenuOpen(false)
buttonRef.current?.focus()
}
if (!isAuthorizedForAction) {
return false
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await renameFile(file, action.payload.name)
break
}
application.sync.sync().catch(console.error)
return true
}
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
const dragCounter = useRef(0)
const handleDrag = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
}
const handleDragIn = useCallback(
(event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true)
createDragOverlay()
if (!open) {
toggleAttachedFilesMenu().catch(console.error)
}
}
},
[open, toggleAttachedFilesMenu],
)
const handleDragOut = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
dragCounter.current = dragCounter.current - 1
if (dragCounter.current > 0) {
return
}
removeDragOverlay()
setIsDraggingFiles(false)
}
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
removeDragOverlay()
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
const fileOrHandle = StreamingFileReader.available()
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
: item.getAsFile()
if (!fileOrHandle) {
return
}
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
if (!uploadedFiles) {
return
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
})
event.dataTransfer.clearData()
dragCounter.current = 0
}
},
[appState.files, attachFileToNote, currentTab],
)
useEffect(() => {
window.addEventListener('dragenter', handleDragIn)
window.addEventListener('dragleave', handleDragOut)
window.addEventListener('dragover', handleDrag)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragIn)
window.removeEventListener('dragleave', handleDragOut)
window.removeEventListener('dragover', handleDrag)
window.removeEventListener('drop', handleDrop)
}
}, [handleDragIn, handleDrop])
return (
<div ref={containerRef}>
<Disclosure open={open} onChange={toggleAttachedFilesMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
ref={buttonRef}
className={`sn-icon-button border-contrast ${
attachedFilesCount > 0 ? 'py-1 px-3' : ''
}`}
onBlur={closeOnBlur}
>
<VisuallyHidden>Attached files</VisuallyHidden>
<Icon type="attachment-file" className="block" />
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{open && (
<AttachedFilesPopover
application={application}
appState={appState}
note={note}
handleFileAction={handleFileAction}
currentTab={currentTab}
closeOnBlur={closeOnBlur}
setCurrentTab={setCurrentTab}
isDraggingFiles={isDraggingFiles}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
},
)

View File

@ -1,18 +1,15 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants'; import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { ContentType, SNFile, SNNote } from '@standardnotes/snjs'; import { ContentType, SNFile, SNNote } from '@standardnotes/snjs'
import { FilesIllustration } from '@standardnotes/stylekit'; import { FilesIllustration } from '@standardnotes/stylekit'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'; import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { PopoverFileItem } from './PopoverFileItem'; import { PopoverFileItem } from './PopoverFileItem'
import { import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
export enum PopoverTabs { export enum PopoverTabs {
AttachedFiles, AttachedFiles,
@ -20,15 +17,15 @@ export enum PopoverTabs {
} }
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
currentTab: PopoverTabs; currentTab: PopoverTabs
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>; handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
isDraggingFiles: boolean; isDraggingFiles: boolean
note: SNNote; note: SNNote
setCurrentTab: StateUpdater<PopoverTabs>; setCurrentTab: StateUpdater<PopoverTabs>
}; }
export const AttachedFilesPopover: FunctionComponent<Props> = observer( export const AttachedFilesPopover: FunctionComponent<Props> = observer(
({ ({
@ -41,70 +38,61 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
note, note,
setCurrentTab, setCurrentTab,
}) => { }) => {
const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([]); const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([])
const [allFiles, setAllFiles] = useState<SNFile[]>([]); const [allFiles, setAllFiles] = useState<SNFile[]>([])
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null)
const filesList = const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles;
const filteredList = const filteredList =
searchQuery.length > 0 searchQuery.length > 0
? filesList.filter( ? filesList.filter(
(file) => (file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1,
file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1
) )
: filesList; : filesList
useEffect(() => { useEffect(() => {
const unregisterFileStream = application.streamItems( const unregisterFileStream = application.streamItems(ContentType.File, () => {
ContentType.File, setAttachedFiles(
() => { application.items
setAttachedFiles( .getFilesForNote(note)
application.items .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)),
.getFilesForNote(note) )
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
);
setAllFiles( setAllFiles(
application.items application.items
.getItems(ContentType.File) .getItems(ContentType.File)
.sort((a, b) => .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) as SNFile[],
a.created_at < b.created_at ? 1 : -1 )
) as SNFile[] })
);
}
);
return () => { return () => {
unregisterFileStream(); unregisterFileStream()
}; }
}, [application, note]); }, [application, note])
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) { if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => { uploadedFiles.forEach((file) => {
handleFileAction({ handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote, type: PopoverFileItemActionType.AttachFileToNote,
payload: file, 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 border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
? '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">
@ -115,7 +103,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
: 'color-text' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles); setCurrentTab(PopoverTabs.AttachedFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -128,7 +116,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
: 'color-text' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AllFiles); setCurrentTab(PopoverTabs.AllFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -145,7 +133,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
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} onBlur={closeOnBlur}
ref={searchInputRef} ref={searchInputRef}
@ -154,15 +142,12 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
<button <button
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer" className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={() => { onClick={() => {
setSearchQuery(''); setSearchQuery('')
searchInputRef.current?.focus(); searchInputRef.current?.focus()
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
<Icon <Icon type="clear-circle-filled" className="color-neutral" />
type="clear-circle-filled"
className="color-neutral"
/>
</button> </button>
)} )}
</div> </div>
@ -179,7 +164,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
getIconType={application.iconsController.getIconForFileType} getIconType={application.iconsController.getIconForFileType}
closeOnBlur={closeOnBlur} closeOnBlur={closeOnBlur}
/> />
); )
}) })
) : ( ) : (
<div className="flex flex-col items-center justify-center w-full py-8"> <div className="flex flex-col items-center justify-center w-full py-8">
@ -193,17 +178,10 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
? 'No files attached to this note' ? 'No files attached to this note'
: 'No files found in this account'} : 'No files found in this account'}
</div> </div>
<Button <Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
variant="normal" {currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
onClick={handleAttachFilesClick}
onBlur={closeOnBlur}
>
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'}{' '}
files
</Button> </Button>
<div className="text-xs color-grey-0 mt-3"> <div className="text-xs color-grey-0 mt-3">Or drop your files here</div>
Or drop your files here
</div>
</div> </div>
)} )}
</div> </div>
@ -214,13 +192,10 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
<Icon type="add" className="mr-2 color-neutral" /> <Icon type="add" className="mr-2 color-neutral" />
{currentTab === PopoverTabs.AttachedFiles {currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
? 'Attach'
: 'Upload'}{' '}
files
</button> </button>
)} )}
</div> </div>
); )
} },
); )

View File

@ -1,29 +1,26 @@
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, SNFile } from '@standardnotes/snjs'; import { IconType, SNFile } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks'
import { Icon, ICONS } from '../Icon'; import { Icon, ICONS } from '@/Components/Icon'
import { import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
PopoverFileItemAction, import { PopoverFileSubmenu } from './PopoverFileSubmenu'
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { PopoverFileSubmenu } from './PopoverFileSubmenu';
export const getFileIconComponent = (iconType: string, className: string) => { export const getFileIconComponent = (iconType: string, className: string) => {
const IconComponent = ICONS[iconType as keyof typeof ICONS]; const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />; return <IconComponent className={className} />
}; }
export type PopoverFileItemProps = { export type PopoverFileItemProps = {
file: SNFile; file: SNFile
isAttachedToNote: boolean; isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>; handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType; getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}; }
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file, file,
@ -32,16 +29,16 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
getIconType, getIconType,
closeOnBlur, closeOnBlur,
}) => { }) => {
const [fileName, setFileName] = useState(file.name); const [fileName, setFileName] = useState(file.name)
const [isRenamingFile, setIsRenamingFile] = useState(false); const [isRenamingFile, setIsRenamingFile] = useState(false)
const itemRef = useRef<HTMLDivElement>(null); const itemRef = useRef<HTMLDivElement>(null)
const fileNameInputRef = useRef<HTMLInputElement>(null); const fileNameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (isRenamingFile) { if (isRenamingFile) {
fileNameInputRef.current?.focus(); fileNameInputRef.current?.focus()
} }
}, [isRenamingFile]); }, [isRenamingFile])
const renameFile = async (file: SNFile, name: string) => { const renameFile = async (file: SNFile, name: string) => {
await handleFileAction({ await handleFileAction({
@ -50,23 +47,23 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file, file,
name, name,
}, },
}); })
setIsRenamingFile(false); setIsRenamingFile(false)
}; }
const handleFileNameInput = (event: Event) => { const handleFileNameInput = (event: Event) => {
setFileName((event.target as HTMLInputElement).value); setFileName((event.target as HTMLInputElement).value)
}; }
const handleFileNameInputKeyDown = (event: KeyboardEvent) => { const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
itemRef.current?.focus(); itemRef.current?.focus()
} }
}; }
const handleFileNameInputBlur = () => { const handleFileNameInputBlur = () => {
renameFile(file, fileName); renameFile(file, fileName).catch(console.error)
}; }
return ( return (
<div <div
@ -75,10 +72,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
> >
<div className="flex items-center"> <div className="flex items-center">
{getFileIconComponent( {getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
getIconType(file.mimeType),
'w-8 h-8 flex-shrink-0'
)}
<div className="flex flex-col mx-4"> <div className="flex flex-col mx-4">
{isRenamingFile ? ( {isRenamingFile ? (
<input <input
@ -102,8 +96,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
</div> </div>
)} )}
<div className="text-xs color-grey-0"> <div className="text-xs color-grey-0">
{file.created_at.toLocaleString()} ·{' '} {file.created_at.toLocaleString()} · {formatSizeToReadableString(file.size)}
{formatSizeToReadableString(file.size)}
</div> </div>
</div> </div>
</div> </div>
@ -115,5 +108,5 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
closeOnBlur={closeOnBlur} closeOnBlur={closeOnBlur}
/> />
</div> </div>
); )
}; }

View File

@ -0,0 +1,31 @@
import { SNFile } from '@standardnotes/snjs'
export enum PopoverFileItemActionType {
AttachFileToNote,
DetachFileToNote,
DeleteFile,
DownloadFile,
RenameFile,
ToggleFileProtection,
}
export type PopoverFileItemAction =
| {
type: Exclude<
PopoverFileItemActionType,
PopoverFileItemActionType.RenameFile | PopoverFileItemActionType.ToggleFileProtection
>
payload: SNFile
}
| {
type: PopoverFileItemActionType.ToggleFileProtection
payload: SNFile
callback: (isProtected: boolean) => void
}
| {
type: PopoverFileItemActionType.RenameFile
payload: {
file: SNFile
name: string
}
}

View File

@ -1,31 +1,18 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants'; import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
calculateSubmenuStyle, import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
SubmenuStyle, import { FunctionComponent } from 'preact'
} from '@/utils/calculateSubmenuStyle'; import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { import { Icon } from '@/Components/Icon'
Disclosure, import { Switch } from '@/Components/Switch'
DisclosureButton, import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
DisclosurePanel, import { useFilePreviewModal } from '@/Components/Files/FilePreviewModalProvider'
} from '@reach/disclosure'; import { PopoverFileItemProps } from './PopoverFileItem'
import { FunctionComponent } from 'preact'; import { PopoverFileItemActionType } from './PopoverFileItemAction'
import {
StateUpdater,
useCallback,
useEffect,
useRef,
useState,
} from 'preact/hooks';
import { Icon } from '../Icon';
import { Switch } from '../Switch';
import { useCloseOnBlur } from '../utils';
import { useFilePreviewModal } from '../Files/FilePreviewModalProvider';
import { PopoverFileItemProps } from './PopoverFileItem';
import { PopoverFileItemActionType } from './PopoverFileItemAction';
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & { type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
setIsRenamingFile: StateUpdater<boolean>; setIsRenamingFile: StateUpdater<boolean>
}; }
export const PopoverFileSubmenu: FunctionComponent<Props> = ({ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
file, file,
@ -33,54 +20,51 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
handleFileAction, handleFileAction,
setIsRenamingFile, setIsRenamingFile,
}) => { }) => {
const filePreviewModal = useFilePreviewModal(); const filePreviewModal = useFilePreviewModal()
const menuContainerRef = useRef<HTMLDivElement>(null); const menuContainerRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isFileProtected, setIsFileProtected] = useState(file.protected); const [isFileProtected, setIsFileProtected] = useState(file.protected)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({ const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0, right: 0,
bottom: 0, bottom: 0,
maxHeight: 'auto', maxHeight: 'auto',
}); })
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen); const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const closeMenu = () => { const closeMenu = () => {
setIsMenuOpen(false); setIsMenuOpen(false)
}; }
const toggleMenu = () => { const toggleMenu = () => {
if (!isMenuOpen) { if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current); const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) { if (menuPosition) {
setMenuStyle(menuPosition); setMenuStyle(menuPosition)
} }
} }
setIsMenuOpen(!isMenuOpen); setIsMenuOpen(!isMenuOpen)
}; }
const recalculateMenuStyle = useCallback(() => { const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle( const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
menuButtonRef.current,
menuRef.current
);
if (newMenuPosition) { if (newMenuPosition) {
setMenuStyle(newMenuPosition); setMenuStyle(newMenuPosition)
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
if (isMenuOpen) { if (isMenuOpen) {
setTimeout(() => { setTimeout(() => {
recalculateMenuStyle(); recalculateMenuStyle()
}); })
} }
}, [isMenuOpen, recalculateMenuStyle]); }, [isMenuOpen, recalculateMenuStyle])
return ( return (
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
@ -106,8 +90,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop" className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => { onClick={() => {
filePreviewModal.activate(file); filePreviewModal.activate(file)
closeMenu(); closeMenu()
}} }}
> >
<Icon type="file" className="mr-2 color-neutral" /> <Icon type="file" className="mr-2 color-neutral" />
@ -121,8 +105,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
handleFileAction({ handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote, type: PopoverFileItemActionType.DetachFileToNote,
payload: file, payload: file,
}); }).catch(console.error)
closeMenu(); closeMenu()
}} }}
> >
<Icon type="link-off" className="mr-2 color-neutral" /> <Icon type="link-off" className="mr-2 color-neutral" />
@ -136,8 +120,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
handleFileAction({ handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote, type: PopoverFileItemActionType.AttachFileToNote,
payload: file, payload: file,
}); }).catch(console.error)
closeMenu(); closeMenu()
}} }}
> >
<Icon type="link" className="mr-2 color-neutral" /> <Icon type="link" className="mr-2 color-neutral" />
@ -152,9 +136,9 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
type: PopoverFileItemActionType.ToggleFileProtection, type: PopoverFileItemActionType.ToggleFileProtection,
payload: file, payload: file,
callback: (isProtected: boolean) => { callback: (isProtected: boolean) => {
setIsFileProtected(isProtected); setIsFileProtected(isProtected)
}, },
}); }).catch(console.error)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -176,8 +160,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
handleFileAction({ handleFileAction({
type: PopoverFileItemActionType.DownloadFile, type: PopoverFileItemActionType.DownloadFile,
payload: file, payload: file,
}); }).catch(console.error)
closeMenu(); closeMenu()
}} }}
> >
<Icon type="download" className="mr-2 color-neutral" /> <Icon type="download" className="mr-2 color-neutral" />
@ -187,7 +171,7 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop" className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => { onClick={() => {
setIsRenamingFile(true); setIsRenamingFile(true)
}} }}
> >
<Icon type="pencil" className="mr-2 color-neutral" /> <Icon type="pencil" className="mr-2 color-neutral" />
@ -200,8 +184,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
handleFileAction({ handleFileAction({
type: PopoverFileItemActionType.DeleteFile, type: PopoverFileItemActionType.DeleteFile,
payload: file, payload: file,
}); }).catch(console.error)
closeMenu(); closeMenu()
}} }}
> >
<Icon type="trash" className="mr-2 color-danger" /> <Icon type="trash" className="mr-2 color-danger" />
@ -212,5 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
</div> </div>
); )
}; }

View File

@ -1,25 +1,23 @@
interface BubbleProperties { interface BubbleProperties {
label: string; label: string
selected: boolean; selected: boolean
onSelect: () => void; onSelect: () => void
} }
const styles = { const styles = {
base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast', base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast',
unselected: 'color-neutral border-secondary', unselected: 'color-neutral border-secondary',
selected: 'border-info bg-info color-neutral-contrast', selected: 'border-info bg-info color-neutral-contrast',
}; }
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => ( const Bubble = ({ label, selected, onSelect }: BubbleProperties) => (
<span <span
role="tab" role="tab"
className={`bubble ${styles.base} ${ className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}
selected ? styles.selected : styles.unselected
}`}
onClick={onSelect} onClick={onSelect}
> >
{label} {label}
</span> </span>
); )
export default Bubble; export default Bubble

View File

@ -1,58 +1,44 @@
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import { ComponentChildren, FunctionComponent, Ref } from 'preact'; import { ComponentChildren, FunctionComponent, Ref } from 'preact'
import { forwardRef } from 'preact/compat'; 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'
type ButtonVariant = 'normal' | 'primary'; type ButtonVariant = 'normal' | 'primary'
const getClassName = ( const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean) => {
variant: ButtonVariant, const borders = variant === 'normal' ? 'border-solid border-main border-1' : 'no-border'
danger: boolean, const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'
disabled: boolean
) => {
const borders =
variant === 'normal' ? 'border-solid border-main border-1' : 'no-border';
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer';
let colors = let colors = variant === 'normal' ? 'bg-default color-text' : 'bg-info color-info-contrast'
variant === 'normal'
? 'bg-default color-text'
: 'bg-info color-info-contrast';
let focusHoverStates = let focusHoverStates =
variant === 'normal' variant === 'normal'
? 'focus:bg-contrast focus:outline-none hover:bg-contrast' ? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
: 'hover:brightness-130 focus:outline-none focus:brightness-130'; : 'hover:brightness-130 focus:outline-none focus:brightness-130'
if (danger) { if (danger) {
colors = colors = variant === 'normal' ? 'bg-default color-danger' : 'bg-danger color-info-contrast'
variant === 'normal'
? 'bg-default color-danger'
: 'bg-danger color-info-contrast';
} }
if (disabled) { if (disabled) {
colors = colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast'
variant === 'normal'
? 'bg-default color-grey-2'
: 'bg-grey-2 color-info-contrast';
focusHoverStates = focusHoverStates =
variant === 'normal' variant === 'normal'
? 'focus:bg-default focus:outline-none hover:bg-default' ? 'focus:bg-default focus:outline-none hover:bg-default'
: 'focus:brightness-default focus:outline-none hover:brightness-default'; : 'focus:brightness-default focus:outline-none hover:brightness-default'
} }
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`; return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
}; }
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & { type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & {
children?: ComponentChildren; children?: ComponentChildren
className?: string; className?: string
variant?: ButtonVariant; variant?: ButtonVariant
dangerStyle?: boolean; dangerStyle?: boolean
label?: string; label?: string
}; }
export const Button: FunctionComponent<ButtonProps> = forwardRef( export const Button: FunctionComponent<ButtonProps> = forwardRef(
( (
@ -65,7 +51,7 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
children, children,
...props ...props
}: ButtonProps, }: ButtonProps,
ref: Ref<HTMLButtonElement> ref: Ref<HTMLButtonElement>,
) => { ) => {
return ( return (
<button <button
@ -77,6 +63,6 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
> >
{label ?? children} {label ?? children}
</button> </button>
); )
} },
); )

View File

@ -1,27 +1,27 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'; import { IconType } from '@standardnotes/snjs'
interface Props { interface Props {
/** /**
* onClick - preventDefault is handled within the component * 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 * Button tooltip
*/ */
title: string; title: string
focusable: boolean; focusable: boolean
disabled?: boolean; disabled?: boolean
} }
/** /**
@ -38,10 +38,10 @@ export const IconButton: FunctionComponent<Props> = ({
disabled = false, disabled = false,
}) => { }) => {
const click = (e: MouseEvent) => { const click = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault()
onClick(); onClick()
}; }
const focusableClass = focusable ? '' : 'focus:shadow-none'; const focusableClass = focusable ? '' : 'focus:shadow-none'
return ( return (
<button <button
type="button" type="button"
@ -52,5 +52,5 @@ export const IconButton: FunctionComponent<Props> = ({
> >
<Icon type={icon} className={iconClassName} /> <Icon type={icon} className={iconClassName} />
</button> </button>
); )
}; }

View File

@ -0,0 +1,40 @@
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'
type ButtonType = 'normal' | 'primary'
interface Props {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void
type: ButtonType
className?: string
icon: IconType
}
/**
* IconButton component with an icon
* preventDefault is already handled within the component
*/
export const RoundIconButton: FunctionComponent<Props> = ({
onClick,
type,
className,
icon: iconType,
}) => {
const click = (e: MouseEvent) => {
e.preventDefault()
onClick()
}
const classes = type === 'primary' ? 'info ' : ''
return (
<button className={`sn-icon-button ${classes} ${className ?? ''}`} onClick={click}>
<Icon type={iconType} />
</button>
)
}

View File

@ -1,5 +1,5 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog'; import { DialogContent, DialogOverlay } from '@reach/dialog'
import { import {
ButtonType, ButtonType,
Challenge, Challenge,
@ -7,90 +7,87 @@ import {
ChallengeReason, ChallengeReason,
ChallengeValue, ChallengeValue,
removeFromArray, removeFromArray,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { ProtectedIllustration } from '@standardnotes/stylekit'; import { ProtectedIllustration } from '@standardnotes/stylekit'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { ChallengeModalPrompt } from './ChallengePrompt'; import { ChallengeModalPrompt } from './ChallengePrompt'
type InputValue = { type InputValue = {
prompt: ChallengePrompt; prompt: ChallengePrompt
value: string | number | boolean; value: string | number | boolean
invalid: boolean; invalid: boolean
}; }
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>; export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
type Props = { type Props = {
application: WebApplication; application: WebApplication
challenge: Challenge; challenge: Challenge
onDismiss: (challenge: Challenge) => Promise<void>; onDismiss: (challenge: Challenge) => Promise<void>
}; }
const validateValues = ( const validateValues = (
values: ChallengeModalValues, values: ChallengeModalValues,
prompts: ChallengePrompt[] prompts: ChallengePrompt[],
): ChallengeModalValues | undefined => { ): ChallengeModalValues | undefined => {
let hasInvalidValues = false; let hasInvalidValues = false
const validatedValues = { ...values }; const validatedValues = { ...values }
for (const prompt of prompts) { for (const prompt of prompts) {
const value = validatedValues[prompt.id]; const value = validatedValues[prompt.id]
if (typeof value.value === 'string' && value.value.length === 0) { if (typeof value.value === 'string' && value.value.length === 0) {
validatedValues[prompt.id].invalid = true; validatedValues[prompt.id].invalid = true
hasInvalidValues = true; hasInvalidValues = true
} }
} }
if (!hasInvalidValues) { if (!hasInvalidValues) {
return validatedValues; return validatedValues
} }
}; return undefined
}
export const ChallengeModal: FunctionComponent<Props> = ({ export const ChallengeModal: FunctionComponent<Props> = ({ application, challenge, onDismiss }) => {
application,
challenge,
onDismiss,
}) => {
const [values, setValues] = useState<ChallengeModalValues>(() => { const [values, setValues] = useState<ChallengeModalValues>(() => {
const values = {} as ChallengeModalValues; const values = {} as ChallengeModalValues
for (const prompt of challenge.prompts) { for (const prompt of challenge.prompts) {
values[prompt.id] = { values[prompt.id] = {
prompt, prompt,
value: prompt.initialValue ?? '', value: prompt.initialValue ?? '',
invalid: false, invalid: false,
}; }
} }
return values; return values
}); })
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false)
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false)
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([]); const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false); const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
const shouldShowForgotPasscode = [ const shouldShowForgotPasscode = [
ChallengeReason.ApplicationUnlock, ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration, ChallengeReason.Migration,
].includes(challenge.reason); ].includes(challenge.reason)
const submit = async () => { const submit = async () => {
const validatedValues = validateValues(values, challenge.prompts); const validatedValues = validateValues(values, challenge.prompts)
if (!validatedValues) { if (!validatedValues) {
return; return
} }
if (isSubmitting || isProcessing) { if (isSubmitting || isProcessing) {
return; return
} }
setIsSubmitting(true); setIsSubmitting(true)
setIsProcessing(true); setIsProcessing(true)
const valuesToProcess: ChallengeValue[] = []; const valuesToProcess: ChallengeValue[] = []
for (const inputValue of Object.values(validatedValues)) { for (const inputValue of Object.values(validatedValues)) {
const rawValue = inputValue.value; const rawValue = inputValue.value
const value = new ChallengeValue(inputValue.prompt, rawValue); const value = new ChallengeValue(inputValue.prompt, rawValue)
valuesToProcess.push(value); valuesToProcess.push(value)
} }
const processingPrompts = valuesToProcess.map((v) => v.prompt); const processingPrompts = valuesToProcess.map((v) => v.prompt)
setIsProcessing(processingPrompts.length > 0); setIsProcessing(processingPrompts.length > 0)
setProcessingPrompts(processingPrompts); setProcessingPrompts(processingPrompts)
/** /**
* Unfortunately neccessary to wait 50ms so that the above setState call completely * Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation * updates the UI to change processing state, before we enter into UI blocking operation
@ -98,90 +95,85 @@ export const ChallengeModal: FunctionComponent<Props> = ({
*/ */
setTimeout(() => { setTimeout(() => {
if (valuesToProcess.length > 0) { if (valuesToProcess.length > 0) {
application.submitValuesForChallenge(challenge, valuesToProcess); application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
} else { } else {
setIsProcessing(false); setIsProcessing(false)
} }
setIsSubmitting(false); setIsSubmitting(false)
}, 50); }, 50)
}; }
const onValueChange = useCallback( const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => { (value: string | number, prompt: ChallengePrompt) => {
const newValues = { ...values }; const newValues = { ...values }
newValues[prompt.id].invalid = false; newValues[prompt.id].invalid = false
newValues[prompt.id].value = value; newValues[prompt.id].value = value
setValues(newValues); setValues(newValues)
}, },
[values] [values],
); )
const closeModal = () => { const closeModal = () => {
if (challenge.cancelable) { if (challenge.cancelable) {
onDismiss(challenge); onDismiss(challenge).catch(console.error)
} }
}; }
useEffect(() => { useEffect(() => {
const removeChallengeObserver = application.addChallengeObserver( const removeChallengeObserver = application.addChallengeObserver(challenge, {
challenge, onValidValue: (value) => {
{ setValues((values) => {
onValidValue: (value) => { const newValues = { ...values }
setValues((values) => { newValues[value.prompt.id].invalid = false
const newValues = { ...values }; return newValues
newValues[value.prompt.id].invalid = false; })
return newValues; setProcessingPrompts((currentlyProcessingPrompts) => {
}); const processingPrompts = currentlyProcessingPrompts.slice()
removeFromArray(processingPrompts, value.prompt)
setIsProcessing(processingPrompts.length > 0)
return processingPrompts
})
},
onInvalidValue: (value) => {
setValues((values) => {
const newValues = { ...values }
newValues[value.prompt.id].invalid = true
return newValues
})
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
setProcessingPrompts([])
setIsProcessing(false)
} else {
setProcessingPrompts((currentlyProcessingPrompts) => { setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice(); const processingPrompts = currentlyProcessingPrompts.slice()
removeFromArray(processingPrompts, value.prompt); removeFromArray(processingPrompts, value.prompt)
setIsProcessing(processingPrompts.length > 0); setIsProcessing(processingPrompts.length > 0)
return processingPrompts; return processingPrompts
}); })
}, }
onInvalidValue: (value) => { },
setValues((values) => { onComplete: () => {
const newValues = { ...values }; onDismiss(challenge).catch(console.error)
newValues[value.prompt.id].invalid = true; },
return newValues; onCancel: () => {
}); onDismiss(challenge).catch(console.error)
/** If custom validation, treat all values together and not individually */ },
if (!value.prompt.validates) { })
setProcessingPrompts([]);
setIsProcessing(false);
} else {
setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice();
removeFromArray(processingPrompts, value.prompt);
setIsProcessing(processingPrompts.length > 0);
return processingPrompts;
});
}
},
onComplete: () => {
onDismiss(challenge);
},
onCancel: () => {
onDismiss(challenge);
},
}
);
return () => { return () => {
removeChallengeObserver(); removeChallengeObserver()
}; }
}, [application, challenge, onDismiss]); }, [application, challenge, onDismiss])
if (!challenge.prompts) { if (!challenge.prompts) {
return null; return null
} }
return ( return (
<DialogOverlay <DialogOverlay
className={`sn-component ${ className={`sn-component ${
challenge.reason === ChallengeReason.ApplicationUnlock challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
? 'challenge-modal-overlay'
: ''
}`} }`}
onDismiss={closeModal} onDismiss={closeModal}
dangerouslyBypassFocusLock={bypassModalFocusLock} dangerouslyBypassFocusLock={bypassModalFocusLock}
@ -203,17 +195,13 @@ export const ChallengeModal: FunctionComponent<Props> = ({
</button> </button>
)} )}
<ProtectedIllustration className="w-30 h-30 mb-4" /> <ProtectedIllustration className="w-30 h-30 mb-4" />
<div className="font-bold text-lg text-center max-w-76 mb-3"> <div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
{challenge.heading} <div className="text-center text-sm max-w-76 mb-4">{challenge.subheading}</div>
</div>
<div className="text-center text-sm max-w-76 mb-4">
{challenge.subheading}
</div>
<form <form
className="flex flex-col items-center min-w-76 mb-4" className="flex flex-col items-center min-w-76 mb-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault()
submit(); submit().catch(console.error)
}} }}
> >
{challenge.prompts.map((prompt, index) => ( {challenge.prompts.map((prompt, index) => (
@ -232,7 +220,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
disabled={isProcessing} disabled={isProcessing}
className="min-w-76 mb-3.5" className="min-w-76 mb-3.5"
onClick={() => { onClick={() => {
submit(); submit().catch(console.error)
}} }}
> >
{isProcessing ? 'Generating Keys...' : 'Unlock'} {isProcessing ? 'Generating Keys...' : 'Unlock'}
@ -241,23 +229,23 @@ export const ChallengeModal: FunctionComponent<Props> = ({
<Button <Button
className="flex items-center justify-center min-w-76" className="flex items-center justify-center min-w-76"
onClick={() => { onClick={() => {
setBypassModalFocusLock(true); setBypassModalFocusLock(true)
application.alertService application.alertService
.confirm( .confirm(
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.', 'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
'Forgot passcode?', 'Forgot passcode?',
'Delete local data', 'Delete local data',
ButtonType.Danger ButtonType.Danger,
) )
.then((shouldDeleteLocalData) => { .then((shouldDeleteLocalData) => {
if (shouldDeleteLocalData) { if (shouldDeleteLocalData) {
application.user.signOut(); application.user.signOut().catch(console.error)
} }
}) })
.catch(console.error) .catch(console.error)
.finally(() => { .finally(() => {
setBypassModalFocusLock(false); setBypassModalFocusLock(false)
}); })
}} }}
> >
<Icon type="help" className="mr-2 color-neutral" /> <Icon type="help" className="mr-2 color-neutral" />
@ -266,5 +254,5 @@ export const ChallengeModal: FunctionComponent<Props> = ({
)} )}
</DialogContent> </DialogContent>
</DialogOverlay> </DialogOverlay>
); )
}; }

View File

@ -2,20 +2,20 @@ import {
ChallengePrompt, ChallengePrompt,
ChallengeValidation, ChallengeValidation,
ProtectionSessionDurations, ProtectionSessionDurations,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks'
import { DecoratedInput } from '../DecoratedInput'; import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
import { ChallengeModalValues } from './ChallengeModal'; import { ChallengeModalValues } from './ChallengeModal'
type Props = { type Props = {
prompt: ChallengePrompt; prompt: ChallengePrompt
values: ChallengeModalValues; values: ChallengeModalValues
index: number; index: number
onValueChange: (value: string | number, prompt: ChallengePrompt) => void; onValueChange: (value: string | number, prompt: ChallengePrompt) => void
isInvalid: boolean; isInvalid: boolean
}; }
export const ChallengeModalPrompt: FunctionComponent<Props> = ({ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
prompt, prompt,
@ -24,31 +24,28 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
onValueChange, onValueChange,
isInvalid, isInvalid,
}) => { }) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (index === 0) { if (index === 0) {
inputRef.current?.focus(); inputRef.current?.focus()
} }
}, [index]); }, [index])
useEffect(() => { useEffect(() => {
if (isInvalid) { if (isInvalid) {
inputRef.current?.focus(); inputRef.current?.focus()
} }
}, [isInvalid]); }, [isInvalid])
return ( return (
<> <>
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div className="mt-3 min-w-76"> <div className="mt-3 min-w-76">
<div className="text-sm font-medium mb-2"> <div className="text-sm font-medium mb-2">Allow protected access for</div>
Allow protected access for
</div>
<div className="flex items-center justify-between bg-grey-4 rounded p-1"> <div className="flex items-center justify-between bg-grey-4 rounded p-1">
{ProtectionSessionDurations.map((option) => { {ProtectionSessionDurations.map((option) => {
const selected = const selected = option.valueInSeconds === values[prompt.id].value
option.valueInSeconds === values[prompt.id].value;
return ( return (
<label <label
className={`cursor-pointer px-2 py-1.5 rounded ${ className={`cursor-pointer px-2 py-1.5 rounded ${
@ -60,21 +57,19 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
<input <input
type="radio" type="radio"
name={`session-duration-${prompt.id}`} name={`session-duration-${prompt.id}`}
className={ className={'appearance-none m-0 focus:shadow-none focus:outline-none'}
'appearance-none m-0 focus:shadow-none focus:outline-none'
}
style={{ style={{
marginRight: 0, marginRight: 0,
}} }}
checked={selected} checked={selected}
onChange={(event) => { onChange={(event) => {
event.preventDefault(); event.preventDefault()
onValueChange(option.valueInSeconds, prompt); onValueChange(option.valueInSeconds, prompt)
}} }}
/> />
{option.label} {option.label}
</label> </label>
); )
})} })}
</div> </div>
</div> </div>
@ -94,10 +89,8 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
/> />
)} )}
{isInvalid && ( {isInvalid && (
<div className="text-sm color-danger mt-2"> <div className="text-sm color-danger mt-2">Invalid authentication, please try again.</div>
Invalid authentication, please try again.
</div>
)} )}
</> </>
); )
}; }

View File

@ -1,74 +1,63 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/constants'; import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
Disclosure, import VisuallyHidden from '@reach/visually-hidden'
DisclosureButton, import { observer } from 'mobx-react-lite'
DisclosurePanel, import { FunctionComponent } from 'preact'
} from '@reach/disclosure'; import { useRef, useState } from 'preact/hooks'
import VisuallyHidden from '@reach/visually-hidden'; import { Icon } from '@/Components/Icon'
import { observer } from 'mobx-react-lite'; import { ChangeEditorMenu } from './ChangeEditorMenu'
import { FunctionComponent } from 'preact'; import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useRef, useState } from 'preact/hooks';
import { Icon } from './Icon';
import { ChangeEditorMenu } from './NotesOptions/changeEditor/ChangeEditorMenu';
import { useCloseOnBlur } from './utils';
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
onClickPreprocessing?: () => Promise<void>; onClickPreprocessing?: () => Promise<void>
}; }
export const ChangeEditorButton: FunctionComponent<Props> = observer( export const ChangeEditorButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }) => { ({ application, appState, onClickPreprocessing }) => {
const note = Object.values(appState.notes.selectedNotes)[0]; const note = Object.values(appState.notes.selectedNotes)[0]
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false)
const [position, setPosition] = useState({ const [position, setPosition] = useState({
top: 0, top: 0,
right: 0, right: 0,
}); })
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto'); const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen); const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
const toggleChangeEditorMenu = async () => { const toggleChangeEditorMenu = async () => {
const rect = buttonRef.current?.getBoundingClientRect(); const rect = buttonRef.current?.getBoundingClientRect()
if (rect) { if (rect) {
const { clientHeight } = document.documentElement; const { clientHeight } = document.documentElement
const footerElementRect = document const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
.getElementById('footer-bar') const footerHeightInPx = footerElementRect?.height
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (footerHeightInPx) { if (footerHeightInPx) {
setMaxHeight( setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
clientHeight -
rect.bottom -
footerHeightInPx -
MENU_MARGIN_FROM_APP_BORDER
);
} }
setPosition({ setPosition({
top: rect.bottom, top: rect.bottom,
right: document.body.clientWidth - rect.right, right: document.body.clientWidth - rect.right,
}); })
const newOpenState = !isOpen; const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) { if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing(); await onClickPreprocessing()
} }
setIsOpen(newOpenState); setIsOpen(newOpenState)
setTimeout(() => { setTimeout(() => {
setIsVisible(newOpenState); setIsVisible(newOpenState)
}); })
} }
}; }
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
@ -76,7 +65,7 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
<DisclosureButton <DisclosureButton
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setIsOpen(false); setIsOpen(false)
} }
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
@ -89,8 +78,8 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
<DisclosurePanel <DisclosurePanel
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setIsOpen(false); setIsOpen(false)
buttonRef.current?.focus(); buttonRef.current?.focus()
} }
}} }}
ref={panelRef} ref={panelRef}
@ -108,13 +97,13 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
isVisible={isVisible} isVisible={isVisible}
note={note} note={note}
closeMenu={() => { closeMenu={() => {
setIsOpen(false); setIsOpen(false)
}} }}
/> />
)} )}
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
</div> </div>
); )
} },
); )

View File

@ -1,14 +1,14 @@
import { Icon } from '@/components/Icon'; import { Icon } from '@/Components/Icon'
import { Menu } from '@/components/Menu/Menu'; import { Menu } from '@/Components/Menu/Menu'
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem'; import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
import { import {
reloadFont, reloadFont,
transactionForAssociateComponentWithCurrentNote, transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote, transactionForDisassociateComponentWithCurrentNote,
} from '@/components/NoteView/NoteView'; } from '@/Components/NoteView/NoteView'
import { usePremiumModal } from '@/components/Premium'; import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings'; import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import {
ComponentArea, ComponentArea,
ItemMutator, ItemMutator,
@ -17,25 +17,21 @@ import {
SNComponent, SNComponent,
SNNote, SNNote,
TransactionalMutation, TransactionalMutation,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact'; import { Fragment, FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks'
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption'; import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
import { import { createEditorMenuGroups, PLAIN_EDITOR_NAME } from './createEditorMenuGroups'
createEditorMenuGroups,
PLAIN_EDITOR_NAME,
} from './createEditorMenuGroups';
type ChangeEditorMenuProps = { type ChangeEditorMenuProps = {
application: WebApplication; application: WebApplication
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
closeMenu: () => void; closeMenu: () => void
isVisible: boolean; isVisible: boolean
note: SNNote; note: SNNote
}; }
const getGroupId = (group: EditorMenuGroup) => const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
group.title.toLowerCase().replace(/\s/, '-');
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
application, application,
@ -45,65 +41,59 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
note, note,
}) => { }) => {
const [editors] = useState<SNComponent[]>(() => const [editors] = useState<SNComponent[]>(() =>
application.componentManager application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
.componentsForArea(ComponentArea.Editor) return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
.sort((a, b) => { }),
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; )
}) const [groups, setGroups] = useState<EditorMenuGroup[]>([])
); const [currentEditor, setCurrentEditor] = useState<SNComponent>()
const [groups, setGroups] = useState<EditorMenuGroup[]>([]);
const [currentEditor, setCurrentEditor] = useState<SNComponent>();
useEffect(() => { useEffect(() => {
setGroups(createEditorMenuGroups(application, editors)); setGroups(createEditorMenuGroups(application, editors))
}, [application, editors]); }, [application, editors])
useEffect(() => { useEffect(() => {
if (note) { if (note) {
setCurrentEditor(application.componentManager.editorForNote(note)); setCurrentEditor(application.componentManager.editorForNote(note))
} }
}, [application, note]); }, [application, note])
const premiumModal = usePremiumModal(); const premiumModal = usePremiumModal()
const isSelectedEditor = useCallback( const isSelectedEditor = useCallback(
(item: EditorMenuItem) => { (item: EditorMenuItem) => {
if (currentEditor) { if (currentEditor) {
if (item?.component?.identifier === currentEditor.identifier) { if (item?.component?.identifier === currentEditor.identifier) {
return true; return true
} }
} else if (item.name === PLAIN_EDITOR_NAME) { } else if (item.name === PLAIN_EDITOR_NAME) {
return true; return true
} }
return false; return false
}, },
[currentEditor] [currentEditor],
); )
const selectComponent = async ( const selectComponent = async (component: SNComponent | null, note: SNNote) => {
component: SNComponent | null,
note: SNNote
) => {
if (component) { if (component) {
if (component.conflictOf) { if (component.conflictOf) {
application.mutator.changeAndSaveItem(component, (mutator) => { application.mutator
mutator.conflictOf = undefined; .changeAndSaveItem(component, (mutator) => {
}); mutator.conflictOf = undefined
})
.catch(console.error)
} }
} }
const transactions: TransactionalMutation[] = []; const transactions: TransactionalMutation[] = []
if (application.getAppState().getActiveNoteController()?.isTemplateNote) { if (application.getAppState().getActiveNoteController()?.isTemplateNote) {
await application await application.getAppState().getActiveNoteController().insertTemplatedNote()
.getAppState()
.getActiveNoteController()
.insertTemplatedNote();
} }
if (note.locked) { if (note.locked) {
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT); application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
return; return
} }
if (!component) { if (!component) {
@ -111,97 +101,79 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
transactions.push({ transactions.push({
itemUuid: note.uuid, itemUuid: note.uuid,
mutate: (m: ItemMutator) => { mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator; const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = true; noteMutator.prefersPlainEditor = true
}, },
}); })
} }
const currentEditor = application.componentManager.editorForNote(note); const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) { if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
transactions.push( transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
} }
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)); reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
} else if (component.area === ComponentArea.Editor) { } else if (component.area === ComponentArea.Editor) {
const currentEditor = application.componentManager.editorForNote(note); const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor && component.uuid !== currentEditor.uuid) { if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push( transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
} }
const prefersPlain = note.prefersPlainEditor; const prefersPlain = note.prefersPlainEditor
if (prefersPlain) { if (prefersPlain) {
transactions.push({ transactions.push({
itemUuid: note.uuid, itemUuid: note.uuid,
mutate: (m: ItemMutator) => { mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator; const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = false; noteMutator.prefersPlainEditor = false
}, },
}); })
} }
transactions.push( transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
transactionForAssociateComponentWithCurrentNote(component, note)
);
} }
await application.mutator.runTransactionalMutations(transactions); await application.mutator.runTransactionalMutations(transactions)
/** Dirtying can happen above */ /** Dirtying can happen above */
application.sync.sync(); application.sync.sync().catch(console.error)
setCurrentEditor(application.componentManager.editorForNote(note)); setCurrentEditor(application.componentManager.editorForNote(note))
}; }
const selectEditor = async (itemToBeSelected: EditorMenuItem) => { const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
if (!itemToBeSelected.isEntitled) { if (!itemToBeSelected.isEntitled) {
premiumModal.activate(itemToBeSelected.name); premiumModal.activate(itemToBeSelected.name)
return; return
} }
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component; const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
if (areBothEditorsPlain) { if (areBothEditorsPlain) {
return; return
} }
let shouldSelectEditor = true; let shouldSelectEditor = true
if (itemToBeSelected.component) { if (itemToBeSelected.component) {
const changeRequiresAlert = const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
application.componentManager.doesEditorChangeRequireAlert( currentEditor,
currentEditor, itemToBeSelected.component,
itemToBeSelected.component )
);
if (changeRequiresAlert) { if (changeRequiresAlert) {
shouldSelectEditor = shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
await application.componentManager.showEditorChangeAlert();
} }
} }
if (shouldSelectEditor) { if (shouldSelectEditor) {
selectComponent(itemToBeSelected.component ?? null, note); selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
} }
closeMenu(); closeMenu()
}; }
return ( return (
<Menu <Menu className="pt-0.5 pb-1" a11yLabel="Change editor menu" isOpen={isVisible}>
className="pt-0.5 pb-1"
a11yLabel="Change editor menu"
isOpen={isVisible}
>
{groups {groups
.filter((group) => group.items && group.items.length) .filter((group) => group.items && group.items.length)
.map((group, index) => { .map((group, index) => {
const groupId = getGroupId(group); const groupId = getGroupId(group)
return ( return (
<Fragment key={groupId}> <Fragment key={groupId}>
@ -210,24 +182,21 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
index === 0 ? 'border-t-0 mb-2' : 'my-2' index === 0 ? 'border-t-0 mb-2' : 'my-2'
}`} }`}
> >
{group.icon && ( {group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
<Icon
type={group.icon}
className={`mr-2 ${group.iconClassName}`}
/>
)}
<div className="font-semibold text-input">{group.title}</div> <div className="font-semibold text-input">{group.title}</div>
</div> </div>
{group.items.map((item) => { {group.items.map((item) => {
const onClickEditorItem = () => { const onClickEditorItem = () => {
selectEditor(item); selectEditor(item).catch(console.error)
}; }
return ( return (
<MenuItem <MenuItem
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={onClickEditorItem} onClick={onClickEditorItem}
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none`} className={
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none'
}
onBlur={closeOnBlur} onBlur={closeOnBlur}
checked={isSelectedEditor(item)} checked={isSelectedEditor(item)}
> >
@ -236,11 +205,11 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
{!item.isEntitled && <Icon type="premium-feature" />} {!item.isEntitled && <Icon type="premium-feature" />}
</div> </div>
</MenuItem> </MenuItem>
); )
})} })}
</Fragment> </Fragment>
); )
})} })}
</Menu> </Menu>
); )
}; }

View File

@ -1,4 +1,4 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import {
ContentType, ContentType,
FeatureStatus, FeatureStatus,
@ -7,37 +7,32 @@ import {
FeatureDescription, FeatureDescription,
GetFeatures, GetFeatures,
NoteType, NoteType,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption'; import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
export const PLAIN_EDITOR_NAME = 'Plain Editor'; export const PLAIN_EDITOR_NAME = 'Plain Editor'
type EditorGroup = NoteType | 'plain' | 'others'; type EditorGroup = NoteType | 'plain' | 'others'
const getEditorGroup = ( const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => {
featureDescription: FeatureDescription
): EditorGroup => {
if (featureDescription.note_type) { if (featureDescription.note_type) {
return featureDescription.note_type; return featureDescription.note_type
} else if (featureDescription.file_type) { } else if (featureDescription.file_type) {
switch (featureDescription.file_type) { switch (featureDescription.file_type) {
case 'txt': case 'txt':
return 'plain'; return 'plain'
case 'html': case 'html':
return NoteType.RichText; return NoteType.RichText
case 'md': case 'md':
return NoteType.Markdown; return NoteType.Markdown
default: default:
return 'others'; return 'others'
} }
} }
return 'others'; return 'others'
}; }
export const createEditorMenuGroups = ( export const createEditorMenuGroups = (application: WebApplication, editors: SNComponent[]) => {
application: WebApplication,
editors: SNComponent[]
) => {
const editorItems: Record<EditorGroup, EditorMenuItem[]> = { const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
plain: [ plain: [
{ {
@ -52,40 +47,34 @@ export const createEditorMenuGroups = (
spreadsheet: [], spreadsheet: [],
authentication: [], authentication: [],
others: [], others: [],
}; }
GetFeatures() GetFeatures()
.filter( .filter(
(feature) => (feature) =>
feature.content_type === ContentType.Component && feature.content_type === ContentType.Component && feature.area === ComponentArea.Editor,
feature.area === ComponentArea.Editor
) )
.forEach((editorFeature) => { .forEach((editorFeature) => {
const notInstalled = !editors.find( const notInstalled = !editors.find((editor) => editor.identifier === editorFeature.identifier)
(editor) => editor.identifier === editorFeature.identifier const isExperimental = application.features.isExperimentalFeature(editorFeature.identifier)
);
const isExperimental = application.features.isExperimentalFeature(
editorFeature.identifier
);
if (notInstalled && !isExperimental) { if (notInstalled && !isExperimental) {
editorItems[getEditorGroup(editorFeature)].push({ editorItems[getEditorGroup(editorFeature)].push({
name: editorFeature.name as string, name: editorFeature.name as string,
isEntitled: false, isEntitled: false,
}); })
} }
}); })
editors.forEach((editor) => { editors.forEach((editor) => {
const editorItem: EditorMenuItem = { const editorItem: EditorMenuItem = {
name: editor.name, name: editor.name,
component: editor, component: editor,
isEntitled: isEntitled:
application.features.getFeatureStatus(editor.identifier) === application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
FeatureStatus.Entitled, }
};
editorItems[getEditorGroup(editor.package_info)].push(editorItem); editorItems[getEditorGroup(editor.package_info)].push(editorItem)
}); })
const editorMenuGroups: EditorMenuGroup[] = [ const editorMenuGroups: EditorMenuGroup[] = [
{ {
@ -136,7 +125,7 @@ export const createEditorMenuGroups = (
title: 'Others', title: 'Others',
items: editorItems.others, items: editorItems.others,
}, },
]; ]
return editorMenuGroups; return editorMenuGroups
}; }

View File

@ -1,12 +1,12 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
type CheckboxProps = { type CheckboxProps = {
name: string; name: string
checked: boolean; checked: boolean
onChange: (e: Event) => void; onChange: (e: Event) => void
disabled?: boolean; disabled?: boolean
label: string; label: string
}; }
export const Checkbox: FunctionComponent<CheckboxProps> = ({ export const Checkbox: FunctionComponent<CheckboxProps> = ({
name, name,
@ -28,5 +28,5 @@ export const Checkbox: FunctionComponent<CheckboxProps> = ({
/> />
{label} {label}
</label> </label>
); )
}; }

View File

@ -1,14 +1,14 @@
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
interface IProps { interface IProps {
deprecationMessage: string | undefined; deprecationMessage: string | undefined
dismissDeprecationMessage: () => void; dismissDeprecationMessage: () => void
} }
export const IsDeprecated: FunctionalComponent<IProps> = ({ export const IsDeprecated: FunctionalComponent<IProps> = ({
deprecationMessage, deprecationMessage,
dismissDeprecationMessage 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'}>
@ -21,12 +21,10 @@ export const IsDeprecated: FunctionalComponent<IProps> = ({
</div> </div>
<div className={'right'}> <div className={'right'}>
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}> <div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
<button className={'sn-button small info'}> <button className={'sn-button small info'}>Dismiss</button>
Dismiss
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,29 +1,25 @@
import { FeatureStatus } from '@standardnotes/snjs'; import { FeatureStatus } from '@standardnotes/snjs'
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
interface IProps { interface IProps {
expiredDate: string; expiredDate: string
componentName: string; componentName: string
featureStatus: FeatureStatus; featureStatus: FeatureStatus
manageSubscription: () => void; manageSubscription: () => void
} }
const statusString = ( const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => {
featureStatus: FeatureStatus,
expiredDate: string,
componentName: string
) => {
switch (featureStatus) { switch (featureStatus) {
case FeatureStatus.InCurrentPlanButExpired: case FeatureStatus.InCurrentPlanButExpired:
return `Your subscription expired on ${expiredDate}`; return `Your subscription expired on ${expiredDate}`
case FeatureStatus.NoUserSubscription: case FeatureStatus.NoUserSubscription:
return `You do not have an active subscription`; return 'You do not have an active subscription'
case FeatureStatus.NotInCurrentPlan: case FeatureStatus.NotInCurrentPlan:
return `Please upgrade your plan to access ${componentName}`; return `Please upgrade your plan to access ${componentName}`
default: default:
return `${componentName} is valid and you should not be seeing this message`; return `${componentName} is valid and you should not be seeing this message`
} }
}; }
export const IsExpired: FunctionalComponent<IProps> = ({ export const IsExpired: FunctionalComponent<IProps> = ({
expiredDate, expiredDate,
@ -41,27 +37,18 @@ export const IsExpired: FunctionalComponent<IProps> = ({
</div> </div>
<div className={'sk-app-bar-item-column'}> <div className={'sk-app-bar-item-column'}>
<div> <div>
<strong> <strong>{statusString(featureStatus, expiredDate, componentName)}</strong>
{statusString(featureStatus, expiredDate, componentName)} <div className={'sk-p'}>{componentName} is in a read-only state.</div>
</strong>
<div className={'sk-p'}>
{componentName} is in a read-only state.
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className={'right'}> <div className={'right'}>
<div <div className={'sk-app-bar-item'} onClick={() => manageSubscription()}>
className={'sk-app-bar-item'} <button className={'sn-button small success'}>Manage Subscription</button>
onClick={() => manageSubscription()}
>
<button className={'sn-button small success'}>
Manage Subscription
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,22 +1,17 @@
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
interface IProps { interface IProps {
componentName: string; componentName: string
reloadIframe: () => void; reloadIframe: () => void
} }
export const IssueOnLoading: FunctionalComponent<IProps> = ({ export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => {
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'}>
<div className={'left'}> <div className={'left'}>
<div className={'sk-app-bar-item'}> <div className={'sk-app-bar-item'}>
<div className={'sk-label.warning'}> <div className={'sk-label.warning'}>There was an issue loading {componentName}.</div>
There was an issue loading {componentName}.
</div>
</div> </div>
</div> </div>
<div className={'right'}> <div className={'right'}>
@ -26,5 +21,5 @@ export const IssueOnLoading: FunctionalComponent<IProps> = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
export const OfflineRestricted: FunctionalComponent = () => { export const OfflineRestricted: FunctionalComponent = () => {
return ( return (
@ -11,21 +11,17 @@ export const OfflineRestricted: FunctionalComponent = () => {
You have restricted this component to not use a hosted version. You have restricted this component to not use a hosted version.
</div> </div>
<div className={'sk-subtitle'}> <div className={'sk-subtitle'}>
Locally-installed components are not available in the web Locally-installed components are not available in the web application.
application.
</div> </div>
<div className={'sk-panel-row'} /> <div className={'sk-panel-row'} />
<div className={'sk-panel-row'}> <div className={'sk-panel-row'}>
<div className={'sk-panel-column'}> <div className={'sk-panel-column'}>
<div className={'sk-p'}> <div className={'sk-p'}>To continue, choose from the following options:</div>
To continue, choose from the following options:
</div>
<ul> <ul>
<li className={'sk-p'}> <li className={'sk-p'}>
Enable the Hosted option for this component by opening the Enable the Hosted option for this component by opening the Preferences {'>'}{' '}
Preferences {'>'} General {'>'} Advanced Settings menu and{' '} General {'>'} Advanced Settings menu and toggling 'Use hosted when local is
toggling 'Use hosted when local is unavailable' under this unavailable' under this component's options. Then press Reload.
component's options. Then press Reload.
</li> </li>
<li className={'sk-p'}>Use the desktop application.</li> <li className={'sk-p'}>Use the desktop application.</li>
</ul> </ul>
@ -35,5 +31,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
</div> </div>
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,7 +1,7 @@
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
interface IProps { interface IProps {
componentName: string; componentName: string
} }
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => { export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
@ -14,16 +14,14 @@ export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
This extension is missing its URL property. This extension is missing its URL property.
</div> </div>
<p> <p>
In order to access your note immediately, In order to access your note immediately, please switch from {componentName} to the
please switch from {componentName} to the Plain Editor. Plain Editor.
</p>
<br/>
<p>
Please contact help@standardnotes.com to remedy this issue.
</p> </p>
<br />
<p>Please contact help@standardnotes.com to remedy this issue.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
}; }

View File

@ -0,0 +1,221 @@
import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ComponentViewer,
ComponentViewerEvent,
ComponentViewerError,
} from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { FunctionalComponent } from 'preact'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { observer } from 'mobx-react-lite'
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted'
import { UrlMissing } from '@/Components/ComponentView/UrlMissing'
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated'
import { IsExpired } from '@/Components/ComponentView/IsExpired'
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading'
import { AppState } from '@/UIModels/AppState'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
interface IProps {
application: WebApplication
appState: AppState
componentViewer: ComponentViewer
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
onLoad?: (component: SNComponent) => void
manualDealloc?: boolean
}
/**
* The maximum amount of time we'll wait for a component
* to load before displaying error
*/
const MaxLoadThreshold = 4000
const VisibilityChangeKey = 'visibilitychange'
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
export const ComponentView: FunctionalComponent<IProps> = observer(
({ application, onLoad, componentViewer, requestReload }) => {
const iframeRef = useRef<HTMLIFrameElement>(null)
const excessiveLoadingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const [hasIssueLoading, setHasIssueLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
componentViewer.getFeatureStatus(),
)
const [isComponentValid, setIsComponentValid] = useState(true)
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
const [didAttemptReload, setDidAttemptReload] = useState(false)
const component = componentViewer.component
const manageSubscription = useCallback(() => {
openSubscriptionDashboard(application)
}, [application])
const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus())
if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
}
setIsComponentValid(componentViewer.shouldRender())
if (isLoading && !isComponentValid) {
setIsLoading(false)
}
setError(componentViewer.getError())
setDeprecationMessage(component.deprecationMessage)
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
useEffect(() => {
reloadValidityStatus()
}, [reloadValidityStatus])
const dismissDeprecationMessage = () => {
setIsDeprecationMessageDismissed(true)
}
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return
}
if (hasIssueLoading) {
requestReload?.(componentViewer)
}
}, [hasIssueLoading, componentViewer, requestReload])
const handleIframeTakingTooLongToLoad = useCallback(async () => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
}
}, [didAttemptReload, onVisibilityChange, componentViewer, requestReload])
useMemo(() => {
const loadTimeout = setTimeout(() => {
handleIframeTakingTooLongToLoad().catch(console.error)
}, MaxLoadThreshold)
excessiveLoadingTimeout.current = loadTimeout
return () => {
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
}
}, [handleIframeTakingTooLongToLoad])
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement
const contentWindow = iframe.contentWindow as Window
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
componentViewer.setWindow(contentWindow).catch(console.error)
setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(false)
onLoad?.(component)
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, excessiveLoadingTimeout])
useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus())
}
})
return () => {
removeFeaturesChangedObserver()
}
}, [componentViewer])
useEffect(() => {
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
switch (action) {
case ComponentAction.KeyDown:
application.io.handleComponentKeyDown(data.keyboardModifier)
break
case ComponentAction.KeyUp:
application.io.handleComponentKeyUp(data.keyboardModifier)
break
case ComponentAction.Click:
application.getAppState().notes.setContextMenuOpen(false)
break
default:
return
}
})
return () => {
removeActionObserver()
}
}, [componentViewer, application])
useEffect(() => {
const unregisterDesktopObserver = application
.getDesktopService()
.registerUpdateObserver((updatedComponent: SNComponent) => {
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
requestReload?.(componentViewer)
}
})
return () => {
unregisterDesktopObserver()
}
}, [application, requestReload, componentViewer, component.uuid])
return (
<>
{hasIssueLoading && (
<IssueOnLoading
componentName={component.name}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer, true)
}}
/>
)}
{featureStatus !== FeatureStatus.Entitled && (
<IsExpired
expiredDate={dateToLocalizedString(component.valid_until)}
featureStatus={featureStatus}
componentName={component.name}
manageSubscription={manageSubscription}
/>
)}
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated
deprecationMessage={deprecationMessage}
dismissDeprecationMessage={dismissDeprecationMessage}
/>
)}
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.name} />}
{component.uuid && isComponentValid && (
<iframe
ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier}
frameBorder={0}
src={componentViewer.url || ''}
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
>
Loading
</iframe>
)}
{isLoading && <div className={'loading-overlay'} />}
</>
)
},
)

View File

@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
type Props = {
application: WebApplication
appState: AppState
}
export const ConfirmSignoutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
})
export const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
const cancelRef = useRef<HTMLButtonElement>(null)
function closeDialog() {
appState.accountMenu.setSigningOut(false)
}
const [localBackupsCount, setLocalBackupsCount] = useState(0)
useEffect(() => {
application.bridge.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
}, [appState.accountMenu.signingOut, application.bridge])
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
Sign out workspace?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
</AlertDialogDescription>
{localBackupsCount > 0 && (
<div className="flex">
<div className="sk-panel-row"></div>
<label className="flex items-center">
<input
type="checkbox"
checked={deleteLocalBackups}
onChange={(event) => {
setDeleteLocalBackups((event.target as HTMLInputElement).checked)
}}
/>
<span className="ml-2">
Delete {localBackupsCount} local backup file
{localBackupsCount > 1 ? 's' : ''}
</span>
</label>
<button
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
onClick={() => {
application.bridge.viewlocalBackups()
}}
>
View backup files
</button>
</div>
)}
<div className="flex my-1 mt-4">
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
Cancel
</button>
<button
className="sn-button small danger ml-2"
onClick={() => {
if (deleteLocalBackups) {
application.signOutAndDeleteLocalBackups().catch(console.error)
} else {
application.user.signOut().catch(console.error)
}
closeDialog()
}}
>
{application.hasAccount() ? 'Sign Out' : 'Clear Session Data'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
)
})

View File

@ -5,32 +5,32 @@ import {
ListboxList, ListboxList,
ListboxOption, ListboxOption,
ListboxPopover, ListboxPopover,
} from '@reach/listbox'; } from '@reach/listbox'
import VisuallyHidden from '@reach/visually-hidden'; import VisuallyHidden from '@reach/visually-hidden'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'; import { IconType } from '@standardnotes/snjs'
export type DropdownItem = { export type DropdownItem = {
icon?: IconType; icon?: IconType
iconClassName?: string; iconClassName?: string
label: string; label: string
value: string; value: string
disabled?: boolean; disabled?: boolean
}; }
type DropdownProps = { type DropdownProps = {
id: string; id: string
label: string; label: string
items: DropdownItem[]; items: DropdownItem[]
value: string; value: string
onChange: (value: string, item: DropdownItem) => void; onChange: (value: string, item: DropdownItem) => void
disabled?: boolean; disabled?: boolean
}; }
type ListboxButtonProps = DropdownItem & { type ListboxButtonProps = DropdownItem & {
isExpanded: boolean; isExpanded: boolean
}; }
const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
label, label,
@ -47,15 +47,11 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
) : null} ) : null}
<div className="dropdown-selected-label">{label}</div> <div className="dropdown-selected-label">{label}</div>
</div> </div>
<ListboxArrow <ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
className={`sn-dropdown-arrow ${
isExpanded ? 'sn-dropdown-arrow-flipped' : ''
}`}
>
<Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" /> <Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" />
</ListboxArrow> </ListboxArrow>
</> </>
); )
export const Dropdown: FunctionComponent<DropdownProps> = ({ export const Dropdown: FunctionComponent<DropdownProps> = ({
id, id,
@ -65,15 +61,13 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
onChange, onChange,
disabled, disabled,
}) => { }) => {
const labelId = `${id}-label`; const labelId = `${id}-label`
const handleChange = (value: string) => { const handleChange = (value: string) => {
const selectedItem = items.find( const selectedItem = items.find((item) => item.value === value) as DropdownItem
(item) => item.value === value
) as DropdownItem;
onChange(value, selectedItem); onChange(value, selectedItem)
}; }
return ( return (
<> <>
@ -87,16 +81,16 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
<ListboxButton <ListboxButton
className="sn-dropdown-button" className="sn-dropdown-button"
children={({ value, label, isExpanded }) => { children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value); const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null; const icon = current ? current?.icon : null
const iconClassName = current ? current?.iconClassName : null; const iconClassName = current ? current?.iconClassName : null
return CustomDropdownButton({ return CustomDropdownButton({
value: value ? value : label.toLowerCase(), value: value ? value : label.toLowerCase(),
label, label,
isExpanded, isExpanded,
...(icon ? { icon } : null), ...(icon ? { icon } : null),
...(iconClassName ? { iconClassName } : null), ...(iconClassName ? { iconClassName } : null),
}); })
}} }}
/> />
<ListboxPopover className="sn-dropdown sn-dropdown-popover"> <ListboxPopover className="sn-dropdown sn-dropdown-popover">
@ -125,5 +119,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
</ListboxPopover> </ListboxPopover>
</ListboxInput> </ListboxInput>
</> </>
); )
}; }

View File

@ -1,11 +1,11 @@
import { formatSizeToReadableString } from '@standardnotes/filepicker'; import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { SNFile } from '@standardnotes/snjs'; import { SNFile } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
type Props = { type Props = {
file: SNFile; file: SNFile
}; }
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => { export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
return ( return (
@ -18,12 +18,10 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
<span className="font-semibold">Type:</span> {file.mimeType} <span className="font-semibold">Type:</span> {file.mimeType}
</div> </div>
<div className="mb-3"> <div className="mb-3">
<span className="font-semibold">Size:</span>{' '} <span className="font-semibold">Size:</span> {formatSizeToReadableString(file.size)}
{formatSizeToReadableString(file.size)}
</div> </div>
<div className="mb-3"> <div className="mb-3">
<span className="font-semibold">Created:</span>{' '} <span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
{file.created_at.toLocaleString()}
</div> </div>
<div className="mb-3"> <div className="mb-3">
<span className="font-semibold">Last Modified:</span>{' '} <span className="font-semibold">Last Modified:</span>{' '}
@ -33,5 +31,5 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
<span className="font-semibold">File ID:</span> {file.uuid} <span className="font-semibold">File ID:</span> {file.uuid}
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,88 +1,81 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { concatenateUint8Arrays } from '@/utils/concatenateUint8Arrays'; import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import { DialogContent, DialogOverlay } from '@reach/dialog'; import { DialogContent, DialogOverlay } from '@reach/dialog'
import { SNFile } from '@standardnotes/snjs'; import { SNFile } from '@standardnotes/snjs'
import { NoPreviewIllustration } from '@standardnotes/stylekit'; import { NoPreviewIllustration } from '@standardnotes/stylekit'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { getFileIconComponent } from '../AttachedFilesPopover/PopoverFileItem'; import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem'
import { Button } from '../Button'; import { Button } from '@/Components/Button/Button'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'; import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
import { isFileTypePreviewable } from './isFilePreviewable'; import { isFileTypePreviewable } from './isFilePreviewable'
type Props = { type Props = {
application: WebApplication; application: WebApplication
file: SNFile; file: SNFile
onDismiss: () => void; onDismiss: () => void
}; }
const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => { const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => {
if (file.mimeType.startsWith('image/')) { if (file.mimeType.startsWith('image/')) {
return <img src={objectUrl} />; return <img src={objectUrl} />
} }
if (file.mimeType.startsWith('video/')) { if (file.mimeType.startsWith('video/')) {
return <video className="w-full h-full" src={objectUrl} controls />; return <video className="w-full h-full" src={objectUrl} controls />
} }
if (file.mimeType.startsWith('audio/')) { if (file.mimeType.startsWith('audio/')) {
return <audio src={objectUrl} controls />; return <audio src={objectUrl} controls />
} }
return <object className="w-full h-full" data={objectUrl} />; return <object className="w-full h-full" data={objectUrl} />
}; }
export const FilePreviewModal: FunctionComponent<Props> = ({ export const FilePreviewModal: FunctionComponent<Props> = ({ application, file, onDismiss }) => {
application, const [objectUrl, setObjectUrl] = useState<string>()
file, const [isFilePreviewable, setIsFilePreviewable] = useState(false)
onDismiss, const [isLoadingFile, setIsLoadingFile] = useState(false)
}) => { const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
const [objectUrl, setObjectUrl] = useState<string>(); const closeButtonRef = useRef<HTMLButtonElement>(null)
const [isFilePreviewable, setIsFilePreviewable] = useState(false);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const getObjectUrl = useCallback(async () => { const getObjectUrl = useCallback(async () => {
setIsLoadingFile(true); setIsLoadingFile(true)
try { try {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = []
await application.files.downloadFile( await application.files.downloadFile(file, async (decryptedChunk: Uint8Array) => {
file, chunks.push(decryptedChunk)
async (decryptedChunk: Uint8Array) => { })
chunks.push(decryptedChunk); const finalDecryptedBytes = concatenateUint8Arrays(chunks)
}
);
const finalDecryptedBytes = concatenateUint8Arrays(chunks);
setObjectUrl( setObjectUrl(
URL.createObjectURL( URL.createObjectURL(
new Blob([finalDecryptedBytes], { new Blob([finalDecryptedBytes], {
type: file.mimeType, type: file.mimeType,
}) }),
) ),
); )
} catch (error) { } catch (error) {
console.error(error); console.error(error)
} finally { } finally {
setIsLoadingFile(false); setIsLoadingFile(false)
} }
}, [application.files, file]); }, [application.files, file])
useEffect(() => { useEffect(() => {
const isPreviewable = isFileTypePreviewable(file.mimeType); const isPreviewable = isFileTypePreviewable(file.mimeType)
setIsFilePreviewable(isPreviewable); setIsFilePreviewable(isPreviewable)
if (!objectUrl && isPreviewable) { if (!objectUrl && isPreviewable) {
getObjectUrl(); getObjectUrl().catch(console.error)
} }
return () => { return () => {
if (objectUrl) { if (objectUrl) {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl)
} }
}; }
}, [file.mimeType, getObjectUrl, objectUrl]); }, [file.mimeType, getObjectUrl, objectUrl])
return ( return (
<DialogOverlay <DialogOverlay
@ -105,7 +98,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
<div className="w-6 h-6"> <div className="w-6 h-6">
{getFileIconComponent( {getFileIconComponent(
application.iconsController.getIconForFileType(file.mimeType), application.iconsController.getIconForFileType(file.mimeType),
'w-6 h-6 flex-shrink-0' 'w-6 h-6 flex-shrink-0',
)} )}
</div> </div>
<span className="ml-3 font-medium">{file.name}</span> <span className="ml-3 font-medium">{file.name}</span>
@ -122,9 +115,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
variant="primary" variant="primary"
className="mr-4" className="mr-4"
onClick={() => { onClick={() => {
application application.getArchiveService().downloadData(objectUrl, file.name)
.getArchiveService()
.downloadData(objectUrl, file.name);
}} }}
> >
Download Download
@ -149,21 +140,19 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<NoPreviewIllustration className="w-30 h-30 mb-4" /> <NoPreviewIllustration className="w-30 h-30 mb-4" />
<div className="font-bold text-base mb-2"> <div className="font-bold text-base mb-2">This file can't be previewed.</div>
This file can't be previewed.
</div>
{isFilePreviewable ? ( {isFilePreviewable ? (
<> <>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch"> <div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
There was an error loading the file. Try again, or There was an error loading the file. Try again, or download the file and open
download the file and open it using another application. it using another application.
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
variant="primary" variant="primary"
className="mr-3" className="mr-3"
onClick={() => { onClick={() => {
getObjectUrl(); getObjectUrl().catch(console.error)
}} }}
> >
Try again Try again
@ -171,7 +160,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
<Button <Button
variant="normal" variant="normal"
onClick={() => { onClick={() => {
application.getAppState().files.downloadFile(file); application.getAppState().files.downloadFile(file).catch(console.error)
}} }}
> >
Download Download
@ -181,13 +170,12 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
) : ( ) : (
<> <>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch"> <div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
To view this file, download it and open it using another To view this file, download it and open it using another application.
application.
</div> </div>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
application.getAppState().files.downloadFile(file); application.getAppState().files.downloadFile(file).catch(console.error)
}} }}
> >
Download Download
@ -201,5 +189,5 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
</div> </div>
</DialogContent> </DialogContent>
</DialogOverlay> </DialogOverlay>
); )
}; }

View File

@ -0,0 +1,48 @@
import { WebApplication } from '@/UIModels/Application'
import { SNFile } from '@standardnotes/snjs'
import { createContext, FunctionComponent } from 'preact'
import { useContext, useState } from 'preact/hooks'
import { FilePreviewModal } from './FilePreviewModal'
type FilePreviewModalContextData = {
activate: (file: SNFile) => void
}
const FilePreviewModalContext = createContext<FilePreviewModalContextData | null>(null)
export const useFilePreviewModal = (): FilePreviewModalContextData => {
const value = useContext(FilePreviewModalContext)
if (!value) {
throw new Error('FilePreviewModalProvider not found.')
}
return value
}
export const FilePreviewModalProvider: FunctionComponent<{
application: WebApplication
}> = ({ application, children }) => {
const [isOpen, setIsOpen] = useState(false)
const [file, setFile] = useState<SNFile>()
const activate = (file: SNFile) => {
setFile(file)
setIsOpen(true)
}
const close = () => {
setIsOpen(false)
}
return (
<>
{isOpen && file && (
<FilePreviewModal application={application} file={file} onDismiss={close} />
)}
<FilePreviewModalContext.Provider value={{ activate }}>
{children}
</FilePreviewModalContext.Provider>
</>
)
}

View File

@ -0,0 +1,12 @@
export const isFileTypePreviewable = (fileType: string) => {
const isImage = fileType.startsWith('image/')
const isVideo = fileType.startsWith('video/')
const isAudio = fileType.startsWith('audio/')
const isPdf = fileType === 'application/pdf'
if (isImage || isVideo || isAudio || isPdf) {
return true
}
return false
}

View File

@ -1,58 +1,57 @@
import { WebAppEvent, WebApplication } from '@/ui_models/application'; import { WebAppEvent, WebApplication } from '@/UIModels/Application'
import { ApplicationGroup } from '@/ui_models/application_group'; import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { PureComponent } from './Abstract/PureComponent'; import { PureComponent } from '@/Components/Abstract/PureComponent'
import { preventRefreshing } from '@/utils'; import { preventRefreshing } from '@/Utils'
import { import {
ApplicationEvent, ApplicationEvent,
ContentType, ContentType,
CollectionSort, CollectionSort,
ApplicationDescriptor, ApplicationDescriptor,
ItemInterface, } from '@standardnotes/snjs'
} from '@standardnotes/snjs';
import { import {
STRING_NEW_UPDATE_READY, STRING_NEW_UPDATE_READY,
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT, STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE, STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
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, AccountMenuPane } from '@/components/AccountMenu'; import { AccountMenu, AccountMenuPane } from '@/Components/AccountMenu'
import { AppStateEvent, EventSource } from '@/ui_models/app_state'; import { AppStateEvent, EventSource } from '@/UIModels/AppState'
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu'; import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu'
import { SyncResolutionMenu } from './SyncResolutionMenu'; import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu'
import { Fragment } from 'preact'; import { Fragment } from 'preact'
type Props = { type Props = {
application: WebApplication; application: WebApplication
applicationGroup: ApplicationGroup; applicationGroup: ApplicationGroup
}; }
type State = { type State = {
outOfSync: boolean; outOfSync: boolean
dataUpgradeAvailable: boolean; dataUpgradeAvailable: boolean
hasPasscode: boolean; hasPasscode: boolean
descriptors: ApplicationDescriptor[]; descriptors: ApplicationDescriptor[]
showBetaWarning: boolean; showBetaWarning: boolean
showSyncResolution: boolean; showSyncResolution: boolean
newUpdateAvailable: boolean; newUpdateAvailable: boolean
showAccountMenu: boolean; showAccountMenu: boolean
showQuickSettingsMenu: boolean; showQuickSettingsMenu: boolean
offline: boolean; offline: boolean
hasError: boolean; hasError: boolean
arbitraryStatusMessage?: string; arbitraryStatusMessage?: string
}; }
export class Footer extends PureComponent<Props, State> { export class Footer extends PureComponent<Props, State> {
public user?: unknown; public user?: unknown
private didCheckForOffline = false; private didCheckForOffline = false
private completedInitialSync = false; private completedInitialSync = false
private showingDownloadStatus = false; private showingDownloadStatus = false
private webEventListenerDestroyer: () => void; private webEventListenerDestroyer: () => void
constructor(props: Props) { constructor(props: Props) {
super(props, props.application); super(props, props.application)
this.state = { this.state = {
hasError: false, hasError: false,
offline: true, offline: true,
@ -65,231 +64,215 @@ export class Footer extends PureComponent<Props, State> {
newUpdateAvailable: false, newUpdateAvailable: false,
showAccountMenu: false, showAccountMenu: false,
showQuickSettingsMenu: false, showQuickSettingsMenu: false,
}; }
this.webEventListenerDestroyer = props.application.addWebEventObserver( this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => {
(event) => { if (event === WebAppEvent.NewUpdateAvailable) {
if (event === WebAppEvent.NewUpdateAvailable) { this.onNewUpdateAvailable()
this.onNewUpdateAvailable();
}
} }
); })
} }
deinit() { override deinit() {
this.webEventListenerDestroyer(); this.webEventListenerDestroyer()
(this.webEventListenerDestroyer as unknown) = undefined; ;(this.webEventListenerDestroyer as unknown) = undefined
super.deinit(); super.deinit()
} }
componentDidMount(): void { override componentDidMount(): void {
super.componentDidMount(); super.componentDidMount()
this.application.getStatusManager().onStatusChange((message) => { this.application.getStatusManager().onStatusChange((message) => {
this.setState({ this.setState({
arbitraryStatusMessage: message, arbitraryStatusMessage: message,
}); })
}); })
this.autorun(() => { this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning; const showBetaWarning = this.appState.showBetaWarning
this.setState({ this.setState({
showBetaWarning: showBetaWarning, showBetaWarning: showBetaWarning,
showAccountMenu: this.appState.accountMenu.show, showAccountMenu: this.appState.accountMenu.show,
showQuickSettingsMenu: this.appState.quickSettingsMenu.open, showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
}); })
}); })
} }
reloadUpgradeStatus() { reloadUpgradeStatus() {
this.application.checkForSecurityUpdate().then((available) => { this.application
this.setState({ .checkForSecurityUpdate()
dataUpgradeAvailable: available, .then((available) => {
}); this.setState({
}); dataUpgradeAvailable: available,
})
})
.catch(console.error)
} }
async onAppLaunch() { override async onAppLaunch() {
super.onAppLaunch(); super.onAppLaunch().catch(console.error)
this.reloadPasscodeStatus(); this.reloadPasscodeStatus().catch(console.error)
this.reloadUser(); this.reloadUser()
this.reloadUpgradeStatus(); this.reloadUpgradeStatus()
this.updateOfflineStatus(); this.updateOfflineStatus()
this.findErrors(); this.findErrors()
this.streamItems(); this.streamItems()
} }
reloadUser() { reloadUser() {
this.user = this.application.getUser(); this.user = this.application.getUser()
} }
async reloadPasscodeStatus() { async reloadPasscodeStatus() {
const hasPasscode = this.application.hasPasscode(); const hasPasscode = this.application.hasPasscode()
this.setState({ this.setState({
hasPasscode: hasPasscode, hasPasscode: hasPasscode,
}); })
} }
/** @override */ override onAppStateEvent(eventName: AppStateEvent, data: any) {
onAppStateEvent(eventName: AppStateEvent, data: any) { const statusService = this.application.getStatusManager()
const statusService = this.application.getStatusManager();
switch (eventName) { switch (eventName) {
case AppStateEvent.EditorFocused: case AppStateEvent.EditorFocused:
if (data.eventSource === EventSource.UserInteraction) { if (data.eventSource === EventSource.UserInteraction) {
this.closeAccountMenu(); this.closeAccountMenu()
} }
break; break
case AppStateEvent.BeganBackupDownload: case AppStateEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…'); statusService.setMessage('Saving local backup…')
break; break
case AppStateEvent.EndedBackupDownload: { case AppStateEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.'; const successMessage = 'Successfully saved backup.'
const errorMessage = 'Unable to save local backup.'; const errorMessage = 'Unable to save local backup.'
statusService.setMessage(data.success ? successMessage : errorMessage); statusService.setMessage(data.success ? successMessage : errorMessage)
const twoSeconds = 2000; const twoSeconds = 2000
setTimeout(() => { setTimeout(() => {
if ( if (statusService.message === successMessage || statusService.message === errorMessage) {
statusService.message === successMessage || statusService.setMessage('')
statusService.message === errorMessage
) {
statusService.setMessage('');
} }
}, twoSeconds); }, twoSeconds)
break; break
} }
} }
} }
/** @override */ override async onAppKeyChange() {
async onAppKeyChange() { super.onAppKeyChange().catch(console.error)
super.onAppKeyChange(); this.reloadPasscodeStatus().catch(console.error)
this.reloadPasscodeStatus();
} }
/** @override */ override onAppEvent(eventName: ApplicationEvent) {
onAppEvent(eventName: ApplicationEvent) {
switch (eventName) { switch (eventName) {
case ApplicationEvent.KeyStatusChanged: case ApplicationEvent.KeyStatusChanged:
this.reloadUpgradeStatus(); this.reloadUpgradeStatus()
break; break
case ApplicationEvent.EnteredOutOfSync: case ApplicationEvent.EnteredOutOfSync:
this.setState({ this.setState({
outOfSync: true, outOfSync: true,
}); })
break; break
case ApplicationEvent.ExitedOutOfSync: case ApplicationEvent.ExitedOutOfSync:
this.setState({ this.setState({
outOfSync: false, outOfSync: false,
}); })
break; break
case ApplicationEvent.CompletedFullSync: case ApplicationEvent.CompletedFullSync:
if (!this.completedInitialSync) { if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage(''); this.application.getStatusManager().setMessage('')
this.completedInitialSync = true; this.completedInitialSync = true
} }
if (!this.didCheckForOffline) { if (!this.didCheckForOffline) {
this.didCheckForOffline = true; this.didCheckForOffline = true
if ( if (this.state.offline && this.application.items.getNoteCount() === 0) {
this.state.offline && this.appState.accountMenu.setShow(true)
this.application.items.getNoteCount() === 0
) {
this.appState.accountMenu.setShow(true);
} }
} }
this.findErrors(); this.findErrors()
this.updateOfflineStatus(); this.updateOfflineStatus()
break; break
case ApplicationEvent.SyncStatusChanged: case ApplicationEvent.SyncStatusChanged:
this.updateSyncStatus(); this.updateSyncStatus()
break; break
case ApplicationEvent.FailedSync: case ApplicationEvent.FailedSync:
this.updateSyncStatus(); this.updateSyncStatus()
this.findErrors(); this.findErrors()
this.updateOfflineStatus(); this.updateOfflineStatus()
break; break
case ApplicationEvent.LocalDataIncrementalLoad: case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded: case ApplicationEvent.LocalDataLoaded:
this.updateLocalDataStatus(); this.updateLocalDataStatus()
break; break
case ApplicationEvent.SignedIn: case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut: case ApplicationEvent.SignedOut:
this.reloadUser(); this.reloadUser()
break; break
case ApplicationEvent.WillSync: case ApplicationEvent.WillSync:
if (!this.completedInitialSync) { if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage('Syncing…'); this.application.getStatusManager().setMessage('Syncing…')
} }
break; break
} }
} }
streamItems() { streamItems() {
this.application.items.setDisplayOptions( this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
ContentType.Theme,
CollectionSort.Title,
'asc'
);
} }
updateSyncStatus() { updateSyncStatus() {
const statusManager = this.application.getStatusManager(); const statusManager = this.application.getStatusManager()
const syncStatus = this.application.sync.getSyncStatus(); const syncStatus = this.application.sync.getSyncStatus()
const stats = syncStatus.getStats(); const stats = syncStatus.getStats()
if (syncStatus.hasError()) { if (syncStatus.hasError()) {
statusManager.setMessage('Unable to Sync'); statusManager.setMessage('Unable to Sync')
} else if (stats.downloadCount > 20) { } else if (stats.downloadCount > 20) {
const text = `Downloading ${stats.downloadCount} items. Keep app open.`; const text = `Downloading ${stats.downloadCount} items. Keep app open.`
statusManager.setMessage(text); statusManager.setMessage(text)
this.showingDownloadStatus = true; this.showingDownloadStatus = true
} else if (this.showingDownloadStatus) { } else if (this.showingDownloadStatus) {
this.showingDownloadStatus = false; this.showingDownloadStatus = false
statusManager.setMessage('Download Complete.'); statusManager.setMessage('Download Complete.')
setTimeout(() => { setTimeout(() => {
statusManager.setMessage(''); statusManager.setMessage('')
}, 2000); }, 2000)
} else if (stats.uploadTotalCount > 20) { } else if (stats.uploadTotalCount > 20) {
const completionPercentage = const completionPercentage =
stats.uploadCompletionCount === 0 stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
? 0
: stats.uploadCompletionCount / stats.uploadTotalCount;
const stringPercentage = completionPercentage.toLocaleString(undefined, { const stringPercentage = completionPercentage.toLocaleString(undefined, {
style: 'percent', style: 'percent',
}); })
statusManager.setMessage( statusManager.setMessage(
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)` `Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`,
); )
} else { } else {
statusManager.setMessage(''); statusManager.setMessage('')
} }
} }
updateLocalDataStatus() { updateLocalDataStatus() {
const statusManager = this.application.getStatusManager(); const statusManager = this.application.getStatusManager()
const syncStatus = this.application.sync.getSyncStatus(); const syncStatus = this.application.sync.getSyncStatus()
const stats = syncStatus.getStats(); const stats = syncStatus.getStats()
const encryption = this.application.isEncryptionAvailable(); const encryption = this.application.isEncryptionAvailable()
if (stats.localDataDone) { if (stats.localDataDone) {
statusManager.setMessage(''); statusManager.setMessage('')
return; return
} }
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`; const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`
const loadingStatus = encryption const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
? `Decrypting ${notesString}` statusManager.setMessage(loadingStatus)
: `Loading ${notesString}`;
statusManager.setMessage(loadingStatus);
} }
updateOfflineStatus() { updateOfflineStatus() {
this.setState({ this.setState({
offline: this.application.noAccount(), offline: this.application.noAccount(),
}); })
} }
findErrors() { findErrors() {
this.setState({ this.setState({
hasError: this.application.sync.getSyncStatus().hasError(), hasError: this.application.sync.getSyncStatus().hasError(),
}); })
} }
securityUpdateClickHandler = async () => { securityUpdateClickHandler = async () => {
@ -301,48 +284,48 @@ export class Footer extends PureComponent<Props, State> {
}) })
) { ) {
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => { preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
await this.application.upgradeProtocolVersion(); await this.application.upgradeProtocolVersion()
}); }).catch(console.error)
} }
}; }
accountMenuClickHandler = () => { accountMenuClickHandler = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu(); this.appState.quickSettingsMenu.closeQuickSettingsMenu()
this.appState.accountMenu.toggleShow(); this.appState.accountMenu.toggleShow()
}; }
quickSettingsClickHandler = () => { quickSettingsClickHandler = () => {
this.appState.accountMenu.closeAccountMenu(); this.appState.accountMenu.closeAccountMenu()
this.appState.quickSettingsMenu.toggle(); this.appState.quickSettingsMenu.toggle()
}; }
syncResolutionClickHandler = () => { syncResolutionClickHandler = () => {
this.setState({ this.setState({
showSyncResolution: !this.state.showSyncResolution, showSyncResolution: !this.state.showSyncResolution,
}); })
}; }
closeAccountMenu = () => { closeAccountMenu = () => {
this.appState.accountMenu.setShow(false); this.appState.accountMenu.setShow(false)
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu); this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
}; }
lockClickHandler = () => { lockClickHandler = () => {
this.application.lock(); this.application.lock().catch(console.error)
}; }
onNewUpdateAvailable = () => { onNewUpdateAvailable = () => {
this.setState({ this.setState({
newUpdateAvailable: true, newUpdateAvailable: true,
}); })
}; }
newUpdateClickHandler = () => { newUpdateClickHandler = () => {
this.setState({ this.setState({
newUpdateAvailable: false, newUpdateAvailable: false,
}); })
this.application.alertService.alert(STRING_NEW_UPDATE_READY); this.application.alertService.alert(STRING_NEW_UPDATE_READY).catch(console.error)
}; }
betaMessageClickHandler = () => { betaMessageClickHandler = () => {
alertDialog({ alertDialog({
@ -350,18 +333,18 @@ export class Footer extends PureComponent<Props, State> {
text: text:
'If you wish to go back to a stable version, make sure to sign out ' + 'If you wish to go back to a stable version, make sure to sign out ' +
'of this beta app first.', 'of this beta app first.',
}); }).catch(console.error)
}; }
clickOutsideAccountMenu = () => { clickOutsideAccountMenu = () => {
this.appState.accountMenu.closeAccountMenu(); this.appState.accountMenu.closeAccountMenu()
}; }
clickOutsideQuickSettingsMenu = () => { clickOutsideQuickSettingsMenu = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu(); this.appState.quickSettingsMenu.closeQuickSettingsMenu()
}; }
render() { override render() {
return ( return (
<div className="sn-component"> <div className="sn-component">
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge"> <div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
@ -376,15 +359,10 @@ export class Footer extends PureComponent<Props, State> {
> >
<div <div
className={ className={
this.state.hasError this.state.hasError ? 'danger' : (this.user ? 'info' : 'neutral') + ' w-5 h-5'
? 'danger'
: (this.user ? 'info' : 'neutral') + ' w-5 h-5'
} }
> >
<Icon <Icon type="account-circle" className="hover:color-info w-5 h-5 max-h-5" />
type="account-circle"
className="hover:color-info w-5 h-5 max-h-5"
/>
</div> </div>
</div> </div>
{this.state.showAccountMenu && ( {this.state.showAccountMenu && (
@ -437,39 +415,26 @@ export class Footer extends PureComponent<Props, State> {
{this.state.arbitraryStatusMessage && ( {this.state.arbitraryStatusMessage && (
<div className="sk-app-bar-item"> <div className="sk-app-bar-item">
<div className="sk-app-bar-item-column"> <div className="sk-app-bar-item-column">
<span className="neutral sk-label"> <span className="neutral sk-label">{this.state.arbitraryStatusMessage}</span>
{this.state.arbitraryStatusMessage}
</span>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="right"> <div className="right">
{this.state.dataUpgradeAvailable && ( {this.state.dataUpgradeAvailable && (
<div <div onClick={this.securityUpdateClickHandler} className="sk-app-bar-item">
onClick={this.securityUpdateClickHandler} <span className="success sk-label">Encryption upgrade available.</span>
className="sk-app-bar-item"
>
<span className="success sk-label">
Encryption upgrade available.
</span>
</div> </div>
)} )}
{this.state.newUpdateAvailable && ( {this.state.newUpdateAvailable && (
<div <div onClick={this.newUpdateClickHandler} className="sk-app-bar-item">
onClick={this.newUpdateClickHandler}
className="sk-app-bar-item"
>
<span className="info sk-label">New update available.</span> <span className="info sk-label">New update available.</span>
</div> </div>
)} )}
{(this.state.outOfSync || this.state.showSyncResolution) && ( {(this.state.outOfSync || this.state.showSyncResolution) && (
<div className="sk-app-bar-item"> <div className="sk-app-bar-item">
{this.state.outOfSync && ( {this.state.outOfSync && (
<div <div onClick={this.syncResolutionClickHandler} className="sk-label warning">
onClick={this.syncResolutionClickHandler}
className="sk-label warning"
>
Potentially Out of Sync Potentially Out of Sync
</div> </div>
)} )}
@ -504,6 +469,6 @@ export class Footer extends PureComponent<Props, State> {
</div> </div>
</div> </div>
</div> </div>
); )
} }
} }

View File

@ -1,5 +1,5 @@
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
import { IconType } from '@standardnotes/snjs'; import { IconType } from '@standardnotes/snjs'
import { import {
AccessibilityIcon, AccessibilityIcon,
@ -88,7 +88,7 @@ import {
UserSwitch, UserSwitch,
WarningIcon, WarningIcon,
WindowIcon, WindowIcon,
} from '@standardnotes/stylekit'; } from '@standardnotes/stylekit'
export const ICONS = { export const ICONS = {
'account-circle': AccountCircleIcon, 'account-circle': AccountCircleIcon,
@ -177,22 +177,18 @@ export const ICONS = {
user: UserIcon, user: UserIcon,
warning: WarningIcon, warning: WarningIcon,
window: WindowIcon, window: WindowIcon,
}; }
type Props = { type Props = {
type: IconType; type: IconType
className?: string; className?: string
ariaLabel?: string; ariaLabel?: string
}; }
export const Icon: FunctionalComponent<Props> = ({ export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => {
type, const IconComponent = ICONS[type as keyof typeof ICONS]
className = '',
ariaLabel,
}) => {
const IconComponent = ICONS[type as keyof typeof ICONS];
if (!IconComponent) { if (!IconComponent) {
return null; return null
} }
return ( return (
<IconComponent <IconComponent
@ -200,5 +196,5 @@ export const Icon: FunctionalComponent<Props> = ({
role="img" role="img"
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
/> />
); )
}; }

View File

@ -0,0 +1,75 @@
import { FunctionalComponent, Ref } from 'preact'
import { forwardRef } from 'preact/compat'
import { DecoratedInputProps } from './DecoratedInputProps'
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
return {
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
}`,
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
} ${hasRightDecorations ? 'pr-2' : ''}`,
disabled: 'bg-grey-5 cursor-not-allowed',
}
}
/**
* Input that can be decorated on the left and right side
*/
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef(
(
{
type = 'text',
className = '',
disabled = false,
left,
right,
value,
placeholder = '',
onChange,
onFocus,
onKeyDown,
autocomplete = false,
}: DecoratedInputProps,
ref: Ref<HTMLInputElement>,
) => {
const hasLeftDecorations = Boolean(left?.length)
const hasRightDecorations = Boolean(right?.length)
const classNames = getClassNames(hasLeftDecorations, hasRightDecorations)
return (
<div
className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}
>
{left && (
<div className="flex items-center px-2 py-1.5">
{left.map((leftChild) => (
<>{leftChild}</>
))}
</div>
)}
<input
type={type}
className={`${classNames.input} ${disabled ? classNames.disabled : ''}`}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={(e) => onChange && onChange((e.target as HTMLInputElement).value)}
onFocus={onFocus}
onKeyDown={onKeyDown}
data-lpignore={type !== 'password' ? true : false}
autocomplete={autocomplete ? 'on' : 'off'}
ref={ref}
/>
{right && (
<div className="flex items-center px-2 py-1.5">
{right.map((rightChild, index) => (
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div>
))}
</div>
)}
</div>
)
},
)

View File

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

View File

@ -0,0 +1,42 @@
import { FunctionComponent, Ref } from 'preact'
import { forwardRef } from 'preact/compat'
import { StateUpdater, useState } from 'preact/hooks'
import { DecoratedInput } from './DecoratedInput'
import { IconButton } from '@/Components/Button/IconButton'
import { DecoratedInputProps } from './DecoratedInputProps'
const Toggle: FunctionComponent<{
isToggled: boolean
setIsToggled: StateUpdater<boolean>
}> = ({ isToggled, setIsToggled }) => (
<IconButton
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4 color-neutral"
icon={isToggled ? 'eye-off' : 'eye'}
iconClassName="sn-icon--small"
title="Show/hide password"
onClick={() => setIsToggled((isToggled) => !isToggled)}
focusable={true}
/>
)
/**
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
*/
export const DecoratedPasswordInput: FunctionComponent<Omit<DecoratedInputProps, 'type'>> =
forwardRef((props, ref: Ref<HTMLInputElement>) => {
const [isToggled, setIsToggled] = useState(false)
const rightSideDecorations = props.right ? [...props.right] : []
return (
<DecoratedInput
{...props}
ref={ref}
type={isToggled ? 'text' : 'password'}
right={[
...rightSideDecorations,
<Toggle isToggled={isToggled} setIsToggled={setIsToggled} />,
]}
/>
)
})

View File

@ -1,20 +1,20 @@
import { FunctionComponent, Ref } from 'preact'; import { FunctionComponent, Ref } from 'preact'
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat'
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks'
type Props = { type Props = {
id: string; id: string
type: 'text' | 'email' | 'password'; // Have no use cases for other types so far type: 'text' | 'email' | 'password'
label: string; label: string
value: string; value: string
onChange: JSXInternal.GenericEventHandler<HTMLInputElement>; onChange: JSXInternal.GenericEventHandler<HTMLInputElement>
disabled?: boolean; disabled?: boolean
className?: string; className?: string
labelClassName?: string; labelClassName?: string
inputClassName?: string; inputClassName?: string
isInvalid?: boolean; isInvalid?: boolean
}; }
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef( export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
( (
@ -30,27 +30,25 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
labelClassName = '', labelClassName = '',
inputClassName = '', inputClassName = '',
}: Props, }: Props,
ref: Ref<HTMLInputElement> ref: Ref<HTMLInputElement>,
) => { ) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false)
const BASE_CLASSNAME = `relative bg-default`; const BASE_CLASSNAME = 'relative bg-default'
const LABEL_CLASSNAME = `hidden absolute ${ const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
!focused ? 'color-neutral' : 'color-info' focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
} ${focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''} ${ } ${isInvalid ? 'color-dark-red' : ''} ${labelClassName}`
isInvalid ? 'color-dark-red' : ''
} ${labelClassName}`;
const INPUT_CLASSNAME = `w-full h-full ${ const INPUT_CLASSNAME = `w-full h-full ${
focused || value ? 'pt-6 pb-2' : 'py-2.5' focused || value ? 'pt-6 pb-2' : 'py-2.5'
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${ } px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
isInvalid ? 'border-dark-red placeholder-dark-red' : '' isInvalid ? 'border-dark-red placeholder-dark-red' : ''
} ${inputClassName}`; } ${inputClassName}`
const handleFocus = () => setFocused(true); const handleFocus = () => setFocused(true)
const handleBlur = () => setFocused(false); const handleBlur = () => setFocused(false)
return ( return (
<div className={`${BASE_CLASSNAME} ${className}`}> <div className={`${BASE_CLASSNAME} ${className}`}>
@ -70,6 +68,6 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
disabled={disabled} disabled={disabled}
/> />
</div> </div>
); )
} },
); )

View File

@ -0,0 +1,14 @@
import { FunctionalComponent } from 'preact'
interface Props {
text?: string
disabled?: boolean
className?: string
}
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => {
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
const classes = `${base} ${stateClasses} ${className}`
return <input type="text" className={classes} disabled={disabled} value={text} />
}

View File

@ -6,22 +6,22 @@ import {
RefCallback, RefCallback,
ComponentChild, ComponentChild,
toChildArray, toChildArray,
} from 'preact'; } from 'preact'
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks'
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import { MenuItem, MenuItemListElement } from './MenuItem'; import { MenuItem, MenuItemListElement } from './MenuItem'
import { KeyboardKey } from '@/services/ioService'; import { KeyboardKey } from '@/Services/IOService'
import { useListKeyboardNavigation } from '../utils'; import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type MenuProps = { type MenuProps = {
className?: string; className?: string
style?: string | JSX.CSSProperties | undefined; style?: string | JSX.CSSProperties | undefined
a11yLabel: string; a11yLabel: string
children: ComponentChildren; children: ComponentChildren
closeMenu?: () => void; closeMenu?: () => void
isOpen: boolean; isOpen: boolean
initialFocus?: number; initialFocus?: number
}; }
export const Menu: FunctionComponent<MenuProps> = ({ export const Menu: FunctionComponent<MenuProps> = ({
children, children,
@ -32,32 +32,30 @@ export const Menu: FunctionComponent<MenuProps> = ({
isOpen, isOpen,
initialFocus, initialFocus,
}: MenuProps) => { }: MenuProps) => {
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const menuElementRef = useRef<HTMLMenuElement>(null); const menuElementRef = useRef<HTMLMenuElement>(null)
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = ( const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (event) => {
event
) => {
if (!menuItemRefs.current) { if (!menuItemRefs.current) {
return; return
} }
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
closeMenu?.(); closeMenu?.()
return; return
} }
}; }
useListKeyboardNavigation(menuElementRef, initialFocus); useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => { useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) { if (isOpen && menuItemRefs.current.length > 0) {
setTimeout(() => { setTimeout(() => {
menuElementRef.current?.focus(); menuElementRef.current?.focus()
}); })
} }
}, [isOpen]); }, [isOpen])
const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => { const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => {
if (instance && instance.children) { if (instance && instance.children) {
@ -66,49 +64,45 @@ export const Menu: FunctionComponent<MenuProps> = ({
child.getAttribute('role')?.includes('menuitem') && child.getAttribute('role')?.includes('menuitem') &&
!menuItemRefs.current.includes(child as HTMLButtonElement) !menuItemRefs.current.includes(child as HTMLButtonElement)
) { ) {
menuItemRefs.current.push(child as HTMLButtonElement); menuItemRefs.current.push(child as HTMLButtonElement)
} }
}); })
} }
}; }
const mapMenuItems = ( const mapMenuItems = (
child: ComponentChild, child: ComponentChild,
index: number, index: number,
array: ComponentChild[] array: ComponentChild[],
): ComponentChild => { ): ComponentChild => {
if (!child || (Array.isArray(child) && child.length < 1)) return; if (!child || (Array.isArray(child) && child.length < 1)) {
return
if (Array.isArray(child)) {
return child.map(mapMenuItems);
} }
const _child = child as VNode<unknown>; if (Array.isArray(child)) {
return child.map(mapMenuItems)
}
const _child = child as VNode<unknown>
const isFirstMenuItem = const isFirstMenuItem =
index === index === array.findIndex((child) => (child as VNode<unknown>).type === MenuItem)
array.findIndex((child) => (child as VNode<unknown>).type === MenuItem);
const hasMultipleItems = Array.isArray(_child.props.children) const hasMultipleItems = Array.isArray(_child.props.children)
? Array.from(_child.props.children as ComponentChild[]).some( ? Array.from(_child.props.children as ComponentChild[]).some(
(child) => (child as VNode<unknown>).type === MenuItem (child) => (child as VNode<unknown>).type === MenuItem,
) )
: false; : false
const items = hasMultipleItems const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
? [...(_child.props.children as ComponentChild[])]
: [_child];
return items.map((child) => { return items.map((child) => {
return ( return (
<MenuItemListElement <MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
isFirstMenuItem={isFirstMenuItem}
ref={pushRefToArray}
>
{child} {child}
</MenuItemListElement> </MenuItemListElement>
); )
}); })
}; }
return ( return (
<menu <menu
@ -120,5 +114,5 @@ export const Menu: FunctionComponent<MenuProps> = ({
> >
{toChildArray(children).map(mapMenuItems)} {toChildArray(children).map(mapMenuItems)}
</menu> </menu>
); )
}; }

View File

@ -1,10 +1,10 @@
import { ComponentChildren, FunctionComponent, VNode } from 'preact'; import { ComponentChildren, FunctionComponent, VNode } from 'preact'
import { forwardRef, Ref } from 'preact/compat'; import { forwardRef, Ref } from 'preact/compat'
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { Switch, SwitchProps } from '../Switch'; import { Switch, SwitchProps } from '@/Components/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'
export enum MenuItemType { export enum MenuItemType {
IconButton, IconButton,
@ -13,17 +13,17 @@ export enum MenuItemType {
} }
type MenuItemProps = { type MenuItemProps = {
type: MenuItemType; type: MenuItemType
children: ComponentChildren; children: ComponentChildren
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>; onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>
onChange?: SwitchProps['onChange']; onChange?: SwitchProps['onChange']
onBlur?: (event: { relatedTarget: EventTarget | null }) => void; onBlur?: (event: { relatedTarget: EventTarget | null }) => void
className?: string; className?: string
checked?: boolean; checked?: boolean
icon?: IconType; icon?: IconType
iconClassName?: string; iconClassName?: string
tabIndex?: number; tabIndex?: number
}; }
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef( export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
( (
@ -39,19 +39,16 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
iconClassName, iconClassName,
tabIndex, tabIndex,
}: MenuItemProps, }: MenuItemProps,
ref: Ref<HTMLButtonElement> ref: Ref<HTMLButtonElement>,
) => { ) => {
return type === MenuItemType.SwitchButton && return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
typeof onChange === 'function' ? (
<button <button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between" className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
onClick={() => { onClick={() => {
onChange(!checked); onChange(!checked)
}} }}
onBlur={onBlur} onBlur={onBlur}
tabIndex={ tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
}
role="menuitemcheckbox" role="menuitemcheckbox"
aria-checked={checked} aria-checked={checked}
> >
@ -62,15 +59,11 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
<button <button
ref={ref} ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'} role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={ tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`} className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onClick={onClick} onClick={onClick}
onBlur={onBlur} onBlur={onBlur}
{...(type === MenuItemType.RadioButton {...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
? { 'aria-checked': checked }
: {})}
> >
{type === MenuItemType.IconButton && icon ? ( {type === MenuItemType.IconButton && icon ? (
<Icon type={icon} className={iconClassName} /> <Icon type={icon} className={iconClassName} />
@ -84,41 +77,37 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
) : null} ) : null}
{children} {children}
</button> </button>
); )
} },
); )
export const MenuItemSeparator: FunctionComponent = () => ( export const MenuItemSeparator: FunctionComponent = () => (
<div role="separator" className="h-1px my-2 bg-border"></div> <div role="separator" className="h-1px my-2 bg-border"></div>
); )
type ListElementProps = { type ListElementProps = {
isFirstMenuItem: boolean; isFirstMenuItem: boolean
children: ComponentChildren; children: ComponentChildren
}; }
export const MenuItemListElement: FunctionComponent<ListElementProps> = export const MenuItemListElement: FunctionComponent<ListElementProps> = forwardRef(
forwardRef( ({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
( const child = children as VNode<unknown>
{ children, isFirstMenuItem }: ListElementProps,
ref: Ref<HTMLLIElement>
) => {
const child = children as VNode<unknown>;
return ( return (
<li className="list-style-none" role="none" ref={ref}> <li className="list-style-none" role="none" ref={ref}>
{{ {{
...child, ...child,
props: { props: {
...(child.props ? { ...child.props } : {}), ...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem ...(child.type === MenuItem
? { ? {
tabIndex: isFirstMenuItem ? 0 : -1, tabIndex: isFirstMenuItem ? 0 : -1,
} }
: {}), : {}),
}, },
}} }}
</li> </li>
); )
} },
); )

View File

@ -0,0 +1,36 @@
import { AppState } from '@/UIModels/AppState'
import { IlNotesIcon } from '@standardnotes/stylekit'
import { observer } from 'mobx-react-lite'
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
import { WebApplication } from '@/UIModels/Application'
import { PinNoteButton } from '@/Components/PinNoteButton'
type Props = {
application: WebApplication
appState: AppState
}
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex">
<div className="mr-3">
<PinNoteButton appState={appState} />
</div>
<NotesOptionsPanel application={application} appState={appState} />
</div>
</div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<IlNotesIcon className="block" />
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
<p className="text-sm mt-2 text-center max-w-60">
Actions will be performed on all selected notes.
</p>
</div>
</div>
)
})

View File

@ -0,0 +1,82 @@
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection'
import { TagsSection } from '@/Components/Tags/TagsSection'
import { WebApplication } from '@/UIModels/Application'
import { PANEL_NAME_NAVIGATION } from '@/Constants'
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
type Props = {
application: WebApplication
}
export const Navigation: FunctionComponent<Props> = observer(({ application }) => {
const appState = useMemo(() => application.getAppState(), [application])
const [ref, setRef] = useState<HTMLDivElement | null>()
const [panelWidth, setPanelWidth] = useState<number>(0)
useEffect(() => {
const removeObserver = application.addEventObserver(async () => {
const width = application.getPreference(PrefKey.TagsPanelWidth)
if (width) {
setPanelWidth(width)
}
}, ApplicationEvent.PreferencesChanged)
return () => {
removeObserver()
}
}, [application])
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
appState.noteTags.reloadTagsContainerMaxWidth()
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed)
},
[application, appState],
)
const panelWidthEventCallback = useCallback(() => {
appState.noteTags.reloadTagsContainerMaxWidth()
}, [appState])
return (
<div
id="navigation"
className="sn-component section app-column app-column-first"
data-aria-label="Navigation"
ref={setRef}
>
<div id="navigation-content" className="content">
<div className="section-title-bar">
<div className="section-title-bar-header">
<div className="sk-h3 title">
<span className="sk-bold">Views</span>
</div>
</div>
</div>
<div className="scrollable">
<SmartViewsSection appState={appState} />
<TagsSection appState={appState} />
</div>
</div>
{ref && (
<PanelResizer
collapsable={true}
defaultWidth={150}
panel={ref}
hoverable={true}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>
)}
</div>
)
})

View File

@ -1,32 +1,30 @@
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
type Props = { appState: AppState }; type Props = { appState: AppState }
export const NoAccountWarning = observer(({ appState }: Props) => { export const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show; const canShow = appState.noAccountWarning.show
if (!canShow) { if (!canShow) {
return null; return null
} }
return ( return (
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr"> <div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1> <h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
<p className="m-0 mt-1 col-start-1 col-end-3"> <p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
Sign in or register to back up your notes.
</p>
<button <button
className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
appState.accountMenu.setShow(true); appState.accountMenu.setShow(true)
}} }}
> >
Open Account menu Open Account menu
</button> </button>
<button <button
onClick={() => { onClick={() => {
appState.noAccountWarning.hide(); appState.noAccountWarning.hide()
}} }}
title="Ignore" title="Ignore"
label="Ignore" label="Ignore"
@ -36,5 +34,5 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
<Icon type="close" className="block" /> <Icon type="close" className="block" />
</button> </button>
</div> </div>
); )
}); })

View File

@ -0,0 +1,57 @@
import { NoteViewController } from '@standardnotes/snjs'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/UIModels/Application'
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes'
import { NoteView } from '@/Components/NoteView/NoteView'
import { ElementIds } from '@/ElementIDs'
type State = {
showMultipleSelectedNotes: boolean
controllers: NoteViewController[]
}
type Props = {
application: WebApplication
}
export class NoteGroupView extends PureComponent<Props, State> {
constructor(props: Props) {
super(props, props.application)
this.state = {
showMultipleSelectedNotes: false,
controllers: [],
}
}
override componentDidMount(): void {
super.componentDidMount()
this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
this.setState({
controllers: this.application.noteControllerGroup.noteControllers,
})
})
this.autorun(() => {
this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
})
})
}
override render() {
return (
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
{this.state.showMultipleSelectedNotes && (
<MultipleSelectedNotes application={this.application} appState={this.appState} />
)}
{!this.state.showMultipleSelectedNotes && (
<>
{this.state.controllers.map((controller) => {
return <NoteView application={this.application} controller={controller} />
})}
</>
)}
</div>
)
}
}

View File

@ -1,98 +1,98 @@
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks'
import { AppState } from '@/ui_models/app_state'; 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'
type Props = { type Props = {
appState: AppState; appState: AppState
tag: SNTag; tag: SNTag
}; }
export const NoteTag = observer(({ appState, tag }: Props) => { export const NoteTag = observer(({ appState, tag }: Props) => {
const noteTags = appState.noteTags; const noteTags = appState.noteTags
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags; const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
const [showDeleteButton, setShowDeleteButton] = useState(false); const [showDeleteButton, setShowDeleteButton] = useState(false)
const [tagClicked, setTagClicked] = useState(false); const [tagClicked, setTagClicked] = useState(false)
const deleteTagRef = useRef<HTMLButtonElement>(null); const deleteTagRef = useRef<HTMLButtonElement>(null)
const tagRef = useRef<HTMLButtonElement>(null); const tagRef = useRef<HTMLButtonElement>(null)
const title = tag.title; const title = tag.title
const prefixTitle = noteTags.getPrefixTitle(tag); const prefixTitle = noteTags.getPrefixTitle(tag)
const longTitle = noteTags.getLongTitle(tag); const longTitle = noteTags.getLongTitle(tag)
const deleteTag = () => { const deleteTag = () => {
appState.noteTags.focusPreviousTag(tag); appState.noteTags.focusPreviousTag(tag)
appState.noteTags.removeTagFromActiveNote(tag); appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
}; }
const onDeleteTagClick = (event: MouseEvent) => { const onDeleteTagClick = (event: MouseEvent) => {
event.stopImmediatePropagation(); event.stopImmediatePropagation()
event.stopPropagation(); event.stopPropagation()
deleteTag(); deleteTag()
}; }
const onTagClick = (event: MouseEvent) => { const onTagClick = (event: MouseEvent) => {
if (tagClicked && event.target !== deleteTagRef.current) { if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false); setTagClicked(false)
appState.selectedTag = tag; appState.selectedTag = tag
} else { } else {
setTagClicked(true); setTagClicked(true)
} }
}; }
const onFocus = () => { const onFocus = () => {
appState.noteTags.setFocusedTagUuid(tag.uuid); appState.noteTags.setFocusedTagUuid(tag.uuid)
setShowDeleteButton(true); setShowDeleteButton(true)
}; }
const onBlur = (event: FocusEvent) => { const onBlur = (event: FocusEvent) => {
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)
setShowDeleteButton(false); setShowDeleteButton(false)
} }
}; }
const getTabIndex = () => { const getTabIndex = () => {
if (focusedTagUuid) { if (focusedTagUuid) {
return focusedTagUuid === tag.uuid ? 0 : -1; return focusedTagUuid === tag.uuid ? 0 : -1
} }
if (autocompleteInputFocused) { if (autocompleteInputFocused) {
return -1; return -1
} }
return tags[0].uuid === tag.uuid ? 0 : -1; return tags[0].uuid === tag.uuid ? 0 : -1
}; }
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const tagIndex = appState.noteTags.getTagIndex(tag, tags); const tagIndex = appState.noteTags.getTagIndex(tag, tags)
switch (event.key) { switch (event.key) {
case 'Backspace': case 'Backspace':
deleteTag(); deleteTag()
break; break
case 'ArrowLeft': case 'ArrowLeft':
appState.noteTags.focusPreviousTag(tag); appState.noteTags.focusPreviousTag(tag)
break; break
case 'ArrowRight': case 'ArrowRight':
if (tagIndex === tags.length - 1) { if (tagIndex === tags.length - 1) {
appState.noteTags.setAutocompleteInputFocused(true); appState.noteTags.setAutocompleteInputFocused(true)
} else { } else {
appState.noteTags.focusNextTag(tag); appState.noteTags.focusNextTag(tag)
} }
break; break
default: default:
return; return
} }
}; }
useEffect(() => { useEffect(() => {
if (focusedTagUuid === tag.uuid) { if (focusedTagUuid === tag.uuid) {
tagRef.current!.focus(); tagRef.current?.focus()
} }
}, [appState.noteTags, focusedTagUuid, tag]); }, [appState.noteTags, focusedTagUuid, tag])
return ( return (
<button <button
@ -119,12 +119,9 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
onClick={onDeleteTagClick} onClick={onDeleteTagClick}
tabIndex={-1} tabIndex={-1}
> >
<Icon <Icon type="close" className="sn-icon--small color-neutral hover:color-info" />
type="close"
className="sn-icon--small color-neutral hover:color-info"
/>
</button> </button>
)} )}
</button> </button>
); )
}); })

View File

@ -1,19 +1,19 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { AutocompleteTagInput } from './AutocompleteTagInput'; import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput'
import { NoteTag } from './NoteTag'; import { NoteTag } from './NoteTag'
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks'
type Props = { type Props = {
appState: AppState; appState: AppState
}; }
export const NoteTagsContainer = observer(({ appState }: Props) => { export const NoteTagsContainer = observer(({ appState }: Props) => {
const { tags, tagsContainerMaxWidth } = appState.noteTags; const { tags, tagsContainerMaxWidth } = appState.noteTags
useEffect(() => { useEffect(() => {
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth()
}, [appState.noteTags]); }, [appState.noteTags])
return ( return (
<div <div
@ -27,5 +27,5 @@ export const NoteTagsContainer = observer(({ appState }: Props) => {
))} ))}
<AutocompleteTagInput appState={appState} /> <AutocompleteTagInput appState={appState} />
</div> </div>
); )
}); })

View File

@ -2,20 +2,20 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import { NoteView } from './NoteView'; import { NoteView } from './NoteView'
import { import {
ApplicationEvent, ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction, ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/'; } from '@standardnotes/snjs/'
describe('editor-view', () => { describe('editor-view', () => {
let ctrl: NoteView; let ctrl: NoteView
let setShowProtectedWarningSpy: jest.SpyInstance; let setShowProtectedWarningSpy: jest.SpyInstance
beforeEach(() => { beforeEach(() => {
ctrl = new NoteView({} as any); ctrl = new NoteView({} as any)
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay'); setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay')
Object.defineProperties(ctrl, { Object.defineProperties(ctrl, {
application: { application: {
@ -25,7 +25,7 @@ describe('editor-view', () => {
notes: { notes: {
setShowProtectedWarning: jest.fn(), setShowProtectedWarning: jest.fn(),
}, },
}; }
}, },
hasProtectionSources: () => true, hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(), authorizeNoteAccess: jest.fn(),
@ -48,19 +48,19 @@ describe('editor-view', () => {
clearNoteChangeListener: jest.fn(), clearNoteChangeListener: jest.fn(),
}, },
}, },
}); })
}); })
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers()
}); })
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers()
}); })
afterEach(() => { afterEach(() => {
ctrl.deinit(); ctrl.deinit()
}); })
describe('note is protected', () => { describe('note is protected', () => {
beforeEach(() => { beforeEach(() => {
@ -68,76 +68,71 @@ describe('editor-view', () => {
value: { value: {
protected: true, protected: true,
}, },
}); })
}); })
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => { it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
jest jest
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit') .spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
.mockImplementation( .mockImplementation(
() => () => ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction + 5,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction + )
5
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired); await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true); expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
}); })
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => { it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
const secondsElapsedSinceLastEdit = const secondsElapsedSinceLastEdit =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - 3
3;
Object.defineProperty(ctrl.note, 'userModifiedDate', { Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000), value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
configurable: true, configurable: true,
}); })
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired); await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
const secondsAfterWhichTheNoteShouldHide = const secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit; secondsElapsedSinceLastEdit
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000); jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled(); expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(1 * 1000); jest.advanceTimersByTime(1 * 1000)
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true); expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
}); })
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => { it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
const secondsElapsedSinceLastModification = 3; const secondsElapsedSinceLastModification = 3
Object.defineProperty(ctrl.note, 'userModifiedDate', { Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date( value: new Date(Date.now() - secondsElapsedSinceLastModification * 1000),
Date.now() - secondsElapsedSinceLastModification * 1000
),
configurable: true, configurable: true,
}); })
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired); await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
let secondsAfterWhichTheNoteShouldHide = let secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastModification; secondsElapsedSinceLastModification
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000); jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
// A new modification has just happened // A new modification has just happened
Object.defineProperty(ctrl.note, 'userModifiedDate', { Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(), value: new Date(),
configurable: true, configurable: true,
}); })
secondsAfterWhichTheNoteShouldHide = secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction; ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000); jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000)
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled(); expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(1 * 1000); jest.advanceTimersByTime(1 * 1000)
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true); expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true)
}); })
}); })
describe('note is unprotected', () => { describe('note is unprotected', () => {
it('should not call any hiding logic', async () => { it('should not call any hiding logic', async () => {
@ -145,51 +140,46 @@ describe('editor-view', () => {
value: { value: {
protected: false, protected: false,
}, },
}); })
const hideProtectedNoteIfInactiveSpy = jest.spyOn( const hideProtectedNoteIfInactiveSpy = jest.spyOn(ctrl, 'hideProtectedNoteIfInactive')
ctrl,
'hideProtectedNoteIfInactive'
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired); await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired)
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled(); expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled()
}); })
}); })
describe('dismissProtectedWarning', () => { describe('dismissProtectedWarning', () => {
describe('the note has protection sources', () => { describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => { it('should reveal note contents if the authorization has been passed', async () => {
jest jest
.spyOn(ctrl['application'], 'authorizeNoteAccess') .spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true)); .mockImplementation(async () => Promise.resolve(true))
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning()
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false); expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false)
}); })
it('should not reveal note contents if the authorization has not been passed', async () => { it('should not reveal note contents if the authorization has not been passed', async () => {
jest jest
.spyOn(ctrl['application'], 'authorizeNoteAccess') .spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false)); .mockImplementation(async () => Promise.resolve(false))
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning()
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled(); expect(setShowProtectedWarningSpy).not.toHaveBeenCalled()
}); })
}); })
describe('the note does not have protection sources', () => { describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => { it('should reveal note contents', async () => {
jest jest.spyOn(ctrl['application'], 'hasProtectionSources').mockImplementation(() => false)
.spyOn(ctrl['application'], 'hasProtectionSources')
.mockImplementation(() => false);
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning()
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false); expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false)
}); })
}); })
}); })
}); })

View File

@ -0,0 +1,47 @@
import { AppState } from '@/UIModels/AppState'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { observer } from 'mobx-react-lite'
import { NotesOptions } from '@/Components/NotesOptions/NotesOptions'
import { useCallback, useEffect, useRef } from 'preact/hooks'
import { WebApplication } from '@/UIModels/Application'
type Props = {
application: WebApplication
appState: AppState
}
export const NotesContextMenu = observer(({ application, appState }: Props) => {
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = appState.notes
const contextMenuRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
appState.notes.setContextMenuOpen(open),
)
useCloseOnClickOutside(contextMenuRef, () => appState.notes.setContextMenuOpen(false))
const reloadContextMenuLayout = useCallback(() => {
appState.notes.reloadContextMenuLayout()
}, [appState.notes])
useEffect(() => {
window.addEventListener('resize', reloadContextMenuLayout)
return () => {
window.removeEventListener('resize', reloadContextMenuLayout)
}
}, [reloadContextMenuLayout])
return contextMenuOpen ? (
<div
ref={contextMenuRef}
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
style={{
...contextMenuPosition,
maxHeight: contextMenuMaxHeight,
}}
>
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
</div>
) : null
})

View File

@ -1,43 +1,43 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import {
CollectionSort, CollectionSort,
CollectionSortProperty, CollectionSortProperty,
sanitizeHtmlString, sanitizeHtmlString,
SNNote, SNNote,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
type Props = { type Props = {
application: WebApplication; application: WebApplication
note: SNNote; note: SNNote
tags: string[]; tags: string[]
hideDate: boolean; hideDate: boolean
hidePreview: boolean; hidePreview: boolean
hideTags: boolean; hideTags: boolean
hideEditorIcon: boolean; hideEditorIcon: boolean
onClick: () => void; onClick: () => void
onContextMenu: (e: MouseEvent) => void; onContextMenu: (e: MouseEvent) => void
selected: boolean; selected: boolean
sortedBy?: CollectionSortProperty; sortedBy?: CollectionSortProperty
}; }
type NoteFlag = { type NoteFlag = {
text: string; text: string
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'; class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
}; }
const flagsForNote = (note: SNNote) => { const flagsForNote = (note: SNNote) => {
const flags = [] as NoteFlag[]; const flags = [] as NoteFlag[]
if (note.conflictOf) { if (note.conflictOf) {
flags.push({ flags.push({
text: 'Conflicted Copy', text: 'Conflicted Copy',
class: 'danger', class: 'danger',
}); })
} }
return flags; return flags
}; }
export const NotesListItem: FunctionComponent<Props> = ({ export const NotesListItem: FunctionComponent<Props> = ({
application, application,
@ -52,13 +52,13 @@ export const NotesListItem: FunctionComponent<Props> = ({
sortedBy, sortedBy,
tags, tags,
}) => { }) => {
const flags = flagsForNote(note); const flags = flagsForNote(note)
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt; const showModifiedDate = sortedBy === CollectionSort.UpdatedAt
const editorForNote = application.componentManager.editorForNote(note); const editorForNote = application.componentManager.editorForNote(note)
const editorName = editorForNote?.name ?? 'Plain editor'; const editorName = editorForNote?.name ?? 'Plain editor'
const [icon, tint] = application.iconsController.getIconAndTintForEditor( const [icon, tint] = application.iconsController.getIconAndTintForEditor(
editorForNote?.identifier editorForNote?.identifier,
); )
return ( return (
<div <div
@ -101,22 +101,15 @@ export const NotesListItem: FunctionComponent<Props> = ({
{!hideDate || note.protected ? ( {!hideDate || note.protected ? (
<div className="bottom-info faded"> <div className="bottom-info faded">
{note.protected && <span>Protected {hideDate ? '' : ' • '}</span>} {note.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
{!hideDate && showModifiedDate && ( {!hideDate && showModifiedDate && <span>Modified {note.updatedAtString || 'Now'}</span>}
<span>Modified {note.updatedAtString || 'Now'}</span> {!hideDate && !showModifiedDate && <span>{note.createdAtString || 'Now'}</span>}
)}
{!hideDate && !showModifiedDate && (
<span>{note.createdAtString || 'Now'}</span>
)}
</div> </div>
) : null} ) : null}
{!hideTags && tags.length ? ( {!hideTags && tags.length ? (
<div className="tags-string"> <div className="tags-string">
{tags.map((tag) => ( {tags.map((tag) => (
<span className="tag color-foreground"> <span className="tag color-foreground">
<Icon <Icon type="hashtag" className="sn-icon--small color-grey-1 mr-1" />
type="hashtag"
className="sn-icon--small color-grey-1 mr-1"
/>
<span>{tag}</span> <span>{tag}</span>
</span> </span>
))} ))}
@ -144,11 +137,7 @@ export const NotesListItem: FunctionComponent<Props> = ({
)} )}
{note.trashed && ( {note.trashed && (
<span title="Trashed"> <span title="Trashed">
<Icon <Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
ariaLabel="Trashed"
type="trash-filled"
className="sn-icon--small color-danger"
/>
</span> </span>
)} )}
{note.archived && ( {note.archived && (
@ -162,14 +151,10 @@ export const NotesListItem: FunctionComponent<Props> = ({
)} )}
{note.pinned && ( {note.pinned && (
<span title="Pinned"> <span title="Pinned">
<Icon <Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
ariaLabel="Pinned"
type="pin-filled"
className="sn-icon--small color-info"
/>
</span> </span>
)} )}
</div> </div>
</div> </div>
); )
}; }

View File

@ -1,121 +1,117 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
CollectionSort, import { observer } from 'mobx-react-lite'
CollectionSortProperty, import { FunctionComponent } from 'preact'
PrefKey, import { useState } from 'preact/hooks'
} from '@standardnotes/snjs'; import { Icon } from '@/Components/Icon'
import { observer } from 'mobx-react-lite'; import { Menu } from '@/Components/Menu/Menu'
import { FunctionComponent } from 'preact'; import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
import { useState } from 'preact/hooks';
import { Icon } from './Icon';
import { Menu } from './Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './Menu/MenuItem';
type Props = { type Props = {
application: WebApplication; application: WebApplication
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
closeDisplayOptionsMenu: () => void; closeDisplayOptionsMenu: () => void
isOpen: boolean; isOpen: boolean
}; }
export const NotesListOptionsMenu: FunctionComponent<Props> = observer( export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => { ({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => {
const [sortBy, setSortBy] = useState(() => const [sortBy, setSortBy] = useState(() =>
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt) application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt),
); )
const [sortReverse, setSortReverse] = useState(() => const [sortReverse, setSortReverse] = useState(() =>
application.getPreference(PrefKey.SortNotesReverse, false) application.getPreference(PrefKey.SortNotesReverse, false),
); )
const [hidePreview, setHidePreview] = useState(() => const [hidePreview, setHidePreview] = useState(() =>
application.getPreference(PrefKey.NotesHideNotePreview, false) application.getPreference(PrefKey.NotesHideNotePreview, false),
); )
const [hideDate, setHideDate] = useState(() => const [hideDate, setHideDate] = useState(() =>
application.getPreference(PrefKey.NotesHideDate, false) application.getPreference(PrefKey.NotesHideDate, false),
); )
const [hideTags, setHideTags] = useState(() => const [hideTags, setHideTags] = useState(() =>
application.getPreference(PrefKey.NotesHideTags, true) application.getPreference(PrefKey.NotesHideTags, true),
); )
const [hidePinned, setHidePinned] = useState(() => const [hidePinned, setHidePinned] = useState(() =>
application.getPreference(PrefKey.NotesHidePinned, false) application.getPreference(PrefKey.NotesHidePinned, false),
); )
const [showArchived, setShowArchived] = useState(() => const [showArchived, setShowArchived] = useState(() =>
application.getPreference(PrefKey.NotesShowArchived, false) application.getPreference(PrefKey.NotesShowArchived, false),
); )
const [showTrashed, setShowTrashed] = useState(() => const [showTrashed, setShowTrashed] = useState(() =>
application.getPreference(PrefKey.NotesShowTrashed, false) application.getPreference(PrefKey.NotesShowTrashed, false),
); )
const [hideProtected, setHideProtected] = useState(() => const [hideProtected, setHideProtected] = useState(() =>
application.getPreference(PrefKey.NotesHideProtected, false) application.getPreference(PrefKey.NotesHideProtected, false),
); )
const [hideEditorIcon, setHideEditorIcon] = useState(() => const [hideEditorIcon, setHideEditorIcon] = useState(() =>
application.getPreference(PrefKey.NotesHideEditorIcon, false) application.getPreference(PrefKey.NotesHideEditorIcon, false),
); )
const toggleSortReverse = () => { const toggleSortReverse = () => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse); application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
setSortReverse(!sortReverse); setSortReverse(!sortReverse)
}; }
const toggleSortBy = (sort: CollectionSortProperty) => { const toggleSortBy = (sort: CollectionSortProperty) => {
if (sortBy === sort) { if (sortBy === sort) {
toggleSortReverse(); toggleSortReverse()
} else { } else {
setSortBy(sort); setSortBy(sort)
application.setPreference(PrefKey.SortNotesBy, sort); application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
} }
}; }
const toggleSortByDateModified = () => { const toggleSortByDateModified = () => {
toggleSortBy(CollectionSort.UpdatedAt); toggleSortBy(CollectionSort.UpdatedAt)
}; }
const toggleSortByCreationDate = () => { const toggleSortByCreationDate = () => {
toggleSortBy(CollectionSort.CreatedAt); toggleSortBy(CollectionSort.CreatedAt)
}; }
const toggleSortByTitle = () => { const toggleSortByTitle = () => {
toggleSortBy(CollectionSort.Title); toggleSortBy(CollectionSort.Title)
}; }
const toggleHidePreview = () => { const toggleHidePreview = () => {
setHidePreview(!hidePreview); setHidePreview(!hidePreview)
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview); application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
}; }
const toggleHideDate = () => { const toggleHideDate = () => {
setHideDate(!hideDate); setHideDate(!hideDate)
application.setPreference(PrefKey.NotesHideDate, !hideDate); application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
}; }
const toggleHideTags = () => { const toggleHideTags = () => {
setHideTags(!hideTags); setHideTags(!hideTags)
application.setPreference(PrefKey.NotesHideTags, !hideTags); application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
}; }
const toggleHidePinned = () => { const toggleHidePinned = () => {
setHidePinned(!hidePinned); setHidePinned(!hidePinned)
application.setPreference(PrefKey.NotesHidePinned, !hidePinned); application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
}; }
const toggleShowArchived = () => { const toggleShowArchived = () => {
setShowArchived(!showArchived); setShowArchived(!showArchived)
application.setPreference(PrefKey.NotesShowArchived, !showArchived); application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
}; }
const toggleShowTrashed = () => { const toggleShowTrashed = () => {
setShowTrashed(!showTrashed); setShowTrashed(!showTrashed)
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed); application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
}; }
const toggleHideProtected = () => { const toggleHideProtected = () => {
setHideProtected(!hideProtected); setHideProtected(!hideProtected)
application.setPreference(PrefKey.NotesHideProtected, !hideProtected); application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
}; }
const toggleEditorIcon = () => { const toggleEditorIcon = () => {
setHideEditorIcon(!hideEditorIcon); setHideEditorIcon(!hideEditorIcon)
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon); application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
}; }
return ( return (
<Menu <Menu
@ -128,9 +124,7 @@ export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
closeMenu={closeDisplayOptionsMenu} closeMenu={closeDisplayOptionsMenu}
isOpen={isOpen} isOpen={isOpen}
> >
<div className="px-3 my-1 text-xs font-semibold color-text uppercase"> <div className="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div>
Sort by
</div>
<MenuItem <MenuItem
className="py-2" className="py-2"
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
@ -186,9 +180,7 @@ export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
</div> </div>
</MenuItem> </MenuItem>
<MenuItemSeparator /> <MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold color-text uppercase"> <div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
View
</div>
<MenuItem <MenuItem
type={MenuItemType.SwitchButton} type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
@ -226,9 +218,7 @@ export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
Show editor icon Show editor icon
</MenuItem> </MenuItem>
<div className="h-1px my-2 bg-border"></div> <div className="h-1px my-2 bg-border"></div>
<div className="px-3 py-1 text-xs font-semibold color-text uppercase"> <div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
Other
</div>
<MenuItem <MenuItem
type={MenuItemType.SwitchButton} type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
@ -266,6 +256,6 @@ export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
Show trashed notes Show trashed notes
</MenuItem> </MenuItem>
</Menu> </Menu>
); )
} },
); )

View File

@ -0,0 +1,104 @@
import { WebApplication } from '@/UIModels/Application'
import { KeyboardKey } from '@/Services/IOService'
import { AppState } from '@/UIModels/AppState'
import { DisplayOptions } from '@/UIModels/AppState/NotesViewState'
import { SNNote, SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { NotesListItem } from './NotesListItem'
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants'
type Props = {
application: WebApplication
appState: AppState
notes: SNNote[]
selectedNotes: Record<string, SNNote>
displayOptions: DisplayOptions
paginate: () => void
}
export const NotesList: FunctionComponent<Props> = observer(
({ application, appState, notes, selectedNotes, displayOptions, paginate }) => {
const { selectPreviousNote, selectNextNote } = appState.notesView
const { hideTags, hideDate, hideNotePreview, hideEditorIcon, sortBy } = displayOptions
const tagsForNote = (note: SNNote): string[] => {
if (hideTags) {
return []
}
const selectedTag = appState.selectedTag
if (!selectedTag) {
return []
}
const tags = appState.getNoteTags(note)
if (selectedTag instanceof SNTag && tags.length === 1) {
return []
}
return tags.map((tag) => tag.title).sort()
}
const openNoteContextMenu = (posX: number, posY: number) => {
appState.notes.setContextMenuClickLocation({
x: posX,
y: posY,
})
appState.notes.reloadContextMenuLayout()
appState.notes.setContextMenuOpen(true)
}
const onContextMenu = (note: SNNote, posX: number, posY: number) => {
appState.notes.selectNote(note.uuid, true).catch(console.error)
openNoteContextMenu(posX, posY)
}
const onScroll = (e: Event) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD
const element = e.target as HTMLElement
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
paginate()
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Up) {
e.preventDefault()
selectPreviousNote()
} else if (e.key === KeyboardKey.Down) {
e.preventDefault()
selectNextNote()
}
}
return (
<div
className="infinite-scroll focus:shadow-none focus:outline-none"
id="notes-scrollable"
onScroll={onScroll}
onKeyDown={onKeyDown}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{notes.map((note) => (
<NotesListItem
application={application}
key={note.uuid}
note={note}
tags={tagsForNote(note)}
selected={!!selectedNotes[note.uuid]}
hideDate={hideDate}
hidePreview={hideNotePreview}
hideTags={hideTags}
hideEditorIcon={hideEditorIcon}
sortedBy={sortBy}
onClick={() => {
appState.notes.selectNote(note.uuid, true).catch(console.error)
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault()
onContextMenu(note, e.clientX, e.clientY)
}}
/>
))}
</div>
)
},
)

View File

@ -0,0 +1,111 @@
import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = {
appState: AppState
}
export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
})
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const toggleTagsMenu = () => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen)
}
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
}, [])
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle()
})
}
}, [isMenuOpen, recalculateMenuStyle])
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleTagsMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsMenuOpen(false)
}
}}
onBlur={closeOnBlur}
ref={menuButtonRef}
className="sn-dropdown-item justify-between"
>
<div className="flex items-center">
<Icon type="hashtag" className="mr-2 color-neutral" />
Add tag
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsMenuOpen(false)
menuButtonRef.current?.focus()
}
}}
style={{
...menuStyle,
position: 'fixed',
}}
className="sn-dropdown min-w-80 flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-auto"
>
{appState.tags.tags.map((tag) => (
<button
key={tag.title}
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
onBlur={closeOnBlur}
onClick={() => {
appState.notes.isTagInSelectedNotes(tag)
? appState.notes.removeTagFromSelectedNotes(tag).catch(console.error)
: appState.notes.addTagToSelectedNotes(tag).catch(console.error)
}}
>
<span
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
${appState.notes.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
>
{appState.noteTags.getLongTitle(tag)}
</span>
</button>
))}
</DisclosurePanel>
</Disclosure>
</div>
)
})

View File

@ -1,89 +1,79 @@
import { KeyboardKey } from '@/services/ioService'; import { KeyboardKey } from '@/Services/IOService'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
Disclosure, import { IconType, SNComponent, SNNote } from '@standardnotes/snjs'
DisclosureButton, import { FunctionComponent } from 'preact'
DisclosurePanel, import { useEffect, useRef, useState } from 'preact/hooks'
} from '@reach/disclosure'; import { Icon } from '@/Components/Icon'
import { IconType, SNComponent, SNNote } from '@standardnotes/snjs'; import { ChangeEditorMenu } from '@/Components/ChangeEditor/ChangeEditorMenu'
import { FunctionComponent } from 'preact'; import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { useEffect, useRef, useState } from 'preact/hooks'; import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { Icon } from '../Icon';
import { ChangeEditorMenu } from './changeEditor/ChangeEditorMenu';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import { useCloseOnBlur } from '../utils';
type ChangeEditorOptionProps = { type ChangeEditorOptionProps = {
appState: AppState; appState: AppState
application: WebApplication; application: WebApplication
note: SNNote; note: SNNote
}; }
type AccordionMenuGroup<T> = { type AccordionMenuGroup<T> = {
icon?: IconType; icon?: IconType
iconClassName?: string; iconClassName?: string
title: string; title: string
items: Array<T>; items: Array<T>
}; }
export type EditorMenuItem = { export type EditorMenuItem = {
name: string; name: string
component?: SNComponent; component?: SNComponent
isEntitled: boolean; isEntitled: boolean
}; }
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>; export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
application, application,
note, 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>({
right: 0, right: 0,
bottom: 0, bottom: 0,
maxHeight: 'auto', maxHeight: 'auto',
}); })
const menuContainerRef = useRef<HTMLDivElement>(null); const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null)
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => { const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => {
setIsOpen(open); setIsOpen(open)
setIsVisible(open); setIsVisible(open)
}); })
const toggleChangeEditorMenu = () => { const toggleChangeEditorMenu = () => {
if (!isOpen) { if (!isOpen) {
const menuStyle = calculateSubmenuStyle(buttonRef.current); const menuStyle = calculateSubmenuStyle(buttonRef.current)
if (menuStyle) { if (menuStyle) {
setMenuStyle(menuStyle); setMenuStyle(menuStyle)
} }
} }
setIsOpen(!isOpen); setIsOpen(!isOpen)
}; }
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setTimeout(() => { setTimeout(() => {
const newMenuStyle = calculateSubmenuStyle( const newMenuStyle = calculateSubmenuStyle(buttonRef.current, menuRef.current)
buttonRef.current,
menuRef.current
);
if (newMenuStyle) { if (newMenuStyle) {
setMenuStyle(newMenuStyle); setMenuStyle(newMenuStyle)
setIsVisible(true); setIsVisible(true)
} }
}); })
} }
}, [isOpen]); }, [isOpen])
return ( return (
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
@ -91,7 +81,7 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
<DisclosureButton <DisclosureButton
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
setIsOpen(false); setIsOpen(false)
} }
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
@ -108,8 +98,8 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
ref={menuRef} ref={menuRef}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
setIsOpen(false); setIsOpen(false)
buttonRef.current?.focus(); buttonRef.current?.focus()
} }
}} }}
style={{ style={{
@ -125,12 +115,12 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
note={note} note={note}
isVisible={isVisible} isVisible={isVisible}
closeMenu={() => { closeMenu={() => {
setIsOpen(false); setIsOpen(false)
}} }}
/> />
)} )}
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
</div> </div>
); )
}; }

View File

@ -1,37 +1,30 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
calculateSubmenuStyle, import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
SubmenuStyle, import { Action, ListedAccount, SNNote } from '@standardnotes/snjs'
} from '@/utils/calculateSubmenuStyle'; import { Fragment, FunctionComponent } from 'preact'
import { import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
Disclosure, import { Icon } from '@/Components/Icon'
DisclosureButton, import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
DisclosurePanel,
} from '@reach/disclosure';
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { useCloseOnBlur } from '../utils';
type Props = { type Props = {
application: WebApplication; application: WebApplication
note: SNNote; note: SNNote
}; }
type ListedMenuGroup = { type ListedMenuGroup = {
name: string; name: string
account: ListedAccount; account: ListedAccount
actions: Action[]; actions: Action[]
}; }
type ListedMenuItemProps = { type ListedMenuItemProps = {
action: Action; action: Action
note: SNNote; note: SNNote
group: ListedMenuGroup; group: ListedMenuGroup
application: WebApplication; application: WebApplication
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>; reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>
}; }
const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
action, action,
@ -40,21 +33,21 @@ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
group, group,
reloadMenuGroup, reloadMenuGroup,
}) => { }) => {
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false)
const handleClick = async () => { const handleClick = async () => {
if (isRunning) { if (isRunning) {
return; return
} }
setIsRunning(true); setIsRunning(true)
await application.actionsManager.runAction(action, note); await application.actionsManager.runAction(action, note)
setIsRunning(false); setIsRunning(false)
reloadMenuGroup(group); reloadMenuGroup(group).catch(console.error)
}; }
return ( return (
<button <button
@ -74,109 +67,100 @@ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
</div> </div>
{isRunning && <div className="sk-spinner spinner-info w-3 h-3" />} {isRunning && <div className="sk-spinner spinner-info w-3 h-3" />}
</button> </button>
); )
}; }
type ListedActionsMenuProps = { type ListedActionsMenuProps = {
application: WebApplication; application: WebApplication
note: SNNote; note: SNNote
recalculateMenuStyle: () => void; recalculateMenuStyle: () => void
}; }
const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
application, application,
note, note,
recalculateMenuStyle, recalculateMenuStyle,
}) => { }) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([]); const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true); const [isFetchingAccounts, setIsFetchingAccounts] = useState(true)
const reloadMenuGroup = async (group: ListedMenuGroup) => { const reloadMenuGroup = async (group: ListedMenuGroup) => {
const updatedAccountInfo = await application.getListedAccountInfo( const updatedAccountInfo = await application.getListedAccountInfo(group.account, note.uuid)
group.account,
note.uuid
);
if (!updatedAccountInfo) { if (!updatedAccountInfo) {
return; return
} }
const updatedGroup: ListedMenuGroup = { const updatedGroup: ListedMenuGroup = {
name: updatedAccountInfo.display_name, name: updatedAccountInfo.display_name,
account: group.account, account: group.account,
actions: updatedAccountInfo.actions as Action[], actions: updatedAccountInfo.actions as Action[],
}; }
const updatedGroups = menuGroups.map((group) => { const updatedGroups = menuGroups.map((group) => {
if (updatedGroup.account.authorId === group.account.authorId) { if (updatedGroup.account.authorId === group.account.authorId) {
return updatedGroup; return updatedGroup
} else { } else {
return group; return group
} }
}); })
setMenuGroups(updatedGroups); setMenuGroups(updatedGroups)
}; }
useEffect(() => { useEffect(() => {
const fetchListedAccounts = async () => { const fetchListedAccounts = async () => {
if (!application.hasAccount()) { if (!application.hasAccount()) {
setIsFetchingAccounts(false); setIsFetchingAccounts(false)
return; return
} }
try { try {
const listedAccountEntries = await application.getListedAccounts(); const listedAccountEntries = await application.getListedAccounts()
if (!listedAccountEntries.length) { if (!listedAccountEntries.length) {
throw new Error('No Listed accounts found'); throw new Error('No Listed accounts found')
} }
const menuGroups: ListedMenuGroup[] = []; const menuGroups: ListedMenuGroup[] = []
await Promise.all( await Promise.all(
listedAccountEntries.map(async (account) => { listedAccountEntries.map(async (account) => {
const accountInfo = await application.getListedAccountInfo( const accountInfo = await application.getListedAccountInfo(account, note.uuid)
account,
note.uuid
);
if (accountInfo) { if (accountInfo) {
menuGroups.push({ menuGroups.push({
name: accountInfo.display_name, name: accountInfo.display_name,
account, account,
actions: accountInfo.actions as Action[], actions: accountInfo.actions as Action[],
}); })
} else { } else {
menuGroups.push({ menuGroups.push({
name: account.authorId, name: account.authorId,
account, account,
actions: [], actions: [],
}); })
} }
}) }),
); )
setMenuGroups( setMenuGroups(
menuGroups.sort((a, b) => { menuGroups.sort((a, b) => {
return a.name.toString().toLowerCase() < return a.name.toString().toLowerCase() < b.name.toString().toLowerCase() ? -1 : 1
b.name.toString().toLowerCase() }),
? -1 )
: 1;
})
);
} catch (err) { } catch (err) {
console.error(err); console.error(err)
} finally { } finally {
setIsFetchingAccounts(false); setIsFetchingAccounts(false)
setTimeout(() => { setTimeout(() => {
recalculateMenuStyle(); recalculateMenuStyle()
}); })
} }
}; }
fetchListedAccounts(); void fetchListedAccounts()
}, [application, note.uuid, recalculateMenuStyle]); }, [application, note.uuid, recalculateMenuStyle])
return ( return (
<> <>
@ -208,9 +192,7 @@ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
/> />
)) ))
) : ( ) : (
<div className="px-3 py-2 color-grey-0 select-none"> <div className="px-3 py-2 color-grey-0 select-none">No actions available</div>
No actions available
</div>
)} )}
</Fragment> </Fragment>
))} ))}
@ -218,61 +200,53 @@ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
) : null} ) : null}
{!isFetchingAccounts && !menuGroups.length ? ( {!isFetchingAccounts && !menuGroups.length ? (
<div className="w-full flex items-center justify-center px-4 py-6"> <div className="w-full flex items-center justify-center px-4 py-6">
<div className="color-grey-0 select-none"> <div className="color-grey-0 select-none">No Listed accounts found</div>
No Listed accounts found
</div>
</div> </div>
) : null} ) : null}
</> </>
); )
}; }
export const ListedActionsOption: FunctionComponent<Props> = ({ export const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => {
application, const menuContainerRef = useRef<HTMLDivElement>(null)
note, const menuRef = useRef<HTMLDivElement>(null)
}) => { const menuButtonRef = useRef<HTMLButtonElement>(null)
const menuContainerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({ const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0, right: 0,
bottom: 0, bottom: 0,
maxHeight: 'auto', maxHeight: 'auto',
}); })
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen); const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const toggleListedMenu = () => { const toggleListedMenu = () => {
if (!isMenuOpen) { if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current); const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) { if (menuPosition) {
setMenuStyle(menuPosition); setMenuStyle(menuPosition)
} }
} }
setIsMenuOpen(!isMenuOpen); setIsMenuOpen(!isMenuOpen)
}; }
const recalculateMenuStyle = useCallback(() => { const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle( const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
menuButtonRef.current,
menuRef.current
);
if (newMenuPosition) { if (newMenuPosition) {
setMenuStyle(newMenuPosition); setMenuStyle(newMenuPosition)
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
if (isMenuOpen) { if (isMenuOpen) {
setTimeout(() => { setTimeout(() => {
recalculateMenuStyle(); recalculateMenuStyle()
}); })
} }
}, [isMenuOpen, recalculateMenuStyle]); }, [isMenuOpen, recalculateMenuStyle])
return ( return (
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
@ -306,5 +280,5 @@ export const ListedActionsOption: FunctionComponent<Props> = ({
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
</div> </div>
); )
}; }

View File

@ -1,116 +1,109 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { Icon } from '../Icon'; import { Icon } from '@/Components/Icon'
import { Switch } from '../Switch'; import { Switch } from '@/Components/Switch'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo } from 'preact/hooks'; import { useState, useEffect, useMemo } from 'preact/hooks'
import { SNApplication, SNNote } from '@standardnotes/snjs'; import { SNApplication, SNNote } from '@standardnotes/snjs'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { KeyboardModifier } from '@/services/ioService'; import { KeyboardModifier } from '@/Services/IOService'
import { FunctionComponent } from 'preact'; 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'
export type NotesOptionsProps = { export type NotesOptionsProps = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}; }
type DeletePermanentlyButtonProps = { type DeletePermanentlyButtonProps = {
closeOnBlur: NotesOptionsProps['closeOnBlur']; closeOnBlur: NotesOptionsProps['closeOnBlur']
onClick: () => void; onClick: () => void
}; }
const DeletePermanentlyButton = ({ const DeletePermanentlyButton = ({ closeOnBlur, onClick }: DeletePermanentlyButtonProps) => (
closeOnBlur,
onClick,
}: DeletePermanentlyButtonProps) => (
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}> <button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}>
<Icon type="close" className="color-danger mr-2" /> <Icon type="close" className="color-danger mr-2" />
<span className="color-danger">Delete permanently</span> <span className="color-danger">Delete permanently</span>
</button> </button>
); )
const iconClass = 'color-neutral mr-2'; const iconClass = 'color-neutral mr-2'
const getWordCount = (text: string) => { const getWordCount = (text: string) => {
if (text.trim().length === 0) { if (text.trim().length === 0) {
return 0; return 0
} }
return text.split(/\s+/).length; return text.split(/\s+/).length
}; }
const getParagraphCount = (text: string) => { const getParagraphCount = (text: string) => {
if (text.trim().length === 0) { if (text.trim().length === 0) {
return 0; return 0
} }
return text.replace(/\n$/gm, '').split(/\n/).length; return text.replace(/\n$/gm, '').split(/\n/).length
}; }
const countNoteAttributes = (text: string) => { const countNoteAttributes = (text: string) => {
try { try {
JSON.parse(text); JSON.parse(text)
return { return {
characters: 'N/A', characters: 'N/A',
words: 'N/A', words: 'N/A',
paragraphs: 'N/A', paragraphs: 'N/A',
}; }
} catch { } catch {
const characters = text.length; const characters = text.length
const words = getWordCount(text); const words = getWordCount(text)
const paragraphs = getParagraphCount(text); const paragraphs = getParagraphCount(text)
return { return {
characters, characters,
words, words,
paragraphs, paragraphs,
}; }
} }
}; }
const calculateReadTime = (words: number) => { const calculateReadTime = (words: number) => {
const timeToRead = Math.round(words / 200); const timeToRead = Math.round(words / 200)
if (timeToRead === 0) { if (timeToRead === 0) {
return '< 1 minute'; return '< 1 minute'
} else { } else {
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`; return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
} }
}; }
const formatDate = (date: Date | undefined) => { const formatDate = (date: Date | undefined) => {
if (!date) return; if (!date) {
return `${date.toDateString()} ${date.toLocaleTimeString()}`; return
}; }
return `${date.toDateString()} ${date.toLocaleTimeString()}`
}
const NoteAttributes: FunctionComponent<{ const NoteAttributes: FunctionComponent<{
application: SNApplication; application: SNApplication
note: SNNote; note: SNNote
}> = ({ application, note }) => { }> = ({ application, note }) => {
const { words, characters, paragraphs } = useMemo( const { words, characters, paragraphs } = useMemo(
() => countNoteAttributes(note.text), () => countNoteAttributes(note.text),
[note.text] [note.text],
); )
const readTime = useMemo( const readTime = useMemo(
() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), () => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'),
[words] [words],
); )
const dateLastModified = useMemo( const dateLastModified = useMemo(() => formatDate(note.userModifiedDate), [note.userModifiedDate])
() => formatDate(note.userModifiedDate),
[note.userModifiedDate]
);
const dateCreated = useMemo( const dateCreated = useMemo(() => formatDate(note.created_at), [note.created_at])
() => formatDate(note.created_at),
[note.created_at]
);
const editor = application.componentManager.editorForNote(note); const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'; const format = editor?.package_info?.file_type || 'txt'
return ( return (
<div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium"> <div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium">
@ -134,29 +127,27 @@ const NoteAttributes: FunctionComponent<{
<span className="font-semibold">Note ID:</span> {note.uuid} <span className="font-semibold">Note ID:</span> {note.uuid}
</div> </div>
</div> </div>
); )
}; }
const SpellcheckOptions: FunctionComponent<{ const SpellcheckOptions: FunctionComponent<{
appState: AppState; appState: AppState
note: SNNote; note: SNNote
}> = ({ appState, note }) => { }> = ({ appState, note }) => {
const editor = appState.application.componentManager.editorForNote(note); const editor = appState.application.componentManager.editorForNote(note)
const spellcheckControllable = Boolean( const spellcheckControllable = Boolean(!editor || editor.package_info.spellcheckControl)
!editor || editor.package_info.spellcheckControl
);
const noteSpellcheck = !spellcheckControllable const noteSpellcheck = !spellcheckControllable
? true ? true
: note : note
? appState.notes.getSpellcheckStateForNote(note) ? appState.notes.getSpellcheckStateForNote(note)
: undefined; : undefined
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<button <button
className="sn-dropdown-item justify-between px-3 py-1" className="sn-dropdown-item justify-between px-3 py-1"
onClick={() => { onClick={() => {
appState.notes.toggleGlobalSpellcheckForNote(note); appState.notes.toggleGlobalSpellcheckForNote(note).catch(console.error)
}} }}
disabled={!spellcheckControllable} disabled={!spellcheckControllable}
> >
@ -164,122 +155,110 @@ const SpellcheckOptions: FunctionComponent<{
<Icon type="notes" className={iconClass} /> <Icon type="notes" className={iconClass} />
Spellcheck Spellcheck
</span> </span>
<Switch <Switch className="px-0" checked={noteSpellcheck} disabled={!spellcheckControllable} />
className="px-0"
checked={noteSpellcheck}
disabled={!spellcheckControllable}
/>
</button> </button>
{!spellcheckControllable && ( {!spellcheckControllable && (
<p className="text-xs px-3 py-1.5"> <p className="text-xs px-3 py-1.5">Spellcheck cannot be controlled for this editor.</p>
Spellcheck cannot be controlled for this editor.
</p>
)} )}
</div> </div>
); )
}; }
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE; const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
const NoteSizeWarning: FunctionComponent<{ const NoteSizeWarning: FunctionComponent<{
note: SNNote; note: SNNote
}> = ({ note }) => }> = ({ note }) =>
new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? ( (new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning"> <div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
<Icon <Icon type="warning" className="color-accessory-tint-3 flex-shrink-0 mr-3" />
type="warning"
className="color-accessory-tint-3 flex-shrink-0 mr-3"
/>
<div className="color-grey-0 select-none leading-140% max-w-80%"> <div className="color-grey-0 select-none leading-140% max-w-80%">
This note may have trouble syncing to the mobile application due to its This note may have trouble syncing to the mobile application due to its size.
size.
</div> </div>
</div> </div>
) : null; ) : null)
export const NotesOptions = observer( export const NotesOptions = observer(
({ application, appState, closeOnBlur }: NotesOptionsProps) => { ({ 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) => {
const notesMatchingAttribute = notes.filter(condition); const notesMatchingAttribute = notes.filter(condition)
const notesNotMatchingAttribute = notes.filter( const notesNotMatchingAttribute = notes.filter((note) => !condition(note))
(note) => !condition(note) return notesMatchingAttribute.length > notesNotMatchingAttribute.length
); }
return notesMatchingAttribute.length > notesNotMatchingAttribute.length;
};
const notes = Object.values(appState.notes.selectedNotes); const notes = Object.values(appState.notes.selectedNotes)
const hidePreviews = toggleOn((note) => note.hidePreview); const hidePreviews = toggleOn((note) => note.hidePreview)
const locked = toggleOn((note) => note.locked); const locked = toggleOn((note) => note.locked)
const protect = toggleOn((note) => note.protected); const protect = toggleOn((note) => note.protected)
const archived = notes.some((note) => note.archived); const archived = notes.some((note) => note.archived)
const unarchived = notes.some((note) => !note.archived); const unarchived = notes.some((note) => !note.archived)
const trashed = notes.some((note) => note.trashed); const trashed = notes.some((note) => note.trashed)
const notTrashed = notes.some((note) => !note.trashed); const notTrashed = notes.some((note) => !note.trashed)
const pinned = notes.some((note) => note.pinned); const pinned = notes.some((note) => note.pinned)
const unpinned = notes.some((note) => !note.pinned); const unpinned = notes.some((note) => !note.pinned)
useEffect(() => { useEffect(() => {
const removeAltKeyObserver = application.io.addKeyObserver({ const removeAltKeyObserver = application.io.addKeyObserver({
modifiers: [KeyboardModifier.Alt], modifiers: [KeyboardModifier.Alt],
onKeyDown: () => { onKeyDown: () => {
setAltKeyDown(true); setAltKeyDown(true)
}, },
onKeyUp: () => { onKeyUp: () => {
setAltKeyDown(false); setAltKeyDown(false)
}, },
}); })
return () => { return () => {
removeAltKeyObserver(); removeAltKeyObserver()
}; }
}, [application]); }, [application])
const getNoteFileName = (note: SNNote): string => { const getNoteFileName = (note: SNNote): string => {
const editor = application.componentManager.editorForNote(note); const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'; const format = editor?.package_info?.file_type || 'txt'
return `${note.title}.${format}`; return `${note.title}.${format}`
}; }
const downloadSelectedItems = async () => { const downloadSelectedItems = async () => {
if (notes.length === 1) { if (notes.length === 1) {
application application
.getArchiveService() .getArchiveService()
.downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0])); .downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]))
return; return
} }
if (notes.length > 1) { if (notes.length > 1) {
const loadingToastId = addToast({ const loadingToastId = addToast({
type: ToastType.Loading, type: ToastType.Loading,
message: `Exporting ${notes.length} notes...`, message: `Exporting ${notes.length} notes...`,
}); })
await application.getArchiveService().downloadDataAsZip( await application.getArchiveService().downloadDataAsZip(
notes.map((note) => { notes.map((note) => {
return { return {
filename: getNoteFileName(note), filename: getNoteFileName(note),
content: new Blob([note.text]), content: new Blob([note.text]),
}; }
}) }),
); )
dismissToast(loadingToastId); dismissToast(loadingToastId)
addToast({ addToast({
type: ToastType.Success, type: ToastType.Success,
message: `Exported ${notes.length} notes`, message: `Exported ${notes.length} notes`,
}); })
} }
}; }
const duplicateSelectedItems = () => { const duplicateSelectedItems = () => {
notes.forEach((note) => { notes.forEach((note) => {
application.mutator.duplicateItem(note); application.mutator.duplicateItem(note).catch(console.error)
}); })
}; }
const openRevisionHistoryModal = () => { const openRevisionHistoryModal = () => {
appState.notes.setShowRevisionHistoryModal(true); appState.notes.setShowRevisionHistoryModal(true)
}; }
return ( return (
<> <>
@ -299,7 +278,7 @@ export const NotesOptions = observer(
<button <button
className="sn-dropdown-item justify-between" className="sn-dropdown-item justify-between"
onClick={() => { onClick={() => {
appState.notes.setLockSelectedNotes(!locked); appState.notes.setLockSelectedNotes(!locked)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -312,7 +291,7 @@ export const NotesOptions = observer(
<button <button
className="sn-dropdown-item justify-between" className="sn-dropdown-item justify-between"
onClick={() => { onClick={() => {
appState.notes.setHideSelectedNotePreviews(!hidePreviews); appState.notes.setHideSelectedNotePreviews(!hidePreviews)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -325,7 +304,7 @@ export const NotesOptions = observer(
<button <button
className="sn-dropdown-item justify-between" className="sn-dropdown-item justify-between"
onClick={() => { onClick={() => {
appState.notes.setProtectSelectedNotes(!protect); appState.notes.setProtectSelectedNotes(!protect).catch(console.error)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
@ -338,11 +317,7 @@ export const NotesOptions = observer(
{notes.length === 1 && ( {notes.length === 1 && (
<> <>
<div className="min-h-1px my-2 bg-border"></div> <div className="min-h-1px my-2 bg-border"></div>
<ChangeEditorOption <ChangeEditorOption appState={appState} application={application} note={notes[0]} />
appState={appState}
application={application}
note={notes[0]}
/>
</> </>
)} )}
<div className="min-h-1px my-2 bg-border"></div> <div className="min-h-1px my-2 bg-border"></div>
@ -352,7 +327,7 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={() => { onClick={() => {
appState.notes.setPinSelectedNotes(true); appState.notes.setPinSelectedNotes(true)
}} }}
> >
<Icon type="pin" className={iconClass} /> <Icon type="pin" className={iconClass} />
@ -364,26 +339,18 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={() => { onClick={() => {
appState.notes.setPinSelectedNotes(false); appState.notes.setPinSelectedNotes(false)
}} }}
> >
<Icon type="unpin" className={iconClass} /> <Icon type="unpin" className={iconClass} />
Unpin Unpin
</button> </button>
)} )}
<button <button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={downloadSelectedItems}>
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={downloadSelectedItems}
>
<Icon type="download" className={iconClass} /> <Icon type="download" className={iconClass} />
Export Export
</button> </button>
<button <button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={duplicateSelectedItems}>
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={duplicateSelectedItems}
>
<Icon type="copy" className={iconClass} /> <Icon type="copy" className={iconClass} />
Duplicate Duplicate
</button> </button>
@ -392,7 +359,7 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={() => { onClick={() => {
appState.notes.setArchiveSelectedNotes(true); appState.notes.setArchiveSelectedNotes(true).catch(console.error)
}} }}
> >
<Icon type="archive" className={iconClass} /> <Icon type="archive" className={iconClass} />
@ -404,7 +371,7 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={() => { onClick={() => {
appState.notes.setArchiveSelectedNotes(false); appState.notes.setArchiveSelectedNotes(false).catch(console.error)
}} }}
> >
<Icon type="unarchive" className={iconClass} /> <Icon type="unarchive" className={iconClass} />
@ -416,7 +383,7 @@ export const NotesOptions = observer(
<DeletePermanentlyButton <DeletePermanentlyButton
closeOnBlur={closeOnBlur} closeOnBlur={closeOnBlur}
onClick={async () => { onClick={async () => {
await appState.notes.deleteNotesPermanently(); await appState.notes.deleteNotesPermanently()
}} }}
/> />
) : ( ) : (
@ -424,7 +391,7 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={async () => { onClick={async () => {
await appState.notes.setTrashSelectedNotes(true); await appState.notes.setTrashSelectedNotes(true)
}} }}
> >
<Icon type="trash" className={iconClass} /> <Icon type="trash" className={iconClass} />
@ -437,7 +404,7 @@ export const NotesOptions = observer(
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={async () => { onClick={async () => {
await appState.notes.setTrashSelectedNotes(false); await appState.notes.setTrashSelectedNotes(false)
}} }}
> >
<Icon type="restore" className={iconClass} /> <Icon type="restore" className={iconClass} />
@ -446,23 +413,21 @@ export const NotesOptions = observer(
<DeletePermanentlyButton <DeletePermanentlyButton
closeOnBlur={closeOnBlur} closeOnBlur={closeOnBlur}
onClick={async () => { onClick={async () => {
await appState.notes.deleteNotesPermanently(); await appState.notes.deleteNotesPermanently()
}} }}
/> />
<button <button
onBlur={closeOnBlur} onBlur={closeOnBlur}
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={async () => { onClick={async () => {
await appState.notes.emptyTrash(); await appState.notes.emptyTrash()
}} }}
> >
<div className="flex items-start"> <div className="flex items-start">
<Icon type="trash-sweep" className="color-danger mr-2" /> <Icon type="trash-sweep" className="color-danger mr-2" />
<div className="flex-row"> <div className="flex-row">
<div className="color-danger">Empty Trash</div> <div className="color-danger">Empty Trash</div>
<div className="text-xs"> <div className="text-xs">{appState.notes.trashedNotesCount} notes in Trash</div>
{appState.notes.trashedNotesCount} notes in Trash
</div>
</div> </div>
</div> </div>
</button> </button>
@ -480,6 +445,6 @@ export const NotesOptions = observer(
</> </>
) : null} ) : null}
</> </>
); )
} },
); )

View File

@ -1,66 +1,60 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { Icon } from './Icon'; import { Icon } from '@/Components/Icon'
import VisuallyHidden from '@reach/visually-hidden'; import VisuallyHidden from '@reach/visually-hidden'
import { useCloseOnBlur } from './utils'; import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
Disclosure, import { useRef, useState } from 'preact/hooks'
DisclosureButton, import { observer } from 'mobx-react-lite'
DisclosurePanel, import { NotesOptions } from './NotesOptions'
} from '@reach/disclosure'; import { WebApplication } from '@/UIModels/Application'
import { useRef, useState } from 'preact/hooks'; import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions/NotesOptions';
import { WebApplication } from '@/ui_models/application';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
onClickPreprocessing?: () => Promise<void>; onClickPreprocessing?: () => Promise<void>
}; }
export const NotesOptionsPanel = observer( export const NotesOptionsPanel = observer(
({ application, appState, onClickPreprocessing }: Props) => { ({ 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,
right: 0, right: 0,
}); })
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto'); const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen); const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
return ( return (
<Disclosure <Disclosure
open={open} open={open}
onChange={async () => { onChange={async () => {
const rect = buttonRef.current?.getBoundingClientRect(); const rect = buttonRef.current?.getBoundingClientRect()
if (rect) { if (rect) {
const { clientHeight } = document.documentElement; const { clientHeight } = document.documentElement
const footerElementRect = document const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
.getElementById('footer-bar') const footerHeightInPx = footerElementRect?.height
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (footerHeightInPx) { if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2); setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2)
} }
setPosition({ setPosition({
top: rect.bottom, top: rect.bottom,
right: document.body.clientWidth - rect.right, right: document.body.clientWidth - rect.right,
}); })
const newOpenState = !open; const newOpenState = !open
if (newOpenState && onClickPreprocessing) { if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing(); await onClickPreprocessing()
} }
setOpen(newOpenState); setOpen(newOpenState)
} }
}} }}
> >
<DisclosureButton <DisclosureButton
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false); setOpen(false)
} }
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
@ -73,8 +67,8 @@ export const NotesOptionsPanel = observer(
<DisclosurePanel <DisclosurePanel
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false); setOpen(false)
buttonRef.current?.focus(); buttonRef.current?.focus()
} }
}} }}
ref={panelRef} ref={panelRef}
@ -87,14 +81,10 @@ export const NotesOptionsPanel = observer(
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
> >
{open && ( {open && (
<NotesOptions <NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
/>
)} )}
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
); )
} },
); )

View File

@ -0,0 +1,260 @@
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { PANEL_NAME_NOTES } from '@/Constants'
import { PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'
import { NoAccountWarning } from '@/Components/NoAccountWarning'
import { NotesList } from '@/Components/NotesList'
import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu'
import { SearchOptions } from '@/Components/SearchOptions'
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = {
application: WebApplication
appState: AppState
}
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null)
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
const {
completedFullSync,
createNewNote,
displayOptions,
noteFilterText,
optionsSubtitle,
panelTitle,
renderedNotes,
selectedNotes,
setNoteFilterText,
searchBarElement,
selectNextNote,
selectPreviousNote,
onFilterEnter,
handleFilterTextChanged,
clearFilterText,
paginate,
panelWidth,
} = appState.notesView
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
const [focusedSearch, setFocusedSearch] = useState(false)
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
displayOptionsMenuRef,
setShowDisplayOptionsMenu,
)
useEffect(() => {
handleFilterTextChanged()
}, [noteFilterText, handleFilterTextChanged])
useEffect(() => {
/**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
* use Control modifier as well. These rules don't apply to desktop, but
* probably better to be consistent.
*/
const newNoteKeyObserver = application.io.addKeyObserver({
key: 'n',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
onKeyDown: (event) => {
event.preventDefault()
createNewNote().catch(console.error)
},
})
const nextNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Down,
elements: [document.body, ...(searchBarElement ? [searchBarElement] : [])],
onKeyDown: () => {
if (searchBarElement === document.activeElement) {
searchBarElement?.blur()
}
selectNextNote()
},
})
const previousNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Up,
element: document.body,
onKeyDown: () => {
selectPreviousNote()
},
})
const searchKeyObserver = application.io.addKeyObserver({
key: 'f',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
onKeyDown: () => {
if (searchBarElement) {
searchBarElement.focus()
}
},
})
return () => {
newNoteKeyObserver()
nextNoteKeyObserver()
previousNoteKeyObserver()
searchKeyObserver()
}
}, [application.io, createNewNote, searchBarElement, selectNextNote, selectPreviousNote])
const onNoteFilterTextChange = (e: Event) => {
setNoteFilterText((e.target as HTMLInputElement).value)
}
const onSearchFocused = () => setFocusedSearch(true)
const onSearchBlurred = () => setFocusedSearch(false)
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Enter) {
onFilterEnter()
}
}
const panelResizeFinishCallback: ResizeFinishCallback = (
width,
_lastLeft,
_isMaxWidth,
isCollapsed,
) => {
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
appState.noteTags.reloadTagsContainerMaxWidth()
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed)
}
const panelWidthEventCallback = () => {
appState.noteTags.reloadTagsContainerMaxWidth()
}
const toggleDisplayOptionsMenu = () => {
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
}
return (
<div
id="notes-column"
className="sn-component section notes app-column app-column-second"
aria-label="Notes"
ref={notesViewPanelRef}
>
<div className="content">
<div id="notes-title-bar" className="section-title-bar">
<div id="notes-title-bar-container">
<div className="section-title-bar-header">
<div className="sk-h2 font-semibold title">{panelTitle}</div>
<button
className="sk-button contrast wide"
title="Create a new note in the selected tag"
aria-label="Create new note"
onClick={() => createNewNote()}
>
<div className="sk-label">
<i className="ion-plus add-button" aria-hidden></i>
</div>
</button>
</div>
<div className="filter-section" role="search">
<div>
<input
type="text"
id="search-bar"
className="filter-bar"
placeholder="Search"
title="Searches notes in the currently selected tag"
value={noteFilterText}
onChange={onNoteFilterTextChange}
onKeyUp={onNoteFilterKeyUp}
onFocus={onSearchFocused}
onBlur={onSearchBlurred}
autocomplete="off"
/>
{noteFilterText && (
<button onClick={clearFilterText} aria-role="button" id="search-clear-button">
</button>
)}
</div>
{(focusedSearch || noteFilterText) && (
<div className="animate-fade-from-top">
<SearchOptions application={application} appState={appState} />
</div>
)}
</div>
<NoAccountWarning appState={appState} />
</div>
<div id="notes-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
<div className="sk-app-bar no-edges">
<div className="left">
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
<DisclosureButton
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
showDisplayOptionsMenu ? 'selected' : ''
}`}
onBlur={closeDisplayOptMenuOnBlur}
>
<div className="sk-app-bar-item-column">
<div className="sk-label">Options</div>
</div>
<div className="sk-app-bar-item-column">
<div className="sk-sublabel">{optionsSubtitle}</div>
</div>
</DisclosureButton>
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
closeOnBlur={closeDisplayOptMenuOnBlur}
isOpen={showDisplayOptionsMenu}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
</div>
</div>
</div>
{completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">No notes.</p>
) : null}
{!completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">Loading notes...</p>
) : null}
{renderedNotes.length ? (
<NotesList
notes={renderedNotes}
selectedNotes={selectedNotes}
application={application}
appState={appState}
displayOptions={displayOptions}
paginate={paginate}
/>
) : null}
</div>
{notesViewPanelRef.current && (
<PanelResizer
collapsable={true}
hoverable={true}
defaultWidth={300}
panel={notesViewPanelRef.current}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>
)}
</div>
)
})

View File

@ -0,0 +1,70 @@
import { useRef } from 'preact/hooks'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
type Props = {
application: WebApplication
appState: AppState
}
export const OtherSessionsSignOutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.otherSessionsSignOut) {
return null
}
return <ConfirmOtherSessionsSignOut {...props} />
})
const ConfirmOtherSessionsSignOut = observer(({ application, appState }: Props) => {
const cancelRef = useRef<HTMLButtonElement>(null)
function closeDialog() {
appState.accountMenu.setOtherSessionsSignOut(false)
}
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
End all other sessions?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">
This action will sign out all other devices signed into your account, and remove
your data from those devices when they next regain connection to the internet.
You may sign back in on those devices at any time.
</p>
</AlertDialogDescription>
<div className="flex my-1 mt-4">
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
Cancel
</button>
<button
className="sn-button small danger ml-2"
onClick={() => {
application.revokeAllOtherSessions().catch(console.error)
closeDialog()
application.alertService
.alert(
'You have successfully revoked your sessions from other devices.',
undefined,
'Finish',
)
.catch(console.error)
}}
>
End Sessions
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
)
})

View File

@ -0,0 +1,326 @@
import { Component, createRef } from 'preact'
import { debounce } from '@/Utils'
export type ResizeFinishCallback = (
lastWidth: number,
lastLeft: number,
isMaxWidth: boolean,
isCollapsed: boolean,
) => void
export enum PanelSide {
Right = 'right',
Left = 'left',
}
export enum PanelResizeType {
WidthOnly = 'WidthOnly',
OffsetAndWidth = 'OffsetAndWidth',
}
type Props = {
width: number
left: number
alwaysVisible?: boolean
collapsable?: boolean
defaultWidth?: number
hoverable?: boolean
minWidth?: number
panel: HTMLDivElement
side: PanelSide
type: PanelResizeType
resizeFinishCallback?: ResizeFinishCallback
widthEventCallback?: () => void
}
type State = {
collapsed: boolean
pressed: boolean
}
export class PanelResizer extends Component<Props, State> {
private overlay?: HTMLDivElement
private resizerElementRef = createRef<HTMLDivElement>()
private debouncedResizeHandler: () => void
private startLeft: number
private startWidth: number
private lastDownX: number
private lastLeft: number
private lastWidth: number
private widthBeforeLastDblClick: number
private minWidth: number
constructor(props: Props) {
super(props)
this.state = {
collapsed: false,
pressed: false,
}
this.minWidth = props.minWidth || 5
this.startLeft = props.panel.offsetLeft
this.startWidth = props.panel.scrollWidth
this.lastDownX = 0
this.lastLeft = props.panel.offsetLeft
this.lastWidth = props.panel.scrollWidth
this.widthBeforeLastDblClick = 0
this.setWidth(this.props.width)
this.setLeft(this.props.left)
document.addEventListener('mouseup', this.onMouseUp)
document.addEventListener('mousemove', this.onMouseMove)
this.debouncedResizeHandler = debounce(this.handleResize, 250)
if (this.props.type === PanelResizeType.OffsetAndWidth) {
window.addEventListener('resize', this.debouncedResizeHandler)
}
}
override componentDidUpdate(prevProps: Props) {
if (this.props.width != prevProps.width) {
this.setWidth(this.props.width)
}
if (this.props.left !== prevProps.left) {
this.setLeft(this.props.left)
this.setWidth(this.props.width)
}
const isCollapsed = this.isCollapsed()
if (isCollapsed !== this.state.collapsed) {
this.setState({ collapsed: isCollapsed })
}
}
override componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp)
document.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('resize', this.debouncedResizeHandler)
}
get appFrame() {
return document.getElementById('app')?.getBoundingClientRect() as DOMRect
}
getParentRect() {
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect()
}
isAtMaxWidth = () => {
const marginOfError = 5
const difference = Math.abs(
Math.round(this.lastWidth + this.lastLeft) - Math.round(this.getParentRect().width),
)
return difference < marginOfError
}
isCollapsed() {
return this.lastWidth <= this.minWidth
}
finishSettingWidth = () => {
if (!this.props.collapsable) {
return
}
this.setState({
collapsed: this.isCollapsed(),
})
}
setWidth = (width: number, finish = false): void => {
if (width === 0) {
width = this.computeMaxWidth()
}
if (width < this.minWidth) {
width = this.minWidth
}
const parentRect = this.getParentRect()
if (width > parentRect.width) {
width = parentRect.width
}
const maxWidth = this.appFrame.width - this.props.panel.getBoundingClientRect().x
if (width > maxWidth) {
width = maxWidth
}
const isFullWidth = Math.round(width + this.lastLeft) === Math.round(parentRect.width)
if (isFullWidth) {
if (this.props.type === PanelResizeType.WidthOnly) {
this.props.panel.style.removeProperty('width')
} else {
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`
}
} else {
this.props.panel.style.width = width + 'px'
}
this.lastWidth = width
if (finish) {
this.finishSettingWidth()
if (this.props.resizeFinishCallback) {
this.props.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed(),
)
}
}
}
setLeft = (left: number) => {
this.props.panel.style.left = left + 'px'
this.lastLeft = left
}
onDblClick = () => {
const collapsed = this.isCollapsed()
if (collapsed) {
this.setWidth(this.widthBeforeLastDblClick || this.props.defaultWidth || 0)
} else {
this.widthBeforeLastDblClick = this.lastWidth
this.setWidth(this.minWidth)
}
this.finishSettingWidth()
this.props.resizeFinishCallback?.(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed(),
)
}
handleWidthEvent(event?: MouseEvent) {
if (this.props.widthEventCallback) {
this.props.widthEventCallback()
}
let x
if (event) {
x = event.clientX
} else {
/** Coming from resize event */
x = 0
this.lastDownX = 0
}
const deltaX = x - this.lastDownX
const newWidth = this.startWidth + deltaX
this.setWidth(newWidth, false)
}
handleLeftEvent(event: MouseEvent) {
const panelRect = this.props.panel.getBoundingClientRect()
const x = event.clientX || panelRect.x
let deltaX = x - this.lastDownX
let newLeft = this.startLeft + deltaX
if (newLeft < 0) {
newLeft = 0
deltaX = -this.startLeft
}
const parentRect = this.getParentRect()
let newWidth = this.startWidth - deltaX
if (newWidth < this.minWidth) {
newWidth = this.minWidth
}
if (newWidth > parentRect.width) {
newWidth = parentRect.width
}
if (newLeft + newWidth > parentRect.width) {
newLeft = parentRect.width - newWidth
}
this.setLeft(newLeft)
this.setWidth(newWidth, false)
}
computeMaxWidth(): number {
const parentRect = this.getParentRect()
let width = parentRect.width - this.props.left
if (width < this.minWidth) {
width = this.minWidth
}
return width
}
handleResize = () => {
const startWidth = this.isAtMaxWidth() ? this.computeMaxWidth() : this.props.panel.scrollWidth
this.startWidth = startWidth
this.lastWidth = startWidth
this.handleWidthEvent()
this.finishSettingWidth()
}
onMouseDown = (event: MouseEvent) => {
this.addInvisibleOverlay()
this.lastDownX = event.clientX
this.startWidth = this.props.panel.scrollWidth
this.startLeft = this.props.panel.offsetLeft
this.setState({
pressed: true,
})
}
onMouseUp = () => {
this.removeInvisibleOverlay()
if (!this.state.pressed) {
return
}
this.setState({ pressed: false })
const isMaxWidth = this.isAtMaxWidth()
if (this.props.resizeFinishCallback) {
this.props.resizeFinishCallback(this.lastWidth, this.lastLeft, isMaxWidth, this.isCollapsed())
}
this.finishSettingWidth()
}
onMouseMove = (event: MouseEvent) => {
if (!this.state.pressed) {
return
}
event.preventDefault()
if (this.props.side === PanelSide.Left) {
this.handleLeftEvent(event)
} else {
this.handleWidthEvent(event)
}
}
/**
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
* document[onmouseup] is not triggered because the document is no longer the same over
* the iframe. We add an invisible overlay while resizing so that the mouse context
* remains in our main document.
*/
addInvisibleOverlay = () => {
if (this.overlay) {
return
}
const overlayElement = document.createElement('div')
overlayElement.id = 'resizer-overlay'
this.overlay = overlayElement
document.body.prepend(this.overlay)
}
removeInvisibleOverlay = () => {
if (this.overlay) {
this.overlay.remove()
this.overlay = undefined
}
}
render() {
return (
<div
className={`panel-resizer ${this.props.side} ${this.props.hoverable ? 'hoverable' : ''} ${
this.props.alwaysVisible ? 'alwaysVisible' : ''
} ${this.state.pressed ? 'dragging' : ''} ${this.state.collapsed ? 'collapsed' : ''}`}
onMouseDown={this.onMouseDown}
onDblClick={this.onDblClick}
ref={this.resizerElementRef}
></div>
)
}
}

View File

@ -1,23 +1,23 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { createRef, JSX } from 'preact'; import { createRef, JSX } from 'preact'
import { PureComponent } from './Abstract/PureComponent'; import { PureComponent } from '@/Components/Abstract/PureComponent'
interface Props { interface Props {
application: WebApplication; application: WebApplication
} }
type State = { type State = {
continueTitle: string; continueTitle: string
formData: FormData; formData: FormData
isContinuing?: boolean; isContinuing?: boolean
lockContinue?: boolean; lockContinue?: boolean
processing?: boolean; processing?: boolean
showSpinner?: boolean; showSpinner?: boolean
step: Steps; step: Steps
title: string; title: string
}; }
const DEFAULT_CONTINUE_TITLE = 'Continue'; const DEFAULT_CONTINUE_TITLE = 'Continue'
enum Steps { enum Steps {
PasswordStep = 1, PasswordStep = 1,
@ -25,40 +25,40 @@ enum Steps {
} }
type FormData = { type FormData = {
currentPassword?: string; currentPassword?: string
newPassword?: string; newPassword?: string
newPasswordConfirmation?: string; newPasswordConfirmation?: string
status?: string; status?: string
}; }
export class PasswordWizard extends PureComponent<Props, State> { export class PasswordWizard extends PureComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>(); private currentPasswordInput = createRef<HTMLInputElement>()
constructor(props: Props) { constructor(props: Props) {
super(props, props.application); super(props, props.application)
this.registerWindowUnloadStopper(); this.registerWindowUnloadStopper()
this.state = { this.state = {
formData: {}, formData: {},
continueTitle: DEFAULT_CONTINUE_TITLE, continueTitle: DEFAULT_CONTINUE_TITLE,
step: Steps.PasswordStep, step: Steps.PasswordStep,
title: 'Change Password', title: 'Change Password',
}; }
} }
componentDidMount(): void { override componentDidMount(): void {
super.componentDidMount(); super.componentDidMount()
this.currentPasswordInput.current?.focus(); this.currentPasswordInput.current?.focus()
} }
componentWillUnmount(): void { override componentWillUnmount(): void {
super.componentWillUnmount(); super.componentWillUnmount()
window.onbeforeunload = null; window.onbeforeunload = null
} }
registerWindowUnloadStopper() { registerWindowUnloadStopper() {
window.onbeforeunload = () => { window.onbeforeunload = () => {
return true; return true
}; }
} }
resetContinueState() { resetContinueState() {
@ -66,35 +66,35 @@ export class PasswordWizard extends PureComponent<Props, State> {
showSpinner: false, showSpinner: false,
continueTitle: DEFAULT_CONTINUE_TITLE, continueTitle: DEFAULT_CONTINUE_TITLE,
isContinuing: false, isContinuing: false,
}); })
} }
nextStep = async () => { nextStep = async () => {
if (this.state.lockContinue || this.state.isContinuing) { if (this.state.lockContinue || this.state.isContinuing) {
return; return
} }
if (this.state.step === Steps.FinishStep) { if (this.state.step === Steps.FinishStep) {
this.dismiss(); this.dismiss()
return; return
} }
this.setState({ this.setState({
isContinuing: true, isContinuing: true,
showSpinner: true, showSpinner: true,
continueTitle: 'Generating Keys...', continueTitle: 'Generating Keys...',
}); })
const valid = await this.validateCurrentPassword(); const valid = await this.validateCurrentPassword()
if (!valid) { if (!valid) {
this.resetContinueState(); this.resetContinueState()
return; return
} }
const success = await this.processPasswordChange(); const success = await this.processPasswordChange()
if (!success) { if (!success) {
this.resetContinueState(); this.resetContinueState()
return; return
} }
this.setState({ this.setState({
@ -102,103 +102,105 @@ export class PasswordWizard extends PureComponent<Props, State> {
showSpinner: false, showSpinner: false,
continueTitle: 'Finish', continueTitle: 'Finish',
step: Steps.FinishStep, step: Steps.FinishStep,
}); })
}; }
async validateCurrentPassword() { async validateCurrentPassword() {
const currentPassword = this.state.formData.currentPassword; const currentPassword = this.state.formData.currentPassword
const newPass = this.state.formData.newPassword; const newPass = this.state.formData.newPassword
if (!currentPassword || currentPassword.length === 0) { if (!currentPassword || currentPassword.length === 0) {
this.application.alertService.alert( this.application.alertService
'Please enter your current password.' .alert('Please enter your current password.')
); .catch(console.error)
return false; return false
} }
if (!newPass || newPass.length === 0) { if (!newPass || newPass.length === 0) {
this.application.alertService.alert('Please enter a new password.'); this.application.alertService.alert('Please enter a new password.').catch(console.error)
return false; return false
} }
if (newPass !== this.state.formData.newPasswordConfirmation) { if (newPass !== this.state.formData.newPasswordConfirmation) {
this.application.alertService.alert( this.application.alertService
'Your new password does not match its confirmation.' .alert('Your new password does not match its confirmation.')
); .catch(console.error)
this.setFormDataState({ this.setFormDataState({
status: undefined, status: undefined,
}); }).catch(console.error)
return false; return false
} }
if (!this.application.getUser()?.email) { if (!this.application.getUser()?.email) {
this.application.alertService.alert( this.application.alertService
"We don't have your email stored. Please sign out then log back in to fix this issue." .alert(
); "We don't have your email stored. Please sign out then log back in to fix this issue.",
)
.catch(console.error)
this.setFormDataState({ this.setFormDataState({
status: undefined, status: undefined,
}); }).catch(console.error)
return false; return false
} }
/** Validate current password */ /** Validate current password */
const success = await this.application.validateAccountPassword( const success = await this.application.validateAccountPassword(
this.state.formData.currentPassword! this.state.formData.currentPassword as string,
); )
if (!success) { if (!success) {
this.application.alertService.alert( this.application.alertService
'The current password you entered is not correct. Please try again.' .alert('The current password you entered is not correct. Please try again.')
); .catch(console.error)
} }
return success; return success
} }
async processPasswordChange() { async processPasswordChange() {
await this.application.downloadBackup(); await this.application.downloadBackup()
this.setState({ this.setState({
lockContinue: true, lockContinue: true,
processing: true, processing: true,
}); })
await this.setFormDataState({ await this.setFormDataState({
status: 'Processing encryption keys…', status: 'Processing encryption keys…',
}); })
const newPassword = this.state.formData.newPassword; const newPassword = this.state.formData.newPassword
const response = await this.application.changePassword( const response = await this.application.changePassword(
this.state.formData.currentPassword!, this.state.formData.currentPassword as string,
newPassword! newPassword as string,
); )
const success = !response.error; const success = !response.error
this.setState({ this.setState({
processing: false, processing: false,
lockContinue: false, lockContinue: false,
}); })
if (!success) { if (!success) {
this.setFormDataState({ this.setFormDataState({
status: 'Unable to process your password. Please try again.', status: 'Unable to process your password. Please try again.',
}); }).catch(console.error)
} else { } else {
this.setState({ this.setState({
formData: { formData: {
...this.state.formData, ...this.state.formData,
status: 'Successfully changed password.', status: 'Successfully changed password.',
}, },
}); })
} }
return success; return success
} }
dismiss = () => { dismiss = () => {
if (this.state.lockContinue) { if (this.state.lockContinue) {
this.application.alertService.alert( this.application.alertService
'Cannot close window until pending tasks are complete.' .alert('Cannot close window until pending tasks are complete.')
); .catch(console.error)
} else { } else {
this.dismissModal(); this.dismissModal()
} }
}; }
async setFormDataState(formData: Partial<FormData>) { async setFormDataState(formData: Partial<FormData>) {
return this.setState({ return this.setState({
@ -206,7 +208,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
...this.state.formData, ...this.state.formData,
...formData, ...formData,
}, },
}); })
} }
handleCurrentPasswordInputChange = ({ handleCurrentPasswordInputChange = ({
@ -214,26 +216,26 @@ export class PasswordWizard extends PureComponent<Props, State> {
}: JSX.TargetedEvent<HTMLInputElement, Event>) => { }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({ this.setFormDataState({
currentPassword: currentTarget.value, currentPassword: currentTarget.value,
}); }).catch(console.error)
}; }
handleNewPasswordInputChange = ({ handleNewPasswordInputChange = ({
currentTarget, currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => { }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({ this.setFormDataState({
newPassword: currentTarget.value, newPassword: currentTarget.value,
}); }).catch(console.error)
}; }
handleNewPasswordConfirmationInputChange = ({ handleNewPasswordConfirmationInputChange = ({
currentTarget, currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => { }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({ this.setFormDataState({
newPasswordConfirmation: currentTarget.value, newPasswordConfirmation: currentTarget.value,
}); }).catch(console.error)
}; }
render() { override render() {
return ( return (
<div className="sn-component"> <div className="sn-component">
<div id="password-wizard" className="sk-modal small auto-height"> <div id="password-wizard" className="sk-modal small auto-height">
@ -242,9 +244,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sn-component"> <div className="sn-component">
<div className="sk-panel"> <div className="sk-panel">
<div className="sk-panel-header"> <div className="sk-panel-header">
<div className="sk-panel-header-title"> <div className="sk-panel-header-title">{this.state.title}</div>
{this.state.title}
</div>
<a onClick={this.dismiss} className="sk-a info close-button"> <a onClick={this.dismiss} className="sk-a info close-button">
Close Close
</a> </a>
@ -255,10 +255,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sk-panel-row"> <div className="sk-panel-row">
<div className="sk-panel-column stretch"> <div className="sk-panel-column stretch">
<form className="sk-panel-form"> <form className="sk-panel-form">
<label <label htmlFor="password-wiz-current-password" className="block mb-1">
htmlFor="password-wiz-current-password"
className="block mb-1"
>
Current Password Current Password
</label> </label>
@ -273,10 +270,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sk-panel-row" /> <div className="sk-panel-row" />
<label <label htmlFor="password-wiz-new-password" className="block mb-1">
htmlFor="password-wiz-new-password"
className="block mb-1"
>
New Password New Password
</label> </label>
@ -298,12 +292,8 @@ export class PasswordWizard extends PureComponent<Props, State> {
<input <input
id="password-wiz-confirm-new-password" id="password-wiz-confirm-new-password"
value={ value={this.state.formData.newPasswordConfirmation}
this.state.formData.newPasswordConfirmation onChange={this.handleNewPasswordConfirmationInputChange}
}
onChange={
this.handleNewPasswordConfirmationInputChange
}
type="password" type="password"
className="sk-input contrast" className="sk-input contrast"
/> />
@ -318,9 +308,8 @@ export class PasswordWizard extends PureComponent<Props, State> {
Your password has been successfully changed. Your password has been successfully changed.
</div> </div>
<p className="sk-p"> <p className="sk-p">
Please ensure you are running the latest version of Please ensure you are running the latest version of Standard Notes on all
Standard Notes on all platforms to ensure maximum platforms to ensure maximum compatibility.
compatibility.
</p> </p>
</div> </div>
)} )}
@ -339,6 +328,6 @@ export class PasswordWizard extends PureComponent<Props, State> {
</div> </div>
</div> </div>
</div> </div>
); )
} }
} }

View File

@ -1,43 +1,43 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { SNComponent } from '@standardnotes/snjs'; import { SNComponent } from '@standardnotes/snjs'
import { Component } from 'preact'; import { Component } from 'preact'
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'; import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
interface Props { interface Props {
application: WebApplication; application: WebApplication
callback: (approved: boolean) => void; callback: (approved: boolean) => void
component: SNComponent; component: SNComponent
permissionsString: string; permissionsString: string
} }
export class PermissionsModal extends Component<Props> { export class PermissionsModal extends Component<Props> {
getElement(): Element | null { getElement(): Element | null {
return findDOMNode(this); return findDOMNode(this)
} }
dismiss = () => { dismiss = () => {
const elem = this.getElement(); const elem = this.getElement()
if (!elem) { if (!elem) {
return; return
} }
const parent = elem.parentElement; const parent = elem.parentElement
if (!parent) { if (!parent) {
return; return
} }
parent.remove(); parent.remove()
unmountComponentAtNode(parent); unmountComponentAtNode(parent)
}; }
accept = () => { accept = () => {
this.props.callback(true); this.props.callback(true)
this.dismiss(); this.dismiss()
}; }
deny = () => { deny = () => {
this.props.callback(false); this.props.callback(false)
this.dismiss(); this.dismiss()
}; }
render() { render() {
return ( return (
@ -63,8 +63,7 @@ export class PermissionsModal extends Component<Props> {
</div> </div>
<div className="sk-panel-row"> <div className="sk-panel-row">
<p className="sk-p"> <p className="sk-p">
Components use an offline messaging system to communicate. Components use an offline messaging system to communicate. Learn more at{' '}
Learn more at{' '}
<a <a
href="https://standardnotes.com/permissions" href="https://standardnotes.com/permissions"
rel="noopener" rel="noopener"
@ -89,6 +88,6 @@ export class PermissionsModal extends Component<Props> {
</div> </div>
</div> </div>
</div> </div>
); )
} }
} }

View File

@ -0,0 +1,39 @@
import { AppState } from '@/UIModels/AppState'
import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
type Props = {
appState: AppState
className?: string
onClickPreprocessing?: () => Promise<void>
}
export const PinNoteButton: FunctionComponent<Props> = observer(
({ appState, className = '', onClickPreprocessing }) => {
const notes = Object.values(appState.notes.selectedNotes)
const pinned = notes.some((note) => note.pinned)
const togglePinned = async () => {
if (onClickPreprocessing) {
await onClickPreprocessing()
}
if (!pinned) {
appState.notes.setPinSelectedNotes(true)
} else {
appState.notes.setPinSelectedNotes(false)
}
}
return (
<button
className={`sn-icon-button border-contrast ${pinned ? 'toggled' : ''} ${className}`}
onClick={togglePinned}
>
<VisuallyHidden>Pin selected notes</VisuallyHidden>
<Icon type="pin" className="block" />
</button>
)
},
)

View File

@ -0,0 +1,38 @@
import { FunctionalComponent } from 'preact'
import { PreferencesGroup, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
import { OfflineSubscription } from '@/Components/Preferences/Panes/Account/OfflineSubscription'
import { WebApplication } from '@/UIModels/Application'
import { observer } from 'mobx-react-lite'
import { AppState } from '@/UIModels/AppState'
import { Extensions } from '@/Components/Preferences/Panes/Extensions'
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
import { AccordionItem } from '@/Components/Shared/AccordionItem'
interface IProps {
application: WebApplication
appState: AppState
extensionsLatestVersions: ExtensionsLatestVersions
}
export const Advanced: FunctionalComponent<IProps> = observer(
({ application, appState, extensionsLatestVersions }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<AccordionItem title={'Advanced Settings'}>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<OfflineSubscription application={application} appState={appState} />
<Extensions
className={'mt-3'}
application={application}
extensionsLatestVersions={extensionsLatestVersions}
/>
</div>
</div>
</AccordionItem>
</PreferencesSegment>
</PreferencesGroup>
)
},
)

View File

@ -1,32 +1,32 @@
import { AccountMenuPane } from '@/components/AccountMenu'; import { AccountMenuPane } from '@/Components/AccountMenu'
import { Button } from '@/components/Button'; import { Button } from '@/Components/Button/Button'
import { import {
PreferencesGroup, PreferencesGroup,
PreferencesSegment, PreferencesSegment,
Text, Text,
Title, Title,
} from '@/components/Preferences/components'; } from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { AccountIllustration } from '@standardnotes/stylekit'; import { AccountIllustration } from '@standardnotes/stylekit'
export const Authentication: FunctionComponent<{ export const Authentication: FunctionComponent<{
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
}> = observer(({ appState }) => { }> = observer(({ appState }) => {
const clickSignIn = () => { const clickSignIn = () => {
appState.preferences.closePreferences(); appState.preferences.closePreferences()
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn); appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn)
appState.accountMenu.setShow(true); appState.accountMenu.setShow(true)
}; }
const clickRegister = () => { const clickRegister = () => {
appState.preferences.closePreferences(); appState.preferences.closePreferences()
appState.accountMenu.setCurrentPane(AccountMenuPane.Register); appState.accountMenu.setCurrentPane(AccountMenuPane.Register)
appState.accountMenu.setShow(true); appState.accountMenu.setShow(true)
}; }
return ( return (
<PreferencesGroup> <PreferencesGroup>
@ -35,8 +35,8 @@ export const Authentication: FunctionComponent<{
<AccountIllustration className="mb-3" /> <AccountIllustration className="mb-3" />
<Title>You're not signed in</Title> <Title>You're not signed in</Title>
<Text className="text-center mb-3"> <Text className="text-center mb-3">
Sign in to sync your notes and preferences across all your devices Sign in to sync your notes and preferences across all your devices and enable end-to-end
and enable end-to-end encryption. encryption.
</Text> </Text>
<Button <Button
variant="primary" variant="primary"
@ -56,5 +56,5 @@ export const Authentication: FunctionComponent<{
</div> </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); )
}); })

View File

@ -1,14 +1,14 @@
import { StateUpdater } from 'preact/hooks'; import { StateUpdater } from 'preact/hooks'
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact'
type Props = { type Props = {
setNewEmail: StateUpdater<string>; setNewEmail: StateUpdater<string>
setCurrentPassword: StateUpdater<string>; setCurrentPassword: StateUpdater<string>
}; }
const labelClassName = `block mb-1`; const labelClassName = 'block mb-1'
const inputClassName = 'sk-input contrast'; const inputClassName = 'sk-input contrast'
export const ChangeEmailForm: FunctionalComponent<Props> = ({ export const ChangeEmailForm: FunctionalComponent<Props> = ({
setNewEmail, setNewEmail,
@ -25,7 +25,7 @@ export const ChangeEmailForm: FunctionalComponent<Props> = ({
className={inputClassName} className={inputClassName}
type="email" type="email"
onChange={({ target }) => { onChange={({ target }) => {
setNewEmail((target as HTMLInputElement).value); setNewEmail((target as HTMLInputElement).value)
}} }}
/> />
</div> </div>
@ -38,10 +38,10 @@ export const ChangeEmailForm: FunctionalComponent<Props> = ({
className={inputClassName} className={inputClassName}
type="password" type="password"
onChange={({ target }) => { onChange={({ target }) => {
setCurrentPassword((target as HTMLInputElement).value); setCurrentPassword((target as HTMLInputElement).value)
}} }}
/> />
</div> </div>
</div> </div>
); )
}; }

View File

@ -0,0 +1,13 @@
import { FunctionalComponent } from 'preact'
export const ChangeEmailSuccess: FunctionalComponent = () => {
return (
<div>
<div className={'sk-label sk-bold info mt-2'}>Your email has been successfully changed.</div>
<p className={'sk-p'}>
Please ensure you are running the latest version of Standard Notes on all platforms to
ensure maximum compatibility.
</p>
</div>
)
}

View File

@ -0,0 +1,171 @@
import { useState } from '@node_modules/preact/hooks'
import {
ModalDialog,
ModalDialogButtons,
ModalDialogDescription,
ModalDialogLabel,
} from '@/Components/Shared/ModalDialog'
import { Button } from '@/Components/Button/Button'
import { FunctionalComponent } from 'preact'
import { WebApplication } from '@/UIModels/Application'
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
import { ChangeEmailForm } from './ChangeEmailForm'
import { ChangeEmailSuccess } from './ChangeEmailSuccess'
import { isEmailValid } from '@/Utils'
enum SubmitButtonTitles {
Default = 'Continue',
GeneratingKeys = 'Generating Keys...',
Finish = 'Finish',
}
enum Steps {
InitialStep,
FinishStep,
}
type Props = {
onCloseDialog: () => void
application: WebApplication
}
export const ChangeEmail: FunctionalComponent<Props> = ({ onCloseDialog, application }) => {
const [currentPassword, setCurrentPassword] = useState('')
const [newEmail, setNewEmail] = useState('')
const [isContinuing, setIsContinuing] = useState(false)
const [lockContinue, setLockContinue] = useState(false)
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
useBeforeUnload()
const applicationAlertService = application.alertService
const validateCurrentPassword = async () => {
if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert('Please enter your current password.').catch(console.error)
return false
}
const success = await application.validateAccountPassword(currentPassword)
if (!success) {
applicationAlertService
.alert('The current password you entered is not correct. Please try again.')
.catch(console.error)
return false
}
return success
}
const validateNewEmail = async () => {
if (!isEmailValid(newEmail)) {
applicationAlertService
.alert(
'The email you entered has an invalid format. Please review your input and try again.',
)
.catch(console.error)
return false
}
return true
}
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
}
const processEmailChange = async () => {
await application.downloadBackup()
setLockContinue(true)
const response = await application.changeEmail(newEmail, currentPassword)
const success = !response.error
setLockContinue(false)
return success
}
const dismiss = () => {
if (lockContinue) {
applicationAlertService
.alert('Cannot close window until pending tasks are complete.')
.catch(console.error)
} else {
onCloseDialog()
}
}
const handleSubmit = async () => {
if (lockContinue || isContinuing) {
return
}
if (currentStep === Steps.FinishStep) {
dismiss()
return
}
setIsContinuing(true)
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys)
const valid = (await validateCurrentPassword()) && (await validateNewEmail())
if (!valid) {
resetProgressState()
return
}
const success = await processEmailChange()
if (!success) {
resetProgressState()
return
}
setIsContinuing(false)
setSubmitButtonTitle(SubmitButtonTitles.Finish)
setCurrentStep(Steps.FinishStep)
}
const handleDialogClose = () => {
if (lockContinue) {
applicationAlertService
.alert('Cannot close window until pending tasks are complete.')
.catch(console.error)
} else {
onCloseDialog()
}
}
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Change Email</ModalDialogLabel>
<ModalDialogDescription className="px-4.5">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button
className="min-w-20"
variant="primary"
label={submitButtonTitle}
onClick={handleSubmit}
/>
</ModalDialogButtons>
</ModalDialog>
</div>
)
}

View File

@ -0,0 +1,75 @@
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/Components/Preferences/PreferencesComponents'
import { Button } from '@/Components/Button/Button'
import { WebApplication } from '@/UIModels/Application'
import { observer } from '@node_modules/mobx-react-lite'
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
import { dateToLocalizedString } from '@standardnotes/snjs'
import { useCallback, useState } from 'preact/hooks'
import { ChangeEmail } from '@/Components/Preferences/Panes/Account/ChangeEmail'
import { FunctionComponent, render } from 'preact'
import { AppState } from '@/UIModels/AppState'
import { PasswordWizard } from '@/Components/PasswordWizard'
type Props = {
application: WebApplication
appState: AppState
}
export const Credentials: FunctionComponent<Props> = observer(({ application }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false)
const user = application.getUser()
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp)
const presentPasswordWizard = useCallback(() => {
render(
<PasswordWizard application={application} />,
document.body.appendChild(document.createElement('div')),
)
}, [application])
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Credentials</Title>
<Subtitle>Email</Subtitle>
<Text>
You're signed in as <span className="font-bold wrap">{user?.email}</span>
</Text>
<Button
className="min-w-20 mt-3"
variant="normal"
label="Change email"
onClick={() => {
setIsChangeEmailDialogOpen(true)
}}
/>
<HorizontalSeparator classes="mt-5 mb-3" />
<Subtitle>Password</Subtitle>
<Text>
Current password was set on <span className="font-bold">{passwordCreatedOn}</span>
</Text>
<Button
className="min-w-20 mt-3"
variant="normal"
label="Change password"
onClick={presentPasswordWizard}
/>
{isChangeEmailDialogOpen && (
<ChangeEmail
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
application={application}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
)
})

View File

@ -0,0 +1,128 @@
import { FunctionalComponent } from 'preact'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents'
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { Button } from '@/Components/Button/Button'
import { useEffect, useState } from 'preact/hooks'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/Strings'
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs'
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
interface IProps {
application: WebApplication
appState: AppState
}
export const OfflineSubscription: FunctionalComponent<IProps> = observer(({ application }) => {
const [activationCode, setActivationCode] = useState('')
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] = useState(false)
useEffect(() => {
if (application.features.hasOfflineRepo()) {
setHasUserPreviouslyStoredCode(true)
}
}, [application])
const shouldShowOfflineSubscription = () => {
return (
!application.hasAccount() || application.isThirdPartyHostUsed() || hasUserPreviouslyStoredCode
)
}
const handleSubscriptionCodeSubmit = async (event: Event) => {
event.preventDefault()
const result = await application.features.setOfflineFeaturesCode(activationCode)
if (result instanceof ClientDisplayableError) {
await application.alertService.alert(result.text)
} else {
setIsSuccessfullyActivated(true)
setHasUserPreviouslyStoredCode(true)
setIsSuccessfullyRemoved(false)
}
}
const handleRemoveOfflineKey = async () => {
await application.features.deleteOfflineFeatureRepo()
setIsSuccessfullyActivated(false)
setHasUserPreviouslyStoredCode(false)
setActivationCode('')
setIsSuccessfullyRemoved(true)
}
const handleRemoveClick = async () => {
application.alertService
.confirm(
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
'Remove offline key?',
'Remove Offline Key',
ButtonType.Danger,
'Cancel',
)
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await handleRemoveOfflineKey()
}
})
.catch((err: string) => {
application.alertService.alert(err).catch(console.error)
})
}
if (!shouldShowOfflineSubscription()) {
return null
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col mt-3 w-full">
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
<form onSubmit={handleSubscriptionCodeSubmit}>
<div className={'mt-2'}>
{!hasUserPreviouslyStoredCode && (
<DecoratedInput
onChange={(code) => setActivationCode(code)}
placeholder={'Offline Subscription Code'}
value={activationCode}
disabled={isSuccessfullyActivated}
className={'mb-3'}
/>
)}
</div>
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
<div className={'mt-3 mb-3 info'}>
Your offline subscription code has been successfully{' '}
{isSuccessfullyActivated ? 'activated' : 'removed'}.
</div>
)}
{hasUserPreviouslyStoredCode && (
<Button
dangerStyle={true}
label="Remove offline key"
onClick={() => {
handleRemoveClick().catch(console.error)
}}
/>
)}
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
<Button
label={'Submit'}
variant="primary"
disabled={activationCode === ''}
onClick={(event) => handleSubscriptionCodeSubmit(event)}
/>
)}
</form>
</div>
</div>
<HorizontalSeparator classes="mt-8 mb-5" />
</>
)
})

View File

@ -1,20 +1,20 @@
import { Button } from '@/components/Button'; import { Button } from '@/Components/Button/Button'
import { OtherSessionsSignOutContainer } from '@/components/OtherSessionsSignOut'; import { OtherSessionsSignOutContainer } from '@/Components/OtherSessionsSignOut'
import { import {
PreferencesGroup, PreferencesGroup,
PreferencesSegment, PreferencesSegment,
Subtitle, Subtitle,
Text, Text,
Title, Title,
} from '@/components/Preferences/components'; } from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
const SignOutView: FunctionComponent<{ const SignOutView: FunctionComponent<{
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
}> = observer(({ application, appState }) => { }> = observer(({ application, appState }) => {
return ( return (
<> <>
@ -30,7 +30,7 @@ const SignOutView: FunctionComponent<{
variant="normal" variant="normal"
label="Sign out other sessions" label="Sign out other sessions"
onClick={() => { onClick={() => {
appState.accountMenu.setOtherSessionsSignOut(true); appState.accountMenu.setOtherSessionsSignOut(true)
}} }}
/> />
<Button <Button
@ -42,57 +42,49 @@ const SignOutView: FunctionComponent<{
</PreferencesSegment> </PreferencesSegment>
<PreferencesSegment> <PreferencesSegment>
<Subtitle>This workspace</Subtitle> <Subtitle>This workspace</Subtitle>
<Text> <Text>Remove all data related to the current workspace from the application.</Text>
Remove all data related to the current workspace from the
application.
</Text>
<div className="min-h-3" /> <div className="min-h-3" />
<Button <Button
dangerStyle={true} dangerStyle={true}
label="Sign out workspace" label="Sign out workspace"
onClick={() => { onClick={() => {
appState.accountMenu.setSigningOut(true); appState.accountMenu.setSigningOut(true)
}} }}
/> />
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
<OtherSessionsSignOutContainer <OtherSessionsSignOutContainer appState={appState} application={application} />
appState={appState}
application={application}
/>
</> </>
); )
}); })
const ClearSessionDataView: FunctionComponent<{ const ClearSessionDataView: FunctionComponent<{
appState: AppState; appState: AppState
}> = observer(({ appState }) => { }> = observer(({ appState }) => {
return ( return (
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Clear workspace</Title> <Title>Clear workspace</Title>
<Text> <Text>Remove all data related to the current workspace from the application.</Text>
Remove all data related to the current workspace from the application.
</Text>
<div className="min-h-3" /> <div className="min-h-3" />
<Button <Button
dangerStyle={true} dangerStyle={true}
label="Clear workspace" label="Clear workspace"
onClick={() => { onClick={() => {
appState.accountMenu.setSigningOut(true); appState.accountMenu.setSigningOut(true)
}} }}
/> />
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); )
}); })
export const SignOutWrapper: FunctionComponent<{ export const SignOutWrapper: FunctionComponent<{
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
}> = observer(({ application, appState }) => { }> = observer(({ application, appState }) => {
if (!application.hasAccount()) { if (!application.hasAccount()) {
return <ClearSessionDataView appState={appState} />; return <ClearSessionDataView appState={appState} />
} }
return <SignOutView appState={appState} application={application} />; return <SignOutView appState={appState} application={application} />
}); })

View File

@ -0,0 +1,51 @@
import { FunctionalComponent } from 'preact'
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents'
import { Button } from '@/Components/Button/Button'
import { WebApplication } from '@/UIModels/Application'
import { useState } from 'preact/hooks'
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
export const NoSubscription: FunctionalComponent<{
application: WebApplication
}> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
const onPurchaseClick = async () => {
const errorMessage =
'There was an error when attempting to redirect you to the subscription page.'
setIsLoadingPurchaseFlow(true)
try {
if (!(await loadPurchaseFlowUrl(application))) {
setPurchaseFlowError(errorMessage)
}
} catch (e) {
setPurchaseFlowError(errorMessage)
} finally {
setIsLoadingPurchaseFlow(false)
}
}
return (
<>
<Text>You don't have a Standard Notes subscription yet.</Text>
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="color-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton
className="min-w-20 mt-3 mr-3"
label="Learn More"
link={window.plansUrl as string}
/>
{application.hasAccount() && (
<Button
className="min-w-20 mt-3"
variant="primary"
label="Subscribe"
onClick={onPurchaseClick}
/>
)}
</div>
</>
)
}

View File

@ -1,26 +1,22 @@
import { import { PreferencesGroup, PreferencesSegment, Title } from '@/Components/Preferences/PreferencesComponents'
PreferencesGroup, import { WebApplication } from '@/UIModels/Application'
PreferencesSegment, import { SubscriptionInformation } from './SubscriptionInformation'
Title, import { NoSubscription } from './NoSubscription'
} from '@/components/Preferences/components'; import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/ui_models/application'; import { FunctionComponent } from 'preact'
import { SubscriptionInformation } from './SubscriptionInformation'; import { AppState } from '@/UIModels/AppState'
import { NoSubscription } from './NoSubscription';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { AppState } from '@/ui_models/app_state';
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
}; }
export const Subscription: FunctionComponent<Props> = observer( export const Subscription: FunctionComponent<Props> = observer(
({ application, appState }: Props) => { ({ application, appState }: Props) => {
const subscriptionState = appState.subscription; const subscriptionState = appState.subscription
const { userSubscription } = subscriptionState; const { userSubscription } = subscriptionState
const now = new Date().getTime(); const now = new Date().getTime()
return ( return (
<PreferencesGroup> <PreferencesGroup>
@ -40,6 +36,6 @@ export const Subscription: FunctionComponent<Props> = observer(
</div> </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); )
} },
); )

View File

@ -0,0 +1,85 @@
import { observer } from 'mobx-react-lite'
import { SubscriptionState } from '@/UIModels/AppState/SubscriptionState'
import { Text } from '@/Components/Preferences/PreferencesComponents'
import { Button } from '@/Components/Button/Button'
import { WebApplication } from '@/UIModels/Application'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
type Props = {
subscriptionState: SubscriptionState
application: WebApplication
}
const StatusText = observer(
({ subscriptionState }: { subscriptionState: Props['subscriptionState'] }) => {
const {
userSubscriptionName,
userSubscriptionExpirationDate,
isUserSubscriptionExpired,
isUserSubscriptionCanceled,
} = subscriptionState
const expirationDateString = userSubscriptionExpirationDate?.toLocaleString()
if (isUserSubscriptionCanceled) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription has been canceled{' '}
{isUserSubscriptionExpired ? (
<span className="font-bold">and expired on {expirationDateString}</span>
) : (
<span className="font-bold">but will remain valid until {expirationDateString}</span>
)}
. You may resubscribe below if you wish.
</Text>
)
}
if (isUserSubscriptionExpired) {
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription <span className="font-bold">expired on {expirationDateString}</span>. You may
resubscribe below if you wish.
</Text>
)
}
return (
<Text className="mt-1">
Your{' '}
<span className="font-bold">
Standard Notes{userSubscriptionName ? ' ' : ''}
{userSubscriptionName}
</span>{' '}
subscription will be <span className="font-bold">renewed on {expirationDateString}</span>.
</Text>
)
},
)
export const SubscriptionInformation = observer(({ subscriptionState, application }: Props) => {
const manageSubscription = async () => {
openSubscriptionDashboard(application)
}
return (
<>
<StatusText subscriptionState={subscriptionState} />
<Button
className="min-w-20 mt-3 mr-3"
variant="normal"
label="Manage subscription"
onClick={manageSubscription}
/>
</>
)
})

View File

@ -0,0 +1,65 @@
import {
PreferencesGroup,
PreferencesSegment,
Text,
Title,
} from '@/Components/Preferences/PreferencesComponents'
import { Button } from '@/Components/Button/Button'
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
import { useState } from '@node_modules/preact/hooks'
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/UIModels/Application'
import { FunctionComponent } from 'preact'
type Props = {
application: WebApplication
}
export const formatLastSyncDate = (lastUpdatedDate: Date) => {
return dateToLocalizedString(lastUpdatedDate)
}
export const Sync: FunctionComponent<Props> = observer(({ application }: Props) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.sync.getLastSyncDate() as Date),
)
const doSynchronization = async () => {
setIsSyncingInProgress(true)
const response = await application.sync.sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true,
})
setIsSyncingInProgress(false)
if (response && (response as any).error) {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
} else {
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
}
}
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Sync</Title>
<Text>
Last synced <span className="font-bold">on {lastSyncDate}</span>
</Text>
<Button
className="min-w-20 mt-3"
variant="normal"
label="Sync now"
disabled={isSyncingInProgress}
onClick={doSynchronization}
/>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
})

View File

@ -0,0 +1,29 @@
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
import { observer } from 'mobx-react-lite'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { Authentication } from './Authentication'
import { Credentials } from './Credentials'
import { Sync } from './Sync'
import { Subscription } from './Subscription/Subscription'
import { SignOutWrapper } from './SignOutView'
type Props = {
application: WebApplication
appState: AppState
}
export const AccountPreferences = observer(({ application, appState }: Props) => (
<PreferencesPane>
{!application.hasAccount() ? (
<Authentication application={application} appState={appState} />
) : (
<>
<Credentials application={application} appState={appState} />
<Sync application={application} />
</>
)}
<Subscription application={application} appState={appState} />
<SignOutWrapper application={application} appState={appState} />
</PreferencesPane>
))

View File

@ -0,0 +1,169 @@
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
import { Switch } from '@/Components/Switch'
import { WebApplication } from '@/UIModels/Application'
import {
ContentType,
FeatureIdentifier,
FeatureStatus,
PrefKey,
GetFeatures,
SNTheme,
} from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import {
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
Subtitle,
Title,
Text,
} from '@/Components/Preferences/PreferencesComponents'
import { sortThemes } from '@/Utils/SortThemes'
type Props = {
application: WebApplication
}
export const Appearance: FunctionComponent<Props> = observer(({ application }) => {
const premiumModal = usePremiumModal()
const isEntitledToMidnightTheme =
application.features.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled
const [themeItems, setThemeItems] = useState<DropdownItem[]>([])
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() => application.getPreference(PrefKey.AutoLightThemeIdentifier, 'Default') as string,
)
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default',
) as string,
)
const [useDeviceSettings, setUseDeviceSettings] = useState(
() => application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean,
)
useEffect(() => {
const themesAsItems: DropdownItem[] = application.items
.getDisplayableItems<SNTheme>(ContentType.Theme)
.filter((theme) => !theme.isLayerable())
.sort(sortThemes)
.map((theme) => {
return {
label: theme.name,
value: theme.identifier as string,
}
})
GetFeatures()
.filter((feature) => feature.content_type === ContentType.Theme && !feature.layerable)
.forEach((theme) => {
if (themesAsItems.findIndex((item) => item.value === theme.identifier) === -1) {
themesAsItems.push({
label: theme.name as string,
value: theme.identifier,
icon: 'premium-feature',
})
}
})
themesAsItems.unshift({
label: 'Default',
value: 'Default',
})
setThemeItems(themesAsItems)
}, [application])
const toggleUseDeviceSettings = () => {
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings).catch(console.error)
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
application
.setPreference(PrefKey.AutoLightThemeIdentifier, autoLightTheme as FeatureIdentifier)
.catch(console.error)
}
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application
.setPreference(PrefKey.AutoDarkThemeIdentifier, autoDarkTheme as FeatureIdentifier)
.catch(console.error)
}
setUseDeviceSettings(!useDeviceSettings)
}
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`)
} else {
application
.setPreference(PrefKey.AutoLightThemeIdentifier, value as FeatureIdentifier)
.catch(console.error)
setAutoLightTheme(value)
}
}
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`)
} else {
application
.setPreference(PrefKey.AutoDarkThemeIdentifier, value as FeatureIdentifier)
.catch(console.error)
setAutoDarkTheme(value)
}
}
return (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Themes</Title>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Use system color scheme</Subtitle>
<Text>Automatically change active theme based on your system settings.</Text>
</div>
<Switch onChange={toggleUseDeviceSettings} checked={useDeviceSettings} />
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Light Theme</Subtitle>
<Text>Theme to be used for system light mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-light-theme-dropdown"
label="Select the automatic light theme"
items={themeItems}
value={autoLightTheme}
onChange={changeAutoLightTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Dark Theme</Subtitle>
<Text>Theme to be used for system dark mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-dark-theme-dropdown"
label="Select the automatic dark theme"
items={themeItems}
value={autoDarkTheme}
onChange={changeAutoDarkTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
)
})

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks'
import { import {
ButtonType, ButtonType,
SettingName, SettingName,
@ -6,34 +6,32 @@ import {
DropboxBackupFrequency, DropboxBackupFrequency,
GoogleDriveBackupFrequency, GoogleDriveBackupFrequency,
OneDriveBackupFrequency, OneDriveBackupFrequency,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { Button } from '@/components/Button'; import { Button } from '@/Components/Button/Button'
import { isDev, openInNewTab } from '@/utils'; import { isDev, openInNewTab } from '@/Utils'
import { Subtitle } from '@/components/Preferences/components'; import { Subtitle } from '@/Components/Preferences/PreferencesComponents'
import { KeyboardKey } from '@Services/ioService'; import { KeyboardKey } from '@/Services/IOService'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
type Props = { type Props = {
application: WebApplication; application: WebApplication
providerName: CloudProvider; providerName: CloudProvider
isEntitledToCloudBackups: boolean; isEntitledToCloudBackups: boolean
}; }
export const CloudBackupProvider: FunctionComponent<Props> = ({ export const CloudBackupProvider: FunctionComponent<Props> = ({
application, application,
providerName, providerName,
isEntitledToCloudBackups, isEntitledToCloudBackups,
}) => { }) => {
const [authBegan, setAuthBegan] = useState(false); const [authBegan, setAuthBegan] = useState(false)
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false); const [successfullyInstalled, setSuccessfullyInstalled] = useState(false)
const [backupFrequency, setBackupFrequency] = useState<string | undefined>( const [backupFrequency, setBackupFrequency] = useState<string | undefined>(undefined)
undefined const [confirmation, setConfirmation] = useState('')
);
const [confirmation, setConfirmation] = useState('');
const disable = async (event: Event) => { const disable = async (event: Event) => {
event.stopPropagation(); event.stopPropagation()
try { try {
const shouldDisable = await application.alertService.confirm( const shouldDisable = await application.alertService.confirm(
@ -41,49 +39,48 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
'Disable?', 'Disable?',
'Disable', 'Disable',
ButtonType.Danger, ButtonType.Danger,
'Cancel' 'Cancel',
); )
if (shouldDisable) { if (shouldDisable) {
await application.settings.deleteSetting(backupFrequencySettingName); await application.settings.deleteSetting(backupFrequencySettingName)
await application.settings.deleteSetting(backupTokenSettingName); await application.settings.deleteSetting(backupTokenSettingName)
setBackupFrequency(undefined); setBackupFrequency(undefined)
} }
} catch (error) { } catch (error) {
application.alertService.alert(error as string); application.alertService.alert(error as string).catch(console.error)
} }
}; }
const installIntegration = (event: Event) => { const installIntegration = (event: Event) => {
if (!isEntitledToCloudBackups) { if (!isEntitledToCloudBackups) {
return; return
} }
event.stopPropagation(); event.stopPropagation()
const authUrl = application.getCloudProviderIntegrationUrl( const authUrl = application.getCloudProviderIntegrationUrl(providerName, isDev)
providerName, openInNewTab(authUrl)
isDev setAuthBegan(true)
); }
openInNewTab(authUrl);
setAuthBegan(true);
};
const performBackupNow = async () => { const performBackupNow = async () => {
// A backup is performed anytime the setting is updated with the integration token, so just update it here // A backup is performed anytime the setting is updated with the integration token, so just update it here
try { try {
await application.settings.updateSetting( await application.settings.updateSetting(
backupFrequencySettingName, backupFrequencySettingName,
backupFrequency as string backupFrequency as string,
); )
application.alertService.alert( void application.alertService.alert(
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.' 'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.',
); )
} catch (err) { } catch (err) {
application.alertService.alert( application.alertService
'There was an error while trying to trigger a backup for this provider. Please try again.' .alert(
); 'There was an error while trying to trigger a backup for this provider. Please try again.',
)
.catch(console.error)
} }
}; }
const backupSettingsData = { const backupSettingsData = {
[CloudProvider.Dropbox]: { [CloudProvider.Dropbox]: {
@ -101,115 +98,96 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
backupFrequencySettingName: SettingName.OneDriveBackupFrequency, backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
defaultBackupFrequency: OneDriveBackupFrequency.Daily, defaultBackupFrequency: OneDriveBackupFrequency.Daily,
}, },
}; }
const { const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } =
backupTokenSettingName, backupSettingsData[providerName]
backupFrequencySettingName,
defaultBackupFrequency,
} = backupSettingsData[providerName];
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => { const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
const urlSearchParams = new URLSearchParams(url.search); const urlSearchParams = new URLSearchParams(url.search)
let integrationTokenKeyInUrl = ''; let integrationTokenKeyInUrl = ''
switch (providerName) { switch (providerName) {
case CloudProvider.Dropbox: case CloudProvider.Dropbox:
integrationTokenKeyInUrl = 'dbt'; integrationTokenKeyInUrl = 'dbt'
break; break
case CloudProvider.Google: case CloudProvider.Google:
integrationTokenKeyInUrl = 'key'; integrationTokenKeyInUrl = 'key'
break; break
case CloudProvider.OneDrive: case CloudProvider.OneDrive:
integrationTokenKeyInUrl = 'key'; integrationTokenKeyInUrl = 'key'
break; break
default: default:
throw new Error('Invalid Cloud Provider name'); throw new Error('Invalid Cloud Provider name')
} }
return urlSearchParams.get(integrationTokenKeyInUrl); return urlSearchParams.get(integrationTokenKeyInUrl)
}; }
const handleKeyPress = async (event: KeyboardEvent) => { const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
try { try {
const decryptedCode = atob(confirmation); const decryptedCode = atob(confirmation)
const urlFromDecryptedCode = new URL(decryptedCode); const urlFromDecryptedCode = new URL(decryptedCode)
const cloudProviderToken = const cloudProviderToken = getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode)
getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode);
if (!cloudProviderToken) { if (!cloudProviderToken) {
throw new Error(); throw new Error()
} }
await application.settings.updateSetting( await application.settings.updateSetting(backupTokenSettingName, cloudProviderToken)
backupTokenSettingName, await application.settings.updateSetting(backupFrequencySettingName, defaultBackupFrequency)
cloudProviderToken
);
await application.settings.updateSetting(
backupFrequencySettingName,
defaultBackupFrequency
);
setBackupFrequency(defaultBackupFrequency); setBackupFrequency(defaultBackupFrequency)
setAuthBegan(false); setAuthBegan(false)
setSuccessfullyInstalled(true); setSuccessfullyInstalled(true)
setConfirmation(''); setConfirmation('')
await application.alertService.alert( await application.alertService.alert(
`${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.` `${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`,
); )
} catch (e) { } catch (e) {
await application.alertService.alert('Invalid code. Please try again.'); await application.alertService.alert('Invalid code. Please try again.')
} }
} }
}; }
const handleChange = (event: Event) => { const handleChange = (event: Event) => {
setConfirmation((event.target as HTMLInputElement).value); setConfirmation((event.target as HTMLInputElement).value)
}; }
const getIntegrationStatus = useCallback(async () => { const getIntegrationStatus = useCallback(async () => {
if (!application.getUser()) { if (!application.getUser()) {
return; return
} }
const frequency = await application.settings.getSetting( const frequency = await application.settings.getSetting(backupFrequencySettingName)
backupFrequencySettingName setBackupFrequency(frequency)
); }, [application, backupFrequencySettingName])
setBackupFrequency(frequency);
}, [application, backupFrequencySettingName]);
useEffect(() => { useEffect(() => {
getIntegrationStatus(); getIntegrationStatus().catch(console.error)
}, [getIntegrationStatus]); }, [getIntegrationStatus])
const isExpanded = authBegan || successfullyInstalled; const isExpanded = authBegan || successfullyInstalled
const shouldShowEnableButton = !backupFrequency && !authBegan; const shouldShowEnableButton = !backupFrequency && !authBegan
const additionalClass = isEntitledToCloudBackups const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
? ''
: 'faded cursor-default pointer-events-none';
return ( return (
<div <div
className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${ className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${
shouldShowEnableButton || backupFrequency shouldShowEnableButton || backupFrequency ? 'flex justify-between items-center' : ''
? 'flex justify-between items-center'
: ''
}`} }`}
> >
<div> <div>
<Subtitle className={additionalClass}>{providerName}</Subtitle> <Subtitle className={additionalClass}>{providerName}</Subtitle>
{successfullyInstalled && ( {successfullyInstalled && <p>{providerName} has been successfully enabled.</p>}
<p>{providerName} has been successfully enabled.</p>
)}
</div> </div>
{authBegan && ( {authBegan && (
<div> <div>
<p className="sk-panel-row"> <p className="sk-panel-row">
Complete authentication from the newly opened window. Upon Complete authentication from the newly opened window. Upon completion, a confirmation
completion, a confirmation code will be displayed. Enter this code code will be displayed. Enter this code below:
below:
</p> </p>
<div className={`mt-1`}> <div className={'mt-1'}>
<input <input
className="sk-input sk-base center-text" className="sk-input sk-base center-text"
placeholder="Enter confirmation code" placeholder="Enter confirmation code"
@ -240,14 +218,9 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
label="Perform Backup" label="Perform Backup"
onClick={performBackupNow} onClick={performBackupNow}
/> />
<Button <Button className="min-w-40" variant="normal" label="Disable" onClick={disable} />
className="min-w-40"
variant="normal"
label="Disable"
onClick={disable}
/>
</div> </div>
)} )}
</div> </div>
); )
}; }

View File

@ -1,118 +1,111 @@
import { CloudBackupProvider } from './CloudBackupProvider'; import { CloudBackupProvider } from './CloudBackupProvider'
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { import {
PreferencesGroup, PreferencesGroup,
PreferencesSegment, PreferencesSegment,
Subtitle, Subtitle,
Text, Text,
Title, Title,
} from '@/components/Preferences/components'; } from '@/Components/Preferences/PreferencesComponents'
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator'; import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
import { import {
FeatureStatus, FeatureStatus,
FeatureIdentifier, FeatureIdentifier,
CloudProvider, CloudProvider,
MuteFailedCloudBackupsEmailsOption, MuteFailedCloudBackupsEmailsOption,
SettingName, SettingName,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Switch } from '@/components/Switch'; import { Switch } from '@/Components/Switch'
import { convertStringifiedBooleanToBoolean } from '@/utils'; import { convertStringifiedBooleanToBoolean } from '@/Utils'
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings'; import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Strings'
const providerData = [ const providerData = [
{ name: CloudProvider.Dropbox }, { name: CloudProvider.Dropbox },
{ name: CloudProvider.Google }, { name: CloudProvider.Google },
{ name: CloudProvider.OneDrive }, { name: CloudProvider.OneDrive },
]; ]
type Props = { type Props = {
application: WebApplication; application: WebApplication
}; }
export const CloudLink: FunctionComponent<Props> = ({ application }) => { export const CloudLink: FunctionComponent<Props> = ({ application }) => {
const [isEntitledToCloudBackups, setIsEntitledToCloudBackups] = const [isEntitledToCloudBackups, setIsEntitledToCloudBackups] = useState(false)
useState(false); const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true)
const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = const [isLoading, setIsLoading] = useState(false)
useState(true); const additionalClass = isEntitledToCloudBackups ? '' : 'faded cursor-default pointer-events-none'
const [isLoading, setIsLoading] = useState(false);
const additionalClass = isEntitledToCloudBackups
? ''
: 'faded cursor-default pointer-events-none';
const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => { const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
if (!application.getUser()) { if (!application.getUser()) {
return; return
} }
setIsLoading(true); setIsLoading(true)
try { try {
const userSettings = await application.settings.listSettings(); const userSettings = await application.settings.listSettings()
setIsFailedCloudBackupEmailMuted( setIsFailedCloudBackupEmailMuted(
convertStringifiedBooleanToBoolean( convertStringifiedBooleanToBoolean(
userSettings.getSettingValue( userSettings.getSettingValue(
SettingName.MuteFailedCloudBackupsEmails, SettingName.MuteFailedCloudBackupsEmails,
MuteFailedCloudBackupsEmailsOption.NotMuted MuteFailedCloudBackupsEmailsOption.NotMuted,
) ),
) ),
); )
} catch (error) { } catch (error) {
console.error(error); console.error(error)
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}, [application]); }, [application])
useEffect(() => { useEffect(() => {
const dailyDropboxBackupStatus = application.features.getFeatureStatus( const dailyDropboxBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyDropboxBackup FeatureIdentifier.DailyDropboxBackup,
); )
const dailyGdriveBackupStatus = application.features.getFeatureStatus( const dailyGdriveBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyGDriveBackup FeatureIdentifier.DailyGDriveBackup,
); )
const dailyOneDriveBackupStatus = application.features.getFeatureStatus( const dailyOneDriveBackupStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyOneDriveBackup FeatureIdentifier.DailyOneDriveBackup,
); )
const isCloudBackupsAllowed = [ const isCloudBackupsAllowed = [
dailyDropboxBackupStatus, dailyDropboxBackupStatus,
dailyGdriveBackupStatus, dailyGdriveBackupStatus,
dailyOneDriveBackupStatus, dailyOneDriveBackupStatus,
].every((status) => status === FeatureStatus.Entitled); ].every((status) => status === FeatureStatus.Entitled)
setIsEntitledToCloudBackups(isCloudBackupsAllowed); setIsEntitledToCloudBackups(isCloudBackupsAllowed)
loadIsFailedCloudBackupEmailMutedSetting(); loadIsFailedCloudBackupEmailMutedSetting().catch(console.error)
}, [application, loadIsFailedCloudBackupEmailMutedSetting]); }, [application, loadIsFailedCloudBackupEmailMutedSetting])
const updateSetting = async ( const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
settingName: SettingName,
payload: string
): Promise<boolean> => {
try { try {
await application.settings.updateSetting(settingName, payload); await application.settings.updateSetting(settingName, payload)
return true; return true
} catch (e) { } catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING); application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
return false; return false
} }
}; }
const toggleMuteFailedCloudBackupEmails = async () => { const toggleMuteFailedCloudBackupEmails = async () => {
if (!isEntitledToCloudBackups) { if (!isEntitledToCloudBackups) {
return; return
} }
const previousValue = isFailedCloudBackupEmailMuted; const previousValue = isFailedCloudBackupEmailMuted
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted); setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted)
const updateResult = await updateSetting( const updateResult = await updateSetting(
SettingName.MuteFailedCloudBackupsEmails, SettingName.MuteFailedCloudBackupsEmails,
`${!isFailedCloudBackupEmailMuted}` `${!isFailedCloudBackupEmailMuted}`,
); )
if (!updateResult) { if (!updateResult) {
setIsFailedCloudBackupEmailMuted(previousValue); setIsFailedCloudBackupEmailMuted(previousValue)
} }
}; }
return ( return (
<PreferencesGroup> <PreferencesGroup>
@ -122,8 +115,8 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
<> <>
<Text> <Text>
A <span className={'font-bold'}>Plus</span> or{' '} A <span className={'font-bold'}>Plus</span> or{' '}
<span className={'font-bold'}>Pro</span> subscription plan is <span className={'font-bold'}>Pro</span> subscription plan is required to enable Cloud
required to enable Cloud Backups.{' '} Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features"> <a target="_blank" href="https://standardnotes.com/features">
Learn more Learn more
</a> </a>
@ -134,8 +127,8 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
)} )}
<div> <div>
<Text className={additionalClass}> <Text className={additionalClass}>
Configure the integrations below to enable automatic daily backups Configure the integrations below to enable automatic daily backups of your encrypted
of your encrypted data set to your third-party cloud provider. data set to your third-party cloud provider.
</Text> </Text>
<div> <div>
<HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} /> <HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
@ -147,9 +140,7 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
providerName={name} providerName={name}
isEntitledToCloudBackups={isEntitledToCloudBackups} isEntitledToCloudBackups={isEntitledToCloudBackups}
/> />
<HorizontalSeparator <HorizontalSeparator classes={`mt-3 mb-3 ${additionalClass}`} />
classes={`mt-3 mb-3 ${additionalClass}`}
/>
</> </>
))} ))}
</div> </div>
@ -159,9 +150,7 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
<Subtitle>Email preferences</Subtitle> <Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between mt-1"> <div className="flex items-center justify-between mt-1">
<div className="flex flex-col"> <div className="flex flex-col">
<Text> <Text>Receive a notification email if a cloud backup fails.</Text>
Receive a notification email if a cloud backup fails.
</Text>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className={'sk-spinner info small'} /> <div className={'sk-spinner info small'} />
@ -177,5 +166,5 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
</div> </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); )
}; }

View File

@ -1,5 +1,5 @@
import { isDesktopApplication } from '@/utils'; import { isDesktopApplication } from '@/Utils'
import { alertDialog } from '@Services/alertService'; import { alertDialog } from '@/Services/AlertService'
import { import {
STRING_IMPORT_SUCCESS, STRING_IMPORT_SUCCESS,
STRING_INVALID_IMPORT_FILE, STRING_INVALID_IMPORT_FILE,
@ -9,158 +9,142 @@ import {
STRING_E2E_ENABLED, STRING_E2E_ENABLED,
STRING_LOCAL_ENC_ENABLED, STRING_LOCAL_ENC_ENABLED,
STRING_ENC_NOT_ENABLED, STRING_ENC_NOT_ENABLED,
} from '@/strings'; } from '@/Strings'
import { BackupFile } from '@standardnotes/snjs'; import { BackupFile } from '@standardnotes/snjs'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/UIModels/Application'
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx'
import TargetedEvent = JSXInternal.TargetedEvent; import TargetedEvent = JSXInternal.TargetedEvent
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite'
import { import { PreferencesGroup, PreferencesSegment, Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents'
PreferencesGroup, import { Button } from '@/Components/Button/Button'
PreferencesSegment,
Title,
Text,
Subtitle,
} from '../../components';
import { Button } from '@/components/Button';
type Props = { type Props = {
application: WebApplication; application: WebApplication
appState: AppState; appState: AppState
}; }
export const DataBackups = observer(({ application, appState }: Props) => { export const DataBackups = observer(({ application, appState }: Props) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null)
const [isImportDataLoading, setIsImportDataLoading] = useState(false); const [isImportDataLoading, setIsImportDataLoading] = useState(false)
const { const {
isBackupEncrypted, isBackupEncrypted,
isEncryptionEnabled, isEncryptionEnabled,
setIsBackupEncrypted, setIsBackupEncrypted,
setIsEncryptionEnabled, setIsEncryptionEnabled,
setEncryptionStatusString, setEncryptionStatusString,
} = appState.accountMenu; } = appState.accountMenu
const refreshEncryptionStatus = useCallback(() => { const refreshEncryptionStatus = useCallback(() => {
const hasUser = application.hasAccount(); const hasUser = application.hasAccount()
const hasPasscode = application.hasPasscode(); const hasPasscode = application.hasPasscode()
const encryptionEnabled = hasUser || hasPasscode; const encryptionEnabled = hasUser || hasPasscode
const encryptionStatusString = hasUser const encryptionStatusString = hasUser
? STRING_E2E_ENABLED ? STRING_E2E_ENABLED
: hasPasscode : hasPasscode
? STRING_LOCAL_ENC_ENABLED ? STRING_LOCAL_ENC_ENABLED
: STRING_ENC_NOT_ENABLED; : STRING_ENC_NOT_ENABLED
setEncryptionStatusString(encryptionStatusString); setEncryptionStatusString(encryptionStatusString)
setIsEncryptionEnabled(encryptionEnabled); setIsEncryptionEnabled(encryptionEnabled)
setIsBackupEncrypted(encryptionEnabled); setIsBackupEncrypted(encryptionEnabled)
}, [ }, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled])
application,
setEncryptionStatusString,
setIsBackupEncrypted,
setIsEncryptionEnabled,
]);
useEffect(() => { useEffect(() => {
refreshEncryptionStatus(); refreshEncryptionStatus()
}, [refreshEncryptionStatus]); }, [refreshEncryptionStatus])
const downloadDataArchive = () => { const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted); application.getArchiveService().downloadBackup(isBackupEncrypted).catch(console.error)
}; }
const readFile = async (file: File): Promise<any> => { const readFile = async (file: File): Promise<any> => {
if (file.type === 'application/zip') { if (file.type === 'application/zip') {
application.alertService.alert(STRING_IMPORTING_ZIP_FILE); application.alertService.alert(STRING_IMPORTING_ZIP_FILE).catch(console.error)
return; return
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const data = JSON.parse(e.target!.result as string); const data = JSON.parse(e.target?.result as string)
resolve(data); resolve(data)
} catch (e) { } catch (e) {
application.alertService.alert(STRING_INVALID_IMPORT_FILE); application.alertService.alert(STRING_INVALID_IMPORT_FILE).catch(console.error)
} }
}; }
reader.readAsText(file); reader.readAsText(file)
}); })
}; }
const performImport = async (data: BackupFile) => { const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true); setIsImportDataLoading(true)
const result = await application.mutator.importData(data); const result = await application.mutator.importData(data)
setIsImportDataLoading(false); setIsImportDataLoading(false)
if (!result) { if (!result) {
return; return
} }
let statusText = STRING_IMPORT_SUCCESS; let statusText = STRING_IMPORT_SUCCESS
if ('error' in result) { if ('error' in result) {
statusText = result.error.text; statusText = result.error.text
} else if (result.errorCount) { } else if (result.errorCount) {
statusText = StringImportError(result.errorCount); statusText = StringImportError(result.errorCount)
} }
void alertDialog({ void alertDialog({
text: statusText, text: statusText,
}); })
}; }
const importFileSelected = async ( const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
event: TargetedEvent<HTMLInputElement, Event> const { files } = event.target as HTMLInputElement
) => {
const { files } = event.target as HTMLInputElement;
if (!files) { if (!files) {
return; return
} }
const file = files[0]; const file = files[0]
const data = await readFile(file); const data = await readFile(file)
if (!data) { if (!data) {
return; return
} }
const version = const version = data.version || data.keyParams?.version || data.auth_params?.version
data.version || data.keyParams?.version || data.auth_params?.version;
if (!version) { if (!version) {
await performImport(data); await performImport(data)
return; return
} }
if (application.protocolService.supportedVersions().includes(version)) { if (application.protocolService.supportedVersions().includes(version)) {
await performImport(data); await performImport(data)
} else { } else {
setIsImportDataLoading(false); setIsImportDataLoading(false)
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION }); void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION })
} }
}; }
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import // Whenever "Import Backup" is either clicked or key-pressed, proceed the import
const handleImportFile = ( const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent
) => {
if (event instanceof KeyboardEvent) { if (event instanceof KeyboardEvent) {
const { code } = event; const { code } = event
// Process only when "Enter" or "Space" keys are pressed // Process only when "Enter" or "Space" keys are pressed
if (code !== 'Enter' && code !== 'Space') { if (code !== 'Enter' && code !== 'Space') {
return; return
} }
// Don't proceed the event's default action // Don't proceed the event's default action
// (like scrolling in case the "space" key is pressed) // (like scrolling in case the "space" key is pressed)
event.preventDefault(); event.preventDefault()
} }
(fileInputRef.current as HTMLInputElement).click(); ;(fileInputRef.current as HTMLInputElement).click()
}; }
return ( return (
<> <>
@ -170,8 +154,8 @@ export const DataBackups = observer(({ application, appState }: Props) => {
{!isDesktopApplication() && ( {!isDesktopApplication() && (
<Text className="mb-3"> <Text className="mb-3">
Backups are automatically created on desktop and can be managed Backups are automatically created on desktop and can be managed via the "Backups"
via the "Backups" top-level menu. top-level menu.
</Text> </Text>
)} )}
@ -211,23 +195,17 @@ export const DataBackups = observer(({ application, appState }: Props) => {
<Subtitle>Import a previously saved backup file</Subtitle> <Subtitle>Import a previously saved backup file</Subtitle>
<div class="flex flex-row items-center mt-3"> <div class="flex flex-row items-center mt-3">
<Button <Button variant="normal" label="Import backup" onClick={handleImportFile} />
variant="normal"
label="Import backup"
onClick={handleImportFile}
/>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={importFileSelected} onChange={importFileSelected}
className="hidden" className="hidden"
/> />
{isImportDataLoading && ( {isImportDataLoading && <div className="sk-spinner normal info ml-4" />}
<div className="sk-spinner normal info ml-4" />
)}
</div> </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
</> </>
); )
}); })

View File

@ -1,148 +1,123 @@
import { import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/Utils'
convertStringifiedBooleanToBoolean, import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Strings'
isDesktopApplication, import { useCallback, useEffect, useState } from 'preact/hooks'
} from '@/utils'; import { WebApplication } from '@/UIModels/Application'
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings'; import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useState } from 'preact/hooks'; import { PreferencesGroup, PreferencesSegment, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/ui_models/application'; import { Dropdown, DropdownItem } from '@/Components/Dropdown'
import { observer } from 'mobx-react-lite'; import { Switch } from '@/Components/Switch'
import { import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '../../components';
import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { Switch } from '@/components/Switch';
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
import { import {
FeatureStatus, FeatureStatus,
FeatureIdentifier, FeatureIdentifier,
EmailBackupFrequency, EmailBackupFrequency,
MuteFailedBackupsEmailsOption, MuteFailedBackupsEmailsOption,
SettingName, SettingName,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs'
type Props = { type Props = {
application: WebApplication; application: WebApplication
}; }
export const EmailBackups = observer(({ application }: Props) => { export const EmailBackups = observer(({ application }: Props) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false)
const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>( const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>(
EmailBackupFrequency.Disabled EmailBackupFrequency.Disabled,
); )
const [emailFrequencyOptions, setEmailFrequencyOptions] = useState< const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<DropdownItem[]>([])
DropdownItem[] const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] = useState(true)
>([]); const [isEntitledToEmailBackups, setIsEntitledToEmailBackups] = useState(false)
const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] =
useState(true);
const [isEntitledToEmailBackups, setIsEntitledToEmailBackups] =
useState(false);
const loadEmailFrequencySetting = useCallback(async () => { const loadEmailFrequencySetting = useCallback(async () => {
if (!application.getUser()) { if (!application.getUser()) {
return; return
} }
setIsLoading(true); setIsLoading(true)
try { try {
const userSettings = await application.settings.listSettings(); const userSettings = await application.settings.listSettings()
setEmailFrequency( setEmailFrequency(
userSettings.getSettingValue<EmailBackupFrequency>( userSettings.getSettingValue<EmailBackupFrequency>(
SettingName.EmailBackupFrequency, SettingName.EmailBackupFrequency,
EmailBackupFrequency.Disabled EmailBackupFrequency.Disabled,
) ),
); )
setIsFailedBackupEmailMuted( setIsFailedBackupEmailMuted(
convertStringifiedBooleanToBoolean( convertStringifiedBooleanToBoolean(
userSettings.getSettingValue<MuteFailedBackupsEmailsOption>( userSettings.getSettingValue<MuteFailedBackupsEmailsOption>(
SettingName.MuteFailedBackupsEmails, SettingName.MuteFailedBackupsEmails,
MuteFailedBackupsEmailsOption.NotMuted MuteFailedBackupsEmailsOption.NotMuted,
) ),
) ),
); )
} catch (error) { } catch (error) {
console.error(error); console.error(error)
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}, [application]); }, [application])
useEffect(() => { useEffect(() => {
const emailBackupsFeatureStatus = application.features.getFeatureStatus( const emailBackupsFeatureStatus = application.features.getFeatureStatus(
FeatureIdentifier.DailyEmailBackup FeatureIdentifier.DailyEmailBackup,
); )
setIsEntitledToEmailBackups( setIsEntitledToEmailBackups(emailBackupsFeatureStatus === FeatureStatus.Entitled)
emailBackupsFeatureStatus === FeatureStatus.Entitled
);
const frequencyOptions = []; const frequencyOptions = []
for (const frequency in EmailBackupFrequency) { for (const frequency in EmailBackupFrequency) {
const frequencyValue = const frequencyValue = EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency]
EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency];
frequencyOptions.push({ frequencyOptions.push({
value: frequencyValue, value: frequencyValue,
label: label: application.settings.getEmailBackupFrequencyOptionLabel(frequencyValue),
application.settings.getEmailBackupFrequencyOptionLabel( })
frequencyValue
),
});
} }
setEmailFrequencyOptions(frequencyOptions); setEmailFrequencyOptions(frequencyOptions)
loadEmailFrequencySetting(); loadEmailFrequencySetting().catch(console.error)
}, [application, loadEmailFrequencySetting]); }, [application, loadEmailFrequencySetting])
const updateSetting = async ( const updateSetting = async (settingName: SettingName, payload: string): Promise<boolean> => {
settingName: SettingName,
payload: string
): Promise<boolean> => {
try { try {
await application.settings.updateSetting(settingName, payload, false); await application.settings.updateSetting(settingName, payload, false)
return true; return true
} catch (e) { } catch (e) {
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING); application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING).catch(console.error)
return false; return false
} }
}; }
const updateEmailFrequency = async (frequency: EmailBackupFrequency) => { const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
const previousFrequency = emailFrequency; const previousFrequency = emailFrequency
setEmailFrequency(frequency); setEmailFrequency(frequency)
const updateResult = await updateSetting( const updateResult = await updateSetting(SettingName.EmailBackupFrequency, frequency)
SettingName.EmailBackupFrequency,
frequency
);
if (!updateResult) { if (!updateResult) {
setEmailFrequency(previousFrequency); setEmailFrequency(previousFrequency)
} }
}; }
const toggleMuteFailedBackupEmails = async () => { const toggleMuteFailedBackupEmails = async () => {
if (!isEntitledToEmailBackups) { if (!isEntitledToEmailBackups) {
return; return
} }
const previousValue = isFailedBackupEmailMuted; const previousValue = isFailedBackupEmailMuted
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted); setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted)
const updateResult = await updateSetting( const updateResult = await updateSetting(
SettingName.MuteFailedBackupsEmails, SettingName.MuteFailedBackupsEmails,
`${!isFailedBackupEmailMuted}` `${!isFailedBackupEmailMuted}`,
); )
if (!updateResult) { if (!updateResult) {
setIsFailedBackupEmailMuted(previousValue); setIsFailedBackupEmailMuted(previousValue)
} }
}; }
const handleEmailFrequencyChange = (item: string) => { const handleEmailFrequencyChange = (item: string) => {
if (!isEntitledToEmailBackups) { if (!isEntitledToEmailBackups) {
return; return
} }
updateEmailFrequency(item as EmailBackupFrequency); updateEmailFrequency(item as EmailBackupFrequency).catch(console.error)
}; }
return ( return (
<PreferencesGroup> <PreferencesGroup>
@ -152,8 +127,8 @@ export const EmailBackups = observer(({ application }: Props) => {
<> <>
<Text> <Text>
A <span className={'font-bold'}>Plus</span> or{' '} A <span className={'font-bold'}>Plus</span> or{' '}
<span className={'font-bold'}>Pro</span> subscription plan is <span className={'font-bold'}>Pro</span> subscription plan is required to enable Email
required to enable Email Backups.{' '} Backups.{' '}
<a target="_blank" href="https://standardnotes.com/features"> <a target="_blank" href="https://standardnotes.com/features">
Learn more Learn more
</a> </a>
@ -162,17 +137,11 @@ export const EmailBackups = observer(({ application }: Props) => {
<HorizontalSeparator classes="mt-3 mb-3" /> <HorizontalSeparator classes="mt-3 mb-3" />
</> </>
)} )}
<div <div className={isEntitledToEmailBackups ? '' : 'faded cursor-default pointer-events-none'}>
className={
isEntitledToEmailBackups
? ''
: 'faded cursor-default pointer-events-none'
}
>
{!isDesktopApplication() && ( {!isDesktopApplication() && (
<Text className="mb-3"> <Text className="mb-3">
Daily encrypted email backups of your entire data set delivered Daily encrypted email backups of your entire data set delivered directly to your
directly to your inbox. inbox.
</Text> </Text>
)} )}
<Subtitle>Email frequency</Subtitle> <Subtitle>Email frequency</Subtitle>
@ -195,9 +164,7 @@ export const EmailBackups = observer(({ application }: Props) => {
<Subtitle>Email preferences</Subtitle> <Subtitle>Email preferences</Subtitle>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<Text> <Text>Receive a notification email if an email backup fails.</Text>
Receive a notification email if an email backup fails.
</Text>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className={'sk-spinner info small'} /> <div className={'sk-spinner info small'} />
@ -212,5 +179,5 @@ export const EmailBackups = observer(({ application }: Props) => {
</div> </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
); )
}); })

View File

@ -0,0 +1,22 @@
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { FunctionComponent } from 'preact'
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
import { CloudLink } from './CloudBackups'
import { DataBackups } from './DataBackups'
import { EmailBackups } from './EmailBackups'
interface Props {
appState: AppState
application: WebApplication
}
export const Backups: FunctionComponent<Props> = ({ application, appState }) => {
return (
<PreferencesPane>
<DataBackups application={application} appState={appState} />
<EmailBackups application={application} />
<CloudLink application={application} />
</PreferencesPane>
)
}

View File

@ -1,4 +1,4 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { import {
Title, Title,
Subtitle, Subtitle,
@ -7,7 +7,7 @@ import {
PreferencesGroup, PreferencesGroup,
PreferencesPane, PreferencesPane,
PreferencesSegment, PreferencesSegment,
} from '../components'; } from '@/Components/Preferences/PreferencesComponents'
export const CloudLink: FunctionComponent = () => ( export const CloudLink: FunctionComponent = () => (
<PreferencesPane> <PreferencesPane>
@ -17,11 +17,10 @@ export const CloudLink: FunctionComponent = () => (
<div className="h-2 w-full" /> <div className="h-2 w-full" />
<Subtitle>Who can read my private notes?</Subtitle> <Subtitle>Who can read my private notes?</Subtitle>
<Text> <Text>
Quite simply: no one but you. Not us, not your ISP, not a hacker, and Quite simply: no one but you. Not us, not your ISP, not a hacker, and not a government
not a government agency. As long as you keep your password safe, and agency. As long as you keep your password safe, and your password is reasonably strong,
your password is reasonably strong, then you are the only person in then you are the only person in the world with the ability to decrypt your notes. For more
the world with the ability to decrypt your notes. For more on how we on how we handle your privacy and security, check out our easy to read{' '}
handle your privacy and security, check out our easy to read{' '}
<a target="_blank" href="https://standardnotes.com/privacy"> <a target="_blank" href="https://standardnotes.com/privacy">
Privacy Manifesto. Privacy Manifesto.
</a> </a>
@ -30,18 +29,17 @@ export const CloudLink: FunctionComponent = () => (
<PreferencesSegment> <PreferencesSegment>
<Subtitle>Can I collaborate with others on a note?</Subtitle> <Subtitle>Can I collaborate with others on a note?</Subtitle>
<Text> <Text>
Because of our encrypted architecture, Standard Notes does not Because of our encrypted architecture, Standard Notes does not currently provide a
currently provide a real-time collaboration solution. Multiple users real-time collaboration solution. Multiple users can share the same account however, but
can share the same account however, but editing at the same time may editing at the same time may result in sync conflicts, which may result in the duplication
result in sync conflicts, which may result in the duplication of of notes.
notes.
</Text> </Text>
</PreferencesSegment> </PreferencesSegment>
<PreferencesSegment> <PreferencesSegment>
<Subtitle>Can I use Standard Notes totally offline?</Subtitle> <Subtitle>Can I use Standard Notes totally offline?</Subtitle>
<Text> <Text>
Standard Notes can be used totally offline without an account, and Standard Notes can be used totally offline without an account, and without an internet
without an internet connection. You can find{' '} connection. You can find{' '}
<a <a
target="_blank" target="_blank"
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline" href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
@ -52,20 +50,15 @@ export const CloudLink: FunctionComponent = () => (
</PreferencesSegment> </PreferencesSegment>
<PreferencesSegment> <PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle> <Subtitle>Cant find your question here?</Subtitle>
<LinkButton <LinkButton className="mt-3" label="Open FAQ" link="https://standardnotes.com/help" />
className="mt-3"
label="Open FAQ"
link="https://standardnotes.com/help"
/>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Community forum</Title> <Title>Community forum</Title>
<Text> <Text>
If you have an issue, found a bug or want to suggest a feature, you If you have an issue, found a bug or want to suggest a feature, you can browse or post to
can browse or post to the forum. Its recommended for non-account the forum. Its recommended for non-account related issues. Please read our{' '}
related issues. Please read our{' '}
<a target="_blank" href="https://standardnotes.com/longevity/"> <a target="_blank" href="https://standardnotes.com/longevity/">
Longevity statement Longevity statement
</a>{' '} </a>{' '}
@ -82,9 +75,9 @@ export const CloudLink: FunctionComponent = () => (
<PreferencesSegment> <PreferencesSegment>
<Title>Community groups</Title> <Title>Community groups</Title>
<Text> <Text>
Want to meet other passionate note-takers and privacy enthusiasts? Want to meet other passionate note-takers and privacy enthusiasts? Want to share your
Want to share your feedback with us? Join the Standard Notes community feedback with us? Join the Standard Notes community groups for discussions on security,
groups for discussions on security, themes, editors and more. themes, editors and more.
</Text> </Text>
<LinkButton <LinkButton
className="mt-3" className="mt-3"
@ -101,15 +94,9 @@ export const CloudLink: FunctionComponent = () => (
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Account related issue?</Title> <Title>Account related issue?</Title>
<Text> <Text>Send an email to help@standardnotes.com and well sort it out.</Text>
Send an email to help@standardnotes.com and well sort it out. <LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
</Text>
<LinkButton
className="mt-3"
link="mailto: help@standardnotes.com"
label="Email us"
/>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
</PreferencesPane> </PreferencesPane>
); )

View File

@ -1,11 +1,11 @@
import { DisplayStringForContentType, SNComponent } from '@standardnotes/snjs'; import { DisplayStringForContentType, SNComponent } from '@standardnotes/snjs'
import { Button } from '@/components/Button'; import { Button } from '@/Components/Button/Button'
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact'
import { Title, Text, Subtitle, PreferencesSegment } from '../../components'; import { Title, Text, Subtitle, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
export const ConfirmCustomExtension: FunctionComponent<{ export const ConfirmCustomExtension: FunctionComponent<{
component: SNComponent; component: SNComponent
callback: (confirmed: boolean) => void; callback: (confirmed: boolean) => void
}> = ({ component, callback }) => { }> = ({ component, callback }) => {
const fields = [ const fields = [
{ {
@ -32,7 +32,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
label: 'Extension Type', label: 'Extension Type',
value: DisplayStringForContentType(component.content_type), value: DisplayStringForContentType(component.content_type),
}, },
]; ]
return ( return (
<PreferencesSegment> <PreferencesSegment>
@ -40,7 +40,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
{fields.map((field) => { {fields.map((field) => {
if (!field.value) { if (!field.value) {
return undefined; return undefined
} }
return ( return (
<> <>
@ -48,7 +48,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
<Text className={'wrap'}>{field.value}</Text> <Text className={'wrap'}>{field.value}</Text>
<div className="min-h-2" /> <div className="min-h-2" />
</> </>
); )
})} })}
<div className="min-h-3" /> <div className="min-h-3" />
@ -71,5 +71,5 @@ export const ConfirmCustomExtension: FunctionComponent<{
/> />
</div> </div>
</PreferencesSegment> </PreferencesSegment>
); )
}; }

View File

@ -0,0 +1,104 @@
import { FunctionComponent } from 'preact'
import { SNComponent } from '@standardnotes/snjs'
import { PreferencesSegment, SubtitleLight, Title } from '@/Components/Preferences/PreferencesComponents'
import { Switch } from '@/Components/Switch'
import { WebApplication } from '@/UIModels/Application'
import { useState } from 'preact/hooks'
import { Button } from '@/Components/Button/Button'
import { RenameExtension } from './RenameExtension'
const UseHosted: FunctionComponent<{
offlineOnly: boolean
toggleOfllineOnly: () => void
}> = ({ offlineOnly, toggleOfllineOnly }) => (
<div className="flex flex-row">
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
</div>
)
export interface ExtensionItemProps {
application: WebApplication
extension: SNComponent
first: boolean
latestVersion: string | undefined
uninstall: (extension: SNComponent) => void
toggleActivate?: (extension: SNComponent) => void
}
export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
application,
extension,
first,
uninstall,
}) => {
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false)
const [extensionName, setExtensionName] = useState(extension.name)
const toggleOffllineOnly = () => {
const newOfflineOnly = !offlineOnly
setOfflineOnly(newOfflineOnly)
application.mutator
.changeAndSaveItem(extension, (m: any) => {
if (m.content == undefined) {
m.content = {}
}
m.content.offlineOnly = newOfflineOnly
})
.then((item) => {
const component = item as SNComponent
setOfflineOnly(component.offlineOnly)
})
.catch((e) => {
console.error(e)
})
}
const changeExtensionName = (newName: string) => {
setExtensionName(newName)
application.mutator
.changeAndSaveItem(extension, (m: any) => {
if (m.content == undefined) {
m.content = {}
}
m.content.name = newName
})
.then((item) => {
const component = item as SNComponent
setExtensionName(component.name)
})
.catch(console.error)
}
const localInstallable = extension.package_info.download_url
const isThirParty = application.features.isThirdPartyFeature(extension.identifier)
return (
<PreferencesSegment classes={'mb-5'}>
{first && (
<>
<Title>Extensions</Title>
</>
)}
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
<div className="min-h-2" />
{isThirParty && localInstallable && (
<UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />
)}
<>
<div className="min-h-2" />
<div className="flex flex-row">
<Button
className="min-w-20"
variant="normal"
label="Uninstall"
onClick={() => uninstall(extension)}
/>
</div>
</>
</PreferencesSegment>
)
}

View File

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

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