Merge branch 'release/10.18.0'

This commit is contained in:
Karol Sójko
2022-05-19 15:34:02 +02:00
467 changed files with 22148 additions and 24291 deletions

View File

@@ -5,7 +5,8 @@
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:react-hooks/recommended"
"plugin:react-hooks/recommended",
"./node_modules/@standardnotes/config/src/.eslintrc"
],
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"parserOptions": {
@@ -23,7 +24,9 @@
"react-hooks/exhaustive-deps": "error",
"eol-last": "error",
"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": {
"browser": true

View File

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

View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: any
export default content
}

View File

@@ -1,3 +0,0 @@
declare module '*.svg' {
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,98 @@
'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
webClient?: DesktopManagerInterface
application?: WebApplication
mainApplicationGroup?: ApplicationGroup
}
}
import { IsWebPlatform, WebAppVersion } from '@/Version'
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
import { render } from 'preact'
import { ApplicationGroupView } from './Components/ApplicationGroupView'
import { WebDevice } from './Device/WebDevice'
import { StartApplication } from './Device/StartApplication'
import { ApplicationGroup } from './UIModels/ApplicationGroup'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
import { WebApplication } from './UIModels/Application'
import { unmountComponentAtRoot } from './Utils/PreactUtils'
let keyCount = 0
const getKey = () => {
return keyCount++
}
const RootId = 'app-group-root'
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
device: WebOrDesktopDevice,
enableUnfinishedFeatures: boolean,
webSocketUrl: string,
) {
SNLog.onLog = console.log
SNLog.onError = console.error
const onDestroy = () => {
const root = document.getElementById(RootId) as HTMLElement
unmountComponentAtRoot(root)
root.remove()
renderApp()
}
const renderApp = () => {
const root = document.createElement('div')
root.id = RootId
const parentNode = document.body.appendChild(root)
render(
<ApplicationGroupView
key={getKey()}
server={defaultSyncServerHost}
device={device}
enableUnfinished={enableUnfinishedFeatures}
websocketUrl={webSocketUrl}
onDestroy={onDestroy}
/>,
parentNode,
)
}
const domReady = document.readyState === 'complete' || document.readyState === 'interactive'
if (domReady) {
renderApp()
} else {
window.addEventListener('DOMContentLoaded', function callback() {
renderApp()
window.removeEventListener('DOMContentLoaded', callback)
})
}
}
if (IsWebPlatform) {
startApplication(
window.defaultSyncServer,
new WebDevice(WebAppVersion),
window.enabledUnfinishedFeatures,
window.websocketUrl,
).catch(console.error)
} else {
window.startApplication = startApplication
}

View File

@@ -0,0 +1,138 @@
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
;(this.application as unknown) = undefined
;(this.props as unknown) = undefined
;(this.state as unknown) = undefined
}
protected dismissModal(): void {
const elem = this.getElement()
if (!elem) {
return
}
const parent = elem.parentElement
if (!parent) {
return
}
parent.remove()
unmountComponentAtNode(parent)
}
override componentWillUnmount(): void {
this.deinit()
}
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) => {
if (!this.application) {
return
}
this.onAppEvent(eventName, data)
if (eventName === ApplicationEvent.Started) {
await this.onAppStart()
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch()
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync()
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync()
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange().catch(console.error)
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded()
}
})
}
onAppEvent(_eventName: ApplicationEvent, _data?: unknown) {
/** Optional override */
}
async onAppStart() {
/** Optional override */
}
onLocalDataLoaded() {
/** Optional override */
}
async onAppLaunch() {
/** Optional override */
}
async onAppKeyChange() {
/** Optional override */
}
onAppIncrementalSync() {
/** Optional override */
}
onAppFullSync() {
/** Optional override */
}
}

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,35 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { Icon } from '../Icon';
import { formatLastSyncDate } from '@/components/Preferences/panes/account/Sync';
import { SyncQueueStrategy } from '@standardnotes/snjs';
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
import { useState } from 'preact/hooks';
import { AccountMenuPane } from '.';
import { FunctionComponent } from 'preact';
import { Menu } from '../Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../Menu/MenuItem';
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption';
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { Icon } from '@/Components/Icon'
import { formatLastSyncDate } from '@/Components/Preferences/Panes/Account/Sync'
import { SyncQueueStrategy } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
import { useCallback, useMemo, useState } from 'preact/hooks'
import { AccountMenuPane } from '.'
import { FunctionComponent } from 'preact'
import { Menu } from '@/Components/Menu/Menu'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
type Props = {
appState: AppState;
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
};
appState: AppState
application: WebApplication
mainApplicationGroup: ApplicationGroup
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const iconClassName = 'color-neutral mr-2';
const iconClassName = 'color-neutral mr-2'
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false);
const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
);
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
const doSynchronization = async () => {
setIsSyncingInProgress(true);
const doSynchronization = useCallback(async () => {
setIsSyncingInProgress(true)
application.sync
.sync({
@@ -40,25 +38,47 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
})
.then((res) => {
if (res && (res as any).error) {
throw new Error();
throw new Error()
} else {
setLastSyncDate(
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
);
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
}
})
.catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR);
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
})
.finally(() => {
setIsSyncingInProgress(false);
});
};
setIsSyncingInProgress(false)
})
}, [application])
const user = application.getUser();
const user = useMemo(() => application.getUser(), [application])
const CREATE_ACCOUNT_INDEX = 1;
const SWITCHER_INDEX = 0;
const openPreferences = useCallback(() => {
appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('account')
appState.preferences.openPreferences()
}, [appState])
const openHelp = useCallback(() => {
appState.accountMenu.closeAccountMenu()
appState.preferences.setCurrentPane('help-feedback')
appState.preferences.openPreferences()
}, [appState])
const signOut = useCallback(() => {
appState.accountMenu.setSigningOut(true)
}, [appState])
const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane])
const CREATE_ACCOUNT_INDEX = 1
const SWITCHER_INDEX = 0
return (
<>
@@ -90,10 +110,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
</div>
</div>
)}
<div
className="flex cursor-pointer color-grey-1"
onClick={doSynchronization}
>
<div className="flex cursor-pointer color-grey-1" onClick={doSynchronization}>
<Icon type="sync" />
</div>
</div>
@@ -102,8 +119,8 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<>
<div className="px-3 mb-1">
<div className="mb-3 color-foreground">
Youre offline. Sign in to sync your notes and preferences
across all your devices and enable end-to-end encryption.
Youre offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
encryption.
</div>
<div className="flex items-center color-grey-1">
<Icon type="cloud-off" className="mr-2" />
@@ -116,59 +133,29 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
isOpen={appState.accountMenu.show}
a11yLabel="General account menu"
closeMenu={closeMenu}
initialFocus={
!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX
}
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
>
<MenuItemSeparator />
<WorkspaceSwitcherOption
mainApplicationGroup={mainApplicationGroup}
appState={appState}
/>
<WorkspaceSwitcherOption mainApplicationGroup={mainApplicationGroup} appState={appState} />
<MenuItemSeparator />
{user ? (
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('account');
appState.preferences.openPreferences();
}}
>
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
<Icon type="user" className={iconClassName} />
Account settings
</MenuItem>
) : (
<>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
setMenuPane(AccountMenuPane.Register);
}}
>
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
<Icon type="user" className={iconClassName} />
Create free account
</MenuItem>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
setMenuPane(AccountMenuPane.SignIn);
}}
>
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<Icon type="signIn" className={iconClassName} />
Sign in
</MenuItem>
</>
)}
<MenuItem
className="justify-between"
type={MenuItemType.IconButton}
onClick={() => {
appState.accountMenu.closeAccountMenu();
appState.preferences.setCurrentPane('help-feedback');
appState.preferences.openPreferences();
}}
>
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
<div className="flex items-center">
<Icon type="help" className={iconClassName} />
Help &amp; feedback
@@ -178,12 +165,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
{user ? (
<>
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
>
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
<Icon type="signOut" className={iconClassName} />
Sign out workspace
</MenuItem>
@@ -191,6 +173,6 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
) : null}
</Menu>
</>
);
}
);
)
},
)

View File

@@ -0,0 +1,213 @@
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { isDev } from '@/Utils'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AccountMenuPane } from '.'
import { Button } from '@/Components/Button/Button'
import { Checkbox } from '@/Components/Checkbox'
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
import { Icon } from '@/Components/Icon'
import { IconButton } from '@/Components/Button/IconButton'
import { AdvancedOptions } from './AdvancedOptions'
type Props = {
appState: AppState
application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void
}
export const SignInPane: FunctionComponent<Props> = observer(({ application, appState, setMenuPane }) => {
const { notesAndTagsCount } = appState.accountMenu
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isEphemeral, setIsEphemeral] = useState(false)
const [isStrictSignin, setIsStrictSignin] = useState(false)
const [isSigningIn, setIsSigningIn] = useState(false)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (emailInputRef?.current) {
emailInputRef.current?.focus()
}
if (isDev && window.devAccountEmail) {
setEmail(window.devAccountEmail)
setPassword(window.devAccountPassword as string)
}
}, [])
const resetInvalid = useCallback(() => {
if (error.length) {
setError('')
}
}, [setError, error])
const handleEmailChange = useCallback((text: string) => {
setEmail(text)
}, [])
const handlePasswordChange = useCallback(
(text: string) => {
if (error.length) {
setError('')
}
setPassword(text)
},
[setPassword, error],
)
const handleEphemeralChange = useCallback(() => {
setIsEphemeral(!isEphemeral)
}, [isEphemeral])
const handleStrictSigninChange = useCallback(() => {
setIsStrictSignin(!isStrictSignin)
}, [isStrictSignin])
const handleShouldMergeChange = useCallback(() => {
setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal])
const signIn = useCallback(() => {
setIsSigningIn(true)
emailInputRef?.current?.blur()
passwordInputRef?.current?.blur()
application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
.then((res) => {
if (res.error) {
throw new Error(res.error.message)
}
appState.accountMenu.closeAccountMenu()
})
.catch((err) => {
console.error(err)
setError(err.message ?? err.toString())
setPassword('')
passwordInputRef?.current?.blur()
})
.finally(() => {
setIsSigningIn(false)
})
}, [appState, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
const onPrivateWorkspaceChange = useCallback(
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
setIsPrivateWorkspace(newIsPrivateWorkspace)
if (newIsPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
const handleSignInFormSubmit = useCallback(
(e: Event) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef?.current?.focus()
return
}
if (!password || password.length === 0) {
passwordInputRef?.current?.focus()
return
}
signIn()
},
[email, password, signIn],
)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSignInFormSubmit(e)
}
},
[handleSignInFormSubmit],
)
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral p-0"
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
focusable={true}
disabled={isSigningIn}
/>
<div className="sn-account-menu-headline">Sign in</div>
</div>
<div className="px-3 mb-1">
<DecoratedInput
className={`mb-2 ${error ? 'border-dark-red' : null}`}
left={[<Icon type="email" className="color-neutral" />]}
type="email"
placeholder="Email"
value={email}
onChange={handleEmailChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
disabled={isSigningIn || isPrivateWorkspace}
ref={emailInputRef}
/>
<DecoratedPasswordInput
className={`mb-2 ${error ? 'border-dark-red' : null}`}
disabled={isSigningIn}
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onFocus={resetInvalid}
onKeyDown={handleKeyDown}
placeholder="Password"
ref={passwordInputRef}
value={password}
/>
{error ? <div className="color-dark-red my-2">{error}</div> : null}
<Button
className="btn-w-full mt-1 mb-3"
label={isSigningIn ? 'Signing in...' : 'Sign in'}
variant="primary"
onClick={handleSignInFormSubmit}
disabled={isSigningIn}
/>
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
disabled={isSigningIn}
onChange={handleEphemeralChange}
/>
{notesAndTagsCount > 0 ? (
<Checkbox
name="should-merge-local"
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={shouldMergeLocal}
disabled={isSigningIn}
onChange={handleShouldMergeChange}
/>
) : null}
</div>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
appState={appState}
application={application}
disabled={isSigningIn}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
onStrictSignInChange={handleStrictSigninChange}
/>
</>
)
})

View File

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

View File

@@ -1,43 +1,48 @@
import { Icon } from '@/components/Icon';
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem';
import { KeyboardKey } from '@/services/ioService';
import { ApplicationDescriptor } from '@standardnotes/snjs/dist/@types';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '@/Components/Icon'
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
import { KeyboardKey } from '@/Services/IOService'
import { ApplicationDescriptor } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
type Props = {
descriptor: ApplicationDescriptor;
onClick: () => void;
onDelete: () => void;
renameDescriptor: (label: string) => void;
};
descriptor: ApplicationDescriptor
onClick: () => void
onDelete: () => void
renameDescriptor: (label: string) => void
hideOptions: boolean
}
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor,
onClick,
onDelete,
renameDescriptor,
hideOptions,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [isRenaming, setIsRenaming] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isRenaming) {
inputRef.current?.focus();
inputRef.current?.focus()
}
}, [isRenaming]);
}, [isRenaming])
const handleInputKeyDown = (event: KeyboardEvent) => {
const handleInputKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur();
inputRef.current?.blur()
}
};
}, [])
const handleInputBlur = (event: FocusEvent) => {
const name = (event.target as HTMLInputElement).value;
renameDescriptor(name);
setIsRenaming(false);
};
const handleInputBlur = useCallback(
(event: FocusEvent) => {
const name = (event.target as HTMLInputElement).value
renameDescriptor(name)
setIsRenaming(false)
},
[renameDescriptor],
)
return (
<MenuItem
@@ -58,19 +63,23 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
) : (
<div>{descriptor.label}</div>
)}
{descriptor.primary && (
{descriptor.primary && !hideOptions && (
<div>
<button
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={() => {
setIsRenaming((isRenaming) => !isRenaming);
onClick={(e) => {
e.stopPropagation()
setIsRenaming((isRenaming) => !isRenaming)
}}
>
<Icon type="pencil" className="sn-icon--mid color-neutral" />
</button>
<button
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={onDelete}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Icon type="trash" className="sn-icon--mid color-danger" />
</button>
@@ -78,5 +87,5 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
)}
</div>
</MenuItem>
);
};
)
}

View File

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

View File

@@ -0,0 +1,67 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
type Props = {
mainApplicationGroup: ApplicationGroup
appState: AppState
}
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mainApplicationGroup, appState }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
const toggleMenu = useCallback(() => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsOpen(!isOpen)
}, [isOpen, setIsOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
})
}
}, [isOpen])
return (
<>
<button
ref={buttonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
role="menuitem"
onClick={toggleMenu}
>
<div className="flex items-center">
<Icon type="user-switch" className="color-neutral mr-2" />
Switch workspace
</div>
<Icon type="chevron-right" className="color-neutral" />
</button>
{isOpen && (
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
<WorkspaceSwitcherMenu mainApplicationGroup={mainApplicationGroup} appState={appState} isOpen={isOpen} />
</div>
)}
</>
)
})

View File

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

View File

