refactor: format and lint codebase (#971)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,96 +1,85 @@
import { WebApplication } from '@/ui_models/application';
import { 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}
</>
);
}
);
)
},
)

View File

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

View File

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

View File

@ -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">
Youre offline. Sign in to sync your notes and preferences
across all your devices and enable end-to-end encryption.
Youre offline. Sign in to sync your notes and preferences across all your devices
and enable end-to-end encryption.
</div>
<div className="flex items-center color-grey-1">
<Icon type="cloud-off" className="mr-2" />
@ -116,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>
</>
);
}
);
)
},
)

View File

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

View File

@ -1,16 +1,16 @@
import { observer } from 'mobx-react-lite';
import { AppState } from '@/ui_models/app_state';
import { WebApplication } from '@/ui_models/application';
import { User as UserType } from '@standardnotes/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

View File

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

View File

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

View File

@ -1,53 +1,47 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { 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>
)}
</>
);
}
);
)
},
)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,15 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { 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>
);
}
);
)
},
)

View File

@ -1,29 +1,26 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { KeyboardKey } from '@/services/ioService';
import { formatSizeToReadableString } from '@standardnotes/filepicker';
import { IconType, SNFile } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon, ICONS } from '../Icon';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { PopoverFileSubmenu } from './PopoverFileSubmenu';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
import { KeyboardKey } from '@/Services/IOService'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { IconType, 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>
);
};
)
}

View File

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

View File

@ -1,31 +1,18 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import {
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>
);
};
)
}

View File

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

View File

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

View File

@ -1,27 +1,27 @@
import { FunctionComponent } from 'preact';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact'
import { Icon } from '@/Components/Icon'
import { IconType } from '@standardnotes/snjs'
interface Props {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void;
onClick: () => void
className?: string;
className?: string
icon: IconType;
icon: IconType
iconClassName?: string;
iconClassName?: string
/**
* Button tooltip
*/
title: string;
title: string
focusable: boolean;
focusable: boolean
disabled?: boolean;
disabled?: boolean
}
/**
@ -38,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>
);
};
)
}

View File

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

View File

@ -1,5 +1,5 @@
import { WebApplication } from '@/ui_models/application';
import { 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>
);
};
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact';
import { FunctionalComponent } from 'preact'
export const OfflineRestricted: FunctionalComponent = () => {
return (
@ -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>
);
};
)
}

View File

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

View File

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

View File

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

View File

@ -5,32 +5,32 @@ import {
ListboxList,
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>
</>
);
};
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,58 +1,57 @@
import { WebAppEvent, WebApplication } from '@/ui_models/application';
import { 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>
);
)
}
}

View File

@ -1,5 +1,5 @@
import { FunctionalComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
import { FunctionalComponent } from 'preact'
import { IconType } from '@standardnotes/snjs'
import {
AccessibilityIcon,
@ -88,7 +88,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 } : {})}
/>
);
};
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +1,30 @@
import { Icon } from './Icon';
import { 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>
);
});
)
})

View File

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

View File

@ -1,98 +1,98 @@
import { Icon } from './Icon';
import { 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>
);
});
)
})

View File

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

View File

@ -2,20 +2,20 @@
* @jest-environment jsdom
*/
import { NoteView } from './NoteView';
import { NoteView } from './NoteView'
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/';
} from '@standardnotes/snjs/'
describe('editor-view', () => {
let ctrl: NoteView;
let setShowProtectedWarningSpy: jest.SpyInstance;
let ctrl: NoteView
let setShowProtectedWarningSpy: jest.SpyInstance
beforeEach(() => {
ctrl = new NoteView({} as any);
ctrl = new NoteView({} as any)
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay')
Object.defineProperties(ctrl, {
application: {
@ -25,7 +25,7 @@ describe('editor-view', () => {
notes: {
setShowProtectedWarning: jest.fn(),
},
};
}
},
hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(),
@ -48,19 +48,19 @@ describe('editor-view', () => {
clearNoteChangeListener: jest.fn(),
},
},
});
});
})
})
beforeEach(() => {
jest.useFakeTimers();
});
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers();
});
jest.useRealTimers()
})
afterEach(() => {
ctrl.deinit();
});
ctrl.deinit()
})
describe('note is protected', () => {
beforeEach(() => {
@ -68,76 +68,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)
})
})
})
})

View File

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

View File

@ -1,43 +1,43 @@
import { WebApplication } from '@/ui_models/application';
import { WebApplication } from '@/UIModels/Application'
import {
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>
);
};
)
}

View File

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

View File

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

View File

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

View File

@ -1,89 +1,79 @@
import { KeyboardKey } from '@/services/ioService';
import { 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>
);
};
)
}

View File

@ -1,37 +1,30 @@
import { WebApplication } from '@/ui_models/application';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { useCloseOnBlur } from '../utils';
import { WebApplication } from '@/UIModels/Application'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Icon } from '@/Components/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
type Props = {
application: WebApplication;
note: SNNote;
};
application: WebApplication
note: SNNote
}
type ListedMenuGroup = {
name: string;
account: ListedAccount;
actions: Action[];
};
name: string
account: ListedAccount
actions: Action[]
}
type ListedMenuItemProps = {
action: Action;
note: SNNote;
group: ListedMenuGroup;
application: WebApplication;
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>;
};
action: Action
note: SNNote
group: ListedMenuGroup
application: WebApplication
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>
}
const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
action,
@ -40,21 +33,21 @@ const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
group,
reloadMenuGroup,
}) => {
const [isRunning, setIsRunning] = useState(false);
const [isRunning, setIsRunning] = useState(false)
const handleClick = async () => {
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>
);
};
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,23 @@
import { WebApplication } from '@/ui_models/application';
import { 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>
);
)
}
}

View File

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

View File

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

View File

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

View File

@ -1,32 +1,32 @@
import { AccountMenuPane } from '@/components/AccountMenu';
import { 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>
);
});
)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,20 @@
import { Button } from '@/components/Button';
import { 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} />
})

View File

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

View File

@ -1,26 +1,22 @@
import {
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>
);
}
);
)
},
)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'preact/hooks';
import { useCallback, useEffect, useState } from 'preact/hooks'
import {
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>
);
};
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { FunctionComponent } from 'preact';
import { FunctionComponent } from 'preact'
import {
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>Cant 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. Its 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. Its 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 well 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 well sort it out.</Text>
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
)

View File

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

View File

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

View File

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

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