mirror of
https://github.com/standardnotes/web.git
synced 2024-08-17 18:50:27 +03:00
refactor: format and lint codebase (#971)
This commit is contained in:
parent
dc9c1ea0fc
commit
8e467f9e6d
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
declare module '*.svg' {
|
||||
export default function SvgComponent(props: React.SVGProps<SVGSVGElement>): JSX.Element;
|
||||
export default function SvgComponent(props: React.SVGProps<SVGSVGElement>): JSX.Element
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
declare module 'qrcode.react';
|
||||
declare module 'qrcode.react'
|
||||
|
79
app/assets/javascripts/App.tsx
Normal file
79
app/assets/javascripts/App.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use strict'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dashboardUrl?: string
|
||||
defaultSyncServer: string
|
||||
devAccountEmail?: string
|
||||
devAccountPassword?: string
|
||||
devAccountServer?: string
|
||||
enabledUnfinishedFeatures: boolean
|
||||
plansUrl?: string
|
||||
purchaseUrl?: string
|
||||
startApplication?: StartApplication
|
||||
websocketUrl: string
|
||||
electronAppVersion?: string
|
||||
}
|
||||
}
|
||||
|
||||
import { IsWebPlatform, WebAppVersion } from '@/Version'
|
||||
import { Runtime, SNLog } from '@standardnotes/snjs'
|
||||
import { render } from 'preact'
|
||||
import { ApplicationGroupView } from './Components/ApplicationGroupView'
|
||||
import { Bridge } from './Services/Bridge'
|
||||
import { BrowserBridge } from './Services/BrowserBridge'
|
||||
import { StartApplication } from './StartApplication'
|
||||
import { ApplicationGroup } from './UIModels/ApplicationGroup'
|
||||
import { isDev } from './Utils'
|
||||
|
||||
const startApplication: StartApplication = async function startApplication(
|
||||
defaultSyncServerHost: string,
|
||||
bridge: Bridge,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
webSocketUrl: string,
|
||||
) {
|
||||
SNLog.onLog = console.log
|
||||
SNLog.onError = console.error
|
||||
|
||||
const mainApplicationGroup = new ApplicationGroup(
|
||||
defaultSyncServerHost,
|
||||
bridge,
|
||||
enableUnfinishedFeatures ? Runtime.Dev : Runtime.Prod,
|
||||
webSocketUrl,
|
||||
)
|
||||
|
||||
if (isDev) {
|
||||
Object.defineProperties(window, {
|
||||
application: {
|
||||
get: () => mainApplicationGroup.primaryApplication,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const renderApp = () => {
|
||||
render(
|
||||
<ApplicationGroupView mainApplicationGroup={mainApplicationGroup} />,
|
||||
document.body.appendChild(document.createElement('div')),
|
||||
)
|
||||
}
|
||||
|
||||
const domReady = document.readyState === 'complete' || document.readyState === 'interactive'
|
||||
if (domReady) {
|
||||
renderApp()
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
renderApp()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (IsWebPlatform) {
|
||||
startApplication(
|
||||
window.defaultSyncServer,
|
||||
new BrowserBridge(WebAppVersion),
|
||||
window.enabledUnfinishedFeatures,
|
||||
window.websocketUrl,
|
||||
).catch(console.error)
|
||||
} else {
|
||||
window.startApplication = startApplication
|
||||
}
|
135
app/assets/javascripts/Components/Abstract/PureComponent.tsx
Normal file
135
app/assets/javascripts/Components/Abstract/PureComponent.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState, AppStateEvent } from '@/UIModels/AppState'
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
|
||||
import { Component } from 'preact'
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
|
||||
|
||||
export type PureComponentState = Partial<Record<string, any>>
|
||||
export type PureComponentProps = Partial<Record<string, any>>
|
||||
|
||||
export abstract class PureComponent<
|
||||
P = PureComponentProps,
|
||||
S = PureComponentState,
|
||||
> extends Component<P, S> {
|
||||
private unsubApp!: () => void
|
||||
private unsubState!: () => void
|
||||
private reactionDisposers: IReactionDisposer[] = []
|
||||
|
||||
constructor(props: P, protected application: WebApplication) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
this.addAppEventObserver()
|
||||
this.addAppStateObserver()
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.unsubApp?.()
|
||||
this.unsubState?.()
|
||||
for (const disposer of this.reactionDisposers) {
|
||||
disposer()
|
||||
}
|
||||
this.reactionDisposers.length = 0
|
||||
;(this.unsubApp as unknown) = undefined
|
||||
;(this.unsubState as unknown) = undefined
|
||||
}
|
||||
|
||||
protected dismissModal(): void {
|
||||
const elem = this.getElement()
|
||||
if (!elem) {
|
||||
return
|
||||
}
|
||||
|
||||
const parent = elem.parentElement
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
parent.remove()
|
||||
unmountComponentAtNode(parent)
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.deinit()
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>Must override</div>
|
||||
}
|
||||
|
||||
public get appState(): AppState {
|
||||
return this.application.getAppState()
|
||||
}
|
||||
|
||||
protected getElement(): Element | null {
|
||||
return findDOMNode(this)
|
||||
}
|
||||
|
||||
autorun(view: (r: IReactionPublic) => void): void {
|
||||
this.reactionDisposers.push(autorun(view))
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.unsubState = this.application.getAppState().addObserver(async (eventName, data) => {
|
||||
this.onAppStateEvent(eventName, data)
|
||||
})
|
||||
}
|
||||
|
||||
onAppStateEvent(_eventName: AppStateEvent, _data: unknown) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
if (this.application.isStarted()) {
|
||||
this.onAppStart().catch(console.error)
|
||||
}
|
||||
if (this.application.isLaunched()) {
|
||||
this.onAppLaunch().catch(console.error)
|
||||
}
|
||||
this.unsubApp = this.application.addEventObserver(async (eventName, data: unknown) => {
|
||||
this.onAppEvent(eventName, data)
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart()
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch()
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync()
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync()
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange().catch(console.error)
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onAppEvent(_eventName: ApplicationEvent, _data?: unknown) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppIncrementalSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppFullSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
}
|
@ -1,96 +1,85 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { DecoratedInput } from '../DecoratedInput';
|
||||
import { Icon } from '../Icon';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { Checkbox } from '@/Components/Checkbox'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
disabled?: boolean;
|
||||
onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void;
|
||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
disabled?: boolean
|
||||
onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void
|
||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
||||
}
|
||||
|
||||
export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
({
|
||||
appState,
|
||||
application,
|
||||
disabled = false,
|
||||
onVaultChange,
|
||||
onStrictSignInChange,
|
||||
children,
|
||||
}) => {
|
||||
const { server, setServer, enableServerOption, setEnableServerOption } =
|
||||
appState.accountMenu;
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
({ appState, application, disabled = false, onVaultChange, onStrictSignInChange, children }) => {
|
||||
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const [isVault, setIsVault] = useState(false);
|
||||
const [vaultName, setVaultName] = useState('');
|
||||
const [vaultUserphrase, setVaultUserphrase] = useState('');
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
const [vaultName, setVaultName] = useState('')
|
||||
const [vaultUserphrase, setVaultUserphrase] = useState('')
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false);
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const recomputeVaultedEmail = async () => {
|
||||
const vaultedEmail = await application.vaultToEmail(
|
||||
vaultName,
|
||||
vaultUserphrase
|
||||
);
|
||||
const vaultedEmail = await application.vaultToEmail(vaultName, vaultUserphrase)
|
||||
|
||||
if (!vaultedEmail) {
|
||||
if (vaultName?.length > 0 && vaultUserphrase?.length > 0) {
|
||||
application.alertService.alert('Unable to compute vault name.');
|
||||
application.alertService.alert('Unable to compute vault name.').catch(console.error)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
onVaultChange?.(true, vaultedEmail);
|
||||
};
|
||||
onVaultChange?.(true, vaultedEmail)
|
||||
}
|
||||
|
||||
if (vaultName && vaultUserphrase) {
|
||||
recomputeVaultedEmail();
|
||||
recomputeVaultedEmail().catch(console.error)
|
||||
}
|
||||
}, [vaultName, vaultUserphrase, application, onVaultChange]);
|
||||
}, [vaultName, vaultUserphrase, application, onVaultChange])
|
||||
|
||||
useEffect(() => {
|
||||
onVaultChange?.(isVault);
|
||||
}, [isVault, onVaultChange]);
|
||||
onVaultChange?.(isVault)
|
||||
}, [isVault, onVaultChange])
|
||||
|
||||
const handleIsVaultChange = () => {
|
||||
setIsVault(!isVault);
|
||||
};
|
||||
setIsVault(!isVault)
|
||||
}
|
||||
|
||||
const handleVaultNameChange = (name: string) => {
|
||||
setVaultName(name);
|
||||
};
|
||||
setVaultName(name)
|
||||
}
|
||||
|
||||
const handleVaultUserphraseChange = (userphrase: string) => {
|
||||
setVaultUserphrase(userphrase);
|
||||
};
|
||||
setVaultUserphrase(userphrase)
|
||||
}
|
||||
|
||||
const handleServerOptionChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setEnableServerOption(e.target.checked);
|
||||
setEnableServerOption(e.target.checked)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSyncServerChange = (server: string) => {
|
||||
setServer(server);
|
||||
application.setCustomHost(server);
|
||||
};
|
||||
setServer(server)
|
||||
application.setCustomHost(server).catch(console.error)
|
||||
}
|
||||
|
||||
const handleStrictSigninChange = () => {
|
||||
const newValue = !isStrictSignin;
|
||||
setIsStrictSignin(newValue);
|
||||
onStrictSignInChange?.(newValue);
|
||||
};
|
||||
const newValue = !isStrictSignin
|
||||
setIsStrictSignin(newValue)
|
||||
onStrictSignInChange?.(newValue)
|
||||
}
|
||||
|
||||
const toggleShowAdvanced = () => {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
};
|
||||
setShowAdvanced(!showAdvanced)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -130,7 +119,7 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
{appState.enableUnfinishedFeatures && isVault && (
|
||||
<>
|
||||
<DecoratedInput
|
||||
className={`mb-2`}
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="folder" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Vault name"
|
||||
@ -139,7 +128,7 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DecoratedInput
|
||||
className={`mb-2`}
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="server" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Vault userphrase"
|
||||
@ -188,6 +177,6 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,96 +1,96 @@
|
||||
import { STRING_NON_MATCHING_PASSWORDS } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AccountMenuPane } from '.';
|
||||
import { Button } from '../Button';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
|
||||
import { Icon } from '../Icon';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Checkbox } from '@/Components/Checkbox'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
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 { 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);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}, []);
|
||||
passwordInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
setConfirmPassword(text);
|
||||
};
|
||||
setConfirmPassword(text)
|
||||
}
|
||||
|
||||
const handleEphemeralChange = () => {
|
||||
setIsEphemeral(!isEphemeral);
|
||||
};
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}
|
||||
|
||||
const handleShouldMergeChange = () => {
|
||||
setShouldMergeLocal(!shouldMergeLocal);
|
||||
};
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (error.length) {
|
||||
setError('');
|
||||
setError('')
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirmFormSubmit(e);
|
||||
handleConfirmFormSubmit(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleConfirmFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!password) {
|
||||
passwordInputRef.current?.focus();
|
||||
return;
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (password === confirmPassword) {
|
||||
setIsRegistering(true);
|
||||
setIsRegistering(true)
|
||||
application
|
||||
.register(email, password, isEphemeral, shouldMergeLocal)
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message);
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
appState.accountMenu.closeAccountMenu();
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
console.error(err)
|
||||
setError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRegistering(false);
|
||||
});
|
||||
setIsRegistering(false)
|
||||
})
|
||||
} else {
|
||||
setError(STRING_NON_MATCHING_PASSWORDS);
|
||||
setConfirmPassword('');
|
||||
passwordInputRef.current?.focus();
|
||||
setError(STRING_NON_MATCHING_PASSWORDS)
|
||||
setConfirmPassword('')
|
||||
passwordInputRef.current?.focus()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
setMenuPane(AccountMenuPane.Register);
|
||||
};
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -110,8 +110,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
|
||||
<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.
|
||||
. If you forget your password, you will permanently lose access to your data.
|
||||
</div>
|
||||
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
|
||||
<DecoratedPasswordInput
|
||||
@ -127,9 +126,7 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
|
||||
{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'
|
||||
}
|
||||
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
|
||||
variant="primary"
|
||||
onClick={handleConfirmFormSubmit}
|
||||
disabled={isRegistering}
|
||||
@ -152,6 +149,6 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,90 +1,82 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AccountMenuPane } from '.';
|
||||
import { Button } from '../Button';
|
||||
import { DecoratedInput } from '../DecoratedInput';
|
||||
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
|
||||
import { Icon } from '../Icon';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { AdvancedOptions } from './AdvancedOptions';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AccountMenuPane } from '.'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
email: string;
|
||||
setEmail: StateUpdater<string>;
|
||||
password: string;
|
||||
setPassword: StateUpdater<string>;
|
||||
};
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
email: string
|
||||
setEmail: StateUpdater<string>
|
||||
password: string
|
||||
setPassword: StateUpdater<string>
|
||||
}
|
||||
|
||||
export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
({
|
||||
appState,
|
||||
application,
|
||||
setMenuPane,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
}) => {
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isVault, setIsVault] = useState(false);
|
||||
({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => {
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef.current) {
|
||||
emailInputRef.current?.focus();
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const handleEmailChange = (text: string) => {
|
||||
setEmail(text);
|
||||
};
|
||||
setEmail(text)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
setPassword(text);
|
||||
};
|
||||
setPassword(text)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRegisterFormSubmit(e);
|
||||
handleRegisterFormSubmit(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRegisterFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef.current?.focus();
|
||||
return;
|
||||
emailInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef.current?.focus();
|
||||
return;
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
setEmail(email);
|
||||
setPassword(password);
|
||||
setMenuPane(AccountMenuPane.ConfirmPassword);
|
||||
};
|
||||
setEmail(email)
|
||||
setPassword(password)
|
||||
setMenuPane(AccountMenuPane.ConfirmPassword)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setMenuPane(AccountMenuPane.GeneralMenu);
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
};
|
||||
setMenuPane(AccountMenuPane.GeneralMenu)
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
}
|
||||
|
||||
const onVaultChange = (isVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(isVault);
|
||||
setIsVault(isVault)
|
||||
if (isVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail);
|
||||
setEmail(vaultedEmail)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -133,6 +125,6 @@ export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
onVaultChange={onVaultChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,37 +1,37 @@
|
||||
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 { 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 [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||
const [lastSyncDate, setLastSyncDate] = useState(
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
|
||||
);
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date),
|
||||
)
|
||||
|
||||
const doSynchronization = async () => {
|
||||
setIsSyncingInProgress(true);
|
||||
setIsSyncingInProgress(true)
|
||||
|
||||
application.sync
|
||||
.sync({
|
||||
@ -40,25 +40,23 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
const user = application.getUser();
|
||||
const user = application.getUser()
|
||||
|
||||
const CREATE_ACCOUNT_INDEX = 1;
|
||||
const SWITCHER_INDEX = 0;
|
||||
const CREATE_ACCOUNT_INDEX = 1
|
||||
const SWITCHER_INDEX = 0
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -90,10 +88,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 +97,8 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<>
|
||||
<div className="px-3 mb-1">
|
||||
<div className="mb-3 color-foreground">
|
||||
You’re offline. Sign in to sync your notes and preferences
|
||||
across all your devices and enable end-to-end encryption.
|
||||
You’re offline. Sign in to sync your notes and preferences across all your devices
|
||||
and enable end-to-end encryption.
|
||||
</div>
|
||||
<div className="flex items-center color-grey-1">
|
||||
<Icon type="cloud-off" className="mr-2" />
|
||||
@ -116,9 +111,7 @@ 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
|
||||
@ -130,9 +123,9 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.closeAccountMenu();
|
||||
appState.preferences.setCurrentPane('account');
|
||||
appState.preferences.openPreferences();
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.preferences.setCurrentPane('account')
|
||||
appState.preferences.openPreferences()
|
||||
}}
|
||||
>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
@ -143,7 +136,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
setMenuPane(AccountMenuPane.Register);
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}}
|
||||
>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
@ -152,7 +145,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
setMenuPane(AccountMenuPane.SignIn);
|
||||
setMenuPane(AccountMenuPane.SignIn)
|
||||
}}
|
||||
>
|
||||
<Icon type="signIn" className={iconClassName} />
|
||||
@ -164,9 +157,9 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
className="justify-between"
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.closeAccountMenu();
|
||||
appState.preferences.setCurrentPane('help-feedback');
|
||||
appState.preferences.openPreferences();
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
appState.preferences.setCurrentPane('help-feedback')
|
||||
appState.preferences.openPreferences()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@ -181,7 +174,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true);
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="signOut" className={iconClassName} />
|
||||
@ -191,6 +184,6 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
) : null}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,134 +1,134 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
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 '../Button';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { DecoratedInput } from '../DecoratedInput';
|
||||
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
|
||||
import { Icon } from '../Icon';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { AdvancedOptions } from './AdvancedOptions';
|
||||
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;
|
||||
};
|
||||
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 { notesAndTagsCount } = appState.accountMenu
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false);
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
|
||||
const [isVault, setIsVault] = useState(false);
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||
const [isVault, setIsVault] = useState(false)
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef?.current) {
|
||||
emailInputRef.current?.focus();
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
if (isDev && window.devAccountEmail) {
|
||||
setEmail(window.devAccountEmail);
|
||||
setPassword(window.devAccountPassword as string);
|
||||
setEmail(window.devAccountEmail)
|
||||
setPassword(window.devAccountPassword as string)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const resetInvalid = () => {
|
||||
if (error.length) {
|
||||
setError('');
|
||||
setError('')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleEmailChange = (text: string) => {
|
||||
setEmail(text);
|
||||
};
|
||||
setEmail(text)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
if (error.length) {
|
||||
setError('');
|
||||
setError('')
|
||||
}
|
||||
setPassword(text);
|
||||
};
|
||||
setPassword(text)
|
||||
}
|
||||
|
||||
const handleEphemeralChange = () => {
|
||||
setIsEphemeral(!isEphemeral);
|
||||
};
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}
|
||||
|
||||
const handleStrictSigninChange = () => {
|
||||
setIsStrictSignin(!isStrictSignin);
|
||||
};
|
||||
setIsStrictSignin(!isStrictSignin)
|
||||
}
|
||||
|
||||
const handleShouldMergeChange = () => {
|
||||
setShouldMergeLocal(!shouldMergeLocal);
|
||||
};
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}
|
||||
|
||||
const signIn = () => {
|
||||
setIsSigningIn(true);
|
||||
emailInputRef?.current?.blur();
|
||||
passwordInputRef?.current?.blur();
|
||||
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);
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
appState.accountMenu.closeAccountMenu();
|
||||
appState.accountMenu.closeAccountMenu()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(err.message ?? err.toString());
|
||||
setPassword('');
|
||||
passwordInputRef?.current?.blur();
|
||||
console.error(err)
|
||||
setError(err.message ?? err.toString())
|
||||
setPassword('')
|
||||
passwordInputRef?.current?.blur()
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSigningIn(false);
|
||||
});
|
||||
};
|
||||
setIsSigningIn(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSignInFormSubmit(e);
|
||||
handleSignInFormSubmit(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onVaultChange = useCallback(
|
||||
(newIsVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(newIsVault);
|
||||
setIsVault(newIsVault)
|
||||
if (newIsVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail);
|
||||
setEmail(vaultedEmail)
|
||||
}
|
||||
},
|
||||
[setEmail]
|
||||
);
|
||||
[setEmail],
|
||||
)
|
||||
|
||||
const handleSignInFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef?.current?.focus();
|
||||
return;
|
||||
emailInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef?.current?.focus();
|
||||
return;
|
||||
passwordInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
signIn();
|
||||
};
|
||||
signIn()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -201,6 +201,6 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
onStrictSignInChange={handleStrictSigninChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -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/snjs';
|
||||
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
|
@ -1,16 +1,16 @@
|
||||
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/dist/@types'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
descriptor: ApplicationDescriptor;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
renameDescriptor: (label: string) => void;
|
||||
};
|
||||
descriptor: ApplicationDescriptor
|
||||
onClick: () => void
|
||||
onDelete: () => void
|
||||
renameDescriptor: (label: string) => void
|
||||
}
|
||||
|
||||
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
descriptor,
|
||||
@ -18,26 +18,26 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
onDelete,
|
||||
renameDescriptor,
|
||||
}) => {
|
||||
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) => {
|
||||
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 name = (event.target as HTMLInputElement).value
|
||||
renameDescriptor(name)
|
||||
setIsRenaming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
@ -63,7 +63,7 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
<button
|
||||
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsRenaming((isRenaming) => !isRenaming);
|
||||
setIsRenaming((isRenaming) => !isRenaming)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
||||
@ -78,5 +78,5 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Menu } from '@/Components/Menu/Menu'
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
||||
import { WorkspaceMenuItem } from './WorkspaceMenuItem'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
appState: AppState
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer(
|
||||
({ mainApplicationGroup, appState, isOpen }) => {
|
||||
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>(
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||
setApplicationDescriptors(applicationDescriptors)
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAppGroupObserver()
|
||||
}
|
||||
}, [mainApplicationGroup])
|
||||
|
||||
return (
|
||||
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
|
||||
{applicationDescriptors.map((descriptor) => (
|
||||
<WorkspaceMenuItem
|
||||
descriptor={descriptor}
|
||||
onDelete={() => {
|
||||
appState.accountMenu.setSigningOut(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
mainApplicationGroup.loadApplicationForDescriptor(descriptor).catch(console.error)
|
||||
}}
|
||||
renameDescriptor={(label: string) =>
|
||||
mainApplicationGroup.renameDescriptor(descriptor, label)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<MenuItemSeparator />
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
mainApplicationGroup.addNewApplication().catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="user-add" className="color-neutral mr-2" />
|
||||
Add another workspace
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
},
|
||||
)
|
@ -1,53 +1,47 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import {
|
||||
calculateSubmenuStyle,
|
||||
SubmenuStyle,
|
||||
} from '@/utils/calculateSubmenuStyle';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from '../../Icon';
|
||||
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
appState: AppState;
|
||||
};
|
||||
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 buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current);
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition);
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(
|
||||
buttonRef.current,
|
||||
menuRef.current
|
||||
);
|
||||
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition);
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -78,6 +72,6 @@ export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,15 +1,15 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useCloseOnClickOutside } from '@/components/utils';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { GeneralAccountMenu } from './GeneralAccountMenu';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { SignInPane } from './SignIn';
|
||||
import { CreateAccount } from './CreateAccount';
|
||||
import { ConfirmPassword } from './ConfirmPassword';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useRef, useState } from 'preact/hooks'
|
||||
import { GeneralAccountMenu } from './GeneralAccountMenu'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { SignInPane } from './SignIn'
|
||||
import { CreateAccount } from './CreateAccount'
|
||||
import { ConfirmPassword } from './ConfirmPassword'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
|
||||
export enum AccountMenuPane {
|
||||
GeneralMenu,
|
||||
@ -19,32 +19,25 @@ export enum AccountMenuPane {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
onClickOutside: () => void;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
onClickOutside: () => void
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type PaneSelectorProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
menuPane: AccountMenuPane;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
closeMenu: () => void;
|
||||
};
|
||||
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('');
|
||||
({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
switch (menuPane) {
|
||||
case AccountMenuPane.GeneralMenu:
|
||||
@ -56,15 +49,11 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
setMenuPane={setMenuPane}
|
||||
closeMenu={closeMenu}
|
||||
/>
|
||||
);
|
||||
)
|
||||
case AccountMenuPane.SignIn:
|
||||
return (
|
||||
<SignInPane
|
||||
appState={appState}
|
||||
application={application}
|
||||
setMenuPane={setMenuPane}
|
||||
/>
|
||||
);
|
||||
<SignInPane appState={appState} application={application} setMenuPane={setMenuPane} />
|
||||
)
|
||||
case AccountMenuPane.Register:
|
||||
return (
|
||||
<CreateAccount
|
||||
@ -76,7 +65,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
)
|
||||
case AccountMenuPane.ConfirmPassword:
|
||||
return (
|
||||
<ConfirmPassword
|
||||
@ -86,48 +75,40 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
email={email}
|
||||
password={password}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickOutside, mainApplicationGroup }) => {
|
||||
const {
|
||||
currentPane,
|
||||
setCurrentPane,
|
||||
shouldAnimateCloseMenu,
|
||||
closeAccountMenu,
|
||||
} = appState.accountMenu;
|
||||
const { currentPane, setCurrentPane, shouldAnimateCloseMenu, closeAccountMenu } =
|
||||
appState.accountMenu
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useCloseOnClickOutside(ref, () => {
|
||||
onClickOutside();
|
||||
});
|
||||
onClickOutside()
|
||||
})
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
|
||||
event
|
||||
) => {
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
if (currentPane === AccountMenuPane.GeneralMenu) {
|
||||
closeAccountMenu();
|
||||
closeAccountMenu()
|
||||
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
|
||||
setCurrentPane(AccountMenuPane.Register);
|
||||
setCurrentPane(AccountMenuPane.Register)
|
||||
} else {
|
||||
setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}
|
||||
break;
|
||||
break
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} id="account-menu" className="sn-component">
|
||||
<div
|
||||
className={`sn-menu-border sn-account-menu sn-dropdown ${
|
||||
shouldAnimateCloseMenu
|
||||
? 'slide-up-animation'
|
||||
: 'sn-dropdown--animated'
|
||||
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}
|
||||
>
|
||||
@ -141,6 +122,6 @@ export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,27 +1,26 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Component } from 'preact';
|
||||
import { ApplicationView } from './ApplicationView';
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { Component } from 'preact'
|
||||
import { ApplicationView } from '@/Components/ApplicationView'
|
||||
|
||||
type State = {
|
||||
activeApplication?: WebApplication;
|
||||
};
|
||||
activeApplication?: WebApplication
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
export class ApplicationGroupView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
super(props)
|
||||
|
||||
props.mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
const activeApplication = props.mainApplicationGroup
|
||||
.primaryApplication as WebApplication;
|
||||
this.setState({ activeApplication });
|
||||
});
|
||||
const activeApplication = props.mainApplicationGroup.primaryApplication as WebApplication
|
||||
this.setState({ activeApplication })
|
||||
})
|
||||
|
||||
props.mainApplicationGroup.initialize();
|
||||
props.mainApplicationGroup.initialize().catch(console.error)
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -37,6 +36,6 @@ export class ApplicationGroupView extends Component<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
216
app/assets/javascripts/Components/ApplicationView/index.tsx
Normal file
216
app/assets/javascripts/Components/ApplicationView/index.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { getPlatformString, getWindowUrlParams } from '@/Utils'
|
||||
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState'
|
||||
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs'
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
|
||||
import { alertDialog } from '@/Services/AlertService'
|
||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { Navigation } from '@/Components/Navigation'
|
||||
import { NotesView } from '@/Components/NotesView'
|
||||
import { NoteGroupView } from '@/Components/NoteGroupView'
|
||||
import { Footer } from '@/Components/Footer'
|
||||
import { SessionsModal } from '@/Components/SessionsModal'
|
||||
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper'
|
||||
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal'
|
||||
import { NotesContextMenu } from '@/Components/NotesContextMenu'
|
||||
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
import { render } from 'preact'
|
||||
import { PermissionsModal } from '@/Components/PermissionsModal'
|
||||
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
|
||||
import { PremiumModalProvider } from '@/Hooks/usePremiumModal'
|
||||
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal'
|
||||
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
|
||||
import { ToastContainer } from '@standardnotes/stylekit'
|
||||
import { FilePreviewModalProvider } from '@/Components/Files/FilePreviewModalProvider'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type State = {
|
||||
started?: boolean
|
||||
launched?: boolean
|
||||
needsUnlock?: boolean
|
||||
appClass: string
|
||||
challenges: Challenge[]
|
||||
}
|
||||
|
||||
export class ApplicationView extends PureComponent<Props, State> {
|
||||
public readonly platformString = getPlatformString()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
appClass: '',
|
||||
challenges: [],
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
;(this.application as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.loadApplication().catch(console.error)
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
this.application.componentManager.setDesktopManager(this.application.getDesktopService())
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
const challenges = this.state.challenges.slice()
|
||||
challenges.push(challenge)
|
||||
this.setState({ challenges: challenges })
|
||||
},
|
||||
})
|
||||
await this.application.launch()
|
||||
}
|
||||
|
||||
public removeChallenge = async (challenge: Challenge) => {
|
||||
const challenges = this.state.challenges.slice()
|
||||
removeFromArray(challenges, challenge)
|
||||
this.setState({ challenges: challenges })
|
||||
}
|
||||
|
||||
override async onAppStart() {
|
||||
super.onAppStart().catch(console.error)
|
||||
this.setState({
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
})
|
||||
|
||||
this.application.componentManager.presentPermissionsDialog = this.presentPermissionsDialog
|
||||
}
|
||||
|
||||
override async onAppLaunch() {
|
||||
super.onAppLaunch().catch(console.error)
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
})
|
||||
this.handleDemoSignInFromParams().catch(console.error)
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.application.notifyWebEvent(WebAppEvent.NewUpdateAvailable)
|
||||
}
|
||||
|
||||
override async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName)
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.LocalDatabaseReadError:
|
||||
alertDialog({
|
||||
text: 'Unable to load local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
break
|
||||
case ApplicationEvent.LocalDatabaseWriteError:
|
||||
alertDialog({
|
||||
text: 'Unable to write to local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
override async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
|
||||
if (eventName === AppStateEvent.PanelResized) {
|
||||
const { panel, collapsed } = data as PanelResizedData
|
||||
let appClass = ''
|
||||
if (panel === PANEL_NAME_NOTES && collapsed) {
|
||||
appClass += 'collapsed-notes'
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
|
||||
appClass += ' collapsed-navigation'
|
||||
}
|
||||
this.setState({ appClass })
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
if (!(await this.application.isLocked())) {
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDemoSignInFromParams() {
|
||||
const token = getWindowUrlParams().get('demo-token')
|
||||
if (!token || this.application.hasAccount()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.sessions.populateSessionFromDemoShareToken(token)
|
||||
}
|
||||
|
||||
presentPermissionsDialog = (dialog: PermissionDialog) => {
|
||||
render(
|
||||
<PermissionsModal
|
||||
application={this.application}
|
||||
callback={dialog.callback}
|
||||
component={dialog.component}
|
||||
permissionsString={dialog.permissionsString}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div')),
|
||||
)
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.application['dealloced'] === true) {
|
||||
console.error('Attempting to render dealloced application')
|
||||
return <div></div>
|
||||
}
|
||||
|
||||
const renderAppContents = !this.state.needsUnlock && this.state.launched
|
||||
|
||||
return (
|
||||
<FilePreviewModalProvider application={this.application}>
|
||||
<PremiumModalProvider application={this.application} appState={this.appState}>
|
||||
<div className={this.platformString + ' main-ui-view sn-component'}>
|
||||
{renderAppContents && (
|
||||
<div id="app" className={this.state.appClass + ' app app-column-container'}>
|
||||
<Navigation application={this.application} />
|
||||
<NotesView application={this.application} appState={this.appState} />
|
||||
<NoteGroupView application={this.application} />
|
||||
</div>
|
||||
)}
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<Footer
|
||||
application={this.application}
|
||||
applicationGroup={this.props.mainApplicationGroup}
|
||||
/>
|
||||
<SessionsModal application={this.application} appState={this.appState} />
|
||||
<PreferencesViewWrapper appState={this.appState} application={this.application} />
|
||||
<RevisionHistoryModalWrapper
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{this.state.challenges.map((challenge) => {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<ChallengeModal
|
||||
key={challenge.id}
|
||||
application={this.application}
|
||||
challenge={challenge}
|
||||
onDismiss={this.removeChallenge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<NotesContextMenu application={this.application} appState={this.appState} />
|
||||
<TagsContextMenu appState={this.appState} />
|
||||
<PurchaseFlowWrapper application={this.application} appState={this.appState} />
|
||||
<ConfirmSignoutContainer appState={this.appState} application={this.application} />
|
||||
<ToastContainer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
</FilePreviewModalProvider>
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,368 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import {
|
||||
ChallengeReason,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
SNFile,
|
||||
} from '@standardnotes/snjs'
|
||||
import { confirmDialog } from '@/Services/AlertService'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||
import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
import { AttachedFilesPopover, PopoverTabs } from './AttachedFilesPopover'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const createDragOverlay = () => {
|
||||
if (document.getElementById('drag-overlay')) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlayElementTemplate =
|
||||
'<div class="sn-component" id="drag-overlay"><div class="absolute top-0 left-0 w-full h-full z-index-1001"></div></div>'
|
||||
const overlayFragment = document.createRange().createContextualFragment(overlayElementTemplate)
|
||||
document.body.appendChild(overlayFragment)
|
||||
}
|
||||
|
||||
const removeDragOverlay = () => {
|
||||
document.getElementById('drag-overlay')?.remove()
|
||||
}
|
||||
|
||||
export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickPreprocessing }) => {
|
||||
const premiumModal = usePremiumModal()
|
||||
const note = Object.values(appState.notes.selectedNotes)[0]
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
|
||||
|
||||
const [attachedFilesCount, setAttachedFilesCount] = useState(
|
||||
note ? application.items.getFilesForNote(note).length : 0,
|
||||
)
|
||||
|
||||
const reloadAttachedFilesCount = useCallback(() => {
|
||||
setAttachedFilesCount(note ? application.items.getFilesForNote(note).length : 0)
|
||||
}, [application.items, note])
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
||||
reloadAttachedFilesCount()
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterFileStream()
|
||||
}
|
||||
}, [application, reloadAttachedFilesCount])
|
||||
|
||||
const toggleAttachedFilesMenu = useCallback(async () => {
|
||||
if (
|
||||
application.features.getFeatureStatus(FeatureIdentifier.Files) !== FeatureStatus.Entitled
|
||||
) {
|
||||
premiumModal.activate('Files')
|
||||
return
|
||||
}
|
||||
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
|
||||
const newOpenState = !open
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setOpen(newOpenState)
|
||||
}
|
||||
}, [application.features, onClickPreprocessing, open, premiumModal])
|
||||
|
||||
const deleteFile = async (file: SNFile) => {
|
||||
const shouldDelete = await confirmDialog({
|
||||
text: `Are you sure you want to permanently delete "${file.name}"?`,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
if (shouldDelete) {
|
||||
const deletingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Deleting file "${file.name}"...`,
|
||||
})
|
||||
await application.files.deleteFile(file)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Deleted file "${file.name}"`,
|
||||
})
|
||||
dismissToast(deletingToastId)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async (file: SNFile) => {
|
||||
appState.files.downloadFile(file).catch(console.error)
|
||||
}
|
||||
|
||||
const attachFileToNote = useCallback(
|
||||
async (file: SNFile) => {
|
||||
await application.items.associateFileWithNote(file, note)
|
||||
},
|
||||
[application.items, note],
|
||||
)
|
||||
|
||||
const detachFileFromNote = async (file: SNFile) => {
|
||||
await application.items.disassociateFileWithNote(file, note)
|
||||
}
|
||||
|
||||
const toggleFileProtection = async (file: SNFile) => {
|
||||
let result: SNFile | undefined
|
||||
if (file.protected) {
|
||||
keepMenuOpen(true)
|
||||
result = await application.mutator.unprotectFile(file)
|
||||
keepMenuOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
} else {
|
||||
result = await application.mutator.protectFile(file)
|
||||
}
|
||||
const isProtected = result ? result.protected : file.protected
|
||||
return isProtected
|
||||
}
|
||||
|
||||
const authorizeProtectedActionForFile = async (
|
||||
file: SNFile,
|
||||
challengeReason: ChallengeReason,
|
||||
) => {
|
||||
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles(
|
||||
[file],
|
||||
challengeReason,
|
||||
)
|
||||
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
|
||||
return isAuthorized
|
||||
}
|
||||
|
||||
const renameFile = async (file: SNFile, fileName: string) => {
|
||||
await application.items.renameFile(file, fileName)
|
||||
}
|
||||
|
||||
const handleFileAction = async (action: PopoverFileItemAction) => {
|
||||
const file =
|
||||
action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
|
||||
let isAuthorizedForAction = true
|
||||
|
||||
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
|
||||
keepMenuOpen(true)
|
||||
isAuthorizedForAction = await authorizeProtectedActionForFile(
|
||||
file,
|
||||
ChallengeReason.AccessProtectedFile,
|
||||
)
|
||||
keepMenuOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
|
||||
if (!isAuthorizedForAction) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case PopoverFileItemActionType.AttachFileToNote:
|
||||
await attachFileToNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DetachFileToNote:
|
||||
await detachFileFromNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DeleteFile:
|
||||
await deleteFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DownloadFile:
|
||||
await downloadFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.ToggleFileProtection: {
|
||||
const isProtected = await toggleFileProtection(file)
|
||||
action.callback(isProtected)
|
||||
break
|
||||
}
|
||||
case PopoverFileItemActionType.RenameFile:
|
||||
await renameFile(file, action.payload.name)
|
||||
break
|
||||
}
|
||||
|
||||
application.sync.sync().catch(console.error)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const handleDrag = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragIn = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current + 1
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
setIsDraggingFiles(true)
|
||||
createDragOverlay()
|
||||
if (!open) {
|
||||
toggleAttachedFilesMenu().catch(console.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, toggleAttachedFilesMenu],
|
||||
)
|
||||
|
||||
const handleDragOut = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current - 1
|
||||
|
||||
if (dragCounter.current > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
removeDragOverlay()
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
}
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
removeDragOverlay()
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
Array.from(event.dataTransfer.items).forEach(async (item) => {
|
||||
const fileOrHandle = StreamingFileReader.available()
|
||||
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
|
||||
: item.getAsFile()
|
||||
|
||||
if (!fileOrHandle) {
|
||||
return
|
||||
}
|
||||
|
||||
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
|
||||
|
||||
if (!uploadedFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
attachFileToNote(file).catch(console.error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
event.dataTransfer.clearData()
|
||||
dragCounter.current = 0
|
||||
}
|
||||
},
|
||||
[appState.files, attachFileToNote, currentTab],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragenter', handleDragIn)
|
||||
window.addEventListener('dragleave', handleDragOut)
|
||||
window.addEventListener('dragover', handleDrag)
|
||||
window.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragIn)
|
||||
window.removeEventListener('dragleave', handleDragOut)
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Disclosure open={open} onChange={toggleAttachedFilesMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
ref={buttonRef}
|
||||
className={`sn-icon-button border-contrast ${
|
||||
attachedFilesCount > 0 ? 'py-1 px-3' : ''
|
||||
}`}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<VisuallyHidden>Attached files</VisuallyHidden>
|
||||
<Icon type="attachment-file" className="block" />
|
||||
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{open && (
|
||||
<AttachedFilesPopover
|
||||
application={application}
|
||||
appState={appState}
|
||||
note={note}
|
||||
handleFileAction={handleFileAction}
|
||||
currentTab={currentTab}
|
||||
closeOnBlur={closeOnBlur}
|
||||
setCurrentTab={setCurrentTab}
|
||||
isDraggingFiles={isDraggingFiles}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
@ -1,18 +1,15 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { ContentType, SNFile, SNNote } from '@standardnotes/snjs';
|
||||
import { FilesIllustration } from '@standardnotes/stylekit';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import { PopoverFileItem } from './PopoverFileItem';
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from './PopoverFileItemAction';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { ContentType, SNFile, SNNote } from '@standardnotes/snjs'
|
||||
import { FilesIllustration } from '@standardnotes/stylekit'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { PopoverFileItem } from './PopoverFileItem'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles,
|
||||
@ -20,15 +17,15 @@ export enum PopoverTabs {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
currentTab: PopoverTabs;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>;
|
||||
isDraggingFiles: boolean;
|
||||
note: SNNote;
|
||||
setCurrentTab: StateUpdater<PopoverTabs>;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
currentTab: PopoverTabs
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||
isDraggingFiles: boolean
|
||||
note: SNNote
|
||||
setCurrentTab: StateUpdater<PopoverTabs>
|
||||
}
|
||||
|
||||
export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
({
|
||||
@ -41,70 +38,61 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
note,
|
||||
setCurrentTab,
|
||||
}) => {
|
||||
const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([]);
|
||||
const [allFiles, setAllFiles] = useState<SNFile[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([])
|
||||
const [allFiles, setAllFiles] = useState<SNFile[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const filesList =
|
||||
currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles;
|
||||
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
|
||||
|
||||
const filteredList =
|
||||
searchQuery.length > 0
|
||||
? filesList.filter(
|
||||
(file) =>
|
||||
file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1
|
||||
(file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1,
|
||||
)
|
||||
: filesList;
|
||||
: filesList
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterFileStream = application.streamItems(
|
||||
ContentType.File,
|
||||
() => {
|
||||
setAttachedFiles(
|
||||
application.items
|
||||
.getFilesForNote(note)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||
);
|
||||
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
||||
setAttachedFiles(
|
||||
application.items
|
||||
.getFilesForNote(note)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)),
|
||||
)
|
||||
|
||||
setAllFiles(
|
||||
application.items
|
||||
.getItems(ContentType.File)
|
||||
.sort((a, b) =>
|
||||
a.created_at < b.created_at ? 1 : -1
|
||||
) as SNFile[]
|
||||
);
|
||||
}
|
||||
);
|
||||
setAllFiles(
|
||||
application.items
|
||||
.getItems(ContentType.File)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) as SNFile[],
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterFileStream();
|
||||
};
|
||||
}, [application, note]);
|
||||
unregisterFileStream()
|
||||
}
|
||||
}, [application, note])
|
||||
|
||||
const handleAttachFilesClick = async () => {
|
||||
const uploadedFiles = await appState.files.uploadNewFile();
|
||||
const uploadedFiles = await appState.files.uploadNewFile()
|
||||
if (!uploadedFiles) {
|
||||
return;
|
||||
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)'
|
||||
: '',
|
||||
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
||||
}}
|
||||
>
|
||||
<div className="flex border-0 border-b-1 border-solid border-main">
|
||||
@ -115,7 +103,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
: 'color-text'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AttachedFiles);
|
||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
@ -128,7 +116,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
: 'color-text'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AllFiles);
|
||||
setCurrentTab(PopoverTabs.AllFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
@ -145,7 +133,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onInput={(e) => {
|
||||
setSearchQuery((e.target as HTMLInputElement).value);
|
||||
setSearchQuery((e.target as HTMLInputElement).value)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={searchInputRef}
|
||||
@ -154,15 +142,12 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
<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();
|
||||
setSearchQuery('')
|
||||
searchInputRef.current?.focus()
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon
|
||||
type="clear-circle-filled"
|
||||
className="color-neutral"
|
||||
/>
|
||||
<Icon type="clear-circle-filled" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -179,7 +164,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
getIconType={application.iconsController.getIconForFileType}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
);
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||
@ -193,17 +178,10 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
? 'No files attached to this note'
|
||||
: 'No files found in this account'}
|
||||
</div>
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={handleAttachFilesClick}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'}{' '}
|
||||
files
|
||||
<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 className="text-xs color-grey-0 mt-3">Or drop your files here</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -214,13 +192,10 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="add" className="mr-2 color-neutral" />
|
||||
{currentTab === PopoverTabs.AttachedFiles
|
||||
? 'Attach'
|
||||
: 'Upload'}{' '}
|
||||
files
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -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, SNFile } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon, ICONS } from '@/Components/Icon'
|
||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
import { PopoverFileSubmenu } from './PopoverFileSubmenu'
|
||||
|
||||
export const getFileIconComponent = (iconType: string, className: string) => {
|
||||
const IconComponent = ICONS[iconType as keyof typeof ICONS];
|
||||
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: SNFile
|
||||
isAttachedToNote: boolean
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||
getIconType(type: string): IconType
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
}
|
||||
|
||||
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
file,
|
||||
@ -32,16 +29,16 @@ 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) => {
|
||||
await handleFileAction({
|
||||
@ -50,23 +47,23 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -75,10 +72,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{getFileIconComponent(
|
||||
getIconType(file.mimeType),
|
||||
'w-8 h-8 flex-shrink-0'
|
||||
)}
|
||||
{getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
|
||||
<div className="flex flex-col mx-4">
|
||||
{isRenamingFile ? (
|
||||
<input
|
||||
@ -102,8 +96,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs color-grey-0">
|
||||
{file.created_at.toLocaleString()} ·{' '}
|
||||
{formatSizeToReadableString(file.size)}
|
||||
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -115,5 +108,5 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
|
||||
export enum PopoverFileItemActionType {
|
||||
AttachFileToNote,
|
||||
DetachFileToNote,
|
||||
DeleteFile,
|
||||
DownloadFile,
|
||||
RenameFile,
|
||||
ToggleFileProtection,
|
||||
}
|
||||
|
||||
export type PopoverFileItemAction =
|
||||
| {
|
||||
type: Exclude<
|
||||
PopoverFileItemActionType,
|
||||
PopoverFileItemActionType.RenameFile | PopoverFileItemActionType.ToggleFileProtection
|
||||
>
|
||||
payload: SNFile
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.ToggleFileProtection
|
||||
payload: SNFile
|
||||
callback: (isProtected: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.RenameFile
|
||||
payload: {
|
||||
file: SNFile
|
||||
name: string
|
||||
}
|
||||
}
|
@ -1,31 +1,18 @@
|
||||
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 { useFilePreviewModal } from '@/Components/Files/FilePreviewModalProvider'
|
||||
import { PopoverFileItemProps } from './PopoverFileItem'
|
||||
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
|
||||
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
|
||||
setIsRenamingFile: StateUpdater<boolean>;
|
||||
};
|
||||
setIsRenamingFile: StateUpdater<boolean>
|
||||
}
|
||||
|
||||
export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
file,
|
||||
@ -33,54 +20,51 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
handleFileAction,
|
||||
setIsRenamingFile,
|
||||
}) => {
|
||||
const filePreviewModal = useFilePreviewModal();
|
||||
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);
|
||||
};
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition);
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
setIsMenuOpen(!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 +90,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
filePreviewModal.activate(file);
|
||||
closeMenu();
|
||||
filePreviewModal.activate(file)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="file" className="mr-2 color-neutral" />
|
||||
@ -121,8 +105,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 +120,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 +136,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 +160,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 +171,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 +184,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 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -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
|
@ -1,58 +1,44 @@
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { ComponentChildren, FunctionComponent, Ref } from 'preact';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
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`;
|
||||
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
|
||||
|
||||
type ButtonVariant = 'normal' | 'primary';
|
||||
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';
|
||||
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 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';
|
||||
: 'hover:brightness-130 focus:outline-none focus:brightness-130'
|
||||
|
||||
if (danger) {
|
||||
colors =
|
||||
variant === 'normal'
|
||||
? 'bg-default color-danger'
|
||||
: 'bg-danger color-info-contrast';
|
||||
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';
|
||||
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';
|
||||
: 'focus:brightness-default focus:outline-none hover:brightness-default'
|
||||
}
|
||||
|
||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`;
|
||||
};
|
||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
|
||||
}
|
||||
|
||||
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & {
|
||||
children?: ComponentChildren;
|
||||
className?: string;
|
||||
variant?: ButtonVariant;
|
||||
dangerStyle?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
children?: ComponentChildren
|
||||
className?: string
|
||||
variant?: ButtonVariant
|
||||
dangerStyle?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
(
|
||||
@ -65,7 +51,7 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref: Ref<HTMLButtonElement>
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
@ -77,6 +63,6 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
>
|
||||
{label ?? children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -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,10 +38,10 @@ 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"
|
||||
@ -52,5 +52,5 @@ export const IconButton: FunctionComponent<Props> = ({
|
||||
>
|
||||
<Icon type={icon} className={iconClassName} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
40
app/assets/javascripts/Components/Button/RoundIconButton.tsx
Normal file
40
app/assets/javascripts/Components/Button/RoundIconButton.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
type ButtonType = 'normal' | 'primary'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* onClick - preventDefault is handled within the component
|
||||
*/
|
||||
onClick: () => void
|
||||
|
||||
type: ButtonType
|
||||
|
||||
className?: string
|
||||
|
||||
icon: IconType
|
||||
}
|
||||
|
||||
/**
|
||||
* IconButton component with an icon
|
||||
* preventDefault is already handled within the component
|
||||
*/
|
||||
export const RoundIconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
type,
|
||||
className,
|
||||
icon: iconType,
|
||||
}) => {
|
||||
const click = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
const classes = type === 'primary' ? 'info ' : ''
|
||||
return (
|
||||
<button className={`sn-icon-button ${classes} ${className ?? ''}`} onClick={click}>
|
||||
<Icon type={iconType} />
|
||||
</button>
|
||||
)
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import {
|
||||
ButtonType,
|
||||
Challenge,
|
||||
@ -7,90 +7,87 @@ import {
|
||||
ChallengeReason,
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { ProtectedIllustration } from '@standardnotes/stylekit';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import { ChallengeModalPrompt } from './ChallengePrompt';
|
||||
} from '@standardnotes/snjs'
|
||||
import { ProtectedIllustration } from '@standardnotes/stylekit'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { ChallengeModalPrompt } from './ChallengePrompt'
|
||||
|
||||
type InputValue = {
|
||||
prompt: ChallengePrompt;
|
||||
value: string | number | boolean;
|
||||
invalid: boolean;
|
||||
};
|
||||
prompt: ChallengePrompt
|
||||
value: string | number | boolean
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>;
|
||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
challenge: Challenge;
|
||||
onDismiss: (challenge: Challenge) => Promise<void>;
|
||||
};
|
||||
application: WebApplication
|
||||
challenge: Challenge
|
||||
onDismiss: (challenge: Challenge) => Promise<void>
|
||||
}
|
||||
|
||||
const validateValues = (
|
||||
values: ChallengeModalValues,
|
||||
prompts: ChallengePrompt[]
|
||||
prompts: ChallengePrompt[],
|
||||
): ChallengeModalValues | undefined => {
|
||||
let hasInvalidValues = false;
|
||||
const validatedValues = { ...values };
|
||||
let hasInvalidValues = false
|
||||
const validatedValues = { ...values }
|
||||
for (const prompt of prompts) {
|
||||
const value = validatedValues[prompt.id];
|
||||
const value = validatedValues[prompt.id]
|
||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||
validatedValues[prompt.id].invalid = true;
|
||||
hasInvalidValues = true;
|
||||
validatedValues[prompt.id].invalid = true
|
||||
hasInvalidValues = true
|
||||
}
|
||||
}
|
||||
if (!hasInvalidValues) {
|
||||
return validatedValues;
|
||||
return validatedValues
|
||||
}
|
||||
};
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
application,
|
||||
challenge,
|
||||
onDismiss,
|
||||
}) => {
|
||||
export const ChallengeModal: FunctionComponent<Props> = ({ application, challenge, onDismiss }) => {
|
||||
const [values, setValues] = useState<ChallengeModalValues>(() => {
|
||||
const values = {} as 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);
|
||||
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);
|
||||
].includes(challenge.reason)
|
||||
|
||||
const submit = async () => {
|
||||
const validatedValues = validateValues(values, challenge.prompts);
|
||||
const validatedValues = validateValues(values, challenge.prompts)
|
||||
if (!validatedValues) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (isSubmitting || isProcessing) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setIsProcessing(true);
|
||||
const valuesToProcess: ChallengeValue[] = [];
|
||||
setIsSubmitting(true)
|
||||
setIsProcessing(true)
|
||||
const valuesToProcess: ChallengeValue[] = []
|
||||
for (const inputValue of Object.values(validatedValues)) {
|
||||
const rawValue = inputValue.value;
|
||||
const value = new ChallengeValue(inputValue.prompt, rawValue);
|
||||
valuesToProcess.push(value);
|
||||
const rawValue = inputValue.value
|
||||
const value = new ChallengeValue(inputValue.prompt, rawValue)
|
||||
valuesToProcess.push(value)
|
||||
}
|
||||
const processingPrompts = valuesToProcess.map((v) => v.prompt);
|
||||
setIsProcessing(processingPrompts.length > 0);
|
||||
setProcessingPrompts(processingPrompts);
|
||||
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
|
||||
@ -98,90 +95,85 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (valuesToProcess.length > 0) {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess);
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
} else {
|
||||
setIsProcessing(false);
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 50);
|
||||
};
|
||||
setIsSubmitting(false)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string | number, prompt: ChallengePrompt) => {
|
||||
const newValues = { ...values };
|
||||
newValues[prompt.id].invalid = false;
|
||||
newValues[prompt.id].value = value;
|
||||
setValues(newValues);
|
||||
const newValues = { ...values }
|
||||
newValues[prompt.id].invalid = false
|
||||
newValues[prompt.id].value = value
|
||||
setValues(newValues)
|
||||
},
|
||||
[values]
|
||||
);
|
||||
[values],
|
||||
)
|
||||
|
||||
const closeModal = () => {
|
||||
if (challenge.cancelable) {
|
||||
onDismiss(challenge);
|
||||
onDismiss(challenge).catch(console.error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const removeChallengeObserver = application.addChallengeObserver(
|
||||
challenge,
|
||||
{
|
||||
onValidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values };
|
||||
newValues[value.prompt.id].invalid = false;
|
||||
return newValues;
|
||||
});
|
||||
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;
|
||||
});
|
||||
},
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
onDismiss(challenge).catch(console.error)
|
||||
},
|
||||
onCancel: () => {
|
||||
onDismiss(challenge).catch(console.error)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeChallengeObserver();
|
||||
};
|
||||
}, [application, challenge, onDismiss]);
|
||||
removeChallengeObserver()
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
if (!challenge.prompts) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className={`sn-component ${
|
||||
challenge.reason === ChallengeReason.ApplicationUnlock
|
||||
? 'challenge-modal-overlay'
|
||||
: ''
|
||||
challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
|
||||
}`}
|
||||
onDismiss={closeModal}
|
||||
dangerouslyBypassFocusLock={bypassModalFocusLock}
|
||||
@ -203,17 +195,13 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
</button>
|
||||
)}
|
||||
<ProtectedIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-lg text-center max-w-76 mb-3">
|
||||
{challenge.heading}
|
||||
</div>
|
||||
<div className="text-center text-sm max-w-76 mb-4">
|
||||
{challenge.subheading}
|
||||
</div>
|
||||
<div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
|
||||
<div className="text-center text-sm max-w-76 mb-4">{challenge.subheading}</div>
|
||||
<form
|
||||
className="flex flex-col items-center min-w-76 mb-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
e.preventDefault()
|
||||
submit().catch(console.error)
|
||||
}}
|
||||
>
|
||||
{challenge.prompts.map((prompt, index) => (
|
||||
@ -232,7 +220,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
disabled={isProcessing}
|
||||
className="min-w-76 mb-3.5"
|
||||
onClick={() => {
|
||||
submit();
|
||||
submit().catch(console.error)
|
||||
}}
|
||||
>
|
||||
{isProcessing ? 'Generating Keys...' : 'Unlock'}
|
||||
@ -241,23 +229,23 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
<Button
|
||||
className="flex items-center justify-center min-w-76"
|
||||
onClick={() => {
|
||||
setBypassModalFocusLock(true);
|
||||
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
|
||||
ButtonType.Danger,
|
||||
)
|
||||
.then((shouldDeleteLocalData) => {
|
||||
if (shouldDeleteLocalData) {
|
||||
application.user.signOut();
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBypassModalFocusLock(false);
|
||||
});
|
||||
setBypassModalFocusLock(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="help" className="mr-2 color-neutral" />
|
||||
@ -266,5 +254,5 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
)}
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -2,20 +2,20 @@ import {
|
||||
ChallengePrompt,
|
||||
ChallengeValidation,
|
||||
ProtectionSessionDurations,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { DecoratedInput } from '../DecoratedInput';
|
||||
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
|
||||
import { ChallengeModalValues } from './ChallengeModal';
|
||||
} 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;
|
||||
};
|
||||
prompt: ChallengePrompt
|
||||
values: ChallengeModalValues
|
||||
index: number
|
||||
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
|
||||
isInvalid: boolean
|
||||
}
|
||||
|
||||
export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
prompt,
|
||||
@ -24,31 +24,28 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
onValueChange,
|
||||
isInvalid,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (index === 0) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [index]);
|
||||
}, [index])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInvalid) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isInvalid]);
|
||||
}, [isInvalid])
|
||||
|
||||
return (
|
||||
<>
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div className="mt-3 min-w-76">
|
||||
<div className="text-sm font-medium mb-2">
|
||||
Allow protected access for
|
||||
</div>
|
||||
<div className="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;
|
||||
const selected = option.valueInSeconds === values[prompt.id].value
|
||||
return (
|
||||
<label
|
||||
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||
@ -60,21 +57,19 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
<input
|
||||
type="radio"
|
||||
name={`session-duration-${prompt.id}`}
|
||||
className={
|
||||
'appearance-none m-0 focus:shadow-none focus:outline-none'
|
||||
}
|
||||
className={'appearance-none m-0 focus:shadow-none focus:outline-none'}
|
||||
style={{
|
||||
marginRight: 0,
|
||||
}}
|
||||
checked={selected}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
onValueChange(option.valueInSeconds, prompt);
|
||||
event.preventDefault()
|
||||
onValueChange(option.valueInSeconds, prompt)
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@ -94,10 +89,8 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div className="text-sm color-danger mt-2">
|
||||
Invalid authentication, please try again.
|
||||
</div>
|
||||
<div className="text-sm color-danger mt-2">Invalid authentication, please try again.</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,74 +1,63 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
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 './Icon';
|
||||
import { ChangeEditorMenu } from './NotesOptions/changeEditor/ChangeEditorMenu';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { ChangeEditorMenu } from './ChangeEditorMenu'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
onClickPreprocessing?: () => Promise<void>;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickPreprocessing }) => {
|
||||
const note = Object.values(appState.notes.selectedNotes)[0];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const 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 [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();
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement;
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
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
|
||||
);
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
});
|
||||
})
|
||||
|
||||
const newOpenState = !isOpen;
|
||||
const newOpenState = !isOpen
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing();
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setIsOpen(newOpenState);
|
||||
setIsOpen(newOpenState)
|
||||
setTimeout(() => {
|
||||
setIsVisible(newOpenState);
|
||||
});
|
||||
setIsVisible(newOpenState)
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
@ -76,7 +65,7 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
@ -89,8 +78,8 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
setIsOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
@ -108,13 +97,13 @@ export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
||||
isVisible={isVisible}
|
||||
note={note}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false);
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,14 +1,14 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { Menu } from '@/components/Menu/Menu';
|
||||
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem';
|
||||
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 '@/components/Premium';
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
} from '@/Components/NoteView/NoteView'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ComponentArea,
|
||||
ItemMutator,
|
||||
@ -17,25 +17,21 @@ import {
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs';
|
||||
import { Fragment, FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
import {
|
||||
createEditorMenuGroups,
|
||||
PLAIN_EDITOR_NAME,
|
||||
} from './createEditorMenuGroups';
|
||||
} from '@standardnotes/snjs'
|
||||
import { Fragment, FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
||||
import { createEditorMenuGroups, PLAIN_EDITOR_NAME } from './createEditorMenuGroups'
|
||||
|
||||
type ChangeEditorMenuProps = {
|
||||
application: WebApplication;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
closeMenu: () => void;
|
||||
isVisible: boolean;
|
||||
note: SNNote;
|
||||
};
|
||||
application: WebApplication
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
closeMenu: () => void
|
||||
isVisible: boolean
|
||||
note: SNNote
|
||||
}
|
||||
|
||||
const getGroupId = (group: EditorMenuGroup) =>
|
||||
group.title.toLowerCase().replace(/\s/, '-');
|
||||
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
||||
|
||||
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
application,
|
||||
@ -45,65 +41,59 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
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>();
|
||||
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]);
|
||||
setGroups(createEditorMenuGroups(application, editors))
|
||||
}, [application, editors])
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setCurrentEditor(application.componentManager.editorForNote(note));
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
}
|
||||
}, [application, note]);
|
||||
}, [application, note])
|
||||
|
||||
const premiumModal = usePremiumModal();
|
||||
const premiumModal = usePremiumModal()
|
||||
|
||||
const isSelectedEditor = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
if (currentEditor) {
|
||||
if (item?.component?.identifier === currentEditor.identifier) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
} else if (item.name === PLAIN_EDITOR_NAME) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
[currentEditor]
|
||||
);
|
||||
[currentEditor],
|
||||
)
|
||||
|
||||
const selectComponent = async (
|
||||
component: SNComponent | null,
|
||||
note: SNNote
|
||||
) => {
|
||||
const selectComponent = async (component: SNComponent | null, note: SNNote) => {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
application.mutator.changeAndSaveItem(component, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
const transactions: TransactionalMutation[] = [];
|
||||
const transactions: TransactionalMutation[] = []
|
||||
|
||||
if (application.getAppState().getActiveNoteController()?.isTemplateNote) {
|
||||
await application
|
||||
.getAppState()
|
||||
.getActiveNoteController()
|
||||
.insertTemplatedNote();
|
||||
await application.getAppState().getActiveNoteController().insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (note.locked) {
|
||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
|
||||
return;
|
||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
@ -111,97 +101,79 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = true;
|
||||
const noteMutator = m as NoteMutator
|
||||
noteMutator.prefersPlainEditor = true
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
const prefersPlain = note.prefersPlainEditor;
|
||||
const prefersPlain = note.prefersPlainEditor
|
||||
if (prefersPlain) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = false;
|
||||
const noteMutator = m as NoteMutator
|
||||
noteMutator.prefersPlainEditor = false
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
transactions.push(
|
||||
transactionForAssociateComponentWithCurrentNote(component, note)
|
||||
);
|
||||
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
|
||||
}
|
||||
|
||||
await application.mutator.runTransactionalMutations(transactions);
|
||||
await application.mutator.runTransactionalMutations(transactions)
|
||||
/** Dirtying can happen above */
|
||||
application.sync.sync();
|
||||
application.sync.sync().catch(console.error)
|
||||
|
||||
setCurrentEditor(application.componentManager.editorForNote(note));
|
||||
};
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
}
|
||||
|
||||
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
|
||||
if (!itemToBeSelected.isEntitled) {
|
||||
premiumModal.activate(itemToBeSelected.name);
|
||||
return;
|
||||
premiumModal.activate(itemToBeSelected.name)
|
||||
return
|
||||
}
|
||||
|
||||
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component;
|
||||
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
|
||||
|
||||
if (areBothEditorsPlain) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
let shouldSelectEditor = true;
|
||||
let shouldSelectEditor = true
|
||||
|
||||
if (itemToBeSelected.component) {
|
||||
const changeRequiresAlert =
|
||||
application.componentManager.doesEditorChangeRequireAlert(
|
||||
currentEditor,
|
||||
itemToBeSelected.component
|
||||
);
|
||||
const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
|
||||
currentEditor,
|
||||
itemToBeSelected.component,
|
||||
)
|
||||
|
||||
if (changeRequiresAlert) {
|
||||
shouldSelectEditor =
|
||||
await application.componentManager.showEditorChangeAlert();
|
||||
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSelectEditor) {
|
||||
selectComponent(itemToBeSelected.component ?? null, note);
|
||||
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
};
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="pt-0.5 pb-1"
|
||||
a11yLabel="Change editor menu"
|
||||
isOpen={isVisible}
|
||||
>
|
||||
<Menu className="pt-0.5 pb-1" a11yLabel="Change editor menu" isOpen={isVisible}>
|
||||
{groups
|
||||
.filter((group) => group.items && group.items.length)
|
||||
.map((group, index) => {
|
||||
const groupId = getGroupId(group);
|
||||
const groupId = getGroupId(group)
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
@ -210,24 +182,21 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
index === 0 ? 'border-t-0 mb-2' : 'my-2'
|
||||
}`}
|
||||
>
|
||||
{group.icon && (
|
||||
<Icon
|
||||
type={group.icon}
|
||||
className={`mr-2 ${group.iconClassName}`}
|
||||
/>
|
||||
)}
|
||||
{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);
|
||||
};
|
||||
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`}
|
||||
className={
|
||||
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none'
|
||||
}
|
||||
onBlur={closeOnBlur}
|
||||
checked={isSelectedEditor(item)}
|
||||
>
|
||||
@ -236,11 +205,11 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
{!item.isEntitled && <Icon type="premium-feature" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ContentType,
|
||||
FeatureStatus,
|
||||
@ -7,37 +7,32 @@ import {
|
||||
FeatureDescription,
|
||||
GetFeatures,
|
||||
NoteType,
|
||||
} from '@standardnotes/snjs';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
} from '@standardnotes/snjs'
|
||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
||||
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor';
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor'
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others';
|
||||
type EditorGroup = NoteType | 'plain' | 'others'
|
||||
|
||||
const getEditorGroup = (
|
||||
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: [
|
||||
{
|
||||
@ -52,40 +47,34 @@ export const createEditorMenuGroups = (
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
};
|
||||
}
|
||||
|
||||
GetFeatures()
|
||||
.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Component &&
|
||||
feature.area === ComponentArea.Editor
|
||||
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,
|
||||
};
|
||||
application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
|
||||
}
|
||||
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem);
|
||||
});
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem)
|
||||
})
|
||||
|
||||
const editorMenuGroups: EditorMenuGroup[] = [
|
||||
{
|
||||
@ -136,7 +125,7 @@ export const createEditorMenuGroups = (
|
||||
title: 'Others',
|
||||
items: editorItems.others,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
return editorMenuGroups;
|
||||
};
|
||||
return editorMenuGroups
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
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,
|
||||
@ -28,5 +28,5 @@ export const Checkbox: FunctionComponent<CheckboxProps> = ({
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
deprecationMessage: string | undefined;
|
||||
dismissDeprecationMessage: () => void;
|
||||
deprecationMessage: string | undefined
|
||||
dismissDeprecationMessage: () => void
|
||||
}
|
||||
|
||||
export const IsDeprecated: FunctionalComponent<IProps> = ({
|
||||
deprecationMessage,
|
||||
dismissDeprecationMessage
|
||||
}) => {
|
||||
deprecationMessage,
|
||||
dismissDeprecationMessage,
|
||||
}) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
@ -21,12 +21,10 @@ export const IsDeprecated: FunctionalComponent<IProps> = ({
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
|
||||
<button className={'sn-button small info'}>
|
||||
Dismiss
|
||||
</button>
|
||||
<button className={'sn-button small info'}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
export const OfflineRestricted: FunctionalComponent = () => {
|
||||
return (
|
||||
@ -11,21 +11,17 @@ export const OfflineRestricted: FunctionalComponent = () => {
|
||||
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.
|
||||
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 +31,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface IProps {
|
||||
componentName: string;
|
||||
componentName: string
|
||||
}
|
||||
|
||||
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
|
||||
@ -14,16 +14,14 @@ export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
|
||||
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.
|
||||
In order to access your note immediately, please switch from {componentName} to the
|
||||
Plain Editor.
|
||||
</p>
|
||||
<br />
|
||||
<p>Please contact help@standardnotes.com to remedy this issue.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
221
app/assets/javascripts/Components/ComponentView/index.tsx
Normal file
221
app/assets/javascripts/Components/ComponentView/index.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import {
|
||||
ComponentAction,
|
||||
FeatureStatus,
|
||||
SNComponent,
|
||||
dateToLocalizedString,
|
||||
ComponentViewer,
|
||||
ComponentViewerEvent,
|
||||
ComponentViewerError,
|
||||
} from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted'
|
||||
import { UrlMissing } from '@/Components/ComponentView/UrlMissing'
|
||||
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated'
|
||||
import { IsExpired } from '@/Components/ComponentView/IsExpired'
|
||||
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
componentViewer: ComponentViewer
|
||||
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
|
||||
onLoad?: (component: SNComponent) => void
|
||||
manualDealloc?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum amount of time we'll wait for a component
|
||||
* to load before displaying error
|
||||
*/
|
||||
const MaxLoadThreshold = 4000
|
||||
const VisibilityChangeKey = 'visibilitychange'
|
||||
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
|
||||
|
||||
export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
({ application, onLoad, componentViewer, requestReload }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const excessiveLoadingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const [hasIssueLoading, setHasIssueLoading] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
|
||||
componentViewer.getFeatureStatus(),
|
||||
)
|
||||
const [isComponentValid, setIsComponentValid] = useState(true)
|
||||
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
|
||||
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
|
||||
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
|
||||
const [didAttemptReload, setDidAttemptReload] = useState(false)
|
||||
|
||||
const component = componentViewer.component
|
||||
|
||||
const manageSubscription = useCallback(() => {
|
||||
openSubscriptionDashboard(application)
|
||||
}, [application])
|
||||
|
||||
const reloadValidityStatus = useCallback(() => {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||
if (!componentViewer.lockReadonly) {
|
||||
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
|
||||
}
|
||||
setIsComponentValid(componentViewer.shouldRender())
|
||||
|
||||
if (isLoading && !isComponentValid) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
setError(componentViewer.getError())
|
||||
setDeprecationMessage(component.deprecationMessage)
|
||||
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
reloadValidityStatus()
|
||||
}, [reloadValidityStatus])
|
||||
|
||||
const dismissDeprecationMessage = () => {
|
||||
setIsDeprecationMessageDismissed(true)
|
||||
}
|
||||
|
||||
const onVisibilityChange = useCallback(() => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return
|
||||
}
|
||||
if (hasIssueLoading) {
|
||||
requestReload?.(componentViewer)
|
||||
}
|
||||
}, [hasIssueLoading, componentViewer, requestReload])
|
||||
|
||||
const handleIframeTakingTooLongToLoad = useCallback(async () => {
|
||||
setIsLoading(false)
|
||||
setHasIssueLoading(true)
|
||||
|
||||
if (!didAttemptReload) {
|
||||
setDidAttemptReload(true)
|
||||
requestReload?.(componentViewer)
|
||||
} else {
|
||||
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
|
||||
}
|
||||
}, [didAttemptReload, onVisibilityChange, componentViewer, requestReload])
|
||||
|
||||
useMemo(() => {
|
||||
const loadTimeout = setTimeout(() => {
|
||||
handleIframeTakingTooLongToLoad().catch(console.error)
|
||||
}, MaxLoadThreshold)
|
||||
|
||||
excessiveLoadingTimeout.current = loadTimeout
|
||||
|
||||
return () => {
|
||||
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
|
||||
}
|
||||
}, [handleIframeTakingTooLongToLoad])
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current as HTMLIFrameElement
|
||||
const contentWindow = iframe.contentWindow as Window
|
||||
excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current)
|
||||
|
||||
componentViewer.setWindow(contentWindow).catch(console.error)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
setHasIssueLoading(false)
|
||||
onLoad?.(component)
|
||||
}, MSToWaitAfterIframeLoadToAvoidFlicker)
|
||||
}, [componentViewer, onLoad, component, excessiveLoadingTimeout])
|
||||
|
||||
useEffect(() => {
|
||||
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
|
||||
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeFeaturesChangedObserver()
|
||||
}
|
||||
}, [componentViewer])
|
||||
|
||||
useEffect(() => {
|
||||
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
|
||||
switch (action) {
|
||||
case ComponentAction.KeyDown:
|
||||
application.io.handleComponentKeyDown(data.keyboardModifier)
|
||||
break
|
||||
case ComponentAction.KeyUp:
|
||||
application.io.handleComponentKeyUp(data.keyboardModifier)
|
||||
break
|
||||
case ComponentAction.Click:
|
||||
application.getAppState().notes.setContextMenuOpen(false)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
removeActionObserver()
|
||||
}
|
||||
}, [componentViewer, application])
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterDesktopObserver = application
|
||||
.getDesktopService()
|
||||
.registerUpdateObserver((updatedComponent: SNComponent) => {
|
||||
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
|
||||
requestReload?.(componentViewer)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unregisterDesktopObserver()
|
||||
}
|
||||
}, [application, requestReload, componentViewer, component.uuid])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasIssueLoading && (
|
||||
<IssueOnLoading
|
||||
componentName={component.name}
|
||||
reloadIframe={() => {
|
||||
reloadValidityStatus(), requestReload?.(componentViewer, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{featureStatus !== FeatureStatus.Entitled && (
|
||||
<IsExpired
|
||||
expiredDate={dateToLocalizedString(component.valid_until)}
|
||||
featureStatus={featureStatus}
|
||||
componentName={component.name}
|
||||
manageSubscription={manageSubscription}
|
||||
/>
|
||||
)}
|
||||
{deprecationMessage && !isDeprecationMessageDismissed && (
|
||||
<IsDeprecated
|
||||
deprecationMessage={deprecationMessage}
|
||||
dismissDeprecationMessage={dismissDeprecationMessage}
|
||||
/>
|
||||
)}
|
||||
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
|
||||
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.name} />}
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
src={componentViewer.url || ''}
|
||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
||||
>
|
||||
Loading
|
||||
</iframe>
|
||||
)}
|
||||
{isLoading && <div className={'loading-overlay'} />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const ConfirmSignoutContainer = observer((props: Props) => {
|
||||
if (!props.appState.accountMenu.signingOut) {
|
||||
return null
|
||||
}
|
||||
return <ConfirmSignoutModal {...props} />
|
||||
})
|
||||
|
||||
export const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false)
|
||||
}
|
||||
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
|
||||
}, [appState.accountMenu.signingOut, application.bridge])
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
|
||||
Sign out workspace?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups((event.target as HTMLInputElement).checked)
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups()
|
||||
}}
|
||||
>
|
||||
View backup files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups().catch(console.error)
|
||||
} else {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
closeDialog()
|
||||
}}
|
||||
>
|
||||
{application.hasAccount() ? 'Sign Out' : 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
})
|
@ -5,32 +5,32 @@ import {
|
||||
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';
|
||||
} 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,15 +47,11 @@ 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,
|
||||
@ -65,15 +61,13 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
const labelId = `${id}-label`;
|
||||
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 (
|
||||
<>
|
||||
@ -87,16 +81,16 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
<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">
|
||||
@ -125,5 +119,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker';
|
||||
import { SNFile } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon } from '../Icon';
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
file: SNFile;
|
||||
};
|
||||
file: SNFile
|
||||
}
|
||||
|
||||
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
return (
|
||||
@ -18,12 +18,10 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
<span className="font-semibold">Type:</span> {file.mimeType}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Size:</span>{' '}
|
||||
{formatSizeToReadableString(file.size)}
|
||||
<span className="font-semibold">Size:</span> {formatSizeToReadableString(file.size)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Created:</span>{' '}
|
||||
{file.created_at.toLocaleString()}
|
||||
<span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Last Modified:</span>{' '}
|
||||
@ -33,5 +31,5 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
<span className="font-semibold">File ID:</span> {file.uuid}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,88 +1,81 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { concatenateUint8Arrays } from '@/utils/concatenateUint8Arrays';
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog';
|
||||
import { SNFile } from '@standardnotes/snjs';
|
||||
import { NoPreviewIllustration } from '@standardnotes/stylekit';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { getFileIconComponent } from '../AttachedFilesPopover/PopoverFileItem';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel';
|
||||
import { isFileTypePreviewable } from './isFilePreviewable';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { NoPreviewIllustration } from '@standardnotes/stylekit'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
|
||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
file: SNFile;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
application: WebApplication
|
||||
file: SNFile
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <img src={objectUrl} />;
|
||||
return <img src={objectUrl} />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return <video className="w-full h-full" src={objectUrl} controls />;
|
||||
return <video className="w-full h-full" src={objectUrl} controls />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('audio/')) {
|
||||
return <audio src={objectUrl} controls />;
|
||||
return <audio src={objectUrl} controls />
|
||||
}
|
||||
|
||||
return <object className="w-full h-full" data={objectUrl} />;
|
||||
};
|
||||
return <object className="w-full h-full" data={objectUrl} />
|
||||
}
|
||||
|
||||
export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
application,
|
||||
file,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const [isFilePreviewable, setIsFilePreviewable] = useState(false);
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
export const FilePreviewModal: FunctionComponent<Props> = ({ application, file, onDismiss }) => {
|
||||
const [objectUrl, setObjectUrl] = useState<string>()
|
||||
const [isFilePreviewable, setIsFilePreviewable] = useState(false)
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false)
|
||||
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const getObjectUrl = useCallback(async () => {
|
||||
setIsLoadingFile(true);
|
||||
setIsLoadingFile(true)
|
||||
try {
|
||||
const chunks: Uint8Array[] = [];
|
||||
await application.files.downloadFile(
|
||||
file,
|
||||
async (decryptedChunk: Uint8Array) => {
|
||||
chunks.push(decryptedChunk);
|
||||
}
|
||||
);
|
||||
const finalDecryptedBytes = concatenateUint8Arrays(chunks);
|
||||
const chunks: Uint8Array[] = []
|
||||
await application.files.downloadFile(file, async (decryptedChunk: Uint8Array) => {
|
||||
chunks.push(decryptedChunk)
|
||||
})
|
||||
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
|
||||
setObjectUrl(
|
||||
URL.createObjectURL(
|
||||
new Blob([finalDecryptedBytes], {
|
||||
type: file.mimeType,
|
||||
})
|
||||
)
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoadingFile(false);
|
||||
setIsLoadingFile(false)
|
||||
}
|
||||
}, [application.files, file]);
|
||||
}, [application.files, file])
|
||||
|
||||
useEffect(() => {
|
||||
const isPreviewable = isFileTypePreviewable(file.mimeType);
|
||||
setIsFilePreviewable(isPreviewable);
|
||||
const isPreviewable = isFileTypePreviewable(file.mimeType)
|
||||
setIsFilePreviewable(isPreviewable)
|
||||
|
||||
if (!objectUrl && isPreviewable) {
|
||||
getObjectUrl();
|
||||
getObjectUrl().catch(console.error)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
};
|
||||
}, [file.mimeType, getObjectUrl, objectUrl]);
|
||||
}
|
||||
}, [file.mimeType, getObjectUrl, objectUrl])
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
@ -105,7 +98,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
<div className="w-6 h-6">
|
||||
{getFileIconComponent(
|
||||
application.iconsController.getIconForFileType(file.mimeType),
|
||||
'w-6 h-6 flex-shrink-0'
|
||||
'w-6 h-6 flex-shrink-0',
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-3 font-medium">{file.name}</span>
|
||||
@ -122,9 +115,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
variant="primary"
|
||||
className="mr-4"
|
||||
onClick={() => {
|
||||
application
|
||||
.getArchiveService()
|
||||
.downloadData(objectUrl, file.name);
|
||||
application.getArchiveService().downloadData(objectUrl, file.name)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
@ -149,21 +140,19 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
) : (
|
||||
<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>
|
||||
<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.
|
||||
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();
|
||||
getObjectUrl().catch(console.error)
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
@ -171,7 +160,7 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file);
|
||||
application.getAppState().files.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
@ -181,13 +170,12 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
) : (
|
||||
<>
|
||||
<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.
|
||||
To view this file, download it and open it using another application.
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file);
|
||||
application.getAppState().files.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
@ -201,5 +189,5 @@ export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SNFile } from '@standardnotes/snjs'
|
||||
import { createContext, FunctionComponent } from 'preact'
|
||||
import { useContext, useState } from 'preact/hooks'
|
||||
import { FilePreviewModal } from './FilePreviewModal'
|
||||
|
||||
type FilePreviewModalContextData = {
|
||||
activate: (file: SNFile) => void
|
||||
}
|
||||
|
||||
const FilePreviewModalContext = createContext<FilePreviewModalContextData | null>(null)
|
||||
|
||||
export const useFilePreviewModal = (): FilePreviewModalContextData => {
|
||||
const value = useContext(FilePreviewModalContext)
|
||||
|
||||
if (!value) {
|
||||
throw new Error('FilePreviewModalProvider not found.')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const FilePreviewModalProvider: FunctionComponent<{
|
||||
application: WebApplication
|
||||
}> = ({ application, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [file, setFile] = useState<SNFile>()
|
||||
|
||||
const activate = (file: SNFile) => {
|
||||
setFile(file)
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && file && (
|
||||
<FilePreviewModal application={application} file={file} onDismiss={close} />
|
||||
)}
|
||||
<FilePreviewModalContext.Provider value={{ activate }}>
|
||||
{children}
|
||||
</FilePreviewModalContext.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
12
app/assets/javascripts/Components/Files/isFilePreviewable.ts
Normal file
12
app/assets/javascripts/Components/Files/isFilePreviewable.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const isFileTypePreviewable = (fileType: string) => {
|
||||
const isImage = fileType.startsWith('image/')
|
||||
const isVideo = fileType.startsWith('video/')
|
||||
const isAudio = fileType.startsWith('audio/')
|
||||
const isPdf = fileType === 'application/pdf'
|
||||
|
||||
if (isImage || isVideo || isAudio || isPdf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
@ -1,58 +1,57 @@
|
||||
import { WebAppEvent, WebApplication } from '@/ui_models/application';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { preventRefreshing } from '@/Utils'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
CollectionSort,
|
||||
ApplicationDescriptor,
|
||||
ItemInterface,
|
||||
} from '@standardnotes/snjs';
|
||||
} 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 '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SyncResolutionMenu } from './SyncResolutionMenu';
|
||||
import { Fragment } from 'preact';
|
||||
} 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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
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;
|
||||
public user?: unknown
|
||||
private didCheckForOffline = false
|
||||
private completedInitialSync = false
|
||||
private showingDownloadStatus = false
|
||||
private webEventListenerDestroyer: () => void
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
offline: true,
|
||||
@ -65,231 +64,215 @@ export class Footer extends PureComponent<Props, State> {
|
||||
newUpdateAvailable: false,
|
||||
showAccountMenu: false,
|
||||
showQuickSettingsMenu: false,
|
||||
};
|
||||
}
|
||||
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver(
|
||||
(event) => {
|
||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
||||
this.onNewUpdateAvailable();
|
||||
}
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => {
|
||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
||||
this.onNewUpdateAvailable()
|
||||
}
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.webEventListenerDestroyer();
|
||||
(this.webEventListenerDestroyer as unknown) = undefined;
|
||||
super.deinit();
|
||||
override deinit() {
|
||||
this.webEventListenerDestroyer()
|
||||
;(this.webEventListenerDestroyer as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
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,
|
||||
});
|
||||
});
|
||||
this.application
|
||||
.checkForSecurityUpdate()
|
||||
.then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadPasscodeStatus();
|
||||
this.reloadUser();
|
||||
this.reloadUpgradeStatus();
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
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();
|
||||
this.user = this.application.getUser()
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode();
|
||||
const hasPasscode = this.application.hasPasscode()
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager();
|
||||
override onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager()
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAccountMenu();
|
||||
this.closeAccountMenu()
|
||||
}
|
||||
break;
|
||||
break
|
||||
case AppStateEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…');
|
||||
break;
|
||||
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 successMessage = 'Successfully saved backup.'
|
||||
const errorMessage = 'Unable to save local backup.'
|
||||
statusService.setMessage(data.success ? successMessage : errorMessage)
|
||||
|
||||
const twoSeconds = 2000;
|
||||
const twoSeconds = 2000
|
||||
setTimeout(() => {
|
||||
if (
|
||||
statusService.message === successMessage ||
|
||||
statusService.message === errorMessage
|
||||
) {
|
||||
statusService.setMessage('');
|
||||
if (statusService.message === successMessage || statusService.message === errorMessage) {
|
||||
statusService.setMessage('')
|
||||
}
|
||||
}, twoSeconds);
|
||||
break;
|
||||
}, twoSeconds)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.reloadPasscodeStatus();
|
||||
override async onAppKeyChange() {
|
||||
super.onAppKeyChange().catch(console.error)
|
||||
this.reloadPasscodeStatus().catch(console.error)
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
override onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus();
|
||||
break;
|
||||
this.reloadUpgradeStatus()
|
||||
break
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
});
|
||||
break;
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
});
|
||||
break;
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('');
|
||||
this.completedInitialSync = true;
|
||||
this.application.getStatusManager().setMessage('')
|
||||
this.completedInitialSync = true
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true;
|
||||
if (
|
||||
this.state.offline &&
|
||||
this.application.items.getNoteCount() === 0
|
||||
) {
|
||||
this.appState.accountMenu.setShow(true);
|
||||
this.didCheckForOffline = true
|
||||
if (this.state.offline && this.application.items.getNoteCount() === 0) {
|
||||
this.appState.accountMenu.setShow(true)
|
||||
}
|
||||
}
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus();
|
||||
break;
|
||||
this.updateSyncStatus()
|
||||
break
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
break;
|
||||
this.updateSyncStatus()
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus();
|
||||
break;
|
||||
this.updateLocalDataStatus()
|
||||
break
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser();
|
||||
break;
|
||||
this.reloadUser()
|
||||
break
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…');
|
||||
this.application.getStatusManager().setMessage('Syncing…')
|
||||
}
|
||||
break;
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.items.setDisplayOptions(
|
||||
ContentType.Theme,
|
||||
CollectionSort.Title,
|
||||
'asc'
|
||||
);
|
||||
this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.sync.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync');
|
||||
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;
|
||||
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.');
|
||||
this.showingDownloadStatus = false
|
||||
statusManager.setMessage('Download Complete.')
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('');
|
||||
}, 2000);
|
||||
statusManager.setMessage('')
|
||||
}, 2000)
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0
|
||||
? 0
|
||||
: stats.uploadCompletionCount / stats.uploadTotalCount;
|
||||
stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
});
|
||||
})
|
||||
|
||||
statusManager.setMessage(
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
|
||||
);
|
||||
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`,
|
||||
)
|
||||
} else {
|
||||
statusManager.setMessage('');
|
||||
statusManager.setMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.sync.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const encryption = this.application.isEncryptionAvailable();
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
const encryption = this.application.isEncryptionAvailable()
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('');
|
||||
return;
|
||||
statusManager.setMessage('')
|
||||
return
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
|
||||
const loadingStatus = encryption
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
statusManager.setMessage(loadingStatus);
|
||||
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 () => {
|
||||
@ -301,48 +284,48 @@ export class Footer extends PureComponent<Props, State> {
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion();
|
||||
});
|
||||
await this.application.upgradeProtocolVersion()
|
||||
}).catch(console.error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
};
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
||||
this.appState.accountMenu.toggleShow()
|
||||
}
|
||||
|
||||
quickSettingsClickHandler = () => {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
this.appState.quickSettingsMenu.toggle();
|
||||
};
|
||||
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);
|
||||
};
|
||||
this.appState.accountMenu.setShow(false)
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}
|
||||
|
||||
lockClickHandler = () => {
|
||||
this.application.lock();
|
||||
};
|
||||
this.application.lock().catch(console.error)
|
||||
}
|
||||
|
||||
onNewUpdateAvailable = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: true,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
newUpdateClickHandler = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: false,
|
||||
});
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
|
||||
};
|
||||
})
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY).catch(console.error)
|
||||
}
|
||||
|
||||
betaMessageClickHandler = () => {
|
||||
alertDialog({
|
||||
@ -350,18 +333,18 @@ export class Footer extends PureComponent<Props, State> {
|
||||
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();
|
||||
};
|
||||
this.appState.accountMenu.closeAccountMenu()
|
||||
}
|
||||
|
||||
clickOutsideQuickSettingsMenu = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
};
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
|
||||
@ -376,15 +359,10 @@ export class Footer extends PureComponent<Props, State> {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.state.hasError
|
||||
? 'danger'
|
||||
: (this.user ? 'info' : 'neutral') + ' w-5 h-5'
|
||||
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"
|
||||
/>
|
||||
<Icon type="account-circle" className="hover:color-info w-5 h-5 max-h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showAccountMenu && (
|
||||
@ -437,39 +415,26 @@ export class Footer extends PureComponent<Props, State> {
|
||||
{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>
|
||||
<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 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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div onClick={this.syncResolutionClickHandler} className="sk-label warning">
|
||||
Potentially Out of Sync
|
||||
</div>
|
||||
)}
|
||||
@ -504,6 +469,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
@ -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,7 @@ import {
|
||||
UserSwitch,
|
||||
WarningIcon,
|
||||
WindowIcon,
|
||||
} from '@standardnotes/stylekit';
|
||||
} from '@standardnotes/stylekit'
|
||||
|
||||
export const ICONS = {
|
||||
'account-circle': AccountCircleIcon,
|
||||
@ -177,22 +177,18 @@ export const ICONS = {
|
||||
user: UserIcon,
|
||||
warning: WarningIcon,
|
||||
window: WindowIcon,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@ -200,5 +196,5 @@ export const Icon: FunctionalComponent<Props> = ({
|
||||
role="img"
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
75
app/assets/javascripts/Components/Input/DecoratedInput.tsx
Normal file
75
app/assets/javascripts/Components/Input/DecoratedInput.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { FunctionalComponent, Ref } from 'preact'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
|
||||
return {
|
||||
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
|
||||
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
|
||||
}`,
|
||||
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
|
||||
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||
disabled: 'bg-grey-5 cursor-not-allowed',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that can be decorated on the left and right side
|
||||
*/
|
||||
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
type = 'text',
|
||||
className = '',
|
||||
disabled = false,
|
||||
left,
|
||||
right,
|
||||
value,
|
||||
placeholder = '',
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
autocomplete = false,
|
||||
}: DecoratedInputProps,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const hasLeftDecorations = Boolean(left?.length)
|
||||
const hasRightDecorations = Boolean(right?.length)
|
||||
const classNames = getClassNames(hasLeftDecorations, hasRightDecorations)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}
|
||||
>
|
||||
{left && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{left.map((leftChild) => (
|
||||
<>{leftChild}</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
className={`${classNames.input} ${disabled ? classNames.disabled : ''}`}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange && onChange((e.target as HTMLInputElement).value)}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
data-lpignore={type !== 'password' ? true : false}
|
||||
autocomplete={autocomplete ? 'on' : 'off'}
|
||||
ref={ref}
|
||||
/>
|
||||
{right && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{right.map((rightChild, index) => (
|
||||
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,15 @@
|
||||
import { ComponentChild } from 'preact'
|
||||
|
||||
export type DecoratedInputProps = {
|
||||
type?: 'text' | 'email' | 'password'
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
left?: ComponentChild[]
|
||||
right?: ComponentChild[]
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange?: (text: string) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
autocomplete?: boolean
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { FunctionComponent, Ref } from 'preact'
|
||||
import { forwardRef } from 'preact/compat'
|
||||
import { StateUpdater, useState } from 'preact/hooks'
|
||||
import { DecoratedInput } from './DecoratedInput'
|
||||
import { IconButton } from '@/Components/Button/IconButton'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const Toggle: FunctionComponent<{
|
||||
isToggled: boolean
|
||||
setIsToggled: StateUpdater<boolean>
|
||||
}> = ({ isToggled, setIsToggled }) => (
|
||||
<IconButton
|
||||
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4 color-neutral"
|
||||
icon={isToggled ? 'eye-off' : 'eye'}
|
||||
iconClassName="sn-icon--small"
|
||||
title="Show/hide password"
|
||||
onClick={() => setIsToggled((isToggled) => !isToggled)}
|
||||
focusable={true}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
|
||||
*/
|
||||
export const DecoratedPasswordInput: FunctionComponent<Omit<DecoratedInputProps, 'type'>> =
|
||||
forwardRef((props, ref: Ref<HTMLInputElement>) => {
|
||||
const [isToggled, setIsToggled] = useState(false)
|
||||
|
||||
const rightSideDecorations = props.right ? [...props.right] : []
|
||||
|
||||
return (
|
||||
<DecoratedInput
|
||||
{...props}
|
||||
ref={ref}
|
||||
type={isToggled ? 'text' : 'password'}
|
||||
right={[
|
||||
...rightSideDecorations,
|
||||
<Toggle isToggled={isToggled} setIsToggled={setIsToggled} />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
14
app/assets/javascripts/Components/Input/Input.tsx
Normal file
14
app/assets/javascripts/Components/Input/Input.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => {
|
||||
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
|
||||
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
|
||||
const classes = `${base} ${stateClasses} ${className}`
|
||||
return <input type="text" className={classes} disabled={disabled} value={text} />
|
||||
}
|
@ -6,22 +6,22 @@ import {
|
||||
RefCallback,
|
||||
ComponentChild,
|
||||
toChildArray,
|
||||
} from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { MenuItem, MenuItemListElement } from './MenuItem';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { useListKeyboardNavigation } from '../utils';
|
||||
} from 'preact'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { JSXInternal } from 'preact/src/jsx'
|
||||
import { MenuItem, MenuItemListElement } from './MenuItem'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
||||
|
||||
type MenuProps = {
|
||||
className?: string;
|
||||
style?: string | JSX.CSSProperties | undefined;
|
||||
a11yLabel: string;
|
||||
children: ComponentChildren;
|
||||
closeMenu?: () => void;
|
||||
isOpen: boolean;
|
||||
initialFocus?: number;
|
||||
};
|
||||
className?: string
|
||||
style?: string | JSX.CSSProperties | undefined
|
||||
a11yLabel: string
|
||||
children: ComponentChildren
|
||||
closeMenu?: () => void
|
||||
isOpen: boolean
|
||||
initialFocus?: number
|
||||
}
|
||||
|
||||
export const Menu: FunctionComponent<MenuProps> = ({
|
||||
children,
|
||||
@ -32,32 +32,30 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
isOpen,
|
||||
initialFocus,
|
||||
}: MenuProps) => {
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
const menuElementRef = useRef<HTMLMenuElement>(null);
|
||||
const menuElementRef = useRef<HTMLMenuElement>(null)
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (
|
||||
event
|
||||
) => {
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (event) => {
|
||||
if (!menuItemRefs.current) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
closeMenu?.();
|
||||
return;
|
||||
closeMenu?.()
|
||||
return
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useListKeyboardNavigation(menuElementRef, initialFocus);
|
||||
useListKeyboardNavigation(menuElementRef, initialFocus)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && menuItemRefs.current.length > 0) {
|
||||
setTimeout(() => {
|
||||
menuElementRef.current?.focus();
|
||||
});
|
||||
menuElementRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen])
|
||||
|
||||
const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => {
|
||||
if (instance && instance.children) {
|
||||
@ -66,49 +64,45 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
child.getAttribute('role')?.includes('menuitem') &&
|
||||
!menuItemRefs.current.includes(child as HTMLButtonElement)
|
||||
) {
|
||||
menuItemRefs.current.push(child as HTMLButtonElement);
|
||||
menuItemRefs.current.push(child as HTMLButtonElement)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const mapMenuItems = (
|
||||
child: ComponentChild,
|
||||
index: number,
|
||||
array: ComponentChild[]
|
||||
array: ComponentChild[],
|
||||
): ComponentChild => {
|
||||
if (!child || (Array.isArray(child) && child.length < 1)) return;
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
return child.map(mapMenuItems);
|
||||
if (!child || (Array.isArray(child) && child.length < 1)) {
|
||||
return
|
||||
}
|
||||
|
||||
const _child = child as VNode<unknown>;
|
||||
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);
|
||||
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
|
||||
(child) => (child as VNode<unknown>).type === MenuItem,
|
||||
)
|
||||
: false;
|
||||
: false
|
||||
|
||||
const items = hasMultipleItems
|
||||
? [...(_child.props.children as ComponentChild[])]
|
||||
: [_child];
|
||||
const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
|
||||
|
||||
return items.map((child) => {
|
||||
return (
|
||||
<MenuItemListElement
|
||||
isFirstMenuItem={isFirstMenuItem}
|
||||
ref={pushRefToArray}
|
||||
>
|
||||
<MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
|
||||
{child}
|
||||
</MenuItemListElement>
|
||||
);
|
||||
});
|
||||
};
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<menu
|
||||
@ -120,5 +114,5 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
>
|
||||
{toChildArray(children).map(mapMenuItems)}
|
||||
</menu>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { ComponentChildren, FunctionComponent, VNode } from 'preact';
|
||||
import { forwardRef, Ref } from 'preact/compat';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch, SwitchProps } from '../Switch';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
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,
|
||||
@ -13,17 +13,17 @@ export enum MenuItemType {
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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(
|
||||
(
|
||||
@ -39,19 +39,16 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
}: MenuItemProps,
|
||||
ref: Ref<HTMLButtonElement>
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return type === MenuItemType.SwitchButton &&
|
||||
typeof onChange === 'function' ? (
|
||||
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||
onClick={() => {
|
||||
onChange(!checked);
|
||||
onChange(!checked)
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
tabIndex={
|
||||
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
|
||||
}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
@ -62,15 +59,11 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
<button
|
||||
ref={ref}
|
||||
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
||||
tabIndex={
|
||||
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
|
||||
}
|
||||
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.RadioButton ? { 'aria-checked': checked } : {})}
|
||||
>
|
||||
{type === MenuItemType.IconButton && icon ? (
|
||||
<Icon type={icon} className={iconClassName} />
|
||||
@ -84,41 +77,37 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const MenuItemSeparator: FunctionComponent = () => (
|
||||
<div role="separator" className="h-1px my-2 bg-border"></div>
|
||||
);
|
||||
)
|
||||
|
||||
type ListElementProps = {
|
||||
isFirstMenuItem: boolean;
|
||||
children: ComponentChildren;
|
||||
};
|
||||
isFirstMenuItem: boolean
|
||||
children: ComponentChildren
|
||||
}
|
||||
|
||||
export const MenuItemListElement: FunctionComponent<ListElementProps> =
|
||||
forwardRef(
|
||||
(
|
||||
{ children, isFirstMenuItem }: ListElementProps,
|
||||
ref: Ref<HTMLLIElement>
|
||||
) => {
|
||||
const child = children as VNode<unknown>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<li className="list-style-none" role="none" ref={ref}>
|
||||
{{
|
||||
...child,
|
||||
props: {
|
||||
...(child.props ? { ...child.props } : {}),
|
||||
...(child.type === MenuItem
|
||||
? {
|
||||
tabIndex: isFirstMenuItem ? 0 : -1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,36 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { IlNotesIcon } from '@standardnotes/stylekit'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { PinNoteButton } from '@/Components/PinNoteButton'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<IlNotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
82
app/assets/javascripts/Components/Navigation/index.tsx
Normal file
82
app/assets/javascripts/Components/Navigation/index.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection'
|
||||
import { TagsSection } from '@/Components/Tags/TagsSection'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { PANEL_NAME_NAVIGATION } from '@/Constants'
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Navigation: FunctionComponent<Props> = observer(({ application }) => {
|
||||
const appState = useMemo(() => application.getAppState(), [application])
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>()
|
||||
const [panelWidth, setPanelWidth] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addEventObserver(async () => {
|
||||
const width = application.getPreference(PrefKey.TagsPanelWidth)
|
||||
if (width) {
|
||||
setPanelWidth(width)
|
||||
}
|
||||
}, ApplicationEvent.PreferencesChanged)
|
||||
|
||||
return () => {
|
||||
removeObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed)
|
||||
},
|
||||
[application, appState],
|
||||
)
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}, [appState])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section app-column app-column-first"
|
||||
data-aria-label="Navigation"
|
||||
ref={setRef}
|
||||
>
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartViewsSection appState={appState} />
|
||||
<TagsSection appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
{ref && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={ref}
|
||||
hoverable={true}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
@ -1,32 +1,30 @@
|
||||
import { Icon } from './Icon';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = { appState: AppState };
|
||||
type Props = { appState: AppState }
|
||||
|
||||
export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
const canShow = appState.noAccountWarning.show;
|
||||
const canShow = appState.noAccountWarning.show
|
||||
if (!canShow) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
|
||||
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
|
||||
<p className="m-0 mt-1 col-start-1 col-end-3">
|
||||
Sign in or register to back up your notes.
|
||||
</p>
|
||||
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
|
||||
<button
|
||||
className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
appState.accountMenu.setShow(true);
|
||||
event.stopPropagation()
|
||||
appState.accountMenu.setShow(true)
|
||||
}}
|
||||
>
|
||||
Open Account menu
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
appState.noAccountWarning.hide();
|
||||
appState.noAccountWarning.hide()
|
||||
}}
|
||||
title="Ignore"
|
||||
label="Ignore"
|
||||
@ -36,5 +34,5 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
<Icon type="close" className="block" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
57
app/assets/javascripts/Components/NoteGroupView/index.tsx
Normal file
57
app/assets/javascripts/Components/NoteGroupView/index.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { NoteViewController } from '@standardnotes/snjs'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes'
|
||||
import { NoteView } from '@/Components/NoteView/NoteView'
|
||||
import { ElementIds } from '@/ElementIDs'
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean
|
||||
controllers: NoteViewController[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export class NoteGroupView extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
controllers: [],
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
|
||||
this.setState({
|
||||
controllers: this.application.noteControllerGroup.noteControllers,
|
||||
})
|
||||
})
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
|
||||
{this.state.showMultipleSelectedNotes && (
|
||||
<MultipleSelectedNotes application={this.application} appState={this.appState} />
|
||||
)}
|
||||
|
||||
{!this.state.showMultipleSelectedNotes && (
|
||||
<>
|
||||
{this.state.controllers.map((controller) => {
|
||||
return <NoteView application={this.application} controller={controller} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,98 +1,98 @@
|
||||
import { Icon } from './Icon';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { SNTag } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { SNTag } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
tag: SNTag;
|
||||
};
|
||||
appState: AppState
|
||||
tag: SNTag
|
||||
}
|
||||
|
||||
export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
const noteTags = appState.noteTags;
|
||||
const noteTags = appState.noteTags
|
||||
|
||||
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags;
|
||||
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
|
||||
|
||||
const [showDeleteButton, setShowDeleteButton] = useState(false);
|
||||
const [tagClicked, setTagClicked] = useState(false);
|
||||
const deleteTagRef = useRef<HTMLButtonElement>(null);
|
||||
const [showDeleteButton, setShowDeleteButton] = useState(false)
|
||||
const [tagClicked, setTagClicked] = useState(false)
|
||||
const deleteTagRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const tagRef = useRef<HTMLButtonElement>(null);
|
||||
const tagRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const title = tag.title;
|
||||
const prefixTitle = noteTags.getPrefixTitle(tag);
|
||||
const longTitle = noteTags.getLongTitle(tag);
|
||||
const title = tag.title
|
||||
const prefixTitle = noteTags.getPrefixTitle(tag)
|
||||
const longTitle = noteTags.getLongTitle(tag)
|
||||
|
||||
const deleteTag = () => {
|
||||
appState.noteTags.focusPreviousTag(tag);
|
||||
appState.noteTags.removeTagFromActiveNote(tag);
|
||||
};
|
||||
appState.noteTags.focusPreviousTag(tag)
|
||||
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
|
||||
}
|
||||
|
||||
const onDeleteTagClick = (event: MouseEvent) => {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
deleteTag();
|
||||
};
|
||||
event.stopImmediatePropagation()
|
||||
event.stopPropagation()
|
||||
deleteTag()
|
||||
}
|
||||
|
||||
const onTagClick = (event: MouseEvent) => {
|
||||
if (tagClicked && event.target !== deleteTagRef.current) {
|
||||
setTagClicked(false);
|
||||
appState.selectedTag = tag;
|
||||
setTagClicked(false)
|
||||
appState.selectedTag = tag
|
||||
} else {
|
||||
setTagClicked(true);
|
||||
setTagClicked(true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
appState.noteTags.setFocusedTagUuid(tag.uuid);
|
||||
setShowDeleteButton(true);
|
||||
};
|
||||
appState.noteTags.setFocusedTagUuid(tag.uuid)
|
||||
setShowDeleteButton(true)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const relatedTarget = event.relatedTarget as Node;
|
||||
const relatedTarget = event.relatedTarget as Node
|
||||
if (relatedTarget !== deleteTagRef.current) {
|
||||
appState.noteTags.setFocusedTagUuid(undefined);
|
||||
setShowDeleteButton(false);
|
||||
appState.noteTags.setFocusedTagUuid(undefined)
|
||||
setShowDeleteButton(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getTabIndex = () => {
|
||||
if (focusedTagUuid) {
|
||||
return focusedTagUuid === tag.uuid ? 0 : -1;
|
||||
return focusedTagUuid === tag.uuid ? 0 : -1
|
||||
}
|
||||
if (autocompleteInputFocused) {
|
||||
return -1;
|
||||
return -1
|
||||
}
|
||||
return tags[0].uuid === tag.uuid ? 0 : -1;
|
||||
};
|
||||
return tags[0].uuid === tag.uuid ? 0 : -1
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const tagIndex = appState.noteTags.getTagIndex(tag, tags);
|
||||
const tagIndex = appState.noteTags.getTagIndex(tag, tags)
|
||||
switch (event.key) {
|
||||
case 'Backspace':
|
||||
deleteTag();
|
||||
break;
|
||||
deleteTag()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
appState.noteTags.focusPreviousTag(tag);
|
||||
break;
|
||||
appState.noteTags.focusPreviousTag(tag)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (tagIndex === tags.length - 1) {
|
||||
appState.noteTags.setAutocompleteInputFocused(true);
|
||||
appState.noteTags.setAutocompleteInputFocused(true)
|
||||
} else {
|
||||
appState.noteTags.focusNextTag(tag);
|
||||
appState.noteTags.focusNextTag(tag)
|
||||
}
|
||||
break;
|
||||
break
|
||||
default:
|
||||
return;
|
||||
return
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedTagUuid === tag.uuid) {
|
||||
tagRef.current!.focus();
|
||||
tagRef.current?.focus()
|
||||
}
|
||||
}, [appState.noteTags, focusedTagUuid, tag]);
|
||||
}, [appState.noteTags, focusedTagUuid, tag])
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -119,12 +119,9 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
onClick={onDeleteTagClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon
|
||||
type="close"
|
||||
className="sn-icon--small color-neutral hover:color-info"
|
||||
/>
|
||||
<Icon type="close" className="sn-icon--small color-neutral hover:color-info" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
@ -1,19 +1,19 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AutocompleteTagInput } from './AutocompleteTagInput';
|
||||
import { NoteTag } from './NoteTag';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput'
|
||||
import { NoteTag } from './NoteTag'
|
||||
import { useEffect } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const { tags, tagsContainerMaxWidth } = appState.noteTags;
|
||||
const { tags, tagsContainerMaxWidth } = appState.noteTags
|
||||
|
||||
useEffect(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
}, [appState.noteTags]);
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}, [appState.noteTags])
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -27,5 +27,5 @@ export const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
@ -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,71 @@ 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
|
||||
);
|
||||
() => 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;
|
||||
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();
|
||||
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);
|
||||
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();
|
||||
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 +140,46 @@ 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));
|
||||
.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));
|
||||
.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
47
app/assets/javascripts/Components/NotesContextMenu/index.tsx
Normal file
47
app/assets/javascripts/Components/NotesContextMenu/index.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NotesOptions } from '@/Components/NotesOptions/NotesOptions'
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = appState.notes
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
|
||||
appState.notes.setContextMenuOpen(open),
|
||||
)
|
||||
|
||||
useCloseOnClickOutside(contextMenuRef, () => appState.notes.setContextMenuOpen(false))
|
||||
|
||||
const reloadContextMenuLayout = useCallback(() => {
|
||||
appState.notes.reloadContextMenuLayout()
|
||||
}, [appState.notes])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', reloadContextMenuLayout)
|
||||
return () => {
|
||||
window.removeEventListener('resize', reloadContextMenuLayout)
|
||||
}
|
||||
}, [reloadContextMenuLayout])
|
||||
|
||||
return contextMenuOpen ? (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
|
||||
style={{
|
||||
...contextMenuPosition,
|
||||
maxHeight: contextMenuMaxHeight,
|
||||
}}
|
||||
>
|
||||
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
|
||||
</div>
|
||||
) : null
|
||||
})
|
@ -1,43 +1,43 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
CollectionSort,
|
||||
CollectionSortProperty,
|
||||
sanitizeHtmlString,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon } from './Icon';
|
||||
} from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
note: SNNote;
|
||||
tags: string[];
|
||||
hideDate: boolean;
|
||||
hidePreview: boolean;
|
||||
hideTags: boolean;
|
||||
hideEditorIcon: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
selected: boolean;
|
||||
sortedBy?: CollectionSortProperty;
|
||||
};
|
||||
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',
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return flags;
|
||||
};
|
||||
return flags
|
||||
}
|
||||
|
||||
export const NotesListItem: FunctionComponent<Props> = ({
|
||||
application,
|
||||
@ -52,13 +52,13 @@ 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 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
|
||||
);
|
||||
editorForNote?.identifier,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -101,22 +101,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>
|
||||
))}
|
||||
@ -144,11 +137,7 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
)}
|
||||
{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 && (
|
||||
@ -162,14 +151,10 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,121 +1,117 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
CollectionSort,
|
||||
CollectionSortProperty,
|
||||
PrefKey,
|
||||
} from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Icon } from './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 { 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)
|
||||
);
|
||||
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt),
|
||||
)
|
||||
const [sortReverse, setSortReverse] = useState(() =>
|
||||
application.getPreference(PrefKey.SortNotesReverse, false)
|
||||
);
|
||||
application.getPreference(PrefKey.SortNotesReverse, false),
|
||||
)
|
||||
const [hidePreview, setHidePreview] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideNotePreview, false)
|
||||
);
|
||||
application.getPreference(PrefKey.NotesHideNotePreview, false),
|
||||
)
|
||||
const [hideDate, setHideDate] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideDate, false)
|
||||
);
|
||||
application.getPreference(PrefKey.NotesHideDate, false),
|
||||
)
|
||||
const [hideTags, setHideTags] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideTags, true)
|
||||
);
|
||||
application.getPreference(PrefKey.NotesHideTags, true),
|
||||
)
|
||||
const [hidePinned, setHidePinned] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHidePinned, false)
|
||||
);
|
||||
application.getPreference(PrefKey.NotesHidePinned, false),
|
||||
)
|
||||
const [showArchived, setShowArchived] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesShowArchived, false)
|
||||
);
|
||||
application.getPreference(PrefKey.NotesShowArchived, false),
|
||||
)
|
||||
const [showTrashed, setShowTrashed] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesShowTrashed, false)
|
||||
);
|
||||
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);
|
||||
};
|
||||
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
|
||||
setSortReverse(!sortReverse)
|
||||
}
|
||||
|
||||
const toggleSortBy = (sort: CollectionSortProperty) => {
|
||||
if (sortBy === sort) {
|
||||
toggleSortReverse();
|
||||
toggleSortReverse()
|
||||
} else {
|
||||
setSortBy(sort);
|
||||
application.setPreference(PrefKey.SortNotesBy, sort);
|
||||
setSortBy(sort)
|
||||
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const toggleSortByDateModified = () => {
|
||||
toggleSortBy(CollectionSort.UpdatedAt);
|
||||
};
|
||||
toggleSortBy(CollectionSort.UpdatedAt)
|
||||
}
|
||||
|
||||
const toggleSortByCreationDate = () => {
|
||||
toggleSortBy(CollectionSort.CreatedAt);
|
||||
};
|
||||
toggleSortBy(CollectionSort.CreatedAt)
|
||||
}
|
||||
|
||||
const toggleSortByTitle = () => {
|
||||
toggleSortBy(CollectionSort.Title);
|
||||
};
|
||||
toggleSortBy(CollectionSort.Title)
|
||||
}
|
||||
|
||||
const toggleHidePreview = () => {
|
||||
setHidePreview(!hidePreview);
|
||||
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview);
|
||||
};
|
||||
setHidePreview(!hidePreview)
|
||||
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideDate = () => {
|
||||
setHideDate(!hideDate);
|
||||
application.setPreference(PrefKey.NotesHideDate, !hideDate);
|
||||
};
|
||||
setHideDate(!hideDate)
|
||||
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideTags = () => {
|
||||
setHideTags(!hideTags);
|
||||
application.setPreference(PrefKey.NotesHideTags, !hideTags);
|
||||
};
|
||||
setHideTags(!hideTags)
|
||||
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHidePinned = () => {
|
||||
setHidePinned(!hidePinned);
|
||||
application.setPreference(PrefKey.NotesHidePinned, !hidePinned);
|
||||
};
|
||||
setHidePinned(!hidePinned)
|
||||
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleShowArchived = () => {
|
||||
setShowArchived(!showArchived);
|
||||
application.setPreference(PrefKey.NotesShowArchived, !showArchived);
|
||||
};
|
||||
setShowArchived(!showArchived)
|
||||
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleShowTrashed = () => {
|
||||
setShowTrashed(!showTrashed);
|
||||
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed);
|
||||
};
|
||||
setShowTrashed(!showTrashed)
|
||||
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleHideProtected = () => {
|
||||
setHideProtected(!hideProtected);
|
||||
application.setPreference(PrefKey.NotesHideProtected, !hideProtected);
|
||||
};
|
||||
setHideProtected(!hideProtected)
|
||||
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
|
||||
}
|
||||
|
||||
const toggleEditorIcon = () => {
|
||||
setHideEditorIcon(!hideEditorIcon);
|
||||
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon);
|
||||
};
|
||||
setHideEditorIcon(!hideEditorIcon)
|
||||
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@ -128,9 +124,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}
|
||||
@ -186,9 +180,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"
|
||||
@ -226,9 +218,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"
|
||||
@ -266,6 +256,6 @@ export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
|
||||
Show trashed notes
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
104
app/assets/javascripts/Components/NotesList/index.tsx
Normal file
104
app/assets/javascripts/Components/NotesList/index.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { DisplayOptions } from '@/UIModels/AppState/NotesViewState'
|
||||
import { SNNote, SNTag } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { NotesListItem } from './NotesListItem'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
notes: SNNote[]
|
||||
selectedNotes: Record<string, SNNote>
|
||||
displayOptions: DisplayOptions
|
||||
paginate: () => void
|
||||
}
|
||||
|
||||
export const NotesList: FunctionComponent<Props> = observer(
|
||||
({ application, appState, notes, selectedNotes, displayOptions, paginate }) => {
|
||||
const { selectPreviousNote, selectNextNote } = appState.notesView
|
||||
const { hideTags, hideDate, hideNotePreview, hideEditorIcon, sortBy } = displayOptions
|
||||
|
||||
const tagsForNote = (note: SNNote): string[] => {
|
||||
if (hideTags) {
|
||||
return []
|
||||
}
|
||||
const selectedTag = appState.selectedTag
|
||||
if (!selectedTag) {
|
||||
return []
|
||||
}
|
||||
const tags = appState.getNoteTags(note)
|
||||
if (selectedTag instanceof SNTag && tags.length === 1) {
|
||||
return []
|
||||
}
|
||||
return tags.map((tag) => tag.title).sort()
|
||||
}
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
appState.notes.setContextMenuClickLocation({
|
||||
x: posX,
|
||||
y: posY,
|
||||
})
|
||||
appState.notes.reloadContextMenuLayout()
|
||||
appState.notes.setContextMenuOpen(true)
|
||||
}
|
||||
|
||||
const onContextMenu = (note: SNNote, posX: number, posY: number) => {
|
||||
appState.notes.selectNote(note.uuid, true).catch(console.error)
|
||||
openNoteContextMenu(posX, posY)
|
||||
}
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
const offset = NOTES_LIST_SCROLL_THRESHOLD
|
||||
const element = e.target as HTMLElement
|
||||
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
|
||||
paginate()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Up) {
|
||||
e.preventDefault()
|
||||
selectPreviousNote()
|
||||
} else if (e.key === KeyboardKey.Down) {
|
||||
e.preventDefault()
|
||||
selectNextNote()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="infinite-scroll focus:shadow-none focus:outline-none"
|
||||
id="notes-scrollable"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<NotesListItem
|
||||
application={application}
|
||||
key={note.uuid}
|
||||
note={note}
|
||||
tags={tagsForNote(note)}
|
||||
selected={!!selectedNotes[note.uuid]}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
hideEditorIcon={hideEditorIcon}
|
||||
sortedBy={sortBy}
|
||||
onClick={() => {
|
||||
appState.notes.selectNote(note.uuid, true).catch(console.error)
|
||||
}}
|
||||
onContextMenu={(e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onContextMenu(note, e.clientX, e.clientY)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
111
app/assets/javascripts/Components/NotesOptions/AddTagOption.tsx
Normal file
111
app/assets/javascripts/Components/NotesOptions/AddTagOption.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const AddTagOption: FunctionComponent<Props> = observer(({ appState }) => {
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
|
||||
|
||||
const toggleTagsMenu = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}
|
||||
|
||||
const recalculateMenuStyle = useCallback(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}, [isMenuOpen, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isMenuOpen} onChange={toggleTagsMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={menuButtonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="hashtag" className="mr-2 color-neutral" />
|
||||
Add tag
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsMenuOpen(false)
|
||||
menuButtonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown min-w-80 flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-auto"
|
||||
>
|
||||
{appState.tags.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.title}
|
||||
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
|
||||
onBlur={closeOnBlur}
|
||||
onClick={() => {
|
||||
appState.notes.isTagInSelectedNotes(tag)
|
||||
? appState.notes.removeTagFromSelectedNotes(tag).catch(console.error)
|
||||
: appState.notes.addTagToSelectedNotes(tag).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
|
||||
${appState.notes.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
|
||||
>
|
||||
{appState.noteTags.getLongTitle(tag)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
})
|
@ -1,89 +1,79 @@
|
||||
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 { 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);
|
||||
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 = () => {
|
||||
if (!isOpen) {
|
||||
const menuStyle = calculateSubmenuStyle(buttonRef.current);
|
||||
const menuStyle = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuStyle) {
|
||||
setMenuStyle(menuStyle);
|
||||
setMenuStyle(menuStyle)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
setIsOpen(!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 +81,7 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setIsOpen(false);
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
@ -108,8 +98,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 +115,12 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
note={note}
|
||||
isVisible={isVisible}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false);
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -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 () => {
|
||||
if (isRunning) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setIsRunning(true)
|
||||
|
||||
await application.actionsManager.runAction(action, note);
|
||||
await application.actionsManager.runAction(action, note)
|
||||
|
||||
setIsRunning(false);
|
||||
setIsRunning(false)
|
||||
|
||||
reloadMenuGroup(group);
|
||||
};
|
||||
reloadMenuGroup(group).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -74,109 +67,100 @@ 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 [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
|
||||
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true)
|
||||
|
||||
const reloadMenuGroup = async (group: ListedMenuGroup) => {
|
||||
const updatedAccountInfo = await application.getListedAccountInfo(
|
||||
group.account,
|
||||
note.uuid
|
||||
);
|
||||
const updatedAccountInfo = await application.getListedAccountInfo(group.account, note.uuid)
|
||||
|
||||
if (!updatedAccountInfo) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const updatedGroup: ListedMenuGroup = {
|
||||
name: updatedAccountInfo.display_name,
|
||||
account: group.account,
|
||||
actions: updatedAccountInfo.actions as Action[],
|
||||
};
|
||||
}
|
||||
|
||||
const updatedGroups = menuGroups.map((group) => {
|
||||
if (updatedGroup.account.authorId === group.account.authorId) {
|
||||
return updatedGroup;
|
||||
return updatedGroup
|
||||
} else {
|
||||
return group;
|
||||
return group
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
setMenuGroups(updatedGroups);
|
||||
};
|
||||
setMenuGroups(updatedGroups)
|
||||
}
|
||||
|
||||
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 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 +192,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,61 +200,53 @@ 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 = () => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition);
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
setIsMenuOpen(!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}>
|
||||
@ -306,5 +280,5 @@ export const ListedActionsOption: FunctionComponent<Props> = ({
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,116 +1,109 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch } from '../Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/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';
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState, useEffect, useMemo } from 'preact/hooks'
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { KeyboardModifier } from '@/Services/IOService'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { ChangeEditorOption } from './ChangeEditorOption'
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
|
||||
import { ListedActionsOption } from './ListedActionsOption'
|
||||
import { AddTagOption } from './AddTagOption'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||
|
||||
export type NotesOptionsProps = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
}
|
||||
|
||||
type DeletePermanentlyButtonProps = {
|
||||
closeOnBlur: NotesOptionsProps['closeOnBlur'];
|
||||
onClick: () => void;
|
||||
};
|
||||
closeOnBlur: NotesOptionsProps['closeOnBlur']
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const DeletePermanentlyButton = ({
|
||||
closeOnBlur,
|
||||
onClick,
|
||||
}: DeletePermanentlyButtonProps) => (
|
||||
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 iconClass = 'color-neutral mr-2'
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
return text.split(/\s+/).length;
|
||||
};
|
||||
return text.split(/\s+/).length
|
||||
}
|
||||
|
||||
const getParagraphCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
return text.replace(/\n$/gm, '').split(/\n/).length;
|
||||
};
|
||||
return text.replace(/\n$/gm, '').split(/\n/).length
|
||||
}
|
||||
|
||||
const countNoteAttributes = (text: string) => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
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);
|
||||
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);
|
||||
const timeToRead = Math.round(words / 200)
|
||||
if (timeToRead === 0) {
|
||||
return '< 1 minute';
|
||||
return '< 1 minute'
|
||||
} else {
|
||||
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`;
|
||||
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
if (!date) {
|
||||
return
|
||||
}
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`
|
||||
}
|
||||
|
||||
const NoteAttributes: FunctionComponent<{
|
||||
application: SNApplication;
|
||||
note: SNNote;
|
||||
application: SNApplication
|
||||
note: SNNote
|
||||
}> = ({ application, note }) => {
|
||||
const { words, characters, paragraphs } = useMemo(
|
||||
() => countNoteAttributes(note.text),
|
||||
[note.text]
|
||||
);
|
||||
[note.text],
|
||||
)
|
||||
|
||||
const readTime = useMemo(
|
||||
() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'),
|
||||
[words]
|
||||
);
|
||||
[words],
|
||||
)
|
||||
|
||||
const dateLastModified = useMemo(
|
||||
() => formatDate(note.userModifiedDate),
|
||||
[note.userModifiedDate]
|
||||
);
|
||||
const dateLastModified = useMemo(() => formatDate(note.userModifiedDate), [note.userModifiedDate])
|
||||
|
||||
const dateCreated = useMemo(
|
||||
() => formatDate(note.created_at),
|
||||
[note.created_at]
|
||||
);
|
||||
const dateCreated = useMemo(() => formatDate(note.created_at), [note.created_at])
|
||||
|
||||
const editor = application.componentManager.editorForNote(note);
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
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">
|
||||
@ -134,29 +127,27 @@ const NoteAttributes: FunctionComponent<{
|
||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const SpellcheckOptions: FunctionComponent<{
|
||||
appState: AppState;
|
||||
note: SNNote;
|
||||
appState: AppState
|
||||
note: SNNote
|
||||
}> = ({ appState, note }) => {
|
||||
const editor = appState.application.componentManager.editorForNote(note);
|
||||
const spellcheckControllable = Boolean(
|
||||
!editor || editor.package_info.spellcheckControl
|
||||
);
|
||||
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;
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="sn-dropdown-item justify-between px-3 py-1"
|
||||
onClick={() => {
|
||||
appState.notes.toggleGlobalSpellcheckForNote(note);
|
||||
appState.notes.toggleGlobalSpellcheckForNote(note).catch(console.error)
|
||||
}}
|
||||
disabled={!spellcheckControllable}
|
||||
>
|
||||
@ -164,122 +155,110 @@ const SpellcheckOptions: FunctionComponent<{
|
||||
<Icon type="notes" className={iconClass} />
|
||||
Spellcheck
|
||||
</span>
|
||||
<Switch
|
||||
className="px-0"
|
||||
checked={noteSpellcheck}
|
||||
disabled={!spellcheckControllable}
|
||||
/>
|
||||
<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>
|
||||
<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 NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
||||
|
||||
const NoteSizeWarning: FunctionComponent<{
|
||||
note: SNNote;
|
||||
note: SNNote
|
||||
}> = ({ note }) =>
|
||||
new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
||||
(new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
||||
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
|
||||
<Icon
|
||||
type="warning"
|
||||
className="color-accessory-tint-3 flex-shrink-0 mr-3"
|
||||
/>
|
||||
<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.
|
||||
This note may have trouble syncing to the mobile application due to its size.
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
) : null)
|
||||
|
||||
export const NotesOptions = observer(
|
||||
({ application, appState, closeOnBlur }: NotesOptionsProps) => {
|
||||
const [altKeyDown, setAltKeyDown] = useState(false);
|
||||
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 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);
|
||||
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);
|
||||
setAltKeyDown(true)
|
||||
},
|
||||
onKeyUp: () => {
|
||||
setAltKeyDown(false);
|
||||
setAltKeyDown(false)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAltKeyObserver();
|
||||
};
|
||||
}, [application]);
|
||||
removeAltKeyObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const getNoteFileName = (note: SNNote): string => {
|
||||
const editor = application.componentManager.editorForNote(note);
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
return `${note.title}.${format}`;
|
||||
};
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
return `${note.title}.${format}`
|
||||
}
|
||||
|
||||
const downloadSelectedItems = async () => {
|
||||
if (notes.length === 1) {
|
||||
application
|
||||
.getArchiveService()
|
||||
.downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]));
|
||||
return;
|
||||
.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);
|
||||
}
|
||||
}),
|
||||
)
|
||||
dismissToast(loadingToastId)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${notes.length} notes`,
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const duplicateSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
application.mutator.duplicateItem(note);
|
||||
});
|
||||
};
|
||||
application.mutator.duplicateItem(note).catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
const openRevisionHistoryModal = () => {
|
||||
appState.notes.setShowRevisionHistoryModal(true);
|
||||
};
|
||||
appState.notes.setShowRevisionHistoryModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -299,7 +278,7 @@ export const NotesOptions = observer(
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setLockSelectedNotes(!locked);
|
||||
appState.notes.setLockSelectedNotes(!locked)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
@ -312,7 +291,7 @@ export const NotesOptions = observer(
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
@ -325,7 +304,7 @@ export const NotesOptions = observer(
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setProtectSelectedNotes(!protect);
|
||||
appState.notes.setProtectSelectedNotes(!protect).catch(console.error)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
@ -338,11 +317,7 @@ export const NotesOptions = observer(
|
||||
{notes.length === 1 && (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<ChangeEditorOption
|
||||
appState={appState}
|
||||
application={application}
|
||||
note={notes[0]}
|
||||
/>
|
||||
<ChangeEditorOption appState={appState} application={application} note={notes[0]} />
|
||||
</>
|
||||
)}
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
@ -352,7 +327,7 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(true);
|
||||
appState.notes.setPinSelectedNotes(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="pin" className={iconClass} />
|
||||
@ -364,26 +339,18 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(false);
|
||||
appState.notes.setPinSelectedNotes(false)
|
||||
}}
|
||||
>
|
||||
<Icon type="unpin" className={iconClass} />
|
||||
Unpin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={downloadSelectedItems}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={duplicateSelectedItems}>
|
||||
<Icon type="copy" className={iconClass} />
|
||||
Duplicate
|
||||
</button>
|
||||
@ -392,7 +359,7 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(true);
|
||||
appState.notes.setArchiveSelectedNotes(true).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="archive" className={iconClass} />
|
||||
@ -404,7 +371,7 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(false);
|
||||
appState.notes.setArchiveSelectedNotes(false).catch(console.error)
|
||||
}}
|
||||
>
|
||||
<Icon type="unarchive" className={iconClass} />
|
||||
@ -416,7 +383,7 @@ export const NotesOptions = observer(
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently();
|
||||
await appState.notes.deleteNotesPermanently()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@ -424,7 +391,7 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(true);
|
||||
await appState.notes.setTrashSelectedNotes(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className={iconClass} />
|
||||
@ -437,7 +404,7 @@ export const NotesOptions = observer(
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(false);
|
||||
await appState.notes.setTrashSelectedNotes(false)
|
||||
}}
|
||||
>
|
||||
<Icon type="restore" className={iconClass} />
|
||||
@ -446,23 +413,21 @@ export const NotesOptions = observer(
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently();
|
||||
await appState.notes.deleteNotesPermanently()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.emptyTrash();
|
||||
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 className="text-xs">{appState.notes.trashedNotesCount} notes in Trash</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -480,6 +445,6 @@ export const NotesOptions = observer(
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -1,66 +1,60 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptions } from './NotesOptions/NotesOptions';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
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>;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const NotesOptionsPanel = observer(
|
||||
({ application, appState, onClickPreprocessing }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
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);
|
||||
})
|
||||
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();
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement;
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2);
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2)
|
||||
}
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
});
|
||||
const newOpenState = !open;
|
||||
})
|
||||
const newOpenState = !open
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing();
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
setOpen(newOpenState);
|
||||
setOpen(newOpenState)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
@ -73,8 +67,8 @@ export const NotesOptionsPanel = observer(
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
@ -87,14 +81,10 @@ export const NotesOptionsPanel = observer(
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{open && (
|
||||
<NotesOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
<NotesOptions application={application} appState={appState} closeOnBlur={closeOnBlur} />
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
260
app/assets/javascripts/Components/NotesView/index.tsx
Normal file
260
app/assets/javascripts/Components/NotesView/index.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { PANEL_NAME_NOTES } from '@/Constants'
|
||||
import { PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { NoAccountWarning } from '@/Components/NoAccountWarning'
|
||||
import { NotesList } from '@/Components/NotesList'
|
||||
import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu'
|
||||
import { SearchOptions } from '@/Components/SearchOptions'
|
||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }) => {
|
||||
const notesViewPanelRef = useRef<HTMLDivElement>(null)
|
||||
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
completedFullSync,
|
||||
createNewNote,
|
||||
displayOptions,
|
||||
noteFilterText,
|
||||
optionsSubtitle,
|
||||
panelTitle,
|
||||
renderedNotes,
|
||||
selectedNotes,
|
||||
setNoteFilterText,
|
||||
searchBarElement,
|
||||
selectNextNote,
|
||||
selectPreviousNote,
|
||||
onFilterEnter,
|
||||
handleFilterTextChanged,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
} = appState.notesView
|
||||
|
||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
|
||||
const [focusedSearch, setFocusedSearch] = useState(false)
|
||||
|
||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
||||
displayOptionsMenuRef,
|
||||
setShowDisplayOptionsMenu,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterTextChanged()
|
||||
}, [noteFilterText, handleFilterTextChanged])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
const newNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault()
|
||||
createNewNote().catch(console.error)
|
||||
},
|
||||
})
|
||||
|
||||
const nextNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [document.body, ...(searchBarElement ? [searchBarElement] : [])],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement === document.activeElement) {
|
||||
searchBarElement?.blur()
|
||||
}
|
||||
selectNextNote()
|
||||
},
|
||||
})
|
||||
|
||||
const previousNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: () => {
|
||||
selectPreviousNote()
|
||||
},
|
||||
})
|
||||
|
||||
const searchKeyObserver = application.io.addKeyObserver({
|
||||
key: 'f',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement) {
|
||||
searchBarElement.focus()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
newNoteKeyObserver()
|
||||
nextNoteKeyObserver()
|
||||
previousNoteKeyObserver()
|
||||
searchKeyObserver()
|
||||
}
|
||||
}, [application.io, createNewNote, searchBarElement, selectNextNote, selectPreviousNote])
|
||||
|
||||
const onNoteFilterTextChange = (e: Event) => {
|
||||
setNoteFilterText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const onSearchFocused = () => setFocusedSearch(true)
|
||||
const onSearchBlurred = () => setFocusedSearch(false)
|
||||
|
||||
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Enter) {
|
||||
onFilterEnter()
|
||||
}
|
||||
}
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
width,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed,
|
||||
) => {
|
||||
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed)
|
||||
}
|
||||
|
||||
const panelWidthEventCallback = () => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
||||
}
|
||||
|
||||
const toggleDisplayOptionsMenu = () => {
|
||||
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notes-column"
|
||||
className="sn-component section notes app-column app-column-second"
|
||||
aria-label="Notes"
|
||||
ref={notesViewPanelRef}
|
||||
>
|
||||
<div className="content">
|
||||
<div id="notes-title-bar" className="section-title-bar">
|
||||
<div id="notes-title-bar-container">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||
<button
|
||||
className="sk-button contrast wide"
|
||||
title="Create a new note in the selected tag"
|
||||
aria-label="Create new note"
|
||||
onClick={() => createNewNote()}
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i className="ion-plus add-button" aria-hidden></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-section" role="search">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onFocus={onSearchFocused}
|
||||
onBlur={onSearchBlurred}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{noteFilterText && (
|
||||
<button onClick={clearFilterText} aria-role="button" id="search-clear-button">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(focusedSearch || noteFilterText) && (
|
||||
<div className="animate-fade-from-top">
|
||||
<SearchOptions application={application} appState={appState} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NoAccountWarning appState={appState} />
|
||||
</div>
|
||||
<div id="notes-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
|
||||
<div className="sk-app-bar no-edges">
|
||||
<div className="left">
|
||||
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
|
||||
<DisclosureButton
|
||||
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onBlur={closeDisplayOptMenuOnBlur}
|
||||
>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-label">Options</div>
|
||||
</div>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-sublabel">{optionsSubtitle}</div>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
||||
{showDisplayOptionsMenu && (
|
||||
<NotesListOptionsMenu
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||
isOpen={showDisplayOptionsMenu}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">No notes.</p>
|
||||
) : null}
|
||||
{!completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">Loading notes...</p>
|
||||
) : null}
|
||||
{renderedNotes.length ? (
|
||||
<NotesList
|
||||
notes={renderedNotes}
|
||||
selectedNotes={selectedNotes}
|
||||
application={application}
|
||||
appState={appState}
|
||||
displayOptions={displayOptions}
|
||||
paginate={paginate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{notesViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
hoverable={true}
|
||||
defaultWidth={300}
|
||||
panel={notesViewPanelRef.current}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
@ -0,0 +1,70 @@
|
||||
import { useRef } from 'preact/hooks'
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const OtherSessionsSignOutContainer = observer((props: Props) => {
|
||||
if (!props.appState.accountMenu.otherSessionsSignOut) {
|
||||
return null
|
||||
}
|
||||
return <ConfirmOtherSessionsSignOut {...props} />
|
||||
})
|
||||
|
||||
const ConfirmOtherSessionsSignOut = observer(({ application, appState }: Props) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setOtherSessionsSignOut(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End all other sessions?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
This action will sign out all other devices signed into your account, and remove
|
||||
your data from those devices when they next regain connection to the internet.
|
||||
You may sign back in on those devices at any time.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
application.revokeAllOtherSessions().catch(console.error)
|
||||
closeDialog()
|
||||
application.alertService
|
||||
.alert(
|
||||
'You have successfully revoked your sessions from other devices.',
|
||||
undefined,
|
||||
'Finish',
|
||||
)
|
||||
.catch(console.error)
|
||||
}}
|
||||
>
|
||||
End Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
})
|
326
app/assets/javascripts/Components/PanelResizer/index.tsx
Normal file
326
app/assets/javascripts/Components/PanelResizer/index.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
import { Component, createRef } from 'preact'
|
||||
import { debounce } from '@/Utils'
|
||||
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean,
|
||||
) => void
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
|
||||
export enum PanelResizeType {
|
||||
WidthOnly = 'WidthOnly',
|
||||
OffsetAndWidth = 'OffsetAndWidth',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
width: number
|
||||
left: number
|
||||
alwaysVisible?: boolean
|
||||
collapsable?: boolean
|
||||
defaultWidth?: number
|
||||
hoverable?: boolean
|
||||
minWidth?: number
|
||||
panel: HTMLDivElement
|
||||
side: PanelSide
|
||||
type: PanelResizeType
|
||||
resizeFinishCallback?: ResizeFinishCallback
|
||||
widthEventCallback?: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
pressed: boolean
|
||||
}
|
||||
|
||||
export class PanelResizer extends Component<Props, State> {
|
||||
private overlay?: HTMLDivElement
|
||||
private resizerElementRef = createRef<HTMLDivElement>()
|
||||
private debouncedResizeHandler: () => void
|
||||
private startLeft: number
|
||||
private startWidth: number
|
||||
private lastDownX: number
|
||||
private lastLeft: number
|
||||
private lastWidth: number
|
||||
private widthBeforeLastDblClick: number
|
||||
private minWidth: number
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
pressed: false,
|
||||
}
|
||||
|
||||
this.minWidth = props.minWidth || 5
|
||||
this.startLeft = props.panel.offsetLeft
|
||||
this.startWidth = props.panel.scrollWidth
|
||||
this.lastDownX = 0
|
||||
this.lastLeft = props.panel.offsetLeft
|
||||
this.lastWidth = props.panel.scrollWidth
|
||||
this.widthBeforeLastDblClick = 0
|
||||
|
||||
this.setWidth(this.props.width)
|
||||
this.setLeft(this.props.left)
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp)
|
||||
document.addEventListener('mousemove', this.onMouseMove)
|
||||
this.debouncedResizeHandler = debounce(this.handleResize, 250)
|
||||
if (this.props.type === PanelResizeType.OffsetAndWidth) {
|
||||
window.addEventListener('resize', this.debouncedResizeHandler)
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.width != prevProps.width) {
|
||||
this.setWidth(this.props.width)
|
||||
}
|
||||
if (this.props.left !== prevProps.left) {
|
||||
this.setLeft(this.props.left)
|
||||
this.setWidth(this.props.width)
|
||||
}
|
||||
|
||||
const isCollapsed = this.isCollapsed()
|
||||
if (isCollapsed !== this.state.collapsed) {
|
||||
this.setState({ collapsed: isCollapsed })
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount() {
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
document.removeEventListener('mousemove', this.onMouseMove)
|
||||
window.removeEventListener('resize', this.debouncedResizeHandler)
|
||||
}
|
||||
|
||||
get appFrame() {
|
||||
return document.getElementById('app')?.getBoundingClientRect() as DOMRect
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect()
|
||||
}
|
||||
|
||||
isAtMaxWidth = () => {
|
||||
const marginOfError = 5
|
||||
const difference = Math.abs(
|
||||
Math.round(this.lastWidth + this.lastLeft) - Math.round(this.getParentRect().width),
|
||||
)
|
||||
return difference < marginOfError
|
||||
}
|
||||
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.minWidth
|
||||
}
|
||||
|
||||
finishSettingWidth = () => {
|
||||
if (!this.props.collapsable) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
collapsed: this.isCollapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
setWidth = (width: number, finish = false): void => {
|
||||
if (width === 0) {
|
||||
width = this.computeMaxWidth()
|
||||
}
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth
|
||||
}
|
||||
|
||||
const parentRect = this.getParentRect()
|
||||
if (width > parentRect.width) {
|
||||
width = parentRect.width
|
||||
}
|
||||
|
||||
const maxWidth = this.appFrame.width - this.props.panel.getBoundingClientRect().x
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
const isFullWidth = Math.round(width + this.lastLeft) === Math.round(parentRect.width)
|
||||
if (isFullWidth) {
|
||||
if (this.props.type === PanelResizeType.WidthOnly) {
|
||||
this.props.panel.style.removeProperty('width')
|
||||
} else {
|
||||
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`
|
||||
}
|
||||
} else {
|
||||
this.props.panel.style.width = width + 'px'
|
||||
}
|
||||
this.lastWidth = width
|
||||
if (finish) {
|
||||
this.finishSettingWidth()
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLeft = (left: number) => {
|
||||
this.props.panel.style.left = left + 'px'
|
||||
this.lastLeft = left
|
||||
}
|
||||
|
||||
onDblClick = () => {
|
||||
const collapsed = this.isCollapsed()
|
||||
if (collapsed) {
|
||||
this.setWidth(this.widthBeforeLastDblClick || this.props.defaultWidth || 0)
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth
|
||||
this.setWidth(this.minWidth)
|
||||
}
|
||||
this.finishSettingWidth()
|
||||
|
||||
this.props.resizeFinishCallback?.(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed(),
|
||||
)
|
||||
}
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.props.widthEventCallback) {
|
||||
this.props.widthEventCallback()
|
||||
}
|
||||
|
||||
let x
|
||||
if (event) {
|
||||
x = event.clientX
|
||||
} else {
|
||||
/** Coming from resize event */
|
||||
x = 0
|
||||
this.lastDownX = 0
|
||||
}
|
||||
const deltaX = x - this.lastDownX
|
||||
const newWidth = this.startWidth + deltaX
|
||||
this.setWidth(newWidth, false)
|
||||
}
|
||||
|
||||
handleLeftEvent(event: MouseEvent) {
|
||||
const panelRect = this.props.panel.getBoundingClientRect()
|
||||
const x = event.clientX || panelRect.x
|
||||
let deltaX = x - this.lastDownX
|
||||
let newLeft = this.startLeft + deltaX
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0
|
||||
deltaX = -this.startLeft
|
||||
}
|
||||
const parentRect = this.getParentRect()
|
||||
let newWidth = this.startWidth - deltaX
|
||||
if (newWidth < this.minWidth) {
|
||||
newWidth = this.minWidth
|
||||
}
|
||||
if (newWidth > parentRect.width) {
|
||||
newWidth = parentRect.width
|
||||
}
|
||||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth
|
||||
}
|
||||
this.setLeft(newLeft)
|
||||
this.setWidth(newWidth, false)
|
||||
}
|
||||
|
||||
computeMaxWidth(): number {
|
||||
const parentRect = this.getParentRect()
|
||||
let width = parentRect.width - this.props.left
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
const startWidth = this.isAtMaxWidth() ? this.computeMaxWidth() : this.props.panel.scrollWidth
|
||||
|
||||
this.startWidth = startWidth
|
||||
this.lastWidth = startWidth
|
||||
|
||||
this.handleWidthEvent()
|
||||
this.finishSettingWidth()
|
||||
}
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.addInvisibleOverlay()
|
||||
this.lastDownX = event.clientX
|
||||
this.startWidth = this.props.panel.scrollWidth
|
||||
this.startLeft = this.props.panel.offsetLeft
|
||||
this.setState({
|
||||
pressed: true,
|
||||
})
|
||||
}
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay()
|
||||
if (!this.state.pressed) {
|
||||
return
|
||||
}
|
||||
this.setState({ pressed: false })
|
||||
const isMaxWidth = this.isAtMaxWidth()
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(this.lastWidth, this.lastLeft, isMaxWidth, this.isCollapsed())
|
||||
}
|
||||
this.finishSettingWidth()
|
||||
}
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (!this.state.pressed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.props.side === PanelSide.Left) {
|
||||
this.handleLeftEvent(event)
|
||||
} else {
|
||||
this.handleWidthEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
return
|
||||
}
|
||||
const overlayElement = document.createElement('div')
|
||||
overlayElement.id = 'resizer-overlay'
|
||||
this.overlay = overlayElement
|
||||
document.body.prepend(this.overlay)
|
||||
}
|
||||
|
||||
removeInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove()
|
||||
this.overlay = undefined
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className={`panel-resizer ${this.props.side} ${this.props.hoverable ? 'hoverable' : ''} ${
|
||||
this.props.alwaysVisible ? 'alwaysVisible' : ''
|
||||
} ${this.state.pressed ? 'dragging' : ''} ${this.state.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onDblClick={this.onDblClick}
|
||||
ref={this.resizerElementRef}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
}
|
@ -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,105 @@ 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!
|
||||
);
|
||||
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,7 +208,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
handleCurrentPasswordInputChange = ({
|
||||
@ -214,26 +216,26 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
currentPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
handleNewPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
handleNewPasswordConfirmationInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPasswordConfirmation: currentTarget.value,
|
||||
});
|
||||
};
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="password-wizard" className="sk-modal small auto-height">
|
||||
@ -242,9 +244,7 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
<div className="sn-component">
|
||||
<div className="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 +255,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 +270,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>
|
||||
|
||||
@ -298,12 +292,8 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
|
||||
<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"
|
||||
/>
|
||||
@ -318,9 +308,8 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
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 +328,6 @@ export class PasswordWizard extends PureComponent<Props, State> {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
@ -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"
|
||||
@ -89,6 +88,6 @@ export class PermissionsModal extends Component<Props> {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
39
app/assets/javascripts/Components/PinNoteButton/index.tsx
Normal file
39
app/assets/javascripts/Components/PinNoteButton/index.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
|
||||
type Props = {
|
||||
appState: AppState
|
||||
className?: string
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const PinNoteButton: FunctionComponent<Props> = observer(
|
||||
({ appState, className = '', onClickPreprocessing }) => {
|
||||
const notes = Object.values(appState.notes.selectedNotes)
|
||||
const pinned = notes.some((note) => note.pinned)
|
||||
|
||||
const togglePinned = async () => {
|
||||
if (onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
if (!pinned) {
|
||||
appState.notes.setPinSelectedNotes(true)
|
||||
} else {
|
||||
appState.notes.setPinSelectedNotes(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sn-icon-button border-contrast ${pinned ? 'toggled' : ''} ${className}`}
|
||||
onClick={togglePinned}
|
||||
>
|
||||
<VisuallyHidden>Pin selected notes</VisuallyHidden>
|
||||
<Icon type="pin" className="block" />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,38 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { PreferencesGroup, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { OfflineSubscription } from '@/Components/Preferences/Panes/Account/OfflineSubscription'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Extensions } from '@/Components/Preferences/Panes/Extensions'
|
||||
import { ExtensionsLatestVersions } from '@/Components/Preferences/Panes/Extensions/ExtensionsLatestVersions'
|
||||
import { AccordionItem } from '@/Components/Shared/AccordionItem'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
extensionsLatestVersions: ExtensionsLatestVersions
|
||||
}
|
||||
|
||||
export const Advanced: FunctionalComponent<IProps> = observer(
|
||||
({ application, appState, extensionsLatestVersions }) => {
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Advanced Settings'}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<OfflineSubscription application={application} appState={appState} />
|
||||
<Extensions
|
||||
className={'mt-3'}
|
||||
application={application}
|
||||
extensionsLatestVersions={extensionsLatestVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
},
|
||||
)
|
@ -1,32 +1,32 @@
|
||||
import { AccountMenuPane } from '@/components/AccountMenu';
|
||||
import { Button } from '@/components/Button';
|
||||
import { AccountMenuPane } from '@/Components/AccountMenu'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
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';
|
||||
import { AccountIllustration } from '@standardnotes/stylekit';
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { AccountIllustration } from '@standardnotes/stylekit'
|
||||
|
||||
export const Authentication: FunctionComponent<{
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}> = observer(({ appState }) => {
|
||||
const clickSignIn = () => {
|
||||
appState.preferences.closePreferences();
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.SignIn);
|
||||
appState.accountMenu.setShow(true);
|
||||
};
|
||||
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);
|
||||
};
|
||||
appState.preferences.closePreferences()
|
||||
appState.accountMenu.setCurrentPane(AccountMenuPane.Register)
|
||||
appState.accountMenu.setShow(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
@ -35,8 +35,8 @@ export const Authentication: FunctionComponent<{
|
||||
<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.
|
||||
Sign in to sync your notes and preferences across all your devices and enable end-to-end
|
||||
encryption.
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
@ -56,5 +56,5 @@ export const Authentication: FunctionComponent<{
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
@ -1,14 +1,14 @@
|
||||
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,
|
||||
@ -25,7 +25,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 +38,10 @@ export const ChangeEmailForm: FunctionalComponent<Props> = ({
|
||||
className={inputClassName}
|
||||
type="password"
|
||||
onChange={({ target }) => {
|
||||
setCurrentPassword((target as HTMLInputElement).value);
|
||||
setCurrentPassword((target as HTMLInputElement).value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
|
||||
export const ChangeEmailSuccess: FunctionalComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={'sk-label sk-bold info mt-2'}>Your email has been successfully changed.</div>
|
||||
<p className={'sk-p'}>
|
||||
Please ensure you are running the latest version of Standard Notes on all platforms to
|
||||
ensure maximum compatibility.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,171 @@
|
||||
import { useState } from '@node_modules/preact/hooks'
|
||||
import {
|
||||
ModalDialog,
|
||||
ModalDialogButtons,
|
||||
ModalDialogDescription,
|
||||
ModalDialogLabel,
|
||||
} from '@/Components/Shared/ModalDialog'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
|
||||
import { ChangeEmailForm } from './ChangeEmailForm'
|
||||
import { ChangeEmailSuccess } from './ChangeEmailSuccess'
|
||||
import { isEmailValid } from '@/Utils'
|
||||
|
||||
enum SubmitButtonTitles {
|
||||
Default = 'Continue',
|
||||
GeneratingKeys = 'Generating Keys...',
|
||||
Finish = 'Finish',
|
||||
}
|
||||
|
||||
enum Steps {
|
||||
InitialStep,
|
||||
FinishStep,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onCloseDialog: () => void
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const ChangeEmail: FunctionalComponent<Props> = ({ onCloseDialog, application }) => {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [isContinuing, setIsContinuing] = useState(false)
|
||||
const [lockContinue, setLockContinue] = useState(false)
|
||||
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
|
||||
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
|
||||
|
||||
useBeforeUnload()
|
||||
|
||||
const applicationAlertService = application.alertService
|
||||
|
||||
const validateCurrentPassword = async () => {
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
applicationAlertService.alert('Please enter your current password.').catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const success = await application.validateAccountPassword(currentPassword)
|
||||
if (!success) {
|
||||
applicationAlertService
|
||||
.alert('The current password you entered is not correct. Please try again.')
|
||||
.catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
const validateNewEmail = async () => {
|
||||
if (!isEmailValid(newEmail)) {
|
||||
applicationAlertService
|
||||
.alert(
|
||||
'The email you entered has an invalid format. Please review your input and try again.',
|
||||
)
|
||||
.catch(console.error)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resetProgressState = () => {
|
||||
setSubmitButtonTitle(SubmitButtonTitles.Default)
|
||||
setIsContinuing(false)
|
||||
}
|
||||
|
||||
const processEmailChange = async () => {
|
||||
await application.downloadBackup()
|
||||
|
||||
setLockContinue(true)
|
||||
|
||||
const response = await application.changeEmail(newEmail, currentPassword)
|
||||
|
||||
const success = !response.error
|
||||
|
||||
setLockContinue(false)
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (lockContinue) {
|
||||
applicationAlertService
|
||||
.alert('Cannot close window until pending tasks are complete.')
|
||||
.catch(console.error)
|
||||
} else {
|
||||
onCloseDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (lockContinue || isContinuing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep === Steps.FinishStep) {
|
||||
dismiss()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsContinuing(true)
|
||||
setSubmitButtonTitle(SubmitButtonTitles.GeneratingKeys)
|
||||
|
||||
const valid = (await validateCurrentPassword()) && (await validateNewEmail())
|
||||
|
||||
if (!valid) {
|
||||
resetProgressState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const success = await processEmailChange()
|
||||
if (!success) {
|
||||
resetProgressState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsContinuing(false)
|
||||
setSubmitButtonTitle(SubmitButtonTitles.Finish)
|
||||
setCurrentStep(Steps.FinishStep)
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
if (lockContinue) {
|
||||
applicationAlertService
|
||||
.alert('Cannot close window until pending tasks are complete.')
|
||||
.catch(console.error)
|
||||
} else {
|
||||
onCloseDialog()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModalDialog>
|
||||
<ModalDialogLabel closeDialog={handleDialogClose}>Change Email</ModalDialogLabel>
|
||||
<ModalDialogDescription className="px-4.5">
|
||||
{currentStep === Steps.InitialStep && (
|
||||
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
|
||||
)}
|
||||
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
|
||||
</ModalDialogDescription>
|
||||
<ModalDialogButtons className="px-4.5">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="primary"
|
||||
label={submitButtonTitle}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</ModalDialogButtons>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from '@node_modules/mobx-react-lite'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { dateToLocalizedString } from '@standardnotes/snjs'
|
||||
import { useCallback, useState } from 'preact/hooks'
|
||||
import { ChangeEmail } from '@/Components/Preferences/Panes/Account/ChangeEmail'
|
||||
import { FunctionComponent, render } from 'preact'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { PasswordWizard } from '@/Components/PasswordWizard'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const Credentials: FunctionComponent<Props> = observer(({ application }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false)
|
||||
|
||||
const user = application.getUser()
|
||||
|
||||
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp)
|
||||
|
||||
const presentPasswordWizard = useCallback(() => {
|
||||
render(
|
||||
<PasswordWizard application={application} />,
|
||||
document.body.appendChild(document.createElement('div')),
|
||||
)
|
||||
}, [application])
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<Subtitle>Email</Subtitle>
|
||||
<Text>
|
||||
You're signed in as <span className="font-bold wrap">{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Change email"
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<Subtitle>Password</Subtitle>
|
||||
<Text>
|
||||
Current password was set on <span className="font-bold">{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Change password"
|
||||
onClick={presentPasswordWizard}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
@ -0,0 +1,128 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { Subtitle } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/Strings'
|
||||
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const OfflineSubscription: FunctionalComponent<IProps> = observer(({ application }) => {
|
||||
const [activationCode, setActivationCode] = useState('')
|
||||
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
|
||||
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
|
||||
const [hasUserPreviouslyStoredCode, setHasUserPreviouslyStoredCode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (application.features.hasOfflineRepo()) {
|
||||
setHasUserPreviouslyStoredCode(true)
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const shouldShowOfflineSubscription = () => {
|
||||
return (
|
||||
!application.hasAccount() || application.isThirdPartyHostUsed() || hasUserPreviouslyStoredCode
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubscriptionCodeSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const result = await application.features.setOfflineFeaturesCode(activationCode)
|
||||
|
||||
if (result instanceof ClientDisplayableError) {
|
||||
await application.alertService.alert(result.text)
|
||||
} else {
|
||||
setIsSuccessfullyActivated(true)
|
||||
setHasUserPreviouslyStoredCode(true)
|
||||
setIsSuccessfullyRemoved(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveOfflineKey = async () => {
|
||||
await application.features.deleteOfflineFeatureRepo()
|
||||
|
||||
setIsSuccessfullyActivated(false)
|
||||
setHasUserPreviouslyStoredCode(false)
|
||||
setActivationCode('')
|
||||
setIsSuccessfullyRemoved(true)
|
||||
}
|
||||
|
||||
const handleRemoveClick = async () => {
|
||||
application.alertService
|
||||
.confirm(
|
||||
STRING_REMOVE_OFFLINE_KEY_CONFIRMATION,
|
||||
'Remove offline key?',
|
||||
'Remove Offline Key',
|
||||
ButtonType.Danger,
|
||||
'Cancel',
|
||||
)
|
||||
.then(async (shouldRemove: boolean) => {
|
||||
if (shouldRemove) {
|
||||
await handleRemoveOfflineKey()
|
||||
}
|
||||
})
|
||||
.catch((err: string) => {
|
||||
application.alertService.alert(err).catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
if (!shouldShowOfflineSubscription()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col mt-3 w-full">
|
||||
<Subtitle>{!hasUserPreviouslyStoredCode && 'Activate'} Offline Subscription</Subtitle>
|
||||
<form onSubmit={handleSubscriptionCodeSubmit}>
|
||||
<div className={'mt-2'}>
|
||||
{!hasUserPreviouslyStoredCode && (
|
||||
<DecoratedInput
|
||||
onChange={(code) => setActivationCode(code)}
|
||||
placeholder={'Offline Subscription Code'}
|
||||
value={activationCode}
|
||||
disabled={isSuccessfullyActivated}
|
||||
className={'mb-3'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isSuccessfullyActivated || isSuccessfullyRemoved) && (
|
||||
<div className={'mt-3 mb-3 info'}>
|
||||
Your offline subscription code has been successfully{' '}
|
||||
{isSuccessfullyActivated ? 'activated' : 'removed'}.
|
||||
</div>
|
||||
)}
|
||||
{hasUserPreviouslyStoredCode && (
|
||||
<Button
|
||||
dangerStyle={true}
|
||||
label="Remove offline key"
|
||||
onClick={() => {
|
||||
handleRemoveClick().catch(console.error)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hasUserPreviouslyStoredCode && !isSuccessfullyActivated && (
|
||||
<Button
|
||||
label={'Submit'}
|
||||
variant="primary"
|
||||
disabled={activationCode === ''}
|
||||
onClick={(event) => handleSubscriptionCodeSubmit(event)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-8 mb-5" />
|
||||
</>
|
||||
)
|
||||
})
|
@ -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 (
|
||||
<>
|
||||
@ -30,7 +30,7 @@ const SignOutView: FunctionComponent<{
|
||||
variant="normal"
|
||||
label="Sign out other sessions"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setOtherSessionsSignOut(true);
|
||||
appState.accountMenu.setOtherSessionsSignOut(true)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@ -42,57 +42,49 @@ const SignOutView: FunctionComponent<{
|
||||
</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
|
||||
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
|
||||
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} />
|
||||
})
|
@ -0,0 +1,51 @@
|
||||
import { FunctionalComponent } from 'preact'
|
||||
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
|
||||
export const NoSubscription: FunctionalComponent<{
|
||||
application: WebApplication
|
||||
}> = ({ application }) => {
|
||||
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
|
||||
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
|
||||
|
||||
const onPurchaseClick = async () => {
|
||||
const errorMessage =
|
||||
'There was an error when attempting to redirect you to the subscription page.'
|
||||
setIsLoadingPurchaseFlow(true)
|
||||
try {
|
||||
if (!(await loadPurchaseFlowUrl(application))) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
}
|
||||
} catch (e) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
} finally {
|
||||
setIsLoadingPurchaseFlow(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>You don't have a Standard Notes subscription yet.</Text>
|
||||
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
|
||||
{purchaseFlowError && <Text className="color-danger">{purchaseFlowError}</Text>}
|
||||
<div className="flex">
|
||||
<LinkButton
|
||||
className="min-w-20 mt-3 mr-3"
|
||||
label="Learn More"
|
||||
link={window.plansUrl as string}
|
||||
/>
|
||||
{application.hasAccount() && (
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="primary"
|
||||
label="Subscribe"
|
||||
onClick={onPurchaseClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,26 +1,22 @@
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SubscriptionInformation } from './SubscriptionInformation';
|
||||
import { NoSubscription } from './NoSubscription';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
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;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const Subscription: FunctionComponent<Props> = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const subscriptionState = appState.subscription;
|
||||
const { userSubscription } = subscriptionState;
|
||||
const subscriptionState = appState.subscription
|
||||
const { userSubscription } = subscriptionState
|
||||
|
||||
const now = new Date().getTime();
|
||||
const now = new Date().getTime()
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
@ -40,6 +36,6 @@ export const Subscription: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,85 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { SubscriptionState } from '@/UIModels/AppState/SubscriptionState'
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
|
||||
type Props = {
|
||||
subscriptionState: SubscriptionState
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const StatusText = observer(
|
||||
({ subscriptionState }: { subscriptionState: Props['subscriptionState'] }) => {
|
||||
const {
|
||||
userSubscriptionName,
|
||||
userSubscriptionExpirationDate,
|
||||
isUserSubscriptionExpired,
|
||||
isUserSubscriptionCanceled,
|
||||
} = subscriptionState
|
||||
const expirationDateString = userSubscriptionExpirationDate?.toLocaleString()
|
||||
|
||||
if (isUserSubscriptionCanceled) {
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription has been canceled{' '}
|
||||
{isUserSubscriptionExpired ? (
|
||||
<span className="font-bold">and expired on {expirationDateString}</span>
|
||||
) : (
|
||||
<span className="font-bold">but will remain valid until {expirationDateString}</span>
|
||||
)}
|
||||
. You may resubscribe below if you wish.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUserSubscriptionExpired) {
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription <span className="font-bold">expired on {expirationDateString}</span>. You may
|
||||
resubscribe below if you wish.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="mt-1">
|
||||
Your{' '}
|
||||
<span className="font-bold">
|
||||
Standard Notes{userSubscriptionName ? ' ' : ''}
|
||||
{userSubscriptionName}
|
||||
</span>{' '}
|
||||
subscription will be <span className="font-bold">renewed on {expirationDateString}</span>.
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const SubscriptionInformation = observer(({ subscriptionState, application }: Props) => {
|
||||
const manageSubscription = async () => {
|
||||
openSubscriptionDashboard(application)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusText subscriptionState={subscriptionState} />
|
||||
<Button
|
||||
className="min-w-20 mt-3 mr-3"
|
||||
variant="normal"
|
||||
label="Manage subscription"
|
||||
onClick={manageSubscription}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
@ -0,0 +1,65 @@
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs'
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
|
||||
import { useState } from '@node_modules/preact/hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const formatLastSyncDate = (lastUpdatedDate: Date) => {
|
||||
return dateToLocalizedString(lastUpdatedDate)
|
||||
}
|
||||
|
||||
export const Sync: FunctionComponent<Props> = observer(({ application }: Props) => {
|
||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||
const [lastSyncDate, setLastSyncDate] = useState(
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date),
|
||||
)
|
||||
|
||||
const doSynchronization = async () => {
|
||||
setIsSyncingInProgress(true)
|
||||
|
||||
const response = await application.sync.sync({
|
||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||
checkIntegrity: true,
|
||||
})
|
||||
setIsSyncingInProgress(false)
|
||||
if (response && (response as any).error) {
|
||||
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
||||
} else {
|
||||
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<Title>Sync</Title>
|
||||
<Text>
|
||||
Last synced <span className="font-bold">on {lastSyncDate}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
variant="normal"
|
||||
label="Sync now"
|
||||
disabled={isSyncingInProgress}
|
||||
onClick={doSynchronization}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
})
|
@ -0,0 +1,29 @@
|
||||
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { Authentication } from './Authentication'
|
||||
import { Credentials } from './Credentials'
|
||||
import { Sync } from './Sync'
|
||||
import { Subscription } from './Subscription/Subscription'
|
||||
import { SignOutWrapper } from './SignOutView'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const AccountPreferences = observer(({ application, appState }: Props) => (
|
||||
<PreferencesPane>
|
||||
{!application.hasAccount() ? (
|
||||
<Authentication application={application} appState={appState} />
|
||||
) : (
|
||||
<>
|
||||
<Credentials application={application} appState={appState} />
|
||||
<Sync application={application} />
|
||||
</>
|
||||
)}
|
||||
<Subscription application={application} appState={appState} />
|
||||
<SignOutWrapper application={application} appState={appState} />
|
||||
</PreferencesPane>
|
||||
))
|
@ -0,0 +1,169 @@
|
||||
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
PrefKey,
|
||||
GetFeatures,
|
||||
SNTheme,
|
||||
} from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Title,
|
||||
Text,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { sortThemes } from '@/Utils/SortThemes'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Appearance: FunctionComponent<Props> = observer(({ application }) => {
|
||||
const premiumModal = usePremiumModal()
|
||||
const isEntitledToMidnightTheme =
|
||||
application.features.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
|
||||
FeatureStatus.Entitled
|
||||
|
||||
const [themeItems, setThemeItems] = useState<DropdownItem[]>([])
|
||||
const [autoLightTheme, setAutoLightTheme] = useState<string>(
|
||||
() => application.getPreference(PrefKey.AutoLightThemeIdentifier, 'Default') as string,
|
||||
)
|
||||
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default',
|
||||
) as string,
|
||||
)
|
||||
const [useDeviceSettings, setUseDeviceSettings] = useState(
|
||||
() => application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const themesAsItems: DropdownItem[] = application.items
|
||||
.getDisplayableItems<SNTheme>(ContentType.Theme)
|
||||
.filter((theme) => !theme.isLayerable())
|
||||
.sort(sortThemes)
|
||||
.map((theme) => {
|
||||
return {
|
||||
label: theme.name,
|
||||
value: theme.identifier as string,
|
||||
}
|
||||
})
|
||||
|
||||
GetFeatures()
|
||||
.filter((feature) => feature.content_type === ContentType.Theme && !feature.layerable)
|
||||
.forEach((theme) => {
|
||||
if (themesAsItems.findIndex((item) => item.value === theme.identifier) === -1) {
|
||||
themesAsItems.push({
|
||||
label: theme.name as string,
|
||||
value: theme.identifier,
|
||||
icon: 'premium-feature',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
themesAsItems.unshift({
|
||||
label: 'Default',
|
||||
value: 'Default',
|
||||
})
|
||||
|
||||
setThemeItems(themesAsItems)
|
||||
}, [application])
|
||||
|
||||
const toggleUseDeviceSettings = () => {
|
||||
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings).catch(console.error)
|
||||
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
|
||||
application
|
||||
.setPreference(PrefKey.AutoLightThemeIdentifier, autoLightTheme as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
}
|
||||
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
|
||||
application
|
||||
.setPreference(PrefKey.AutoDarkThemeIdentifier, autoDarkTheme as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
}
|
||||
setUseDeviceSettings(!useDeviceSettings)
|
||||
}
|
||||
|
||||
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`)
|
||||
} else {
|
||||
application
|
||||
.setPreference(PrefKey.AutoLightThemeIdentifier, value as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
setAutoLightTheme(value)
|
||||
}
|
||||
}
|
||||
|
||||
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`)
|
||||
} else {
|
||||
application
|
||||
.setPreference(PrefKey.AutoDarkThemeIdentifier, value as FeatureIdentifier)
|
||||
.catch(console.error)
|
||||
setAutoDarkTheme(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Themes</Title>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Use system color scheme</Subtitle>
|
||||
<Text>Automatically change active theme based on your system settings.</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleUseDeviceSettings} checked={useDeviceSettings} />
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Light Theme</Subtitle>
|
||||
<Text>Theme to be used for system light mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-light-theme-dropdown"
|
||||
label="Select the automatic light theme"
|
||||
items={themeItems}
|
||||
value={autoLightTheme}
|
||||
onChange={changeAutoLightTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Dark Theme</Subtitle>
|
||||
<Text>Theme to be used for system dark mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-dark-theme-dropdown"
|
||||
label="Select the automatic dark theme"
|
||||
items={themeItems}
|
||||
value={autoDarkTheme}
|
||||
onChange={changeAutoDarkTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
)
|
||||
})
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import {
|
||||
ButtonType,
|
||||
SettingName,
|
||||
@ -6,34 +6,32 @@ import {
|
||||
DropboxBackupFrequency,
|
||||
GoogleDriveBackupFrequency,
|
||||
OneDriveBackupFrequency,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Button } from '@/components/Button';
|
||||
import { isDev, openInNewTab } from '@/utils';
|
||||
import { Subtitle } from '@/components/Preferences/components';
|
||||
import { KeyboardKey } from '@Services/ioService';
|
||||
import { FunctionComponent } from 'preact';
|
||||
} 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;
|
||||
};
|
||||
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 [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();
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const shouldDisable = await application.alertService.confirm(
|
||||
@ -41,49 +39,48 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
'Disable?',
|
||||
'Disable',
|
||||
ButtonType.Danger,
|
||||
'Cancel'
|
||||
);
|
||||
'Cancel',
|
||||
)
|
||||
if (shouldDisable) {
|
||||
await application.settings.deleteSetting(backupFrequencySettingName);
|
||||
await application.settings.deleteSetting(backupTokenSettingName);
|
||||
await application.settings.deleteSetting(backupFrequencySettingName)
|
||||
await application.settings.deleteSetting(backupTokenSettingName)
|
||||
|
||||
setBackupFrequency(undefined);
|
||||
setBackupFrequency(undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
application.alertService.alert(error as string);
|
||||
application.alertService.alert(error as string).catch(console.error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const installIntegration = (event: Event) => {
|
||||
if (!isEntitledToCloudBackups) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.stopPropagation()
|
||||
|
||||
const authUrl = application.getCloudProviderIntegrationUrl(
|
||||
providerName,
|
||||
isDev
|
||||
);
|
||||
openInNewTab(authUrl);
|
||||
setAuthBegan(true);
|
||||
};
|
||||
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
|
||||
);
|
||||
application.alertService.alert(
|
||||
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.'
|
||||
);
|
||||
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.'
|
||||
);
|
||||
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]: {
|
||||
@ -101,115 +98,96 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
|
||||
defaultBackupFrequency: OneDriveBackupFrequency.Daily,
|
||||
},
|
||||
};
|
||||
const {
|
||||
backupTokenSettingName,
|
||||
backupFrequencySettingName,
|
||||
defaultBackupFrequency,
|
||||
} = backupSettingsData[providerName];
|
||||
}
|
||||
const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } =
|
||||
backupSettingsData[providerName]
|
||||
|
||||
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
|
||||
const urlSearchParams = new URLSearchParams(url.search);
|
||||
let integrationTokenKeyInUrl = '';
|
||||
const urlSearchParams = new URLSearchParams(url.search)
|
||||
let integrationTokenKeyInUrl = ''
|
||||
|
||||
switch (providerName) {
|
||||
case CloudProvider.Dropbox:
|
||||
integrationTokenKeyInUrl = 'dbt';
|
||||
break;
|
||||
integrationTokenKeyInUrl = 'dbt'
|
||||
break
|
||||
case CloudProvider.Google:
|
||||
integrationTokenKeyInUrl = 'key';
|
||||
break;
|
||||
integrationTokenKeyInUrl = 'key'
|
||||
break
|
||||
case CloudProvider.OneDrive:
|
||||
integrationTokenKeyInUrl = 'key';
|
||||
break;
|
||||
integrationTokenKeyInUrl = 'key'
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid Cloud Provider name');
|
||||
throw new Error('Invalid Cloud Provider name')
|
||||
}
|
||||
return urlSearchParams.get(integrationTokenKeyInUrl);
|
||||
};
|
||||
return urlSearchParams.get(integrationTokenKeyInUrl)
|
||||
}
|
||||
|
||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
try {
|
||||
const decryptedCode = atob(confirmation);
|
||||
const urlFromDecryptedCode = new URL(decryptedCode);
|
||||
const cloudProviderToken =
|
||||
getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode);
|
||||
const decryptedCode = atob(confirmation)
|
||||
const urlFromDecryptedCode = new URL(decryptedCode)
|
||||
const cloudProviderToken = getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode)
|
||||
|
||||
if (!cloudProviderToken) {
|
||||
throw new Error();
|
||||
throw new Error()
|
||||
}
|
||||
await application.settings.updateSetting(
|
||||
backupTokenSettingName,
|
||||
cloudProviderToken
|
||||
);
|
||||
await application.settings.updateSetting(
|
||||
backupFrequencySettingName,
|
||||
defaultBackupFrequency
|
||||
);
|
||||
await application.settings.updateSetting(backupTokenSettingName, cloudProviderToken)
|
||||
await application.settings.updateSetting(backupFrequencySettingName, defaultBackupFrequency)
|
||||
|
||||
setBackupFrequency(defaultBackupFrequency);
|
||||
setBackupFrequency(defaultBackupFrequency)
|
||||
|
||||
setAuthBegan(false);
|
||||
setSuccessfullyInstalled(true);
|
||||
setConfirmation('');
|
||||
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.`
|
||||
);
|
||||
`${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.');
|
||||
await application.alertService.alert('Invalid code. Please try again.')
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
setConfirmation((event.target as HTMLInputElement).value);
|
||||
};
|
||||
setConfirmation((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const getIntegrationStatus = useCallback(async () => {
|
||||
if (!application.getUser()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const frequency = await application.settings.getSetting(
|
||||
backupFrequencySettingName
|
||||
);
|
||||
setBackupFrequency(frequency);
|
||||
}, [application, backupFrequencySettingName]);
|
||||
const frequency = await application.settings.getSetting(backupFrequencySettingName)
|
||||
setBackupFrequency(frequency)
|
||||
}, [application, backupFrequencySettingName])
|
||||
|
||||
useEffect(() => {
|
||||
getIntegrationStatus();
|
||||
}, [getIntegrationStatus]);
|
||||
getIntegrationStatus().catch(console.error)
|
||||
}, [getIntegrationStatus])
|
||||
|
||||
const isExpanded = authBegan || successfullyInstalled;
|
||||
const shouldShowEnableButton = !backupFrequency && !authBegan;
|
||||
const additionalClass = isEntitledToCloudBackups
|
||||
? ''
|
||||
: 'faded cursor-default pointer-events-none';
|
||||
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'
|
||||
: ''
|
||||
shouldShowEnableButton || backupFrequency ? 'flex justify-between items-center' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<Subtitle className={additionalClass}>{providerName}</Subtitle>
|
||||
|
||||
{successfullyInstalled && (
|
||||
<p>{providerName} has been successfully enabled.</p>
|
||||
)}
|
||||
{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:
|
||||
Complete authentication from the newly opened window. Upon completion, a confirmation
|
||||
code will be displayed. Enter this code below:
|
||||
</p>
|
||||
<div className={`mt-1`}>
|
||||
<div className={'mt-1'}>
|
||||
<input
|
||||
className="sk-input sk-base center-text"
|
||||
placeholder="Enter confirmation code"
|
||||
@ -240,14 +218,9 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
label="Perform Backup"
|
||||
onClick={performBackupNow}
|
||||
/>
|
||||
<Button
|
||||
className="min-w-40"
|
||||
variant="normal"
|
||||
label="Disable"
|
||||
onClick={disable}
|
||||
/>
|
||||
<Button className="min-w-40" variant="normal" label="Disable" onClick={disable} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,118 +1,111 @@
|
||||
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';
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import {
|
||||
FeatureStatus,
|
||||
FeatureIdentifier,
|
||||
CloudProvider,
|
||||
MuteFailedCloudBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
} from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { convertStringifiedBooleanToBoolean } from '@/utils';
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
|
||||
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
|
||||
);
|
||||
FeatureIdentifier.DailyDropboxBackup,
|
||||
)
|
||||
const dailyGdriveBackupStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyGDriveBackup
|
||||
);
|
||||
FeatureIdentifier.DailyGDriveBackup,
|
||||
)
|
||||
const dailyOneDriveBackupStatus = application.features.getFeatureStatus(
|
||||
FeatureIdentifier.DailyOneDriveBackup
|
||||
);
|
||||
FeatureIdentifier.DailyOneDriveBackup,
|
||||
)
|
||||
const isCloudBackupsAllowed = [
|
||||
dailyDropboxBackupStatus,
|
||||
dailyGdriveBackupStatus,
|
||||
dailyOneDriveBackupStatus,
|
||||
].every((status) => status === FeatureStatus.Entitled);
|
||||
].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 () => {
|
||||
if (!isEntitledToCloudBackups) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const previousValue = isFailedCloudBackupEmailMuted;
|
||||
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted);
|
||||
const previousValue = isFailedCloudBackupEmailMuted
|
||||
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted)
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
`${!isFailedCloudBackupEmailMuted}`
|
||||
);
|
||||
`${!isFailedCloudBackupEmailMuted}`,
|
||||
)
|
||||
if (!updateResult) {
|
||||
setIsFailedCloudBackupEmailMuted(previousValue);
|
||||
setIsFailedCloudBackupEmailMuted(previousValue)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
@ -122,8 +115,8 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is
|
||||
required to enable Cloud Backups.{' '}
|
||||
<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>
|
||||
@ -134,8 +127,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}`} />
|
||||
@ -147,9 +140,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>
|
||||
@ -159,9 +150,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'} />
|
||||
@ -177,5 +166,5 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { alertDialog } from '@Services/alertService';
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
import { alertDialog } from '@/Services/AlertService'
|
||||
import {
|
||||
STRING_IMPORT_SUCCESS,
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
@ -9,158 +9,142 @@ import {
|
||||
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 '@/ui_models/application';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import TargetedEvent = JSXInternal.TargetedEvent;
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
Subtitle,
|
||||
} from '../../components';
|
||||
import { Button } from '@/components/Button';
|
||||
} 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;
|
||||
};
|
||||
application: WebApplication
|
||||
appState: AppState
|
||||
}
|
||||
|
||||
export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isImportDataLoading, setIsImportDataLoading] = useState(false)
|
||||
const {
|
||||
isBackupEncrypted,
|
||||
isEncryptionEnabled,
|
||||
setIsBackupEncrypted,
|
||||
setIsEncryptionEnabled,
|
||||
setEncryptionStatusString,
|
||||
} = appState.accountMenu;
|
||||
} = appState.accountMenu
|
||||
|
||||
const refreshEncryptionStatus = useCallback(() => {
|
||||
const hasUser = application.hasAccount();
|
||||
const hasPasscode = application.hasPasscode();
|
||||
const hasUser = application.hasAccount()
|
||||
const hasPasscode = application.hasPasscode()
|
||||
|
||||
const encryptionEnabled = hasUser || hasPasscode;
|
||||
const encryptionEnabled = hasUser || hasPasscode
|
||||
|
||||
const encryptionStatusString = hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED;
|
||||
: STRING_ENC_NOT_ENABLED
|
||||
|
||||
setEncryptionStatusString(encryptionStatusString);
|
||||
setIsEncryptionEnabled(encryptionEnabled);
|
||||
setIsBackupEncrypted(encryptionEnabled);
|
||||
}, [
|
||||
application,
|
||||
setEncryptionStatusString,
|
||||
setIsBackupEncrypted,
|
||||
setIsEncryptionEnabled,
|
||||
]);
|
||||
setEncryptionStatusString(encryptionStatusString)
|
||||
setIsEncryptionEnabled(encryptionEnabled)
|
||||
setIsBackupEncrypted(encryptionEnabled)
|
||||
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
refreshEncryptionStatus();
|
||||
}, [refreshEncryptionStatus]);
|
||||
refreshEncryptionStatus()
|
||||
}, [refreshEncryptionStatus])
|
||||
|
||||
const downloadDataArchive = () => {
|
||||
application.getArchiveService().downloadBackup(isBackupEncrypted);
|
||||
};
|
||||
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);
|
||||
return;
|
||||
application.alertService.alert(STRING_IMPORTING_ZIP_FILE).catch(console.error)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target!.result as string);
|
||||
resolve(data);
|
||||
const data = JSON.parse(e.target?.result as string)
|
||||
resolve(data)
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_INVALID_IMPORT_FILE);
|
||||
application.alertService.alert(STRING_INVALID_IMPORT_FILE).catch(console.error)
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
}
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
const performImport = async (data: BackupFile) => {
|
||||
setIsImportDataLoading(true);
|
||||
setIsImportDataLoading(true)
|
||||
|
||||
const result = await application.mutator.importData(data);
|
||||
const result = await application.mutator.importData(data)
|
||||
|
||||
setIsImportDataLoading(false);
|
||||
setIsImportDataLoading(false)
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
let statusText = STRING_IMPORT_SUCCESS;
|
||||
let statusText = STRING_IMPORT_SUCCESS
|
||||
if ('error' in result) {
|
||||
statusText = result.error.text;
|
||||
statusText = result.error.text
|
||||
} else if (result.errorCount) {
|
||||
statusText = StringImportError(result.errorCount);
|
||||
statusText = StringImportError(result.errorCount)
|
||||
}
|
||||
void alertDialog({
|
||||
text: statusText,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const importFileSelected = async (
|
||||
event: TargetedEvent<HTMLInputElement, Event>
|
||||
) => {
|
||||
const { files } = event.target as HTMLInputElement;
|
||||
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
|
||||
const { files } = event.target as HTMLInputElement
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const file = files[0];
|
||||
const data = await readFile(file);
|
||||
const file = files[0]
|
||||
const data = await readFile(file)
|
||||
if (!data) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const version =
|
||||
data.version || data.keyParams?.version || data.auth_params?.version;
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version
|
||||
if (!version) {
|
||||
await performImport(data);
|
||||
return;
|
||||
await performImport(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (application.protocolService.supportedVersions().includes(version)) {
|
||||
await performImport(data);
|
||||
await performImport(data)
|
||||
} else {
|
||||
setIsImportDataLoading(false);
|
||||
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
|
||||
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
|
||||
) => {
|
||||
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const { code } = event;
|
||||
const { code } = event
|
||||
|
||||
// Process only when "Enter" or "Space" keys are pressed
|
||||
if (code !== 'Enter' && code !== 'Space') {
|
||||
return;
|
||||
return
|
||||
}
|
||||
// Don't proceed the event's default action
|
||||
// (like scrolling in case the "space" key is pressed)
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
(fileInputRef.current as HTMLInputElement).click();
|
||||
};
|
||||
;(fileInputRef.current as HTMLInputElement).click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -170,8 +154,8 @@ export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
|
||||
{!isDesktopApplication() && (
|
||||
<Text className="mb-3">
|
||||
Backups are automatically created on desktop and can be managed
|
||||
via the "Backups" top-level menu.
|
||||
Backups are automatically created on desktop and can be managed via the "Backups"
|
||||
top-level menu.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@ -211,23 +195,17 @@ export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
<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}
|
||||
/>
|
||||
<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" />
|
||||
)}
|
||||
{isImportDataLoading && <div className="sk-spinner normal info ml-4" />}
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
@ -1,148 +1,123 @@
|
||||
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 {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '../../components';
|
||||
import { Dropdown, DropdownItem } from '@/components/Dropdown';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/Utils'
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/Strings'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PreferencesGroup, PreferencesSegment, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Dropdown, DropdownItem } from '@/Components/Dropdown'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import {
|
||||
FeatureStatus,
|
||||
FeatureIdentifier,
|
||||
EmailBackupFrequency,
|
||||
MuteFailedBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/snjs';
|
||||
} from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const EmailBackups = observer(({ application }: Props) => {
|
||||
const [isLoading, setIsLoading] = 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);
|
||||
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
|
||||
);
|
||||
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 () => {
|
||||
if (!isEntitledToEmailBackups) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const previousValue = isFailedBackupEmailMuted;
|
||||
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted);
|
||||
const previousValue = isFailedBackupEmailMuted
|
||||
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted)
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
`${!isFailedBackupEmailMuted}`
|
||||
);
|
||||
`${!isFailedBackupEmailMuted}`,
|
||||
)
|
||||
if (!updateResult) {
|
||||
setIsFailedBackupEmailMuted(previousValue);
|
||||
setIsFailedBackupEmailMuted(previousValue)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleEmailFrequencyChange = (item: string) => {
|
||||
if (!isEntitledToEmailBackups) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
updateEmailFrequency(item as EmailBackupFrequency);
|
||||
};
|
||||
updateEmailFrequency(item as EmailBackupFrequency).catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
@ -152,8 +127,8 @@ export const EmailBackups = observer(({ application }: Props) => {
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is
|
||||
required to enable Email Backups.{' '}
|
||||
<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>
|
||||
@ -162,17 +137,11 @@ 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>
|
||||
@ -195,9 +164,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'} />
|
||||
@ -212,5 +179,5 @@ export const EmailBackups = observer(({ application }: Props) => {
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
@ -0,0 +1,22 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { AppState } from '@/UIModels/AppState'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { CloudLink } from './CloudBackups'
|
||||
import { DataBackups } from './DataBackups'
|
||||
import { EmailBackups } from './EmailBackups'
|
||||
|
||||
interface Props {
|
||||
appState: AppState
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const Backups: FunctionComponent<Props> = ({ application, appState }) => {
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<DataBackups application={application} appState={appState} />
|
||||
<EmailBackups application={application} />
|
||||
<CloudLink application={application} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { FunctionComponent } from 'preact'
|
||||
import {
|
||||
Title,
|
||||
Subtitle,
|
||||
@ -7,7 +7,7 @@ import {
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
} from '../components';
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
|
||||
export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesPane>
|
||||
@ -17,11 +17,10 @@ export const CloudLink: FunctionComponent = () => (
|
||||
<div className="h-2 w-full" />
|
||||
<Subtitle>Who can read my private notes?</Subtitle>
|
||||
<Text>
|
||||
Quite simply: no one but you. Not us, not your ISP, not a hacker, and
|
||||
not a government agency. As long as you keep your password safe, and
|
||||
your password is reasonably strong, then you are the only person in
|
||||
the world with the ability to decrypt your notes. For more on how we
|
||||
handle your privacy and security, check out our easy to read{' '}
|
||||
Quite simply: no one but you. Not us, not your ISP, not a hacker, and not a government
|
||||
agency. As long as you keep your password safe, and your password is reasonably strong,
|
||||
then you are the only person in the world with the ability to decrypt your notes. For more
|
||||
on how we handle your privacy and security, check out our easy to read{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/privacy">
|
||||
Privacy Manifesto.
|
||||
</a>
|
||||
@ -30,18 +29,17 @@ export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I collaborate with others on a note?</Subtitle>
|
||||
<Text>
|
||||
Because of our encrypted architecture, Standard Notes does not
|
||||
currently provide a real-time collaboration solution. Multiple users
|
||||
can share the same account however, but editing at the same time may
|
||||
result in sync conflicts, which may result in the duplication of
|
||||
notes.
|
||||
Because of our encrypted architecture, Standard Notes does not currently provide a
|
||||
real-time collaboration solution. Multiple users can share the same account however, but
|
||||
editing at the same time may result in sync conflicts, which may result in the duplication
|
||||
of notes.
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
|
||||
<Text>
|
||||
Standard Notes can be used totally offline without an account, and
|
||||
without an internet connection. You can find{' '}
|
||||
Standard Notes can be used totally offline without an account, and without an internet
|
||||
connection. You can find{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
|
||||
@ -52,20 +50,15 @@ export const CloudLink: FunctionComponent = () => (
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can’t find your question here?</Subtitle>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
label="Open FAQ"
|
||||
link="https://standardnotes.com/help"
|
||||
/>
|
||||
<LinkButton className="mt-3" label="Open FAQ" link="https://standardnotes.com/help" />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Community forum</Title>
|
||||
<Text>
|
||||
If you have an issue, found a bug or want to suggest a feature, you
|
||||
can browse or post to the forum. It’s recommended for non-account
|
||||
related issues. Please read our{' '}
|
||||
If you have an issue, found a bug or want to suggest a feature, you can browse or post to
|
||||
the forum. It’s recommended for non-account related issues. Please read our{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/longevity/">
|
||||
Longevity statement
|
||||
</a>{' '}
|
||||
@ -82,9 +75,9 @@ export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesSegment>
|
||||
<Title>Community groups</Title>
|
||||
<Text>
|
||||
Want to meet other passionate note-takers and privacy enthusiasts?
|
||||
Want to share your feedback with us? Join the Standard Notes community
|
||||
groups for discussions on security, themes, editors and more.
|
||||
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your
|
||||
feedback with us? Join the Standard Notes community groups for discussions on security,
|
||||
themes, editors and more.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
@ -101,15 +94,9 @@ export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Account related issue?</Title>
|
||||
<Text>
|
||||
Send an email to help@standardnotes.com and we’ll sort it out.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="mailto: help@standardnotes.com"
|
||||
label="Email us"
|
||||
/>
|
||||
<Text>Send an email to help@standardnotes.com and we’ll sort it out.</Text>
|
||||
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
);
|
||||
)
|
@ -1,11 +1,11 @@
|
||||
import { DisplayStringForContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { Button } from '@/components/Button';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Title, Text, Subtitle, PreferencesSegment } from '../../components';
|
||||
import { DisplayStringForContentType, SNComponent } from '@standardnotes/snjs'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { Title, Text, Subtitle, PreferencesSegment } from '@/Components/Preferences/PreferencesComponents'
|
||||
|
||||
export const ConfirmCustomExtension: FunctionComponent<{
|
||||
component: SNComponent;
|
||||
callback: (confirmed: boolean) => void;
|
||||
component: SNComponent
|
||||
callback: (confirmed: boolean) => void
|
||||
}> = ({ component, callback }) => {
|
||||
const fields = [
|
||||
{
|
||||
@ -32,7 +32,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
label: 'Extension Type',
|
||||
value: DisplayStringForContentType(component.content_type),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
return (
|
||||
<PreferencesSegment>
|
||||
@ -40,7 +40,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
|
||||
{fields.map((field) => {
|
||||
if (!field.value) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@ -48,7 +48,7 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
<Text className={'wrap'}>{field.value}</Text>
|
||||
<div className="min-h-2" />
|
||||
</>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="min-h-3" />
|
||||
@ -71,5 +71,5 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
import { FunctionComponent } from 'preact'
|
||||
import { SNComponent } from '@standardnotes/snjs'
|
||||
import { PreferencesSegment, SubtitleLight, Title } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { RenameExtension } from './RenameExtension'
|
||||
|
||||
const UseHosted: FunctionComponent<{
|
||||
offlineOnly: boolean
|
||||
toggleOfllineOnly: () => void
|
||||
}> = ({ offlineOnly, toggleOfllineOnly }) => (
|
||||
<div className="flex flex-row">
|
||||
<SubtitleLight className="flex-grow">Use hosted when local is unavailable</SubtitleLight>
|
||||
<Switch onChange={toggleOfllineOnly} checked={!offlineOnly} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface ExtensionItemProps {
|
||||
application: WebApplication
|
||||
extension: SNComponent
|
||||
first: boolean
|
||||
latestVersion: string | undefined
|
||||
uninstall: (extension: SNComponent) => void
|
||||
toggleActivate?: (extension: SNComponent) => void
|
||||
}
|
||||
|
||||
export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
|
||||
application,
|
||||
extension,
|
||||
first,
|
||||
uninstall,
|
||||
}) => {
|
||||
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false)
|
||||
const [extensionName, setExtensionName] = useState(extension.name)
|
||||
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly
|
||||
setOfflineOnly(newOfflineOnly)
|
||||
application.mutator
|
||||
.changeAndSaveItem(extension, (m: any) => {
|
||||
if (m.content == undefined) {
|
||||
m.content = {}
|
||||
}
|
||||
m.content.offlineOnly = newOfflineOnly
|
||||
})
|
||||
.then((item) => {
|
||||
const component = item as SNComponent
|
||||
setOfflineOnly(component.offlineOnly)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
const changeExtensionName = (newName: string) => {
|
||||
setExtensionName(newName)
|
||||
application.mutator
|
||||
.changeAndSaveItem(extension, (m: any) => {
|
||||
if (m.content == undefined) {
|
||||
m.content = {}
|
||||
}
|
||||
m.content.name = newName
|
||||
})
|
||||
.then((item) => {
|
||||
const component = item as SNComponent
|
||||
setExtensionName(component.name)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const localInstallable = extension.package_info.download_url
|
||||
const isThirParty = application.features.isThirdPartyFeature(extension.identifier)
|
||||
|
||||
return (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
{first && (
|
||||
<>
|
||||
<Title>Extensions</Title>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
|
||||
<div className="min-h-2" />
|
||||
|
||||
{isThirParty && localInstallable && (
|
||||
<UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />
|
||||
)}
|
||||
|
||||
<>
|
||||
<div className="min-h-2" />
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="min-w-20"
|
||||
variant="normal"
|
||||
label="Uninstall"
|
||||
onClick={() => uninstall(extension)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PreferencesSegment>
|
||||
)
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { SNComponent, ClientDisplayableError, FeatureDescription } from '@standardnotes/snjs'
|
||||
import { makeAutoObservable, observable } from 'mobx'
|
||||
|
||||
export class ExtensionsLatestVersions {
|
||||
static async load(application: WebApplication): Promise<ExtensionsLatestVersions | undefined> {
|
||||
const response = await application.getAvailableSubscriptions()
|
||||
|
||||
if (response instanceof ClientDisplayableError) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const versionMap: Map<string, string> = new Map()
|
||||
collectFeatures(response.CORE_PLAN?.features as FeatureDescription[], versionMap)
|
||||
collectFeatures(response.PLUS_PLAN?.features as FeatureDescription[], versionMap)
|
||||
collectFeatures(response.PRO_PLAN?.features as FeatureDescription[], versionMap)
|
||||
|
||||
return new ExtensionsLatestVersions(versionMap)
|
||||
}
|
||||
|
||||
constructor(private readonly latestVersionsMap: Map<string, string>) {
|
||||
makeAutoObservable<ExtensionsLatestVersions, 'latestVersionsMap'>(this, {
|
||||
latestVersionsMap: observable.ref,
|
||||
})
|
||||
}
|
||||
|
||||
getVersion(extension: SNComponent): string | undefined {
|
||||
return this.latestVersionsMap.get(extension.package_info.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
function collectFeatures(
|
||||
features: FeatureDescription[] | undefined,
|
||||
versionMap: Map<string, string>,
|
||||
) {
|
||||
if (features == undefined) {
|
||||
return
|
||||
}
|
||||
for (const feature of features) {
|
||||
versionMap.set(feature.identifier, feature.version as string)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user