@@ -0,0 +1,132 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { WebApplication } from '@/UIModels/Application'
import { Component } from 'preact'
import { ApplicationView } from '@/Components/ApplicationView'
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice'
import { ApplicationGroupEvent, Runtime, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs'
import { unmountComponentAtNode, findDOMNode } from 'preact/compat'
import { DialogContent, DialogOverlay } from '@reach/dialog'
import { isDesktopApplication } from '@/Utils'
type Props = {
server: string
device: WebOrDesktopDevice
enableUnfinished: boolean
websocketUrl: string
onDestroy: () => void
}
type State = {
activeApplication?: WebApplication
dealloced?: boolean
deallocSource?: DeinitSource
deviceDestroyed?: boolean
}
export class ApplicationGroupView extends Component<Props, State> {
applicationObserverRemover?: () => void
private group?: ApplicationGroup
private application?: WebApplication
constructor(props: Props) {
super(props)
if (props.device.isDeviceDestroyed()) {
this.state = {
deviceDestroyed: true,
}
return
}
this.group = new ApplicationGroup(
props.server,
props.device,
props.enableUnfinished ? Runtime.Dev : Runtime.Prod,
props.websocketUrl,
)
window.mainApplicationGroup = this.group
this.applicationObserverRemover = this.group.addEventObserver((event, data) => {
if (event === ApplicationGroupEvent.PrimaryApplicationSet) {
const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.PrimaryApplicationSet]
this.application = castData.application as WebApplication
this.setState({ activeApplication: this.application })
} else if (event === ApplicationGroupEvent.DeviceWillRestart) {
const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.DeviceWillRestart]
this.setState({ dealloced: true, deallocSource: castData.source })
}
})
this.state = {}
this.group.initialize().catch(console.error)
}
deinit() {
this.application = undefined
this.applicationObserverRemover?.()
;(this.applicationObserverRemover as unknown) = undefined
this.group?.deinit()
;(this.group as unknown) = undefined
this.setState({ dealloced: true, activeApplication: undefined })
const onDestroy = this.props.onDestroy
const node = findDOMNode(this) as Element
unmountComponentAtNode(node)
onDestroy()
}
render() {
const renderDialog = (message: string) => {
return (
<DialogOverlay className={'sn-component challenge-modal-overlay'}>
<DialogContent
className={
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
}
>
{message}
</DialogContent>
</DialogOverlay>
)
}
if (this.state.deviceDestroyed) {
const message = `Secure memory has destroyed this application instance. ${
isDesktopApplication()
? 'Restart the app to continue.'
: 'Close this browser tab and open a new one to continue.'
}`
return renderDialog(message)
}
if (this.state.dealloced) {
const message = this.state.deallocSource === DeinitSource.Lock ? 'Locking workspace...' : 'Switching workspace...'
return renderDialog(message)
}
if (!this.group || !this.state.activeApplication || this.state.activeApplication.dealloced) {
return null
}
return (
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
<ApplicationView
key={this.state.activeApplication.ephemeralIdentifier}
mainApplicationGroup={this.group}
application={this.state.activeApplication}
/>
</div>
)
}
}

View File

@@ -0,0 +1,242 @@
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 { WebApplication } from '@/UIModels/Application'
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, FunctionComponent } 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 { FilePreviewModal } from '../Files/FilePreviewModal'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
application: WebApplication
mainApplicationGroup: ApplicationGroup
}
export const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
const platformString = getPlatformString()
const [appClass, setAppClass] = useState('')
const [launched, setLaunched] = useState(false)
const [needsUnlock, setNeedsUnlock] = useState(true)
const [challenges, setChallenges] = useState<Challenge[]>([])
const [dealloced, setDealloced] = useState(false)
const componentManager = application.componentManager
const appState = application.getAppState()
useEffect(() => {
setDealloced(application.dealloced)
}, [application.dealloced])
useEffect(() => {
if (dealloced) {
return
}
const desktopService = application.getDesktopService()
if (desktopService) {
application.componentManager.setDesktopManager(desktopService)
}
application
.prepareForLaunch({
receiveChallenge: async (challenge) => {
const challengesCopy = challenges.slice()
challengesCopy.push(challenge)
setChallenges(challengesCopy)
},
})
.then(() => {
void application.launch()
})
.catch(console.error)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [application, dealloced])
const removeChallenge = useCallback(
(challenge: Challenge) => {
const challengesCopy = challenges.slice()
removeFromArray(challengesCopy, challenge)
setChallenges(challengesCopy)
},
[challenges],
)
const presentPermissionsDialog = useCallback(
(dialog: PermissionDialog) => {
render(
<PermissionsModal
application={application}
callback={dialog.callback}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>,
document.body.appendChild(document.createElement('div')),
)
},
[application],
)
const onAppStart = useCallback(() => {
setNeedsUnlock(application.hasPasscode())
componentManager.presentPermissionsDialog = presentPermissionsDialog
return () => {
;(componentManager.presentPermissionsDialog as unknown) = undefined
}
}, [application, componentManager, presentPermissionsDialog])
const handleDemoSignInFromParams = useCallback(() => {
const token = getWindowUrlParams().get('demo-token')
if (!token || application.hasAccount()) {
return
}
void application.sessions.populateSessionFromDemoShareToken(token)
}, [application])
const onAppLaunch = useCallback(() => {
setLaunched(true)
setNeedsUnlock(false)
handleDemoSignInFromParams()
}, [handleDemoSignInFromParams])
useEffect(() => {
if (application.isStarted()) {
onAppStart()
}
if (application.isLaunched()) {
onAppLaunch()
}
const removeAppObserver = application.addEventObserver(async (eventName) => {
if (eventName === ApplicationEvent.Started) {
onAppStart()
} else if (eventName === ApplicationEvent.Launched) {
onAppLaunch()
} else if (eventName === ApplicationEvent.LocalDatabaseReadError) {
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
}).catch(console.error)
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
}).catch(console.error)
}
})
return () => {
removeAppObserver()
}
}, [application, onAppLaunch, onAppStart])
useEffect(() => {
const removeObserver = application.getAppState().addObserver(async (eventName, data) => {
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'
}
setAppClass(appClass)
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await application.isLocked())) {
application.sync.sync().catch(console.error)
}
}
})
return () => {
removeObserver()
}
}, [application])
const renderAppContents = useMemo(() => {
return !needsUnlock && launched
}, [needsUnlock, launched])
const renderChallenges = useCallback(() => {
return (
<>
{challenges.map((challenge) => {
return (
<div className="sk-modal">
<ChallengeModal
key={`${challenge.id}${application.ephemeralIdentifier}`}
application={application}
appState={appState}
mainApplicationGroup={mainApplicationGroup}
challenge={challenge}
onDismiss={removeChallenge}
/>
</div>
)
})}
</>
)
}, [appState, challenges, mainApplicationGroup, removeChallenge, application])
if (dealloced || isStateDealloced(appState)) {
return null
}
if (!renderAppContents) {
return renderChallenges()
}
return (
<PremiumModalProvider application={application} appState={appState}>
<div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className={appClass + ' app app-column-container'}>
<Navigation application={application} />
<NotesView application={application} appState={appState} />
<NoteGroupView application={application} />
</div>
<>
<Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} appState={appState} />
<PreferencesViewWrapper appState={appState} application={application} />
<RevisionHistoryModalWrapper application={application} appState={appState} />
</>
{renderChallenges()}
<>
<NotesContextMenu application={application} appState={appState} />
<TagsContextMenu appState={appState} />
<PurchaseFlowWrapper application={application} appState={appState} />
<ConfirmSignoutContainer
applicationGroup={mainApplicationGroup}
appState={appState}
application={application}
/>
<ToastContainer />
<FilePreviewModal application={application} appState={appState} />
</>
</div>
</PremiumModalProvider>
)
}

View File

@@ -0,0 +1,423 @@
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, CollectionSort, ContentType, FileItem, SNNote } 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 } from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
application: WebApplication
appState: AppState
onClickPreprocessing?: () => Promise<void>
}
export const AttachedFilesButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const premiumModal = usePremiumModal()
const note: SNNote | undefined = 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)
useEffect(() => {
if (appState.filePreviewModal.isOpen) {
keepMenuOpen(true)
} else {
keepMenuOpen(false)
}
}, [appState.filePreviewModal.isOpen, keepMenuOpen])
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
const [allFiles, setAllFiles] = useState<FileItem[]>([])
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
const attachedFilesCount = attachedFiles.length
useEffect(() => {
application.items.setDisplayOptions(ContentType.File, CollectionSort.Title, 'dsc')
const unregisterFileStream = application.streamItems(ContentType.File, () => {
setAllFiles(application.items.getDisplayableItems<FileItem>(ContentType.File))
if (note) {
setAttachedFiles(application.items.getFilesForNote(note))
}
})
return () => {
unregisterFileStream()
}
}, [application, note])
const toggleAttachedFilesMenu = useCallback(async () => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setOpen(newOpenState)
}
}, [onClickPreprocessing, open])
const prospectivelyShowFilesPremiumModal = useCallback(() => {
if (!appState.features.hasFiles) {
premiumModal.activate('Files')
}
}, [appState.features.hasFiles, premiumModal])
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
prospectivelyShowFilesPremiumModal()
await toggleAttachedFilesMenu()
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
const deleteFile = async (file: FileItem) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
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: FileItem) => {
appState.files.downloadFile(file).catch(console.error)
}
const attachFileToNote = useCallback(
async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.disassociateFileWithNote(file, note)
}
const toggleFileProtection = async (file: FileItem) => {
let result: FileItem | undefined
if (file.protected) {
keepMenuOpen(true)
result = await application.mutator.unprotectFile(file)
keepMenuOpen(false)
buttonRef.current?.focus()
} else {
result = await application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
}
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
const renameFile = async (file: FileItem, fileName: string) => {
await application.items.renameFile(file, fileName)
}
const handleFileAction = async (action: PopoverFileItemAction) => {
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
let isAuthorizedForAction = true
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
keepMenuOpen(true)
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
keepMenuOpen(false)
buttonRef.current?.focus()
}
if (!isAuthorizedForAction) {
return false
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile: {
keepMenuOpen(true)
const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
appState.filePreviewModal.activate(
file,
otherFiles.filter((file) => !file.protected),
)
break
}
}
if (
action.type !== PopoverFileItemActionType.DownloadFile &&
action.type !== PopoverFileItemActionType.PreviewFile
) {
application.sync.sync().catch(console.error)
}
return true
}
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const dragCounter = useRef(0)
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
event.preventDefault()
event.stopPropagation()
}
},
[application],
)
const handleDragIn = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
switch ((event.target as HTMLElement).id) {
case PopoverTabs.AllFiles:
setCurrentTab(PopoverTabs.AllFiles)
break
case PopoverTabs.AttachedFiles:
setCurrentTab(PopoverTabs.AttachedFiles)
break
}
dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true)
if (!open) {
toggleAttachedFilesMenu().catch(console.error)
}
}
},
[open, toggleAttachedFilesMenu, application],
)
const handleDragOut = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
dragCounter.current = dragCounter.current - 1
if (dragCounter.current > 0) {
return
}
setIsDraggingFiles(false)
},
[application],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
if (!appState.features.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
const fileOrHandle = StreamingFileReader.available()
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
: item.getAsFile()
if (!fileOrHandle) {
return
}
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
if (!uploadedFiles) {
return
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
})
event.dataTransfer.clearData()
dragCounter.current = 0
}
},
[
appState.files,
appState.features.hasFiles,
attachFileToNote,
currentTab,
application,
prospectivelyShowFilesPremiumModal,
],
)
useEffect(() => {
window.addEventListener('dragenter', handleDragIn)
window.addEventListener('dragleave', handleDragOut)
window.addEventListener('dragover', handleDrag)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragIn)
window.removeEventListener('dragleave', handleDragOut)
window.removeEventListener('dragover', handleDrag)
window.removeEventListener('drop', handleDrop)
}
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
return (
<div ref={containerRef}>
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
<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}
attachedFiles={attachedFiles}
allFiles={allFiles}
closeOnBlur={closeOnBlur}
currentTab={currentTab}
handleFileAction={handleFileAction}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
},
)

View File

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

View File

@@ -1,29 +1,26 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { KeyboardKey } from '@/services/ioService';
import { formatSizeToReadableString } from '@standardnotes/filepicker';
import { IconType, SNFile } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon, ICONS } from '../Icon';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { PopoverFileSubmenu } from './PopoverFileSubmenu';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { KeyboardKey } from '@/Services/IOService'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { IconType, FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'
import { Icon, ICONS } from '@/Components/Icon'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverFileSubmenu } from './PopoverFileSubmenu'
export const getFileIconComponent = (iconType: string, className: string) => {
const IconComponent = ICONS[iconType as keyof typeof ICONS];
const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />;
};
return <IconComponent className={className} />
}
export type PopoverFileItemProps = {
file: SNFile;
isAttachedToNote: boolean;
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>;
getIconType(type: string): IconType;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
};
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file,
@@ -32,41 +29,48 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
getIconType,
closeOnBlur,
}) => {
const [fileName, setFileName] = useState(file.name);
const [isRenamingFile, setIsRenamingFile] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const fileNameInputRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState(file.name)
const [isRenamingFile, setIsRenamingFile] = useState(false)
const itemRef = useRef<HTMLDivElement>(null)
const fileNameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isRenamingFile) {
fileNameInputRef.current?.focus();
fileNameInputRef.current?.focus()
}
}, [isRenamingFile]);
}, [isRenamingFile])
const renameFile = async (file: SNFile, name: string) => {
const renameFile = async (file: FileItem, name: string) => {
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file,
name,
},
});
setIsRenamingFile(false);
};
})
setIsRenamingFile(false)
}
const handleFileNameInput = (event: Event) => {
setFileName((event.target as HTMLInputElement).value);
};
setFileName((event.target as HTMLInputElement).value)
}
const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) {
itemRef.current?.focus();
itemRef.current?.focus()
}
};
}
const handleFileNameInputBlur = () => {
renameFile(file, fileName);
};
renameFile(file, fileName).catch(console.error)
}
const clickPreviewHandler = () => {
handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
payload: file,
}).catch(console.error)
}
return (
<div
@@ -74,11 +78,8 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
className="flex items-center justify-between p-3 focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<div className="flex items-center">
{getFileIconComponent(
getIconType(file.mimeType),
'w-8 h-8 flex-shrink-0'
)}
<div onClick={clickPreviewHandler} className="flex items-center cursor-pointer">
{getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
<div className="flex flex-col mx-4">
{isRenamingFile ? (
<input
@@ -94,16 +95,12 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
<div className="text-sm mb-1 break-word">
<span className="vertical-middle">{file.name}</span>
{file.protected && (
<Icon
type="lock-filled"
className="sn-icon--small ml-2 color-neutral vertical-middle"
/>
<Icon type="lock-filled" className="sn-icon--small ml-2 color-neutral vertical-middle" />
)}
</div>
)}
<div className="text-xs color-grey-0">
{file.created_at.toLocaleString()} ·{' '}
{formatSizeToReadableString(file.size)}
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
</div>
</div>
</div>
@@ -113,7 +110,8 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
closeOnBlur={closeOnBlur}
previewHandler={clickPreviewHandler}
/>
</div>
);
};
)
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export enum PopoverTabs {
AttachedFiles = 'attached-files-tab',
AllFiles = 'all-files-tab',
}

View File

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

View File

@@ -0,0 +1,68 @@
import { JSXInternal } from 'preact/src/jsx'
import { ComponentChildren, FunctionComponent, Ref } from 'preact'
import { forwardRef } from 'preact/compat'
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
type ButtonVariant = 'normal' | 'primary'
const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean) => {
const borders = variant === 'normal' ? 'border-solid border-main border-1' : 'no-border'
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'
let colors = variant === 'normal' ? 'bg-default color-text' : 'bg-info color-info-contrast'
let focusHoverStates =
variant === 'normal'
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
: 'hover:brightness-130 focus:outline-none focus:brightness-130'
if (danger) {
colors = variant === 'normal' ? 'bg-default color-danger' : 'bg-danger color-info-contrast'
}
if (disabled) {
colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast'
focusHoverStates =
variant === 'normal'
? 'focus:bg-default focus:outline-none hover:bg-default'
: 'focus:brightness-default focus:outline-none hover:brightness-default'
}
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
}
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & {
children?: ComponentChildren
className?: string
variant?: ButtonVariant
dangerStyle?: boolean
label?: string
}
export const Button: FunctionComponent<ButtonProps> = forwardRef(
(
{
variant = 'normal',
label,
className = '',
dangerStyle: danger = false,
disabled = false,
children,
...props
}: ButtonProps,
ref: Ref<HTMLButtonElement>,
) => {
return (
<button
type="button"
className={`${getClassName(variant, danger, disabled)} ${className}`}
disabled={disabled}
ref={ref}
{...props}
>
{label ?? children}
</button>
)
},
)

View File

@@ -1,27 +1,27 @@
import { FunctionComponent } from 'preact';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'
interface Props {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void;
onClick: () => void
className?: string;
className?: string
icon: IconType;
icon: IconType
iconClassName?: string;
iconClassName?: string
/**
* Button tooltip
*/
title: string;
title: string
focusable: boolean;
focusable: boolean
disabled?: boolean;
disabled?: boolean
}
/**
@@ -38,18 +38,20 @@ export const IconButton: FunctionComponent<Props> = ({
disabled = false,
}) => {
const click = (e: MouseEvent) => {
e.preventDefault();
onClick();
};
const focusableClass = focusable ? '' : 'focus:shadow-none';
e.preventDefault()
onClick()
}
const focusableClass = focusable ? '' : 'focus:shadow-none'
return (
<button
type="button"
title={title}
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
className={`no-border cursor-pointer bg-transparent flex flex-row items-center ${focusableClass} ${className}`}
onClick={click}
disabled={disabled}
aria-label={title}
>
<Icon type={icon} className={iconClassName} />
</button>
);
};
)
}

View File

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

View File

@@ -0,0 +1,270 @@
import { WebApplication } from '@/UIModels/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog'
import {
ButtonType,
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValue,
removeFromArray,
} from '@standardnotes/snjs'
import { ProtectedIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'
import { Button } from '@/Components/Button/Button'
import { Icon } from '@/Components/Icon'
import { ChallengeModalPrompt } from './ChallengePrompt'
import { LockscreenWorkspaceSwitcher } from './LockscreenWorkspaceSwitcher'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState'
type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
invalid: boolean
}
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
type Props = {
application: WebApplication
appState: AppState
mainApplicationGroup: ApplicationGroup
challenge: Challenge
onDismiss?: (challenge: Challenge) => void
}
const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]): ChallengeModalValues | undefined => {
let hasInvalidValues = false
const validatedValues = { ...values }
for (const prompt of prompts) {
const value = validatedValues[prompt.id]
if (typeof value.value === 'string' && value.value.length === 0) {
validatedValues[prompt.id].invalid = true
hasInvalidValues = true
}
}
if (!hasInvalidValues) {
return validatedValues
}
return undefined
}
export const ChallengeModal: FunctionComponent<Props> = ({
application,
appState,
mainApplicationGroup,
challenge,
onDismiss,
}) => {
const [values, setValues] = useState<ChallengeModalValues>(() => {
const values = {} as ChallengeModalValues
for (const prompt of challenge.prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
}
}
return values
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
challenge.reason,
)
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
const submit = useCallback(() => {
const validatedValues = validateValues(values, challenge.prompts)
if (!validatedValues) {
return
}
if (isSubmitting || isProcessing) {
return
}
setIsSubmitting(true)
setIsProcessing(true)
const valuesToProcess: ChallengeValue[] = []
for (const inputValue of Object.values(validatedValues)) {
const rawValue = inputValue.value
const value = { prompt: inputValue.prompt, value: rawValue }
valuesToProcess.push(value)
}
const processingPrompts = valuesToProcess.map((v) => v.prompt)
setIsProcessing(processingPrompts.length > 0)
setProcessingPrompts(processingPrompts)
/**
* Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation
* (crypto key generation)
*/
setTimeout(() => {
if (valuesToProcess.length > 0) {
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
} else {
setIsProcessing(false)
}
setIsSubmitting(false)
}, 50)
}, [application, challenge, isProcessing, isSubmitting, values])
const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => {
const newValues = { ...values }
newValues[prompt.id].invalid = false
newValues[prompt.id].value = value
setValues(newValues)
},
[values],
)
const cancelChallenge = useCallback(() => {
if (challenge.cancelable) {
application.cancelChallenge(challenge)
onDismiss?.(challenge)
}
}, [application, challenge, onDismiss])
useEffect(() => {
const removeChallengeObserver = application.addChallengeObserver(challenge, {
onValidValue: (value) => {
setValues((values) => {
const newValues = { ...values }
newValues[value.prompt.id].invalid = false
return newValues
})
setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice()
removeFromArray(processingPrompts, value.prompt)
setIsProcessing(processingPrompts.length > 0)
return processingPrompts
})
},
onInvalidValue: (value) => {
setValues((values) => {
const newValues = { ...values }
newValues[value.prompt.id].invalid = true
return newValues
})
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
setProcessingPrompts([])
setIsProcessing(false)
} else {
setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice()
removeFromArray(processingPrompts, value.prompt)
setIsProcessing(processingPrompts.length > 0)
return processingPrompts
})
}
},
onComplete: () => {
onDismiss?.(challenge)
},
onCancel: () => {
onDismiss?.(challenge)
},
})
return () => {
removeChallengeObserver()
}
}, [application, challenge, onDismiss])
if (!challenge.prompts) {
return null
}
return (
<DialogOverlay
className={`sn-component ${
challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
}`}
onDismiss={cancelChallenge}
dangerouslyBypassFocusLock={bypassModalFocusLock}
key={challenge.id}
>
<DialogContent
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
challenge.reason !== ChallengeReason.ApplicationUnlock
? 'shadow-overlay-light border-1 border-solid border-main'
: 'focus:shadow-none'
}`}
>
{challenge.cancelable && (
<button
onClick={cancelChallenge}
aria-label="Close modal"
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
>
<Icon type="close" className="color-neutral" />
</button>
)}
<ProtectedIllustration className="w-30 h-30 mb-4" />
<div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
{challenge.subheading && (
<div className="text-center text-sm max-w-76 mb-4 break-word">{challenge.subheading}</div>
)}
<form
className="flex flex-col items-center min-w-76"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
>
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
key={prompt.id}
prompt={prompt}
values={values}
index={index}
onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid}
/>
))}
</form>
<Button variant="primary" disabled={isProcessing} className="min-w-76 mt-1 mb-3.5" onClick={submit}>
{isProcessing ? 'Generating Keys...' : 'Submit'}
</Button>
{shouldShowForgotPasscode && (
<Button
className="flex items-center justify-center min-w-76"
onClick={() => {
setBypassModalFocusLock(true)
application.alertService
.confirm(
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
'Forgot passcode?',
'Delete local data',
ButtonType.Danger,
)
.then((shouldDeleteLocalData) => {
if (shouldDeleteLocalData) {
application.user.signOut().catch(console.error)
}
})
.catch(console.error)
.finally(() => {
setBypassModalFocusLock(false)
})
}}
>
<Icon type="help" className="mr-2 color-neutral" />
Forgot passcode?
</Button>
)}
{shouldShowWorkspaceSwitcher && (
<LockscreenWorkspaceSwitcher mainApplicationGroup={mainApplicationGroup} appState={appState} />
)}
</DialogContent>
</DialogOverlay>
)
}

View File

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

View File

@@ -0,0 +1,67 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { AppState } from '@/UIModels/AppState'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { WorkspaceSwitcherMenu } from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
import { Button } from '@/Components/Button/Button'
import { Icon } from '@/Components/Icon'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
type Props = {
mainApplicationGroup: ApplicationGroup
appState: AppState
}
export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
useCloseOnClickOutside(containerRef, () => setIsOpen(false))
const toggleMenu = useCallback(() => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsOpen(!isOpen)
}, [isOpen])
useEffect(() => {
if (isOpen) {
const timeToWaitBeforeCheckingMenuCollision = 0
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
}, timeToWaitBeforeCheckingMenuCollision)
}
}, [isOpen])
return (
<div ref={containerRef}>
<Button ref={buttonRef} onClick={toggleMenu} className="flex items-center justify-center min-w-76 mt-2">
<Icon type="user-switch" className="color-neutral mr-2" />
Switch workspace
</Button>
{isOpen && (
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
appState={appState}
isOpen={isOpen}
hideWorkspaceOptions={true}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { ChangeEditorMenu } from './ChangeEditorMenu'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
application: WebApplication
appState: AppState
onClickPreprocessing?: () => Promise<void>
}
export const ChangeEditorButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const note = Object.values(appState.notes.selectedNotes)[0]
const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
const toggleChangeEditorMenu = async () => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(newOpenState)
setTimeout(() => {
setIsVisible(newOpenState)
})
}
}
return (
<div ref={containerRef}>
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button border-contrast"
>
<VisuallyHidden>Change note type</VisuallyHidden>
<Icon type="dashboard" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{isOpen && (
<ChangeEditorMenu
closeOnBlur={closeOnBlur}
application={application}
isVisible={isVisible}
note={note}
closeMenu={() => {
setIsOpen(false)
}}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
},
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { FunctionalComponent } from 'preact'
interface IProps {
deprecationMessage: string | undefined
dismissDeprecationMessage: () => void
}
export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage, dismissDeprecationMessage }) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-label warning'}>{deprecationMessage || 'This extension is deprecated.'}</div>
</div>
</div>
<div className={'right'}>
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
<button className={'sn-button small info'}>Dismiss</button>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -1,22 +1,17 @@
import { FunctionalComponent } from 'preact';
import { FunctionalComponent } from 'preact'
interface IProps {
componentName: string;
reloadIframe: () => void;
componentName: string
reloadIframe: () => void
}
export const IssueOnLoading: FunctionalComponent<IProps> = ({
componentName,
reloadIframe,
}) => {
export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => {
return (
<div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}>
<div className={'sk-app-bar-item'}>
<div className={'sk-label.warning'}>
There was an issue loading {componentName}.
</div>
<div className={'sk-label.warning'}>There was an issue loading {componentName}.</div>
</div>
</div>
<div className={'right'}>
@@ -26,5 +21,5 @@ export const IssueOnLoading: FunctionalComponent<IProps> = ({
</div>
</div>
</div>
);
};
)
}

View File

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

View File

@@ -0,0 +1,22 @@
import { FunctionalComponent } from 'preact'
interface IProps {
componentName: string
}
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
return (
<div className={'sn-component'}>
<div className={'sk-panel static'}>
<div className={'sk-panel-content'}>
<div className={'sk-panel-section stretch'}>
<div className={'sk-panel-section-title'}>This extension is missing its URL property.</div>
<p>In order to access your note immediately, please switch from {componentName} to the Plain Editor.</p>
<br />
<p>Please contact help@standardnotes.com to remedy this issue.</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,221 @@
import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ComponentViewer,
ComponentViewerEvent,
ComponentViewerError,
} from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { FunctionalComponent } from 'preact'
import { useCallback, useEffect, 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
}
/**
* 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 [loadTimeout, setLoadTimeout] = useState<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])
useEffect(() => {
const loadTimeout = setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
}
}, MaxLoadThreshold)
setLoadTimeout(loadTimeout)
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentViewer])
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement
const contentWindow = iframe.contentWindow as Window
if (loadTimeout) {
clearTimeout(loadTimeout)
}
try {
componentViewer.setWindow(contentWindow)
} catch (error) {
console.error(error)
}
setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(false)
onLoad?.(component)
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, loadTimeout])
useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus())
}
})
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,117 @@
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'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { isDesktopApplication } from '@/Utils'
type Props = {
application: WebApplication
appState: AppState
applicationGroup: ApplicationGroup
}
export const ConfirmSignoutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
})
export const ConfirmSignoutModal = observer(({ application, appState, applicationGroup }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
const cancelRef = useRef<HTMLButtonElement>(null)
function closeDialog() {
appState.accountMenu.setSigningOut(false)
}
const [localBackupsCount, setLocalBackupsCount] = useState(0)
useEffect(() => {
application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
}, [appState.accountMenu.signingOut, application.desktopDevice])
const workspaces = applicationGroup.getDescriptors()
const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication()
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">
<div>
<p className="color-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
{showWorkspaceWarning && (
<>
<br />
<p className="color-foreground">
<strong>Note: </strong>
Because you have other workspaces signed in, this sign out may leave logs and other metadata
of your session on this device. For a more robust sign out that performs a hard clear of all
app-related data, use the <i>Sign out all workspaces</i> option under <i>Switch workspace</i>.
</p>
</>
)}
</div>
</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.desktopDevice?.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' : 'Delete Workspace'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
)
})

View File

@@ -1,36 +1,29 @@
import {
ListboxArrow,
ListboxButton,
ListboxInput,
ListboxList,
ListboxOption,
ListboxPopover,
} from '@reach/listbox';
import VisuallyHidden from '@reach/visually-hidden';
import { FunctionComponent } from 'preact';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
import VisuallyHidden from '@reach/visually-hidden'
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'
export type DropdownItem = {
icon?: IconType;
iconClassName?: string;
label: string;
value: string;
disabled?: boolean;
};
icon?: IconType
iconClassName?: string
label: string
value: string
disabled?: boolean
}
type DropdownProps = {
id: string;
label: string;
items: DropdownItem[];
value: string;
onChange: (value: string, item: DropdownItem) => void;
disabled?: boolean;
};
id: string
label: string
items: DropdownItem[]
value: string
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
}
type ListboxButtonProps = DropdownItem & {
isExpanded: boolean;
};
isExpanded: boolean
}
const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
label,
@@ -47,56 +40,38 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
) : null}
<div className="dropdown-selected-label">{label}</div>
</div>
<ListboxArrow
className={`sn-dropdown-arrow ${
isExpanded ? 'sn-dropdown-arrow-flipped' : ''
}`}
>
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
<Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" />
</ListboxArrow>
</>
);
)
export const Dropdown: FunctionComponent<DropdownProps> = ({
id,
label,
items,
value,
onChange,
disabled,
}) => {
const labelId = `${id}-label`;
export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
const labelId = `${id}-label`
const handleChange = (value: string) => {
const selectedItem = items.find(
(item) => item.value === value
) as DropdownItem;
const selectedItem = items.find((item) => item.value === value) as DropdownItem
onChange(value, selectedItem);
};
onChange(value, selectedItem)
}
return (
<>
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput
value={value}
onChange={handleChange}
aria-labelledby={labelId}
disabled={disabled}
>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<ListboxButton
className="sn-dropdown-button"
children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value);
const icon = current ? current?.icon : null;
const iconClassName = current ? current?.iconClassName : null;
const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null
const iconClassName = current ? current?.iconClassName : null
return CustomDropdownButton({
value: value ? value : label.toLowerCase(),
label,
isExpanded,
...(icon ? { icon } : null),
...(iconClassName ? { iconClassName } : null),
});
})
}}
/>
<ListboxPopover className="sn-dropdown sn-dropdown-popover">
@@ -111,10 +86,7 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
>
{item.icon ? (
<div className="flex mr-3">
<Icon
type={item.icon}
className={`sn-icon--small ${item.iconClassName ?? ''}`}
/>
<Icon type={item.icon} className={`sn-icon--small ${item.iconClassName ?? ''}`} />
</div>
) : null}
<div className="text-input">{item.label}</div>
@@ -125,5 +97,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
</ListboxPopover>
</ListboxInput>
</>
);
};
)
}

View File

@@ -0,0 +1,37 @@
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
type Props = {
file: FileItem
}
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
return (
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
<div className="flex items-center mb-4">
<Icon type="info" className="mr-2" />
<div className="font-semibold">File information</div>
</div>
<div className="mb-3">
<span className="font-semibold">Type:</span> {file.mimeType}
</div>
<div className="mb-3">
<span className="font-semibold">Decrypted Size:</span> {formatSizeToReadableString(file.decryptedSize)}
</div>
<div className="mb-3">
<span className="font-semibold">Encrypted Size:</span> {formatSizeToReadableString(file.encryptedSize)}
</div>
<div className="mb-3">
<span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
</div>
<div className="mb-3">
<span className="font-semibold">Last Modified:</span> {file.userModifiedDate.toLocaleString()}
</div>
<div>
<span className="font-semibold">File ID:</span> {file.uuid}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { WebApplication } from '@/UIModels/Application'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import { DialogContent, DialogOverlay } from '@reach/dialog'
import { addToast, ToastType } from '@standardnotes/stylekit'
import { NoPreviewIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem'
import { Button } from '@/Components/Button/Button'
import { Icon } from '@/Components/Icon'
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
import { isFileTypePreviewable } from './isFilePreviewable'
import { PreviewComponent } from './PreviewComponent'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { KeyboardKey } from '@/Services/IOService'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
type Props = {
application: WebApplication
appState: AppState
}
export const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appState }) => {
const { currentFile, setCurrentFile, otherFiles, dismiss, isOpen } = appState.filePreviewModal
if (!currentFile || !isOpen) {
return null
}
const [objectUrl, setObjectUrl] = useState<string>()
const [isFilePreviewable, setIsFilePreviewable] = useState(false)
const [isLoadingFile, setIsLoadingFile] = useState(true)
const [fileDownloadProgress, setFileDownloadProgress] = useState(0)
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
const currentFileIdRef = useRef<string>()
const closeButtonRef = useRef<HTMLButtonElement>(null)
const getObjectUrl = useCallback(async () => {
try {
const chunks: Uint8Array[] = []
setFileDownloadProgress(0)
await application.files.downloadFile(currentFile, async (decryptedChunk, progress) => {
chunks.push(decryptedChunk)
if (progress) {
setFileDownloadProgress(Math.round(progress.percentComplete))
}
})
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
setObjectUrl(
URL.createObjectURL(
new Blob([finalDecryptedBytes], {
type: currentFile.mimeType,
}),
),
)
} catch (error) {
console.error(error)
} finally {
setIsLoadingFile(false)
}
}, [application.files, currentFile])
useEffect(() => {
setIsLoadingFile(true)
}, [currentFile.uuid])
useEffect(() => {
const isPreviewable = isFileTypePreviewable(currentFile.mimeType)
setIsFilePreviewable(isPreviewable)
if (!isPreviewable) {
setObjectUrl('')
setIsLoadingFile(false)
}
if (currentFileIdRef.current !== currentFile.uuid && isPreviewable) {
getObjectUrl().catch(console.error)
}
currentFileIdRef.current = currentFile.uuid
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [currentFile, getObjectUrl, objectUrl])
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
return
}
event.preventDefault()
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
switch (event.key) {
case KeyboardKey.Left: {
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
const previousFile = otherFiles[previousFileIndex]
if (previousFile) {
setCurrentFile(previousFile)
}
break
}
case KeyboardKey.Right: {
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
const nextFile = otherFiles[nextFileIndex]
if (nextFile) {
setCurrentFile(nextFile)
}
break
}
}
}
return (
<DialogOverlay
className="sn-component"
aria-label="File preview modal"
onDismiss={dismiss}
initialFocusRef={closeButtonRef}
dangerouslyBypassScrollLock
>
<DialogContent
className="flex flex-col rounded shadow-overlay"
style={{
width: '90%',
maxWidth: '90%',
minHeight: '90%',
background: 'var(--sn-stylekit-background-color)',
}}
>
<div
className="flex flex-shrink-0 justify-between items-center min-h-6 px-4 py-3 border-0 border-b-1 border-solid border-main focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onKeyDown={keyDownHandler}
>
<div className="flex items-center">
<div className="w-6 h-6">
{getFileIconComponent(
application.iconsController.getIconForFileType(currentFile.mimeType),
'w-6 h-6 flex-shrink-0',
)}
</div>
<span className="ml-3 font-medium">{currentFile.name}</span>
</div>
<div className="flex items-center">
<button
className="flex p-1.5 mr-4 bg-transparent hover:bg-contrast border-solid border-main border-1 cursor-pointer rounded"
onClick={() => setShowFileInfoPanel((show) => !show)}
>
<Icon type="info" className="color-neutral" />
</button>
{objectUrl && (
<Button
variant="primary"
className="mr-4"
onClick={() => {
application.getArchiveService().downloadData(objectUrl, currentFile.name)
addToast({
type: ToastType.Success,
message: 'Successfully downloaded file',
})
}}
>
Download
</Button>
)}
<button
ref={closeButtonRef}
onClick={dismiss}
aria-label="Close modal"
className="flex p-1 bg-transparent hover:bg-contrast border-0 cursor-pointer rounded"
>
<Icon type="close" className="color-neutral" />
</button>
</div>
</div>
<div className="flex flex-grow min-h-0">
<div className="flex flex-grow items-center justify-center relative max-w-full">
{isLoadingFile ? (
<div className="flex flex-col items-center">
<div className="flex items-center">
<div className="sk-spinner w-5 h-5 spinner-info mr-3"></div>
<div className="text-base font-semibold">{fileDownloadProgress}%</div>
</div>
<span className="mt-3">Loading file...</span>
</div>
) : objectUrl ? (
<PreviewComponent file={currentFile} objectUrl={objectUrl} />
) : (
<div className="flex flex-col items-center">
<NoPreviewIllustration className="w-30 h-30 mb-4" />
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
{isFilePreviewable ? (
<>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
There was an error loading the file. Try again, or download the file and open it using another
application.
</div>
<div className="flex items-center">
<Button
variant="primary"
className="mr-3"
onClick={() => {
getObjectUrl().catch(console.error)
}}
>
Try again
</Button>
<Button
variant="normal"
onClick={() => {
application.getAppState().files.downloadFile(currentFile).catch(console.error)
}}
>
Download
</Button>
</div>
</>
) : (
<>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
To view this file, download it and open it using another application.
</div>
<Button
variant="primary"
onClick={() => {
application.getAppState().files.downloadFile(currentFile).catch(console.error)
}}
>
Download
</Button>
</>
)}
</div>
)}
</div>
{showFileInfoPanel && <FilePreviewInfoPanel file={currentFile} />}
</div>
</DialogContent>
</DialogOverlay>
)
})

View File

@@ -0,0 +1,71 @@
import { IconType } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { useRef, useState } from 'preact/hooks'
import { IconButton } from '../Button/IconButton'
type Props = {
objectUrl: string
}
export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
const initialImgHeightRef = useRef<number>()
const [imageZoomPercent, setImageZoomPercent] = useState(100)
return (
<div className="flex items-center justify-center w-full h-full">
<div className="flex items-center justify-center w-full h-full relative overflow-auto">
<img
src={objectUrl}
style={{
height: `${imageZoomPercent}%`,
...(imageZoomPercent <= 100
? {
'min-width': '100%',
'object-fit': 'contain',
}
: {
position: 'absolute',
inset: 0,
margin: 'auto',
}),
}}
ref={(imgElement) => {
if (!initialImgHeightRef.current) {
initialImgHeightRef.current = imgElement?.height
}
}}
/>
</div>
<div className="flex items-center absolute left-1/2 -translate-x-1/2 bottom-6 py-1 px-3 bg-default border-1 border-solid border-main rounded">
<span className="mr-1.5">Zoom:</span>
<IconButton
className="hover:bg-contrast p-1 rounded"
icon={'subtract' as IconType}
title="Zoom Out"
focusable={true}
onClick={() => {
setImageZoomPercent((percent) => {
const newPercent = percent - 10
if (newPercent >= 10) {
return newPercent
} else {
return percent
}
})
}}
/>
<span className="mx-2">{imageZoomPercent}%</span>
<IconButton
className="hover:bg-contrast p-1 rounded"
icon="add"
title="Zoom In"
focusable={true}
onClick={() => {
setImageZoomPercent((percent) => percent + 10)
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { ImagePreview } from './ImagePreview'
type Props = {
file: FileItem
objectUrl: string
}
export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
if (file.mimeType.startsWith('image/')) {
return <ImagePreview objectUrl={objectUrl} />
}
if (file.mimeType.startsWith('video/')) {
return <video className="w-full h-full" src={objectUrl} controls />
}
if (file.mimeType.startsWith('audio/')) {
return <audio src={objectUrl} controls />
}
return <object className="w-full h-full" data={objectUrl} />
}

View File

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

View File

@@ -0,0 +1,461 @@
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
import { ApplicationEvent, ContentType, CollectionSort, ApplicationDescriptor } from '@standardnotes/snjs'
import {
STRING_NEW_UPDATE_READY,
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/Strings'
import { alertDialog, confirmDialog } from '@/Services/AlertService'
import { AccountMenu, AccountMenuPane } from '@/Components/AccountMenu'
import { AppStateEvent, EventSource } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon'
import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu'
import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu'
import { Fragment } from 'preact'
type Props = {
application: WebApplication
applicationGroup: ApplicationGroup
}
type State = {
outOfSync: boolean
dataUpgradeAvailable: boolean
hasPasscode: boolean
descriptors: ApplicationDescriptor[]
showBetaWarning: boolean
showSyncResolution: boolean
newUpdateAvailable: boolean
showAccountMenu: boolean
showQuickSettingsMenu: boolean
offline: boolean
hasError: boolean
arbitraryStatusMessage?: string
}
export class Footer extends PureComponent<Props, State> {
public user?: unknown
private didCheckForOffline = false
private completedInitialSync = false
private showingDownloadStatus = false
private webEventListenerDestroyer: () => void
private removeStatusObserver!: () => void
constructor(props: Props) {
super(props, props.application)
this.state = {
hasError: false,
offline: true,
outOfSync: false,
dataUpgradeAvailable: false,
hasPasscode: false,
descriptors: props.applicationGroup.getDescriptors(),
showBetaWarning: false,
showSyncResolution: false,
newUpdateAvailable: false,
showAccountMenu: false,
showQuickSettingsMenu: false,
}
this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => {
if (event === WebAppEvent.NewUpdateAvailable) {
this.onNewUpdateAvailable()
}
})
}
override deinit() {
this.removeStatusObserver()
;(this.removeStatusObserver as unknown) = undefined
this.webEventListenerDestroyer()
;(this.webEventListenerDestroyer as unknown) = undefined
super.deinit()
destroyAllObjectProperties(this)
}
override componentDidMount(): void {
super.componentDidMount()
this.removeStatusObserver = this.application.status.addEventObserver((_event, message) => {
this.setState({
arbitraryStatusMessage: message,
})
})
this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning
this.setState({
showBetaWarning: showBetaWarning,
showAccountMenu: this.appState.accountMenu.show,
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
})
})
}
reloadUpgradeStatus() {
this.application
.checkForSecurityUpdate()
.then((available) => {
this.setState({
dataUpgradeAvailable: available,
})
})
.catch(console.error)
}
override async onAppLaunch() {
super.onAppLaunch().catch(console.error)
this.reloadPasscodeStatus().catch(console.error)
this.reloadUser()
this.reloadUpgradeStatus()
this.updateOfflineStatus()
this.findErrors()
this.streamItems()
}
reloadUser() {
this.user = this.application.getUser()
}
async reloadPasscodeStatus() {
const hasPasscode = this.application.hasPasscode()
this.setState({
hasPasscode: hasPasscode,
})
}
override onAppStateEvent(eventName: AppStateEvent, data: any) {
const statusService = this.application.status
switch (eventName) {
case AppStateEvent.EditorFocused:
if (data.eventSource === EventSource.UserInteraction) {
this.closeAccountMenu()
}
break
case AppStateEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…')
break
case AppStateEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.'
const errorMessage = 'Unable to save local backup.'
statusService.setMessage(data.success ? successMessage : errorMessage)
const twoSeconds = 2000
setTimeout(() => {
if (statusService.message === successMessage || statusService.message === errorMessage) {
statusService.setMessage('')
}
}, twoSeconds)
break
}
}
}
override async onAppKeyChange() {
super.onAppKeyChange().catch(console.error)
this.reloadPasscodeStatus().catch(console.error)
}
override onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.KeyStatusChanged:
this.reloadUpgradeStatus()
break
case ApplicationEvent.EnteredOutOfSync:
this.setState({
outOfSync: true,
})
break
case ApplicationEvent.ExitedOutOfSync:
this.setState({
outOfSync: false,
})
break
case ApplicationEvent.CompletedFullSync:
if (!this.completedInitialSync) {
this.application.status.setMessage('')
this.completedInitialSync = true
}
if (!this.didCheckForOffline) {
this.didCheckForOffline = true
if (this.state.offline && this.application.items.getNoteCount() === 0) {
this.appState.accountMenu.setShow(true)
}
}
this.findErrors()
this.updateOfflineStatus()
break
case ApplicationEvent.SyncStatusChanged:
this.updateSyncStatus()
break
case ApplicationEvent.FailedSync:
this.updateSyncStatus()
this.findErrors()
this.updateOfflineStatus()
break
case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded:
this.updateLocalDataStatus()
break
case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut:
this.reloadUser()
break
case ApplicationEvent.WillSync:
if (!this.completedInitialSync) {
this.application.status.setMessage('Syncing…')
}
break
}
}
streamItems() {
this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
}
updateSyncStatus() {
const statusManager = this.application.status
const syncStatus = this.application.sync.getSyncStatus()
const stats = syncStatus.getStats()
if (syncStatus.hasError()) {
statusManager.setMessage('Unable to Sync')
} else if (stats.downloadCount > 20) {
const text = `Downloading ${stats.downloadCount} items. Keep app open.`
statusManager.setMessage(text)
this.showingDownloadStatus = true
} else if (this.showingDownloadStatus) {
this.showingDownloadStatus = false
statusManager.setMessage('Download Complete.')
setTimeout(() => {
statusManager.setMessage('')
}, 2000)
} else if (stats.uploadTotalCount > 20) {
const completionPercentage =
stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
const stringPercentage = completionPercentage.toLocaleString(undefined, {
style: 'percent',
})
statusManager.setMessage(`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`)
} else {
statusManager.setMessage('')
}
}
updateLocalDataStatus() {
const statusManager = this.application.status
const syncStatus = this.application.sync.getSyncStatus()
const stats = syncStatus.getStats()
const encryption = this.application.isEncryptionAvailable()
if (stats.localDataDone) {
statusManager.setMessage('')
return
}
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`
const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
statusManager.setMessage(loadingStatus)
}
updateOfflineStatus() {
this.setState({
offline: this.application.noAccount(),
})
}
findErrors() {
this.setState({
hasError: this.application.sync.getSyncStatus().hasError(),
})
}
securityUpdateClickHandler = async () => {
if (
await confirmDialog({
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
})
) {
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
await this.application.upgradeProtocolVersion()
}).catch(console.error)
}
}
accountMenuClickHandler = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
this.appState.accountMenu.toggleShow()
}
quickSettingsClickHandler = () => {
this.appState.accountMenu.closeAccountMenu()
this.appState.quickSettingsMenu.toggle()
}
syncResolutionClickHandler = () => {
this.setState({
showSyncResolution: !this.state.showSyncResolution,
})
}
closeAccountMenu = () => {
this.appState.accountMenu.setShow(false)
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
}
lockClickHandler = () => {
this.application.lock().catch(console.error)
}
onNewUpdateAvailable = () => {
this.setState({
newUpdateAvailable: true,
})
}
newUpdateClickHandler = () => {
this.setState({
newUpdateAvailable: false,
})
this.application.alertService.alert(STRING_NEW_UPDATE_READY).catch(console.error)
}
betaMessageClickHandler = () => {
alertDialog({
title: 'You are using a beta version of the app',
text: 'If you wish to go back to a stable version, make sure to sign out ' + 'of this beta app first.',
}).catch(console.error)
}
clickOutsideAccountMenu = () => {
this.appState.accountMenu.closeAccountMenu()
}
clickOutsideQuickSettingsMenu = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
}
override render() {
return (
<div className="sn-component">
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
<div className="left">
<div className="sk-app-bar-item ml-0">
<div
onClick={this.accountMenuClickHandler}
className={
(this.state.showAccountMenu ? 'bg-border' : '') +
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
}
>
<div className={this.state.hasError ? 'danger' : (this.user ? 'info' : 'neutral') + ' w-5 h-5'}>
<Icon type="account-circle" className="hover:color-info w-5 h-5 max-h-5" />
</div>
</div>
{this.state.showAccountMenu && (
<AccountMenu
onClickOutside={this.clickOutsideAccountMenu}
appState={this.appState}
application={this.application}
mainApplicationGroup={this.props.applicationGroup}
/>
)}
</div>
<div className="sk-app-bar-item ml-0-important">
<div
onClick={this.quickSettingsClickHandler}
className="w-8 h-full flex items-center justify-center cursor-pointer"
>
<div className="h-5">
<Icon
type="tune"
className={(this.state.showQuickSettingsMenu ? 'color-info' : '') + ' rounded hover:color-info'}
/>
</div>
</div>
{this.state.showQuickSettingsMenu && (
<QuickSettingsMenu
onClickOutside={this.clickOutsideQuickSettingsMenu}
appState={this.appState}
application={this.application}
/>
)}
</div>
{this.state.showBetaWarning && (
<Fragment>
<div className="sk-app-bar-item border" />
<div className="sk-app-bar-item">
<a onClick={this.betaMessageClickHandler} className="no-decoration sk-label title">
You are using a beta version of the app
</a>
</div>
</Fragment>
)}
</div>
<div className="center">
{this.state.arbitraryStatusMessage && (
<div className="sk-app-bar-item">
<div className="sk-app-bar-item-column">
<span className="neutral sk-label">{this.state.arbitraryStatusMessage}</span>
</div>
</div>
)}
</div>
<div className="right">
{this.state.dataUpgradeAvailable && (
<div onClick={this.securityUpdateClickHandler} className="sk-app-bar-item">
<span className="success sk-label">Encryption upgrade available.</span>
</div>
)}
{this.state.newUpdateAvailable && (
<div onClick={this.newUpdateClickHandler} className="sk-app-bar-item">
<span className="info sk-label">New update available.</span>
</div>
)}
{(this.state.outOfSync || this.state.showSyncResolution) && (
<div className="sk-app-bar-item">
{this.state.outOfSync && (
<div onClick={this.syncResolutionClickHandler} className="sk-label warning">
Potentially Out of Sync
</div>
)}
{this.state.showSyncResolution && (
<SyncResolutionMenu close={this.syncResolutionClickHandler} application={this.application} />
)}
</div>
)}
{this.state.offline && (
<div className="sk-app-bar-item">
<div className="sk-label">Offline</div>
</div>
)}
{this.state.hasPasscode && (
<Fragment>
<div className="sk-app-bar-item border" />
<div
id="lock-item"
onClick={this.lockClickHandler}
title="Locks application and wipes unencrypted data from memory."
className="sk-app-bar-item"
>
<div className="sk-label">
<i id="footer-lock-icon" className="icon ion-locked" />
</div>
</div>
</Fragment>
)}
</div>
</div>
</div>
)
}
}

View File

@@ -1,5 +1,5 @@
import { FunctionalComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
import { FunctionalComponent } from 'preact'
import { IconType } from '@standardnotes/snjs'
import {
AccessibilityIcon,
@@ -88,7 +88,8 @@ import {
UserSwitch,
WarningIcon,
WindowIcon,
} from '@standardnotes/stylekit';
SubtractIcon,
} from '@standardnotes/icons'
export const ICONS = {
'account-circle': AccountCircleIcon,
@@ -177,28 +178,25 @@ export const ICONS = {
user: UserIcon,
warning: WarningIcon,
window: WindowIcon,
};
subtract: SubtractIcon,
}
type Props = {
type: IconType;
className?: string;
ariaLabel?: string;
};
type: IconType
className?: string
ariaLabel?: string
}
export const Icon: FunctionalComponent<Props> = ({
type,
className = '',
ariaLabel,
}) => {
const IconComponent = ICONS[type as keyof typeof ICONS];
export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => {
const IconComponent = ICONS[type as keyof typeof ICONS]
if (!IconComponent) {
return null;
return null
}
return (
<IconComponent
className={`sn-icon ${className}`}
role="img"
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
{...(ariaLabel ? { 'aria-label': ariaLabel } : { 'aria-hidden': true })}
/>
);
};
)
}

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

View File

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

View File

@@ -0,0 +1,111 @@
import { JSX, FunctionComponent, ComponentChildren, VNode, RefCallback, ComponentChild, toChildArray } from 'preact'
import { useCallback, useEffect, useRef } from 'preact/hooks'
import { JSXInternal } from 'preact/src/jsx'
import { MenuItem, MenuItemListElement } from './MenuItem'
import { KeyboardKey } from '@/Services/IOService'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type MenuProps = {
className?: string
style?: string | JSX.CSSProperties | undefined
a11yLabel: string
children: ComponentChildren
closeMenu?: () => void
isOpen: boolean
initialFocus?: number
}
export const Menu: FunctionComponent<MenuProps> = ({
children,
className = '',
style,
a11yLabel,
closeMenu,
isOpen,
initialFocus,
}: MenuProps) => {
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const menuElementRef = useRef<HTMLMenuElement>(null)
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => {
if (!menuItemRefs.current) {
return
}
if (event.key === KeyboardKey.Escape) {
closeMenu?.()
return
}
},
[closeMenu],
)
useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) {
setTimeout(() => {
menuElementRef.current?.focus()
})
}
}, [isOpen])
const pushRefToArray: RefCallback<HTMLLIElement> = useCallback((instance) => {
if (instance && instance.children) {
Array.from(instance.children).forEach((child) => {
if (
child.getAttribute('role')?.includes('menuitem') &&
!menuItemRefs.current.includes(child as HTMLButtonElement)
) {
menuItemRefs.current.push(child as HTMLButtonElement)
}
})
}
}, [])
const mapMenuItems = useCallback(
(child: ComponentChild, index: number, array: ComponentChild[]): ComponentChild => {
if (!child || (Array.isArray(child) && child.length < 1)) {
return
}
if (Array.isArray(child)) {
return child.map(mapMenuItems)
}
const _child = child as VNode<unknown>
const isFirstMenuItem = index === array.findIndex((child) => (child as VNode<unknown>).type === MenuItem)
const hasMultipleItems = Array.isArray(_child.props.children)
? Array.from(_child.props.children as ComponentChild[]).some(
(child) => (child as VNode<unknown>).type === MenuItem,
)
: false
const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
return items.map((child) => {
return (
<MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
{child}
</MenuItemListElement>
)
})
},
[pushRefToArray],
)
return (
<menu
className={`m-0 p-0 list-style-none focus:shadow-none ${className}`}
onKeyDown={handleKeyDown}
ref={menuElementRef}
style={style}
aria-label={a11yLabel}
>
{toChildArray(children).map(mapMenuItems)}
</menu>
)
}

View File

@@ -0,0 +1,104 @@
import { ComponentChildren, FunctionComponent, VNode } from 'preact'
import { forwardRef, Ref } from 'preact/compat'
import { JSXInternal } from 'preact/src/jsx'
import { Icon } from '@/Components/Icon'
import { Switch, SwitchProps } from '@/Components/Switch'
import { IconType } from '@standardnotes/snjs'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}
type MenuItemProps = {
type: MenuItemType
children: ComponentChildren
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>
onChange?: SwitchProps['onChange']
onBlur?: (event: { relatedTarget: EventTarget | null }) => void
className?: string
checked?: boolean
icon?: IconType
iconClassName?: string
tabIndex?: number
}
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
(
{
children,
onClick,
onChange,
onBlur,
className = '',
type,
checked,
icon,
iconClassName,
tabIndex,
}: MenuItemProps,
ref: Ref<HTMLButtonElement>,
) => {
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
onClick={() => {
onChange(!checked)
}}
onBlur={onBlur}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
role="menuitemcheckbox"
aria-checked={checked}
>
<span className="flex flex-grow items-center">{children}</span>
<Switch className="px-0" checked={checked} />
</button>
) : (
<button
ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onClick={onClick}
onBlur={onBlur}
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
>
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} mr-2 flex-shrink-0`}></div>
) : null}
{children}
</button>
)
},
)
export const MenuItemSeparator: FunctionComponent = () => <div role="separator" className="h-1px my-2 bg-border"></div>
type ListElementProps = {
isFirstMenuItem: boolean
children: ComponentChildren
}
export const MenuItemListElement: FunctionComponent<ListElementProps> = forwardRef(
({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
const child = children as VNode<unknown>
return (
<li className="list-style-none" role="none" ref={ref}>
{{
...child,
props: {
...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem
? {
tabIndex: isFirstMenuItem ? 0 : -1,
}
: {}),
},
}}
</li>
)
},
)

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import { Icon } from '@/Components/Icon'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { useCallback } from 'preact/hooks'
type Props = { appState: AppState }
export const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show
if (!canShow) {
return null
}
const showAccountMenu = useCallback(
(event: Event) => {
event.stopPropagation()
appState.accountMenu.setShow(true)
},
[appState],
)
const hideWarning = useCallback(() => {
appState.noAccountWarning.hide()
}, [appState])
return (
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
Open Account menu
</button>
<button
onClick={hideWarning}
title="Ignore"
label="Ignore"
style="height: 20px"
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
>
<Icon type="close" className="block" />
</button>
</div>
)
})

View File

@@ -0,0 +1,72 @@
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> {
private removeChangeObserver!: () => void
constructor(props: Props) {
super(props, props.application)
this.state = {
showMultipleSelectedNotes: false,
controllers: [],
}
}
override componentDidMount(): void {
super.componentDidMount()
const controllerGroup = this.application.noteControllerGroup
this.removeChangeObserver = this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = controllerGroup.noteControllers
this.setState({
controllers: controllers,
})
})
this.autorun(() => {
if (this.appState && this.appState.notes) {
this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
})
}
})
}
override deinit() {
this.removeChangeObserver?.()
;(this.removeChangeObserver as unknown) = undefined
super.deinit()
}
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 key={controller.note.uuid} application={this.application} controller={controller} />
})}
</>
)}
</div>
)
}
}

View File

@@ -0,0 +1,139 @@
import { Icon } from '@/Components/Icon'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { AppState } from '@/UIModels/AppState'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
type Props = {
appState: AppState
tag: SNTag
}
export const NoteTag = observer(({ appState, tag }: Props) => {
const noteTags = appState.noteTags
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
const [showDeleteButton, setShowDeleteButton] = useState(false)
const [tagClicked, setTagClicked] = useState(false)
const deleteTagRef = useRef<HTMLButtonElement>(null)
const tagRef = useRef<HTMLButtonElement>(null)
const title = tag.title
const prefixTitle = noteTags.getPrefixTitle(tag)
const longTitle = noteTags.getLongTitle(tag)
const deleteTag = useCallback(() => {
appState.noteTags.focusPreviousTag(tag)
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
}, [appState, tag])
const onDeleteTagClick = useCallback(
(event: MouseEvent) => {
event.stopImmediatePropagation()
event.stopPropagation()
deleteTag()
},
[deleteTag],
)
const onTagClick = useCallback(
(event: MouseEvent) => {
if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false)
appState.tags.selected = tag
} else {
setTagClicked(true)
}
},
[appState, tagClicked, tag],
)
const onFocus = useCallback(() => {
appState.noteTags.setFocusedTagUuid(tag.uuid)
setShowDeleteButton(true)
}, [appState, tag])
const onBlur = useCallback(
(event: FocusEvent) => {
const relatedTarget = event.relatedTarget as Node
if (relatedTarget !== deleteTagRef.current) {
appState.noteTags.setFocusedTagUuid(undefined)
setShowDeleteButton(false)
}
},
[appState],
)
const getTabIndex = useCallback(() => {
if (focusedTagUuid) {
return focusedTagUuid === tag.uuid ? 0 : -1
}
if (autocompleteInputFocused) {
return -1
}
return tags[0].uuid === tag.uuid ? 0 : -1
}, [autocompleteInputFocused, tags, tag, focusedTagUuid])
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
const tagIndex = appState.noteTags.getTagIndex(tag, tags)
switch (event.key) {
case 'Backspace':
deleteTag()
break
case 'ArrowLeft':
appState.noteTags.focusPreviousTag(tag)
break
case 'ArrowRight':
if (tagIndex === tags.length - 1) {
appState.noteTags.setAutocompleteInputFocused(true)
} else {
appState.noteTags.focusNextTag(tag)
}
break
default:
return
}
},
[appState, deleteTag, tag, tags],
)
useEffect(() => {
if (focusedTagUuid === tag.uuid) {
tagRef.current?.focus()
}
}, [appState, focusedTagUuid, tag])
return (
<button
ref={tagRef}
className="sn-tag pl-1 pr-2 mr-2"
onClick={onTagClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
tabIndex={getTabIndex()}
title={longTitle}
>
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
{prefixTitle && <span className="color-grey-1">{prefixTitle}</span>}
{title}
</span>
{showDeleteButton && (
<button
ref={deleteTagRef}
type="button"
className="ml-2 -mr-1 border-0 p-0 bg-transparent cursor-pointer flex"
onBlur={onBlur}
onClick={onDeleteTagClick}
tabIndex={-1}
>
<Icon type="close" className="sn-icon--small color-neutral hover:color-info" />
</button>
)}
</button>
)
})

View File

@@ -0,0 +1,36 @@
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput'
import { NoteTag } from './NoteTag'
import { useEffect } from 'preact/hooks'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
appState: AppState
}
export const NoteTagsContainer = observer(({ appState }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const { tags, tagsContainerMaxWidth } = appState.noteTags
useEffect(() => {
appState.noteTags.reloadTagsContainerMaxWidth()
}, [appState])
return (
<div
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
style={{
maxWidth: tagsContainerMaxWidth,
}}
>
{tags.map((tag) => (
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
))}
<AutocompleteTagInput appState={appState} />
</div>
)
})

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
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])
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,60 +1,39 @@
import { WebApplication } from '@/ui_models/application';
import {
CollectionSort,
sanitizeHtmlString,
SNNote,
} from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { Icon } from './Icon';
import { WebApplication } from '@/UIModels/Application'
import { CollectionSort, CollectionSortProperty, sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
import { PLAIN_EDITOR_NAME } from '@/Constants'
type Props = {
application: WebApplication;
note: SNNote;
tags: string[];
hideDate: boolean;
hidePreview: boolean;
hideTags: boolean;
hideEditorIcon: boolean;
onClick: () => void;
onContextMenu: (e: MouseEvent) => void;
selected: boolean;
sortedBy?: CollectionSort;
};
application: WebApplication
note: SNNote
tags: string[]
hideDate: boolean
hidePreview: boolean
hideTags: boolean
hideEditorIcon: boolean
onClick: () => void
onContextMenu: (e: MouseEvent) => void
selected: boolean
sortedBy?: CollectionSortProperty
}
type NoteFlag = {
text: string;
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger';
};
text: string
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
}
const flagsForNote = (note: SNNote) => {
const flags = [] as NoteFlag[];
const flags = [] as NoteFlag[]
if (note.conflictOf) {
flags.push({
text: 'Conflicted Copy',
class: 'danger',
});
})
}
if (note.errorDecrypting) {
if (note.waitingForKey) {
flags.push({
text: 'Waiting For Keys',
class: 'info',
});
} else {
flags.push({
text: 'Missing Keys',
class: 'danger',
});
}
}
if (note.deleted) {
flags.push({
text: 'Deletion Pending Sync',
class: 'danger',
});
}
return flags;
};
return flags
}
export const NotesListItem: FunctionComponent<Props> = ({
application,
@@ -69,13 +48,12 @@ export const NotesListItem: FunctionComponent<Props> = ({
sortedBy,
tags,
}) => {
const flags = flagsForNote(note);
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
const editorForNote = application.componentManager.editorForNote(note);
const editorName = editorForNote?.name ?? 'Plain editor';
const [icon, tint] = application.iconsController.getIconAndTintForEditor(
editorForNote?.identifier
);
const flags = flagsForNote(note)
const hasFiles = application.items.getFilesForNote(note).length > 0
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt
const editorForNote = application.componentManager.editorForNote(note)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
return (
<div
@@ -86,17 +64,11 @@ export const NotesListItem: FunctionComponent<Props> = ({
>
{!hideEditorIcon && (
<div className="icon">
<Icon
ariaLabel={`Icon for ${editorName}`}
type={icon}
className={`color-accessory-tint-${tint}`}
/>
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} />
</div>
)}
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
<div className="name-container">
{note.title.length ? <div className="name">{note.title}</div> : null}
</div>
<div className="name-container">{note.title.length ? <div className="name">{note.title}</div> : null}</div>
{!hidePreview && !note.hidePreview && !note.protected && (
<div className="note-preview">
{note.preview_html && (
@@ -107,9 +79,7 @@ export const NotesListItem: FunctionComponent<Props> = ({
}}
></div>
)}
{!note.preview_html && note.preview_plain && (
<div className="plain-preview">{note.preview_plain}</div>
)}
{!note.preview_html && note.preview_plain && <div className="plain-preview">{note.preview_plain}</div>}
{!note.preview_html && !note.preview_plain && note.text && (
<div className="default-preview">{note.text}</div>
)}
@@ -118,22 +88,15 @@ export const NotesListItem: FunctionComponent<Props> = ({
{!hideDate || note.protected ? (
<div className="bottom-info faded">
{note.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
{!hideDate && showModifiedDate && (
<span>Modified {note.updatedAtString || 'Now'}</span>
)}
{!hideDate && !showModifiedDate && (
<span>{note.createdAtString || 'Now'}</span>
)}
{!hideDate && showModifiedDate && <span>Modified {note.updatedAtString || 'Now'}</span>}
{!hideDate && !showModifiedDate && <span>{note.createdAtString || 'Now'}</span>}
</div>
) : null}
{!hideTags && tags.length ? (
<div className="tags-string">
{tags.map((tag) => (
<span className="tag color-foreground">
<Icon
type="hashtag"
className="sn-icon--small color-grey-1 mr-1"
/>
<Icon type="hashtag" className="sn-icon--small color-grey-1 mr-1" />
<span>{tag}</span>
</span>
))}
@@ -152,41 +115,30 @@ export const NotesListItem: FunctionComponent<Props> = ({
<div className="flag-icons">
{note.locked && (
<span title="Editing Disabled">
<Icon
ariaLabel="Editing Disabled"
type="pencil-off"
className="sn-icon--small color-info"
/>
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="sn-icon--small color-info" />
</span>
)}
{note.trashed && (
<span title="Trashed">
<Icon
ariaLabel="Trashed"
type="trash-filled"
className="sn-icon--small color-danger"
/>
<Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
</span>
)}
{note.archived && (
<span title="Archived">
<Icon
ariaLabel="Archived"
type="archive"
className="sn-icon--mid color-accessory-tint-3"
/>
<Icon ariaLabel="Archived" type="archive" className="sn-icon--mid color-accessory-tint-3" />
</span>
)}
{note.pinned && (
<span title="Pinned">
<Icon
ariaLabel="Pinned"
type="pin-filled"
className="sn-icon--small color-info"
/>
<Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
</span>
)}
{hasFiles && (
<span title="Files">
<Icon ariaLabel="Files" type="attachment-file" className="sn-icon--small color-info" />
</span>
)}
</div>
</div>
);
};
)
}

View File

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

View File

@@ -0,0 +1,122 @@
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'
import { useCallback } from 'preact/hooks'
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 selectNextNote = useCallback(() => appState.notesView.selectNextNote(), [appState])
const selectPreviousNote = useCallback(() => appState.notesView.selectPreviousNote(), [appState])
const { hideTags, hideDate, hideNotePreview, hideEditorIcon, sortBy } = displayOptions
const tagsForNote = useCallback(
(note: SNNote): string[] => {
if (hideTags) {
return []
}
const selectedTag = appState.tags.selected
if (!selectedTag) {
return []
}
const tags = appState.getNoteTags(note)
if (selectedTag instanceof SNTag && tags.length === 1) {
return []
}
return tags.map((tag) => tag.title).sort()
},
[appState, hideTags],
)
const openNoteContextMenu = useCallback(
(posX: number, posY: number) => {
appState.notes.setContextMenuClickLocation({
x: posX,
y: posY,
})
appState.notes.reloadContextMenuLayout()
appState.notes.setContextMenuOpen(true)
},
[appState],
)
const onContextMenu = useCallback(
(note: SNNote, posX: number, posY: number) => {
appState.notes.selectNote(note.uuid, true).catch(console.error)
openNoteContextMenu(posX, posY)
},
[appState, openNoteContextMenu],
)
const onScroll = useCallback(
(e: Event) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD
const element = e.target as HTMLElement
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
paginate()
}
},
[paginate],
)
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === KeyboardKey.Up) {
e.preventDefault()
selectPreviousNote()
} else if (e.key === KeyboardKey.Down) {
e.preventDefault()
selectNextNote()
}
},
[selectNextNote, selectPreviousNote],
)
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 = useCallback(() => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen)
}, [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,76 @@
import { KeyboardKey } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { IconType, SNComponent, SNNote } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { ChangeEditorMenu } from './changeEditor/ChangeEditorMenu';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import { useCloseOnBlur } from '../utils';
import { KeyboardKey } from '@/Services/IOService'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { IconType, SNComponent, SNNote } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { ChangeEditorMenu } from '@/Components/ChangeEditor/ChangeEditorMenu'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type ChangeEditorOptionProps = {
appState: AppState;
application: WebApplication;
note: SNNote;
};
appState: AppState
application: WebApplication
note: SNNote
}
type AccordionMenuGroup<T> = {
icon?: IconType;
iconClassName?: string;
title: string;
items: Array<T>;
};
icon?: IconType
iconClassName?: string
title: string
items: Array<T>
}
export type EditorMenuItem = {
name: string;
component?: SNComponent;
isEntitled: boolean;
};
name: string
component?: SNComponent
isEntitled: boolean
}
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>;
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
application,
note,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ application, note }) => {
const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
});
const menuContainerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
})
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => {
setIsOpen(open);
setIsVisible(open);
});
setIsOpen(open)
setIsVisible(open)
})
const toggleChangeEditorMenu = () => {
const toggleChangeEditorMenu = useCallback(() => {
if (!isOpen) {
const menuStyle = calculateSubmenuStyle(buttonRef.current);
const menuStyle = calculateSubmenuStyle(buttonRef.current)
if (menuStyle) {
setMenuStyle(menuStyle);
setMenuStyle(menuStyle)
}
}
setIsOpen(!isOpen);
};
setIsOpen(!isOpen)
}, [isOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuStyle = calculateSubmenuStyle(
buttonRef.current,
menuRef.current
);
const newMenuStyle = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuStyle) {
setMenuStyle(newMenuStyle);
setIsVisible(true);
setMenuStyle(newMenuStyle)
setIsVisible(true)
}
});
})
}
}, [isOpen]);
}, [isOpen])
return (
<div ref={menuContainerRef}>
@@ -91,7 +78,7 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
<DisclosureButton
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false);
setIsOpen(false)
}
}}
onBlur={closeOnBlur}
@@ -100,7 +87,7 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
>
<div className="flex items-center">
<Icon type="dashboard" className="color-neutral mr-2" />
Change editor
Change note type
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
@@ -108,8 +95,8 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
ref={menuRef}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false);
buttonRef.current?.focus();
setIsOpen(false)
buttonRef.current?.focus()
}
}}
style={{
@@ -125,12 +112,12 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
note={note}
isVisible={isVisible}
closeMenu={() => {
setIsOpen(false);
setIsOpen(false)
}}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
);
};
)
}

View File

@@ -1,37 +1,30 @@
import { WebApplication } from '@/ui_models/application';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { useCloseOnBlur } from '../utils';
import { WebApplication } from '@/UIModels/Application'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = {
application: WebApplication;
note: SNNote;
};
application: WebApplication
note: SNNote
}
type ListedMenuGroup = {
name: string;
account: ListedAccount;
actions: Action[];
};
name: string
account: ListedAccount
actions: Action[]
}
type ListedMenuItemProps = {
action: Action;
note: SNNote;
group: ListedMenuGroup;
application: WebApplication;
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>;
};
action: Action
note: SNNote
group: ListedMenuGroup
application: WebApplication
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>
}
const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
action,
@@ -40,21 +33,21 @@ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
group,
reloadMenuGroup,
}) => {
const [isRunning, setIsRunning] = useState(false);
const [isRunning, setIsRunning] = useState(false)
const handleClick = async () => {
const handleClick = useCallback(async () => {
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)
}, [application, action, group, isRunning, note, reloadMenuGroup])
return (
<button
@@ -74,109 +67,99 @@ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
</div>
{isRunning && <div className="sk-spinner spinner-info w-3 h-3" />}
</button>
);
};
)
}
type ListedActionsMenuProps = {
application: WebApplication;
note: SNNote;
recalculateMenuStyle: () => void;
};
application: WebApplication
note: SNNote
recalculateMenuStyle: () => void
}
const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
application,
note,
recalculateMenuStyle,
}) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([]);
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true);
const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({ application, note, recalculateMenuStyle }) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true)
const reloadMenuGroup = async (group: ListedMenuGroup) => {
const updatedAccountInfo = await application.getListedAccountInfo(
group.account,
note.uuid
);
const reloadMenuGroup = useCallback(
async (group: ListedMenuGroup) => {
const updatedAccountInfo = await application.getListedAccountInfo(group.account, note.uuid)
if (!updatedAccountInfo) {
return;
}
const updatedGroup: ListedMenuGroup = {
name: updatedAccountInfo.display_name,
account: group.account,
actions: updatedAccountInfo.actions,
};
const updatedGroups = menuGroups.map((group) => {
if (updatedGroup.account.authorId === group.account.authorId) {
return updatedGroup;
} else {
return group;
if (!updatedAccountInfo) {
return
}
});
setMenuGroups(updatedGroups);
};
const updatedGroup: ListedMenuGroup = {
name: updatedAccountInfo.display_name,
account: group.account,
actions: updatedAccountInfo.actions as Action[],
}
const updatedGroups = menuGroups.map((group) => {
if (updatedGroup.account.authorId === group.account.authorId) {
return updatedGroup
} else {
return group
}
})
setMenuGroups(updatedGroups)
},
[application, menuGroups, note],
)
useEffect(() => {
const fetchListedAccounts = async () => {
if (!application.hasAccount()) {
setIsFetchingAccounts(false);
return;
setIsFetchingAccounts(false)
return
}
try {
const listedAccountEntries = await application.getListedAccounts();
const listedAccountEntries = await application.getListedAccounts()
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(
listedAccountEntries.map(async (account) => {
const accountInfo = await application.getListedAccountInfo(
account,
note.uuid
);
const accountInfo = await application.getListedAccountInfo(account, note.uuid)
if (accountInfo) {
menuGroups.push({
name: accountInfo.display_name,
account,
actions: accountInfo.actions,
});
actions: accountInfo.actions as Action[],
})
} else {
menuGroups.push({
name: account.authorId,
account,
actions: [],
});
})
}
})
);
}),
)
setMenuGroups(
menuGroups.sort((a, b) => {
return a.name.toString().toLowerCase() <
b.name.toString().toLowerCase()
? -1
: 1;
})
);
return a.name.toString().toLowerCase() < b.name.toString().toLowerCase() ? -1 : 1
}),
)
} catch (err) {
console.error(err);
console.error(err)
} finally {
setIsFetchingAccounts(false);
setIsFetchingAccounts(false)
setTimeout(() => {
recalculateMenuStyle();
});
recalculateMenuStyle()
})
}
};
}
fetchListedAccounts();
}, [application, note.uuid, recalculateMenuStyle]);
void fetchListedAccounts()
}, [application, note.uuid, recalculateMenuStyle])
return (
<>
@@ -208,9 +191,7 @@ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
/>
))
) : (
<div className="px-3 py-2 color-grey-0 select-none">
No actions available
</div>
<div className="px-3 py-2 color-grey-0 select-none">No actions available</div>
)}
</Fragment>
))}
@@ -218,70 +199,58 @@ const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
) : null}
{!isFetchingAccounts && !menuGroups.length ? (
<div className="w-full flex items-center justify-center px-4 py-6">
<div className="color-grey-0 select-none">
No Listed accounts found
</div>
<div className="color-grey-0 select-none">No Listed accounts found</div>
</div>
) : null}
</>
);
};
)
}
export const ListedActionsOption: FunctionComponent<Props> = ({
application,
note,
}) => {
const menuContainerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
export const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
});
})
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen);
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const toggleListedMenu = () => {
const toggleListedMenu = useCallback(() => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition);
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen);
};
setIsMenuOpen(!isMenuOpen)
}, [isMenuOpen])
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(
menuButtonRef.current,
menuRef.current
);
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition);
setMenuStyle(newMenuPosition)
}
}, []);
}, [])
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle();
});
recalculateMenuStyle()
})
}
}, [isMenuOpen, recalculateMenuStyle]);
}, [isMenuOpen, recalculateMenuStyle])
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleListedMenu}>
<DisclosureButton
ref={menuButtonRef}
onBlur={closeOnBlur}
className="sn-dropdown-item justify-between"
>
<DisclosureButton ref={menuButtonRef} onBlur={closeOnBlur} className="sn-dropdown-item justify-between">
<div className="flex items-center">
<Icon type="listed" className="color-neutral mr-2" />
Listed actions
@@ -297,14 +266,10 @@ export const ListedActionsOption: FunctionComponent<Props> = ({
className="sn-dropdown flex flex-col max-h-120 min-w-68 pb-1 fixed overflow-y-auto"
>
{isMenuOpen && (
<ListedActionsMenu
application={application}
note={note}
recalculateMenuStyle={recalculateMenuStyle}
/>
<ListedActionsMenu application={application} note={note} recalculateMenuStyle={recalculateMenuStyle} />
)}
</DisclosurePanel>
</Disclosure>
</div>
);
};
)
}

View File

@@ -0,0 +1,443 @@
import { AppState } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon'
import { Switch } from '@/Components/Switch'
import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback } from 'preact/hooks'
import { SNApplication, SNNote } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { KeyboardModifier } from '@/Services/IOService'
import { FunctionComponent } from 'preact'
import { ChangeEditorOption } from './ChangeEditorOption'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
import { ListedActionsOption } from './ListedActionsOption'
import { AddTagOption } from './AddTagOption'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
export type NotesOptionsProps = {
application: WebApplication
appState: AppState
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
type DeletePermanentlyButtonProps = {
closeOnBlur: NotesOptionsProps['closeOnBlur']
onClick: () => void
}
const DeletePermanentlyButton = ({ closeOnBlur, onClick }: DeletePermanentlyButtonProps) => (
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}>
<Icon type="close" className="color-danger mr-2" />
<span className="color-danger">Delete permanently</span>
</button>
)
const iconClass = 'color-neutral mr-2'
const iconClassDanger = 'color-danger mr-2'
const iconClassWarning = 'color-warning mr-2'
const iconClassSuccess = 'color-success mr-2'
const getWordCount = (text: string) => {
if (text.trim().length === 0) {
return 0
}
return text.split(/\s+/).length
}
const getParagraphCount = (text: string) => {
if (text.trim().length === 0) {
return 0
}
return text.replace(/\n$/gm, '').split(/\n/).length
}
const countNoteAttributes = (text: string) => {
try {
JSON.parse(text)
return {
characters: 'N/A',
words: 'N/A',
paragraphs: 'N/A',
}
} catch {
const characters = text.length
const words = getWordCount(text)
const paragraphs = getParagraphCount(text)
return {
characters,
words,
paragraphs,
}
}
}
const calculateReadTime = (words: number) => {
const timeToRead = Math.round(words / 200)
if (timeToRead === 0) {
return '< 1 minute'
} else {
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
}
}
const formatDate = (date: Date | undefined) => {
if (!date) {
return
}
return `${date.toDateString()} ${date.toLocaleTimeString()}`
}
const NoteAttributes: FunctionComponent<{
application: SNApplication
note: SNNote
}> = ({ application, note }) => {
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
const readTime = useMemo(() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words])
const dateLastModified = useMemo(() => formatDate(note.userModifiedDate), [note.userModifiedDate])
const dateCreated = useMemo(() => formatDate(note.created_at), [note.created_at])
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return (
<div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium">
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
<>
<div className="mb-1">
{words} words · {characters} characters · {paragraphs} paragraphs
</div>
<div className="mb-1">
<span className="font-semibold">Read time:</span> {readTime}
</div>
</>
) : null}
<div className="mb-1">
<span className="font-semibold">Last modified:</span> {dateLastModified}
</div>
<div className="mb-1">
<span className="font-semibold">Created:</span> {dateCreated}
</div>
<div>
<span className="font-semibold">Note ID:</span> {note.uuid}
</div>
</div>
)
}
const SpellcheckOptions: FunctionComponent<{
appState: AppState
note: SNNote
}> = ({ appState, note }) => {
const editor = appState.application.componentManager.editorForNote(note)
const spellcheckControllable = Boolean(!editor || editor.package_info.spellcheckControl)
const noteSpellcheck = !spellcheckControllable
? true
: note
? appState.notes.getSpellcheckStateForNote(note)
: undefined
return (
<div className="flex flex-col">
<button
className="sn-dropdown-item justify-between px-3 py-1"
onClick={() => {
appState.notes.toggleGlobalSpellcheckForNote(note).catch(console.error)
}}
disabled={!spellcheckControllable}
>
<span className="flex items-center">
<Icon type="notes" className={iconClass} />
Spellcheck
</span>
<Switch className="px-0" checked={noteSpellcheck} disabled={!spellcheckControllable} />
</button>
{!spellcheckControllable && (
<p className="text-xs px-3 py-1.5">Spellcheck cannot be controlled for this editor.</p>
)}
</div>
)
}
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
const NoteSizeWarning: FunctionComponent<{
note: SNNote
}> = ({ note }) => {
return new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
<Icon type="warning" className="color-accessory-tint-3 flex-shrink-0 mr-3" />
<div className="color-grey-0 select-none leading-140% max-w-80%">
This note may have trouble syncing to the mobile application due to its size.
</div>
</div>
) : null
}
export const NotesOptions = observer(({ application, appState, closeOnBlur }: NotesOptionsProps) => {
const [altKeyDown, setAltKeyDown] = useState(false)
const toggleOn = (condition: (note: SNNote) => boolean) => {
const notesMatchingAttribute = notes.filter(condition)
const notesNotMatchingAttribute = notes.filter((note) => !condition(note))
return notesMatchingAttribute.length > notesNotMatchingAttribute.length
}
const notes = Object.values(appState.notes.selectedNotes)
const hidePreviews = toggleOn((note) => note.hidePreview)
const locked = toggleOn((note) => note.locked)
const protect = toggleOn((note) => note.protected)
const archived = notes.some((note) => note.archived)
const unarchived = notes.some((note) => !note.archived)
const trashed = notes.some((note) => note.trashed)
const notTrashed = notes.some((note) => !note.trashed)
const pinned = notes.some((note) => note.pinned)
const unpinned = notes.some((note) => !note.pinned)
useEffect(() => {
const removeAltKeyObserver = application.io.addKeyObserver({
modifiers: [KeyboardModifier.Alt],
onKeyDown: () => {
setAltKeyDown(true)
},
onKeyUp: () => {
setAltKeyDown(false)
},
})
return () => {
removeAltKeyObserver()
}
}, [application])
const getNoteFileName = useCallback(
(note: SNNote): string => {
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return `${note.title}.${format}`
},
[application],
)
const downloadSelectedItems = useCallback(async () => {
if (notes.length === 1) {
application.getArchiveService().downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]))
return
}
if (notes.length > 1) {
const loadingToastId = addToast({
type: ToastType.Loading,
message: `Exporting ${notes.length} notes...`,
})
await application.getArchiveService().downloadDataAsZip(
notes.map((note) => {
return {
filename: getNoteFileName(note),
content: new Blob([note.text]),
}
}),
)
dismissToast(loadingToastId)
addToast({
type: ToastType.Success,
message: `Exported ${notes.length} notes`,
})
}
}, [application, getNoteFileName, notes])
const duplicateSelectedItems = useCallback(() => {
notes.forEach((note) => {
application.mutator.duplicateItem(note).catch(console.error)
})
}, [application, notes])
const openRevisionHistoryModal = useCallback(() => {
appState.notes.setShowRevisionHistoryModal(true)
}, [appState])
return (
<>
{notes.length === 1 && (
<>
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={openRevisionHistoryModal}>
<Icon type="history" className={iconClass} />
Note history
</button>
<div className="min-h-1px my-2 bg-border"></div>
</>
)}
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setLockSelectedNotes(!locked)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="pencil-off" className={iconClass} />
Prevent editing
</span>
<Switch className="px-0" checked={locked} />
</button>
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setHideSelectedNotePreviews(!hidePreviews)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="rich-text" className={iconClass} />
Show preview
</span>
<Switch className="px-0" checked={!hidePreviews} />
</button>
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setProtectSelectedNotes(!protect).catch(console.error)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className={iconClass} />
Password protect
</span>
<Switch className="px-0" checked={protect} />
</button>
{notes.length === 1 && (
<>
<div className="min-h-1px my-2 bg-border"></div>
<ChangeEditorOption appState={appState} application={application} note={notes[0]} />
</>
)}
<div className="min-h-1px my-2 bg-border"></div>
{appState.tags.tagsCount > 0 && <AddTagOption appState={appState} />}
{unpinned && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setPinSelectedNotes(true)
}}
>
<Icon type="pin" className={iconClass} />
Pin to top
</button>
)}
{pinned && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setPinSelectedNotes(false)
}}
>
<Icon type="unpin" className={iconClass} />
Unpin
</button>
)}
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={downloadSelectedItems}>
<Icon type="download" className={iconClass} />
Export
</button>
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={duplicateSelectedItems}>
<Icon type="copy" className={iconClass} />
Duplicate
</button>
{unarchived && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setArchiveSelectedNotes(true).catch(console.error)
}}
>
<Icon type="archive" className={iconClassWarning} />
<span className="color-warning">Archive</span>
</button>
)}
{archived && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setArchiveSelectedNotes(false).catch(console.error)
}}
>
<Icon type="unarchive" className={iconClassWarning} />
<span className="color-warning">Unarchive</span>
</button>
)}
{notTrashed &&
(altKeyDown ? (
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await appState.notes.deleteNotesPermanently()
}}
/>
) : (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(true)
}}
>
<Icon type="trash" className={iconClassDanger} />
<span className="color-danger">Move to trash</span>
</button>
))}
{trashed && (
<>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(false)
}}
>
<Icon type="restore" className={iconClassSuccess} />
<span className="color-success">Restore</span>
</button>
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await appState.notes.deleteNotesPermanently()
}}
/>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.emptyTrash()
}}
>
<div className="flex items-start">
<Icon type="trash-sweep" className="color-danger mr-2" />
<div className="flex-row">
<div className="color-danger">Empty Trash</div>
<div className="text-xs">{appState.notes.trashedNotesCount} notes in Trash</div>
</div>
</div>
</button>
</>
)}
{notes.length === 1 ? (
<>
<div className="min-h-1px my-2 bg-border"></div>
<ListedActionsOption application={application} note={notes[0]} />
<div className="min-h-1px my-2 bg-border"></div>
<SpellcheckOptions appState={appState} note={notes[0]} />
<div className="min-h-1px my-2 bg-border"></div>
<NoteAttributes application={application} note={notes[0]} />
<NoteSizeWarning note={notes[0]} />
</>
) : null}
</>
)
})

View File

@@ -0,0 +1,86 @@
import { AppState } from '@/UIModels/AppState'
import { Icon } from '@/Components/Icon'
import VisuallyHidden from '@reach/visually-hidden'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useRef, useState } from 'preact/hooks'
import { observer } from 'mobx-react-lite'
import { NotesOptions } from './NotesOptions'
import { WebApplication } from '@/UIModels/Application'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
type Props = {
application: WebApplication
appState: AppState
onClickPreprocessing?: () => Promise<void>
}
export const NotesOptionsPanel = observer(({ application, appState, onClickPreprocessing }: Props) => {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
return (
<Disclosure
open={open}
onChange={async () => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setOpen(newOpenState)
}
}}
>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button border-contrast"
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{open && <NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />}
</DisclosurePanel>
</Disclosure>
)
})

View File

@@ -0,0 +1,261 @@
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 { useCallback, 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'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
application: WebApplication
appState: AppState
}
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const notesViewPanelRef = useRef<HTMLDivElement>(null)
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
const {
completedFullSync,
displayOptions,
noteFilterText,
optionsSubtitle,
panelTitle,
renderedNotes,
searchBarElement,
paginate,
panelWidth,
} = appState.notesView
const { selectedNotes } = appState.notes
const createNewNote = useCallback(() => appState.notesView.createNewNote(), [appState])
const onFilterEnter = useCallback(() => appState.notesView.onFilterEnter(), [appState])
const clearFilterText = useCallback(() => appState.notesView.clearFilterText(), [appState])
const setNoteFilterText = useCallback((text: string) => appState.notesView.setNoteFilterText(text), [appState])
const selectNextNote = useCallback(() => appState.notesView.selectNextNote(), [appState])
const selectPreviousNote = useCallback(() => appState.notesView.selectPreviousNote(), [appState])
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
const [focusedSearch, setFocusedSearch] = useState(false)
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu)
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()
void createNewNote()
},
})
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, createNewNote, selectPreviousNote, searchBarElement, selectNextNote])
const onNoteFilterTextChange = useCallback(
(e: Event) => {
setNoteFilterText((e.target as HTMLInputElement).value)
},
[setNoteFilterText],
)
const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
const onNoteFilterKeyUp = useCallback(
(e: KeyboardEvent) => {
if (e.key === KeyboardKey.Enter) {
onFilterEnter()
}
},
[onFilterEnter],
)
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
appState.noteTags.reloadTagsContainerMaxWidth()
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed)
},
[appState, application],
)
const panelWidthEventCallback = useCallback(() => {
appState.noteTags.reloadTagsContainerMaxWidth()
}, [appState])
const toggleDisplayOptionsMenu = useCallback(() => {
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
}, [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,67 @@
import { useCallback, 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)
const closeDialog = useCallback(() => {
appState.accountMenu.setOtherSessionsSignOut(false)
}, [appState])
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,314 @@
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 { createRef, JSX } from 'preact';
import { PureComponent } from './Abstract/PureComponent';
import { WebApplication } from '@/UIModels/Application'
import { createRef, JSX } from 'preact'
import { PureComponent } from '@/Components/Abstract/PureComponent'
interface Props {
application: WebApplication;
application: WebApplication
}
type State = {
continueTitle: string;
formData: FormData;
isContinuing?: boolean;
lockContinue?: boolean;
processing?: boolean;
showSpinner?: boolean;
step: Steps;
title: string;
};
continueTitle: string
formData: FormData
isContinuing?: boolean
lockContinue?: boolean
processing?: boolean
showSpinner?: boolean
step: Steps
title: string
}
const DEFAULT_CONTINUE_TITLE = 'Continue';
const DEFAULT_CONTINUE_TITLE = 'Continue'
enum Steps {
PasswordStep = 1,
@@ -25,40 +25,40 @@ enum Steps {
}
type FormData = {
currentPassword?: string;
newPassword?: string;
newPasswordConfirmation?: string;
status?: string;
};
currentPassword?: string
newPassword?: string
newPasswordConfirmation?: string
status?: string
}
export class PasswordWizard extends PureComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>();
private currentPasswordInput = createRef<HTMLInputElement>()
constructor(props: Props) {
super(props, props.application);
this.registerWindowUnloadStopper();
super(props, props.application)
this.registerWindowUnloadStopper()
this.state = {
formData: {},
continueTitle: DEFAULT_CONTINUE_TITLE,
step: Steps.PasswordStep,
title: 'Change Password',
};
}
}
componentDidMount(): void {
super.componentDidMount();
this.currentPasswordInput.current?.focus();
override componentDidMount(): void {
super.componentDidMount()
this.currentPasswordInput.current?.focus()
}
componentWillUnmount(): void {
super.componentWillUnmount();
window.onbeforeunload = null;
override componentWillUnmount(): void {
super.componentWillUnmount()
window.onbeforeunload = null
}
registerWindowUnloadStopper() {
window.onbeforeunload = () => {
return true;
};
return true
}
}
resetContinueState() {
@@ -66,35 +66,35 @@ export class PasswordWizard extends PureComponent<Props, State> {
showSpinner: false,
continueTitle: DEFAULT_CONTINUE_TITLE,
isContinuing: false,
});
})
}
nextStep = async () => {
if (this.state.lockContinue || this.state.isContinuing) {
return;
return
}
if (this.state.step === Steps.FinishStep) {
this.dismiss();
return;
this.dismiss()
return
}
this.setState({
isContinuing: true,
showSpinner: true,
continueTitle: 'Generating Keys...',
});
})
const valid = await this.validateCurrentPassword();
const valid = await this.validateCurrentPassword()
if (!valid) {
this.resetContinueState();
return;
this.resetContinueState()
return
}
const success = await this.processPasswordChange();
const success = await this.processPasswordChange()
if (!success) {
this.resetContinueState();
return;
this.resetContinueState()
return
}
this.setState({
@@ -102,103 +102,95 @@ export class PasswordWizard extends PureComponent<Props, State> {
showSpinner: false,
continueTitle: 'Finish',
step: Steps.FinishStep,
});
};
})
}
async validateCurrentPassword() {
const currentPassword = this.state.formData.currentPassword;
const newPass = this.state.formData.newPassword;
const currentPassword = this.state.formData.currentPassword
const newPass = this.state.formData.newPassword
if (!currentPassword || currentPassword.length === 0) {
this.application.alertService.alert(
'Please enter your current password.'
);
return false;
this.application.alertService.alert('Please enter your current password.').catch(console.error)
return false
}
if (!newPass || newPass.length === 0) {
this.application.alertService.alert('Please enter a new password.');
return false;
this.application.alertService.alert('Please enter a new password.').catch(console.error)
return false
}
if (newPass !== this.state.formData.newPasswordConfirmation) {
this.application.alertService.alert(
'Your new password does not match its confirmation.'
);
this.application.alertService.alert('Your new password does not match its confirmation.').catch(console.error)
this.setFormDataState({
status: undefined,
});
return false;
}).catch(console.error)
return false
}
if (!this.application.getUser()?.email) {
this.application.alertService.alert(
"We don't have your email stored. Please sign out then log back in to fix this issue."
);
this.application.alertService
.alert("We don't have your email stored. Please sign out then log back in to fix this issue.")
.catch(console.error)
this.setFormDataState({
status: undefined,
});
return false;
}).catch(console.error)
return false
}
/** Validate current password */
const success = await this.application.validateAccountPassword(
this.state.formData.currentPassword!
);
const success = await this.application.validateAccountPassword(this.state.formData.currentPassword as string)
if (!success) {
this.application.alertService.alert(
'The current password you entered is not correct. Please try again.'
);
this.application.alertService
.alert('The current password you entered is not correct. Please try again.')
.catch(console.error)
}
return success;
return success
}
async processPasswordChange() {
await this.application.downloadBackup();
await this.application.downloadBackup()
this.setState({
lockContinue: true,
processing: true,
});
})
await this.setFormDataState({
status: 'Processing encryption keys…',
});
})
const newPassword = this.state.formData.newPassword;
const newPassword = this.state.formData.newPassword
const response = await this.application.changePassword(
this.state.formData.currentPassword!,
newPassword!
);
this.state.formData.currentPassword as string,
newPassword as string,
)
const success = !response.error;
const success = !response.error
this.setState({
processing: false,
lockContinue: false,
});
})
if (!success) {
this.setFormDataState({
status: 'Unable to process your password. Please try again.',
});
}).catch(console.error)
} else {
this.setState({
formData: {
...this.state.formData,
status: 'Successfully changed password.',
},
});
})
}
return success;
return success
}
dismiss = () => {
if (this.state.lockContinue) {
this.application.alertService.alert(
'Cannot close window until pending tasks are complete.'
);
this.application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
this.dismissModal();
this.dismissModal()
}
};
}
async setFormDataState(formData: Partial<FormData>) {
return this.setState({
@@ -206,34 +198,28 @@ export class PasswordWizard extends PureComponent<Props, State> {
...this.state.formData,
...formData,
},
});
})
}
handleCurrentPasswordInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
handleCurrentPasswordInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
currentPassword: currentTarget.value,
});
};
}).catch(console.error)
}
handleNewPasswordInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
handleNewPasswordInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
newPassword: currentTarget.value,
});
};
}).catch(console.error)
}
handleNewPasswordConfirmationInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
handleNewPasswordConfirmationInputChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
newPasswordConfirmation: currentTarget.value,
});
};
}).catch(console.error)
}
render() {
override render() {
return (
<div className="sn-component">
<div id="password-wizard" className="sk-modal small auto-height">
@@ -242,9 +228,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{this.state.title}
</div>
<div className="sk-panel-header-title">{this.state.title}</div>
<a onClick={this.dismiss} className="sk-a info close-button">
Close
</a>
@@ -255,10 +239,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sk-panel-row">
<div className="sk-panel-column stretch">
<form className="sk-panel-form">
<label
htmlFor="password-wiz-current-password"
className="block mb-1"
>
<label htmlFor="password-wiz-current-password" className="block mb-1">
Current Password
</label>
@@ -273,10 +254,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
<div className="sk-panel-row" />
<label
htmlFor="password-wiz-new-password"
className="block mb-1"
>
<label htmlFor="password-wiz-new-password" className="block mb-1">
New Password
</label>
@@ -289,21 +267,14 @@ export class PasswordWizard extends PureComponent<Props, State> {
/>
<div className="sk-panel-row" />
<label
htmlFor="password-wiz-confirm-new-password"
className="block mb-1"
>
<label htmlFor="password-wiz-confirm-new-password" className="block mb-1">
Confirm New Password
</label>
<input
id="password-wiz-confirm-new-password"
value={
this.state.formData.newPasswordConfirmation
}
onChange={
this.handleNewPasswordConfirmationInputChange
}
value={this.state.formData.newPasswordConfirmation}
onChange={this.handleNewPasswordConfirmationInputChange}
type="password"
className="sk-input contrast"
/>
@@ -314,13 +285,10 @@ export class PasswordWizard extends PureComponent<Props, State> {
)}
{this.state.step === Steps.FinishStep && (
<div className="sk-panel-section">
<div className="sk-label sk-bold info">
Your password has been successfully changed.
</div>
<div className="sk-label sk-bold info">Your password has been successfully changed.</div>
<p className="sk-p">
Please ensure you are running the latest version of
Standard Notes on all platforms to ensure maximum
compatibility.
Please ensure you are running the latest version of Standard Notes on all platforms to ensure
maximum compatibility.
</p>
</div>
)}
@@ -339,6 +307,6 @@ export class PasswordWizard extends PureComponent<Props, State> {
</div>
</div>
</div>
);
)
}
}

View File

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

View File

@@ -0,0 +1,45 @@
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'
import { useCallback } from 'preact/hooks'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = {
appState: AppState
className?: string
onClickPreprocessing?: () => Promise<void>
}
export const PinNoteButton: FunctionComponent<Props> = observer(
({ appState, className = '', onClickPreprocessing }: Props) => {
if (isStateDealloced(appState)) {
return null
}
const notes = Object.values(appState.notes.selectedNotes)
const pinned = notes.some((note) => note.pinned)
const togglePinned = useCallback(async () => {
if (onClickPreprocessing) {
await onClickPreprocessing()
}
if (!pinned) {
appState.notes.setPinSelectedNotes(true)
} else {
appState.notes.setPinSelectedNotes(false)
}
}, [appState, onClickPreprocessing, pinned])
return (
<button
className={`sn-icon-button border-contrast ${pinned ? 'toggled' : ''} ${className}`}
onClick={togglePinned}
>
<VisuallyHidden>Pin selected notes</VisuallyHidden>
<Icon type="pin" className="block" />
</button>
)
},
)

View File

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

View File

@@ -0,0 +1,46 @@
import { AccountMenuPane } from '@/Components/AccountMenu'
import { Button } from '@/Components/Button/Button'
import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { AccountIllustration } from '@standardnotes/icons'
export const Authentication: FunctionComponent<{
application: WebApplication
appState: AppState
}> = observer(({ appState }) => {
const clickSignIn = () => {
appState.preferences.closePreferences()
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn)
appState.accountMenu.setShow(true)
}
const clickRegister = () => {
appState.preferences.closePreferences()
appState.accountMenu.setCurrentPane(AccountMenuPane.Register)
appState.accountMenu.setShow(true)
}
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-col items-center px-12">
<AccountIllustration className="mb-3" />
<Title>You're not signed in</Title>
<Text className="text-center mb-3">
Sign in to sync your notes and preferences across all your devices and enable end-to-end encryption.
</Text>
<Button variant="primary" label="Create free account" onClick={clickRegister} className="mb-3" />
<div className="text-input">
Already have an account?{' '}
<button className="border-0 p-0 bg-default color-info underline cursor-pointer" onClick={clickSignIn}>
Sign in
</button>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
})

View File

@@ -1,19 +1,16 @@
import { StateUpdater } from 'preact/hooks';
import { FunctionalComponent } from 'preact';
import { StateUpdater } from 'preact/hooks'
import { FunctionalComponent } from 'preact'
type Props = {
setNewEmail: StateUpdater<string>;
setCurrentPassword: StateUpdater<string>;
};
setNewEmail: 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> = ({
setNewEmail,
setCurrentPassword,
}) => {
export const ChangeEmailForm: FunctionalComponent<Props> = ({ setNewEmail, setCurrentPassword }) => {
return (
<div className="w-full flex flex-col">
<div className="mt-2 mb-3">
@@ -25,7 +22,7 @@ export const ChangeEmailForm: FunctionalComponent<Props> = ({
className={inputClassName}
type="email"
onChange={({ target }) => {
setNewEmail((target as HTMLInputElement).value);
setNewEmail((target as HTMLInputElement).value)
}}
/>
</div>
@@ -38,10 +35,10 @@ export const ChangeEmailForm: FunctionalComponent<Props> = ({
className={inputClassName}
type="password"
onChange={({ target }) => {
setCurrentPassword((target as HTMLInputElement).value);
setCurrentPassword((target as HTMLInputElement).value)
}}
/>
</div>
</div>
);
};
)
}

View File

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

View File

@@ -0,0 +1,160 @@
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,64 @@
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,65 @@
import { WebApplication } from '@/UIModels/Application'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { SubscriptionSettingName } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { PreferencesGroup, PreferencesSegment, Subtitle, Title } from '../../PreferencesComponents'
type Props = {
application: WebApplication
}
export const FilesSection: FunctionComponent<Props> = ({ application }) => {
const [isLoading, setIsLoading] = useState(true)
const [filesQuotaUsed, setFilesQuotaUsed] = useState<number>(0)
const [filesQuotaTotal, setFilesQuotaTotal] = useState<number>(0)
useEffect(() => {
const getFilesQuota = async () => {
const filesQuotaUsed = await application.settings.getSubscriptionSetting(
SubscriptionSettingName.FileUploadBytesUsed,
)
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
SubscriptionSettingName.FileUploadBytesLimit,
)
if (filesQuotaUsed) {
setFilesQuotaUsed(parseFloat(filesQuotaUsed))
}
if (filesQuotaTotal) {
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
}
setIsLoading(false)
}
getFilesQuota().catch(console.error)
}, [application])
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Files</Title>
<Subtitle>Storage Quota</Subtitle>
{isLoading ? (
<div className="mt-2">
<div className="sk-spinner spinner-info w-3 h-3"></div>
</div>
) : (
<>
<div className="mt-1 mb-1">
<span className="font-semibold">{formatSizeToReadableString(filesQuotaUsed)}</span> of{' '}
<span>{formatSizeToReadableString(filesQuotaTotal)}</span> used
</div>
<progress
className="w-full progress-bar"
aria-label="Files storage used"
value={filesQuotaUsed}
max={filesQuotaTotal}
/>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
)
}

View File

@@ -0,0 +1,126 @@
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 { OtherSessionsSignOutContainer } from '@/components/OtherSessionsSignOut';
import { Button } from '@/Components/Button/Button'
import { OtherSessionsSignOutContainer } from '@/Components/OtherSessionsSignOut'
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/components/Preferences/components';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
} from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/UIModels/Application'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
const SignOutView: FunctionComponent<{
application: WebApplication;
appState: AppState;
application: WebApplication
appState: AppState
}> = observer(({ application, appState }) => {
return (
<>
@@ -27,72 +27,60 @@ const SignOutView: FunctionComponent<{
<div className="flex flex-row">
<Button
className="mr-3"
type="normal"
variant="normal"
label="Sign out other sessions"
onClick={() => {
appState.accountMenu.setOtherSessionsSignOut(true);
appState.accountMenu.setOtherSessionsSignOut(true)
}}
/>
<Button
type="normal"
label="Manage sessions"
onClick={() => appState.openSessionsModal()}
/>
<Button variant="normal" label="Manage sessions" onClick={() => appState.openSessionsModal()} />
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>This workspace</Subtitle>
<Text>
Remove all data related to the current workspace from the
application.
</Text>
<Text>Remove all data related to the current workspace from the application.</Text>
<div className="min-h-3" />
<Button
type="danger"
dangerStyle={true}
label="Sign out workspace"
onClick={() => {
appState.accountMenu.setSigningOut(true);
appState.accountMenu.setSigningOut(true)
}}
/>
</PreferencesSegment>
</PreferencesGroup>
<OtherSessionsSignOutContainer
appState={appState}
application={application}
/>
<OtherSessionsSignOutContainer appState={appState} application={application} />
</>
);
});
)
})
const ClearSessionDataView: FunctionComponent<{
appState: AppState;
appState: AppState
}> = observer(({ appState }) => {
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Clear workspace</Title>
<Text>
Remove all data related to the current workspace from the application.
</Text>
<Text>Remove all data related to the current workspace from the application.</Text>
<div className="min-h-3" />
<Button
type="danger"
dangerStyle={true}
label="Clear workspace"
onClick={() => {
appState.accountMenu.setSigningOut(true);
appState.accountMenu.setSigningOut(true)
}}
/>
</PreferencesSegment>
</PreferencesGroup>
);
});
)
})
export const SignOutWrapper: FunctionComponent<{
application: WebApplication;
appState: AppState;
application: WebApplication
appState: AppState
}> = observer(({ application, appState }) => {
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,41 @@
import { FunctionalComponent } from 'preact'
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents'
import { Button } from '@/Components/Button/Button'
import { WebApplication } from '@/UIModels/Application'
import { useState } from 'preact/hooks'
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
export const NoSubscription: FunctionalComponent<{
application: WebApplication
}> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
const onPurchaseClick = async () => {
const errorMessage = 'There was an error when attempting to redirect you to the subscription page.'
setIsLoadingPurchaseFlow(true)
try {
if (!(await loadPurchaseFlowUrl(application))) {
setPurchaseFlowError(errorMessage)
}
} catch (e) {
setPurchaseFlowError(errorMessage)
} finally {
setIsLoadingPurchaseFlow(false)
}
}
return (
<>
<Text>You don't have a Standard Notes subscription yet.</Text>
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="color-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="min-w-20 mt-3 mr-3" label="Learn More" link={window.plansUrl as string} />
{application.hasAccount() && (
<Button className="min-w-20 mt-3" variant="primary" label="Subscribe" onClick={onPurchaseClick} />
)}
</div>
</>
)
}

View File

@@ -0,0 +1,36 @@
import { PreferencesGroup, PreferencesSegment, Title } from '@/Components/Preferences/PreferencesComponents'
import { WebApplication } from '@/UIModels/Application'
import { SubscriptionInformation } from './SubscriptionInformation'
import { NoSubscription } from './NoSubscription'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact'
import { AppState } from '@/UIModels/AppState'
type Props = {
application: WebApplication
appState: AppState
}
export const Subscription: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
const subscriptionState = appState.subscription
const { userSubscription } = subscriptionState
const now = new Date().getTime()
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Subscription</Title>
{userSubscription && userSubscription.endsAt > now ? (
<SubscriptionInformation subscriptionState={subscriptionState} application={application} />
) : (
<NoSubscription application={application} />
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
})

View File

@@ -0,0 +1,83 @@
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,58 @@
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,31 @@
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'
import { FilesSection } from './Files'
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} />
{application.hasAccount() && appState.features.hasFiles && <FilesSection application={application} />}
<SignOutWrapper application={application} appState={appState} />
</PreferencesPane>
))

View File

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

View File

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

View File

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

View File

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

View File

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

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