Merge branch 'release/10.17.0'
This commit is contained in:
@@ -8,7 +8,6 @@ RAILS_LOG_LEVEL=INFO
|
||||
|
||||
RAILS_SERVE_STATIC_FILES=true
|
||||
SECRET_KEY_BASE=test
|
||||
BUGSNAG_API_KEY=
|
||||
|
||||
APP_HOST=http://localhost:3001
|
||||
PURCHASE_URL=https://standardnotes.com/purchase
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# npx pretty-quick --staged # trying lint-staged for now, it is slower but uses eslint.
|
||||
npx lint-staged
|
||||
|
||||
@@ -12,8 +12,6 @@ WORKDIR /app/
|
||||
|
||||
COPY package.json yarn.lock Gemfile Gemfile.lock /app/
|
||||
|
||||
COPY vendor /app/vendor
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
|
||||
RUN gem install bundler && bundle install
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bugsnagApiKey?: string;
|
||||
dashboardUrl?: string;
|
||||
defaultSyncServer: string;
|
||||
devAccountEmail?: string;
|
||||
@@ -22,7 +21,6 @@ import { render } from 'preact';
|
||||
import { ApplicationGroupView } from './components/ApplicationGroupView';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { BrowserBridge } from './services/browserBridge';
|
||||
import { startErrorReporting } from './services/errorReporting';
|
||||
import { StartApplication } from './startApplication';
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { isDev } from './utils';
|
||||
@@ -34,7 +32,7 @@ const startApplication: StartApplication = async function startApplication(
|
||||
webSocketUrl: string
|
||||
) {
|
||||
SNLog.onLog = console.log;
|
||||
startErrorReporting();
|
||||
SNLog.onError = console.error;
|
||||
|
||||
const mainApplicationGroup = new ApplicationGroup(
|
||||
defaultSyncServerHost,
|
||||
|
||||
@@ -78,7 +78,7 @@ export abstract class PureComponent<
|
||||
);
|
||||
}
|
||||
|
||||
onAppStateEvent(eventName: any, data: any) {
|
||||
onAppStateEvent(_eventName: any, _data: any) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { Icon } from '../Icon';
|
||||
import { InputWithIcon } from '../InputWithIcon';
|
||||
@@ -11,14 +11,70 @@ type Props = {
|
||||
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, children }) => {
|
||||
({
|
||||
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 [isStrictSignin, setIsStrictSignin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const recomputeVaultedEmail = async () => {
|
||||
const vaultedEmail = await application.vaultToEmail(
|
||||
vaultName,
|
||||
vaultUserphrase
|
||||
);
|
||||
|
||||
if (!vaultedEmail) {
|
||||
if (vaultName?.length > 0 && vaultUserphrase?.length > 0) {
|
||||
application.alertService.alert('Unable to compute vault name.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
onVaultChange?.(true, vaultedEmail);
|
||||
};
|
||||
|
||||
if (vaultName && vaultUserphrase) {
|
||||
recomputeVaultedEmail();
|
||||
}
|
||||
}, [vaultName, vaultUserphrase, application, onVaultChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onVaultChange?.(isVault);
|
||||
}, [isVault, onVaultChange]);
|
||||
|
||||
const handleIsVaultChange = () => {
|
||||
setIsVault(!isVault);
|
||||
};
|
||||
|
||||
const handleVaultNameChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setVaultName(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVaultUserphraseChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setVaultUserphrase(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerOptionChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setEnableServerOption(e.target.checked);
|
||||
@@ -32,6 +88,12 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
}
|
||||
};
|
||||
|
||||
const handleStrictSigninChange = () => {
|
||||
const newValue = !isStrictSignin;
|
||||
setIsStrictSignin(newValue);
|
||||
onStrictSignInChange?.(newValue);
|
||||
};
|
||||
|
||||
const toggleShowAdvanced = () => {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
};
|
||||
@@ -50,6 +112,70 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
{showAdvanced ? (
|
||||
<div className="px-3 my-2">
|
||||
{children}
|
||||
|
||||
{appState.enableUnfinishedFeatures && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="vault-mode"
|
||||
label="Vault Mode"
|
||||
checked={isVault}
|
||||
disabled={disabled}
|
||||
onChange={handleIsVaultChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/80"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appState.enableUnfinishedFeatures && isVault && (
|
||||
<>
|
||||
<InputWithIcon
|
||||
className={`mb-2`}
|
||||
icon="folder"
|
||||
inputType="text"
|
||||
placeholder="Vault name"
|
||||
value={vaultName}
|
||||
onChange={handleVaultNameChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<InputWithIcon
|
||||
className={`mb-2 `}
|
||||
icon="server"
|
||||
inputType={'text'}
|
||||
placeholder="Vault userphrase"
|
||||
value={vaultUserphrase}
|
||||
onChange={handleVaultUserphraseChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onStrictSignInChange && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="use-strict-signin"
|
||||
label="Use strict sign-in"
|
||||
checked={isStrictSignin}
|
||||
disabled={disabled}
|
||||
onChange={handleStrictSigninChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/security"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
name="custom-sync-server"
|
||||
label="Custom sync server"
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
import { confirmDialog } from '@Services/alertService';
|
||||
import {
|
||||
STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
STRING_GENERATING_LOGIN_KEYS,
|
||||
STRING_GENERATING_REGISTER_KEYS,
|
||||
STRING_NON_MATCHING_PASSWORDS,
|
||||
} from '@/strings';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import TargetedEvent = JSXInternal.TargetedEvent;
|
||||
import TargetedKeyboardEvent = JSXInternal.TargetedKeyboardEvent;
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const Authentication = observer(({ application, appState }: Props) => {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState<string>('');
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [isEmailFocused, setIsEmailFocused] = useState(false);
|
||||
|
||||
const [isEphemeral, setIsEphemeral] = useState(false);
|
||||
const [isStrictSignIn, setIsStrictSignIn] = useState(false);
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
|
||||
|
||||
const {
|
||||
server,
|
||||
notesAndTagsCount,
|
||||
showSignIn,
|
||||
showRegister,
|
||||
setShowSignIn,
|
||||
setShowRegister,
|
||||
setServer,
|
||||
closeAccountMenu,
|
||||
} = appState.accountMenu;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmailFocused) {
|
||||
emailInputRef.current!.focus();
|
||||
setIsEmailFocused(false);
|
||||
}
|
||||
}, [isEmailFocused]);
|
||||
|
||||
// Reset password and confirmation fields when hiding the form
|
||||
useEffect(() => {
|
||||
if (!showSignIn && !showRegister) {
|
||||
setPassword('');
|
||||
setPasswordConfirmation('');
|
||||
}
|
||||
}, [showSignIn, showRegister]);
|
||||
|
||||
const handleHostInputChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setServer(value);
|
||||
application.setCustomHost(value);
|
||||
};
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordConfirmationInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSignInClick = () => {
|
||||
setShowSignIn(true);
|
||||
setIsEmailFocused(true);
|
||||
};
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
setShowRegister(true);
|
||||
setIsEmailFocused(true);
|
||||
};
|
||||
|
||||
const blurAuthFields = () => {
|
||||
emailInputRef.current!.blur();
|
||||
passwordInputRef.current!.blur();
|
||||
passwordConfirmationInputRef.current?.blur();
|
||||
};
|
||||
|
||||
const signin = async () => {
|
||||
setStatus(STRING_GENERATING_LOGIN_KEYS);
|
||||
setIsAuthenticating(true);
|
||||
|
||||
const response = await application.signIn(
|
||||
email,
|
||||
password,
|
||||
isStrictSignIn,
|
||||
isEphemeral,
|
||||
shouldMergeLocal
|
||||
);
|
||||
const error = response.error;
|
||||
if (!error) {
|
||||
setIsAuthenticating(false);
|
||||
setPassword('');
|
||||
setShowSignIn(false);
|
||||
|
||||
closeAccountMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSignIn(true);
|
||||
setStatus(undefined);
|
||||
setPassword('');
|
||||
|
||||
if (error.message) {
|
||||
await application.alertService.alert(error.message);
|
||||
}
|
||||
|
||||
setIsAuthenticating(false);
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
if (passwordConfirmation !== password) {
|
||||
application.alertService.alert(STRING_NON_MATCHING_PASSWORDS);
|
||||
return;
|
||||
}
|
||||
setStatus(STRING_GENERATING_REGISTER_KEYS);
|
||||
setIsAuthenticating(true);
|
||||
|
||||
const response = await application.register(
|
||||
email,
|
||||
password,
|
||||
isEphemeral,
|
||||
shouldMergeLocal
|
||||
);
|
||||
|
||||
const error = response.error;
|
||||
if (error) {
|
||||
setStatus(undefined);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
application.alertService.alert(error.message);
|
||||
} else {
|
||||
setIsAuthenticating(false);
|
||||
setShowRegister(false);
|
||||
closeAccountMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthFormSubmit = (
|
||||
event:
|
||||
| TargetedEvent<HTMLFormElement>
|
||||
| TargetedMouseEvent<HTMLButtonElement>
|
||||
| TargetedKeyboardEvent<HTMLButtonElement>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!email || !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
blurAuthFields();
|
||||
|
||||
if (showSignIn) {
|
||||
signin();
|
||||
} else {
|
||||
register();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPressKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleAuthFormSubmit(event as TargetedKeyboardEvent<HTMLButtonElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setPassword(value);
|
||||
};
|
||||
|
||||
const handleEmailChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setEmail(value);
|
||||
};
|
||||
|
||||
const handlePasswordConfirmationChange = (
|
||||
event: TargetedEvent<HTMLInputElement>
|
||||
) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setPasswordConfirmation(value);
|
||||
};
|
||||
|
||||
const handleMergeLocalData = async (
|
||||
event: TargetedEvent<HTMLInputElement>
|
||||
) => {
|
||||
const { checked } = event.target as HTMLInputElement;
|
||||
|
||||
setShouldMergeLocal(checked);
|
||||
if (!checked) {
|
||||
const confirmResult = await confirmDialog({
|
||||
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
confirmButtonStyle: 'danger',
|
||||
});
|
||||
setShouldMergeLocal(!confirmResult);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!application.hasAccount() && !showSignIn && !showRegister && (
|
||||
<div className="sk-panel-section sk-panel-hero">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h1">
|
||||
Sign in or register to enable sync and end-to-end encryption.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex my-1">
|
||||
<button
|
||||
className="sn-button info flex-grow text-base py-3 mr-1.5"
|
||||
onClick={handleSignInClick}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
className="sn-button info flex-grow text-base py-3 ml-1.5"
|
||||
onClick={handleRegisterClick}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<div className="sk-panel-row sk-p">
|
||||
Standard Notes is free on every platform, and comes standard with
|
||||
sync and encryption.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(showSignIn || showRegister) && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">
|
||||
{showSignIn ? 'Sign In' : 'Register'}
|
||||
</div>
|
||||
<form
|
||||
className="sk-panel-form"
|
||||
onSubmit={handleAuthFormSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className="sk-panel-section">
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
placeholder="Email"
|
||||
required
|
||||
spellcheck={false}
|
||||
ref={emailInputRef}
|
||||
/>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
onKeyPress={handleKeyPressKeyDown}
|
||||
onKeyDown={handleKeyPressKeyDown}
|
||||
ref={passwordInputRef}
|
||||
/>
|
||||
{showRegister && (
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
name="password_conf"
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
required
|
||||
onKeyPress={handleKeyPressKeyDown}
|
||||
onKeyDown={handleKeyPressKeyDown}
|
||||
value={passwordConfirmation}
|
||||
onChange={handlePasswordConfirmationChange}
|
||||
ref={passwordConfirmationInputRef!}
|
||||
/>
|
||||
)}
|
||||
<div className="sk-panel-row" />
|
||||
<button
|
||||
type="button"
|
||||
className="sk-a info font-bold text-left p-0 cursor-pointer hover:underline mr-1 ml-1"
|
||||
onClick={() => {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
}}
|
||||
>
|
||||
Advanced Options
|
||||
</button>
|
||||
</div>
|
||||
{showAdvanced && (
|
||||
<div className="sk-notification unpadded contrast advanced-options sk-panel-row">
|
||||
<div className="sk-panel-column stretch">
|
||||
<div className="sk-notification-title sk-panel-row padded-row">
|
||||
Advanced Options
|
||||
</div>
|
||||
<div className="bordered-row padded-row">
|
||||
<label className="sk-label">Sync Server Domain</label>
|
||||
<input
|
||||
className="sk-input sk-base"
|
||||
name="server"
|
||||
placeholder="Server URL"
|
||||
onChange={handleHostInputChange}
|
||||
value={server}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{showSignIn && (
|
||||
<label className="sk-label padded-row sk-panel-row justify-left">
|
||||
<div className="sk-horizontal-group tight cursor-pointer">
|
||||
<input
|
||||
className="sk-input"
|
||||
type="checkbox"
|
||||
checked={isStrictSignIn}
|
||||
onChange={() =>
|
||||
setIsStrictSignIn((prevState) => !prevState)
|
||||
}
|
||||
/>
|
||||
<p className="sk-p">Use strict sign in</p>
|
||||
<span>
|
||||
<a
|
||||
className="info"
|
||||
href="https://standardnotes.com/help/security"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
(Learn more)
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isAuthenticating && (
|
||||
<div className="sk-panel-section form-submit">
|
||||
<button
|
||||
className="sn-button info text-base py-3 text-center"
|
||||
type="submit"
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
{showSignIn ? 'Sign In' : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showRegister && (
|
||||
<div className="sk-notification neutral">
|
||||
<div className="sk-notification-title">No Password Reset.</div>
|
||||
<div className="sk-notification-text">
|
||||
Because your notes are encrypted using your password, Standard
|
||||
Notes does not have a password reset option. You cannot forget
|
||||
your password.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status && (
|
||||
<div className="sk-panel-section no-bottom-pad">
|
||||
<div className="sk-horizontal-group">
|
||||
<div className="sk-spinner small neutral" />
|
||||
<div className="sk-label">{status}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isAuthenticating && (
|
||||
<div className="sk-panel-section no-bottom-pad">
|
||||
<label className="sk-panel-row justify-left">
|
||||
<div className="sk-horizontal-group tight cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isEphemeral}
|
||||
onChange={() => setIsEphemeral((prevState) => !prevState)}
|
||||
/>
|
||||
<p className="sk-p">Stay signed in</p>
|
||||
</div>
|
||||
</label>
|
||||
{notesAndTagsCount > 0 && (
|
||||
<label className="sk-panel-row justify-left">
|
||||
<div className="sk-horizontal-group tight cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldMergeLocal}
|
||||
onChange={handleMergeLocalData}
|
||||
/>
|
||||
<p className="sk-p">
|
||||
Merge local data ({notesAndTagsCount}) notes and tags
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Authentication;
|
||||
@@ -162,12 +162,6 @@ export const ConfirmPassword: FunctionComponent<Props> = observer(
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions
|
||||
appState={appState}
|
||||
application={application}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isVault, setIsVault] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef.current) {
|
||||
@@ -82,6 +83,13 @@ export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const onVaultChange = (isVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(isVault);
|
||||
if (isVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
@@ -101,6 +109,7 @@ export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
inputType="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
disabled={isVault}
|
||||
onChange={handleEmailChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={emailInputRef}
|
||||
@@ -130,7 +139,11 @@ export const CreateAccount: FunctionComponent<Props> = observer(
|
||||
/>
|
||||
</form>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions application={application} appState={appState} />
|
||||
<AdvancedOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
onVaultChange={onVaultChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,21 @@ 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 '@/preferences/panes/account/Sync';
|
||||
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 { Menu } from '../Menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '../Menu/MenuItem';
|
||||
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
closeMenu: () => void;
|
||||
};
|
||||
@@ -21,7 +24,7 @@ type Props = {
|
||||
const iconClassName = 'color-neutral mr-2';
|
||||
|
||||
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, setMenuPane, closeMenu }) => {
|
||||
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false);
|
||||
const [lastSyncDate, setLastSyncDate] = useState(
|
||||
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
|
||||
@@ -54,9 +57,12 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
|
||||
const user = application.getUser();
|
||||
|
||||
const CREATE_ACCOUNT_INDEX = 1;
|
||||
const SWITCHER_INDEX = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 mt-1 mb-3">
|
||||
<div className="flex items-center justify-between px-3 mt-1 mb-1">
|
||||
<div className="sn-account-menu-headline">Account</div>
|
||||
<div className="flex cursor-pointer" onClick={closeMenu}>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
@@ -66,10 +72,10 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
<>
|
||||
<div className="px-3 mb-3 color-foreground text-sm">
|
||||
<div>You're signed in as:</div>
|
||||
<div className="my-0.5 font-bold">{user.email}</div>
|
||||
<div className="my-0.5 font-bold wrap">{user.email}</div>
|
||||
<span className="color-neutral">{application.getHost()}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between px-3 mb-2">
|
||||
<div className="flex items-start justify-between px-3 mb-3">
|
||||
{isSyncingInProgress ? (
|
||||
<div className="flex items-center color-info font-semibold">
|
||||
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
|
||||
@@ -106,12 +112,20 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<Menu
|
||||
isOpen={appState.accountMenu.show}
|
||||
a11yLabel="General account menu"
|
||||
closeMenu={closeMenu}
|
||||
initialFocus={
|
||||
!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX
|
||||
}
|
||||
>
|
||||
<MenuItemSeparator />
|
||||
<WorkspaceSwitcherOption
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
appState={appState}
|
||||
/>
|
||||
<MenuItemSeparator />
|
||||
{user ? (
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
@@ -171,7 +185,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
}}
|
||||
>
|
||||
<Icon type="signOut" className={iconClassName} />
|
||||
Sign out and clear local data
|
||||
Sign out workspace
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppState } from '@/ui_models/app_state';
|
||||
import { isDev } from '@/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AccountMenuPane } from '.';
|
||||
import { Button } from '../Button';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
@@ -25,10 +25,12 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isEphemeral, setIsEphemeral] = useState(false);
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false);
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true);
|
||||
const [isVault, setIsVault] = useState(false);
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -106,6 +108,16 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
}
|
||||
};
|
||||
|
||||
const onVaultChange = useCallback(
|
||||
(newIsVault: boolean, vaultedEmail?: string) => {
|
||||
setIsVault(newIsVault);
|
||||
if (newIsVault && vaultedEmail) {
|
||||
setEmail(vaultedEmail);
|
||||
}
|
||||
},
|
||||
[setEmail]
|
||||
);
|
||||
|
||||
const handleSignInFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -145,7 +157,7 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
onChange={handleEmailChange}
|
||||
onFocus={resetInvalid}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSigningIn}
|
||||
disabled={isSigningIn || isVault}
|
||||
ref={emailInputRef}
|
||||
/>
|
||||
<InputWithIcon
|
||||
@@ -197,25 +209,9 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
appState={appState}
|
||||
application={application}
|
||||
disabled={isSigningIn}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="use-strict-signin"
|
||||
label="Use strict sign-in"
|
||||
checked={isStrictSignin}
|
||||
disabled={isSigningIn}
|
||||
onChange={handleStrictSigninChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/security"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
</AdvancedOptions>
|
||||
onVaultChange={onVaultChange}
|
||||
onStrictSignInChange={handleStrictSigninChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
descriptor,
|
||||
onClick,
|
||||
onDelete,
|
||||
renameDescriptor,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isRenaming]);
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = (event: FocusEvent) => {
|
||||
const name = (event.target as HTMLInputElement).value;
|
||||
renameDescriptor(name);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
type={MenuItemType.RadioButton}
|
||||
className="sn-dropdown-item py-2 focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={onClick}
|
||||
checked={descriptor.primary}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={descriptor.label}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div>{descriptor.label}</div>
|
||||
)}
|
||||
{descriptor.primary && (
|
||||
<div>
|
||||
<button
|
||||
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsRenaming((isRenaming) => !isRenaming);
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
||||
</button>
|
||||
<button
|
||||
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Icon type="trash" className="sn-icon--mid color-danger" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Icon } from '../../Icon';
|
||||
import { Menu } from '../../Menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '../../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);
|
||||
}}
|
||||
renameDescriptor={(label: string) =>
|
||||
mainApplicationGroup.renameDescriptor(descriptor, label)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<MenuItemSeparator />
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
mainApplicationGroup.addNewApplication();
|
||||
}}
|
||||
>
|
||||
<Icon type="user-add" className="color-neutral mr-2" />
|
||||
Add another workspace
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
|
||||
({ mainApplicationGroup, appState }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>();
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current);
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition);
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(
|
||||
buttonRef.current,
|
||||
menuRef.current
|
||||
);
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitem"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="user-switch" className="color-neutral mr-2" />
|
||||
Switch workspace
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto"
|
||||
style={menuStyle}
|
||||
>
|
||||
<WorkspaceSwitcherMenu
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
appState={appState}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
export enum AccountMenuPane {
|
||||
GeneralMenu,
|
||||
@@ -21,18 +22,27 @@ type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
onClickOutside: () => void;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type PaneSelectorProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
menuPane: AccountMenuPane;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
closeMenu: () => void;
|
||||
};
|
||||
|
||||
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
({ application, appState, menuPane, setMenuPane, closeMenu }) => {
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
menuPane,
|
||||
setMenuPane,
|
||||
closeMenu,
|
||||
mainApplicationGroup,
|
||||
}) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -42,6 +52,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
<GeneralAccountMenu
|
||||
appState={appState}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
setMenuPane={setMenuPane}
|
||||
closeMenu={closeMenu}
|
||||
/>
|
||||
@@ -81,7 +92,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
);
|
||||
|
||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickOutside }) => {
|
||||
({ application, appState, onClickOutside, mainApplicationGroup }) => {
|
||||
const {
|
||||
currentPane,
|
||||
setCurrentPane,
|
||||
@@ -123,6 +134,7 @@ export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
<MenuPaneSelector
|
||||
appState={appState}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
menuPane={currentPane}
|
||||
setMenuPane={setCurrentPane}
|
||||
closeMenu={closeAccountMenu}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
import { JSX } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type State = {
|
||||
descriptors: ApplicationDescriptor[];
|
||||
editingDescriptor?: ApplicationDescriptor;
|
||||
};
|
||||
|
||||
export class AccountSwitcher extends PureComponent<Props, State> {
|
||||
private removeAppGroupObserver: any;
|
||||
activeApplication!: WebApplication;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.removeAppGroupObserver =
|
||||
props.mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
this.activeApplication = props.mainApplicationGroup
|
||||
.primaryApplication as WebApplication;
|
||||
this.reloadApplications();
|
||||
});
|
||||
}
|
||||
|
||||
reloadApplications() {
|
||||
this.setState({
|
||||
descriptors: this.props.mainApplicationGroup.getDescriptors(),
|
||||
});
|
||||
}
|
||||
|
||||
addNewApplication = () => {
|
||||
this.dismiss();
|
||||
this.props.mainApplicationGroup.addNewApplication();
|
||||
};
|
||||
|
||||
selectDescriptor = (descriptor: ApplicationDescriptor) => {
|
||||
this.dismiss();
|
||||
this.props.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
|
||||
};
|
||||
|
||||
inputForDescriptor(descriptor: ApplicationDescriptor) {
|
||||
return document.getElementById(`input-${descriptor.identifier}`);
|
||||
}
|
||||
|
||||
renameDescriptor = (event: Event, descriptor: ApplicationDescriptor) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.setState({ editingDescriptor: descriptor });
|
||||
|
||||
setTimeout(() => {
|
||||
this.inputForDescriptor(descriptor)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
submitRename = () => {
|
||||
this.props.mainApplicationGroup.renameDescriptor(
|
||||
this.state.editingDescriptor!,
|
||||
this.state.editingDescriptor!.label
|
||||
);
|
||||
this.setState({ editingDescriptor: undefined });
|
||||
};
|
||||
|
||||
deinit() {
|
||||
super.deinit();
|
||||
this.removeAppGroupObserver();
|
||||
this.removeAppGroupObserver = undefined;
|
||||
}
|
||||
|
||||
onDescriptorInputChange = (
|
||||
descriptor: ApplicationDescriptor,
|
||||
{ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>
|
||||
) => {
|
||||
descriptor.label = currentTarget.value;
|
||||
};
|
||||
|
||||
dismiss = () => {
|
||||
this.dismissModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.dismiss} className="sk-modal-background" />
|
||||
<div id="account-switcher" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div id="menu-panel" className="sk-menu-panel">
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Account Switcher
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-menu-panel-column">
|
||||
<a onClick={this.addNewApplication} className="sk-label info">
|
||||
Add Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.descriptors.map((descriptor) => {
|
||||
return (
|
||||
<div
|
||||
key={descriptor.identifier}
|
||||
onClick={() => this.selectDescriptor(descriptor)}
|
||||
className="sk-menu-panel-row"
|
||||
>
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<div className="left">
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-circle small success" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<input
|
||||
value={descriptor.label}
|
||||
disabled={
|
||||
descriptor !== this.state.editingDescriptor
|
||||
}
|
||||
onChange={(event) =>
|
||||
this.onDescriptorInputChange(descriptor, event)
|
||||
}
|
||||
onKeyUp={(event) =>
|
||||
event.keyCode == 13 && this.submitRename()
|
||||
}
|
||||
id={`input-${descriptor.identifier}`}
|
||||
spellcheck={false}
|
||||
className="sk-label clickable"
|
||||
/>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-sublabel">
|
||||
Current Application
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<button
|
||||
onClick={(event) =>
|
||||
this.renameDescriptor(event, descriptor)
|
||||
}
|
||||
className="sn-button success"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import { getPlatformString, getWindowUrlParams } from '@/utils';
|
||||
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
@@ -16,10 +16,10 @@ import { NotesView } from '@/components/NotesView';
|
||||
import { NoteGroupView } from '@/components/NoteGroupView';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { SessionsModal } from '@/components/SessionsModal';
|
||||
import { PreferencesViewWrapper } from '@/preferences/PreferencesViewWrapper';
|
||||
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
|
||||
import { ChallengeModal } from '@/components/ChallengeModal';
|
||||
import { NotesContextMenu } from '@/components/NotesContextMenu';
|
||||
import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper';
|
||||
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
|
||||
import { render } from 'preact';
|
||||
import { PermissionsModal } from './PermissionsModal';
|
||||
import { RevisionHistoryModalWrapper } from './RevisionHistoryModal/RevisionHistoryModalWrapper';
|
||||
@@ -27,6 +27,7 @@ import { PremiumModalProvider } from './Premium';
|
||||
import { ConfirmSignoutContainer } from './ConfirmSignoutModal';
|
||||
import { TagsContextMenu } from './Tags/TagContextMenu';
|
||||
import { ToastContainer } from '@standardnotes/stylekit';
|
||||
import { FilePreviewModalProvider } from './Files/FilePreviewModalProvider';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
@@ -143,15 +144,12 @@ export class ApplicationView extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
async handleDemoSignInFromParams() {
|
||||
if (
|
||||
window.location.href.includes('demo') &&
|
||||
!this.application.hasAccount()
|
||||
) {
|
||||
await this.application.setCustomHost(
|
||||
'https://syncing-server-demo.standardnotes.com'
|
||||
);
|
||||
this.application.signIn('demo@standardnotes.org', 'password');
|
||||
const token = getWindowUrlParams().get('demo-token');
|
||||
if (!token || this.application.hasAccount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.application.sessions.populateSessionFromDemoShareToken(token);
|
||||
}
|
||||
|
||||
presentPermissionsDialog = (dialog: PermissionDialog) => {
|
||||
@@ -175,88 +173,78 @@ export class ApplicationView extends PureComponent<Props, State> {
|
||||
const renderAppContents = !this.state.needsUnlock && this.state.launched;
|
||||
|
||||
return (
|
||||
<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}
|
||||
<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}
|
||||
challenge={challenge}
|
||||
onDismiss={this.removeChallenge}
|
||||
appState={this.appState}
|
||||
/>
|
||||
<NoteGroupView application={this.application} />
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,23 @@ import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from '../Icon';
|
||||
import { useCloseOnClickOutside } from '../utils';
|
||||
import { ChallengeReason, ContentType, SNFile } from '@standardnotes/snjs';
|
||||
import { useCloseOnBlur } from '../utils';
|
||||
import {
|
||||
ChallengeReason,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
SNFile,
|
||||
} from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit';
|
||||
import { parseFileName } from '@standardnotes/filepicker';
|
||||
import { StreamingFileReader } from '@standardnotes/filepicker';
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from './PopoverFileItemAction';
|
||||
import { PopoverDragNDropWrapper } from './PopoverDragNDropWrapper';
|
||||
import { AttachedFilesPopover, PopoverTabs } from './AttachedFilesPopover';
|
||||
import { usePremiumModal } from '../Premium/usePremiumModal';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
@@ -28,8 +35,26 @@ type Props = {
|
||||
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);
|
||||
@@ -41,9 +66,7 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useCloseOnClickOutside(containerRef, () => {
|
||||
setOpen(false);
|
||||
});
|
||||
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen);
|
||||
|
||||
const [attachedFilesCount, setAttachedFilesCount] = useState(
|
||||
note ? application.items.getFilesForNote(note).length : 0
|
||||
@@ -68,7 +91,15 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
};
|
||||
}, [application, reloadAttachedFilesCount]);
|
||||
|
||||
const toggleAttachedFilesMenu = async () => {
|
||||
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;
|
||||
@@ -98,22 +129,22 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
|
||||
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.nameWithExt}"?`,
|
||||
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.nameWithExt}"...`,
|
||||
message: `Deleting file "${file.name}"...`,
|
||||
});
|
||||
await application.deleteItem(file);
|
||||
await application.files.deleteFile(file);
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Deleted file "${file.nameWithExt}"`,
|
||||
message: `Deleted file "${file.name}"`,
|
||||
});
|
||||
dismissToast(deletingToastId);
|
||||
}
|
||||
@@ -123,9 +154,12 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
appState.files.downloadFile(file);
|
||||
};
|
||||
|
||||
const attachFileToNote = async (file: SNFile) => {
|
||||
await application.items.associateFileWithNote(file, note);
|
||||
};
|
||||
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);
|
||||
@@ -134,9 +168,12 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
const toggleFileProtection = async (file: SNFile) => {
|
||||
let result: SNFile | undefined;
|
||||
if (file.protected) {
|
||||
result = await application.protections.unprotectFile(file);
|
||||
keepMenuOpen(true);
|
||||
result = await application.mutator.unprotectFile(file);
|
||||
keepMenuOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
} else {
|
||||
result = await application.protections.protectFile(file);
|
||||
result = await application.mutator.protectFile(file);
|
||||
}
|
||||
const isProtected = result ? result.protected : file.protected;
|
||||
return isProtected;
|
||||
@@ -157,8 +194,7 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
const renameFile = async (file: SNFile, fileName: string) => {
|
||||
const { name, ext } = parseFileName(fileName);
|
||||
await application.items.renameFile(file, name, ext);
|
||||
await application.items.renameFile(file, fileName);
|
||||
};
|
||||
|
||||
const handleFileAction = async (action: PopoverFileItemAction) => {
|
||||
@@ -172,10 +208,13 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
file.protected &&
|
||||
action.type !== PopoverFileItemActionType.ToggleFileProtection
|
||||
) {
|
||||
keepMenuOpen(true);
|
||||
isAuthorizedForAction = await authorizeProtectedActionForFile(
|
||||
file,
|
||||
ChallengeReason.AccessProtectedFile
|
||||
);
|
||||
keepMenuOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
|
||||
if (!isAuthorizedForAction) {
|
||||
@@ -210,6 +249,102 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
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();
|
||||
}
|
||||
}
|
||||
},
|
||||
[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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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}>
|
||||
@@ -223,6 +358,7 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
className={`sn-icon-button border-contrast ${
|
||||
attachedFilesCount > 0 ? 'py-1 px-3' : ''
|
||||
}`}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<VisuallyHidden>Attached files</VisuallyHidden>
|
||||
<Icon type="attachment-file" className="block" />
|
||||
@@ -243,13 +379,18 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
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 && (
|
||||
<PopoverDragNDropWrapper
|
||||
<AttachedFilesPopover
|
||||
application={application}
|
||||
appState={appState}
|
||||
note={note}
|
||||
fileActionHandler={handleFileAction}
|
||||
handleFileAction={handleFileAction}
|
||||
currentTab={currentTab}
|
||||
closeOnBlur={closeOnBlur}
|
||||
setCurrentTab={setCurrentTab}
|
||||
isDraggingFiles={isDraggingFiles}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { ContentType, SNFile } from '@standardnotes/snjs';
|
||||
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, useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { StateUpdater, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import { PopoverTabs, PopoverWrapperProps } from './PopoverDragNDropWrapper';
|
||||
import { PopoverFileItem } from './PopoverFileItem';
|
||||
import { PopoverFileItemActionType } from './PopoverFileItemAction';
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from './PopoverFileItemAction';
|
||||
|
||||
type Props = PopoverWrapperProps & {
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles,
|
||||
AllFiles,
|
||||
}
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -18,14 +34,17 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
note,
|
||||
fileActionHandler,
|
||||
currentTab,
|
||||
closeOnBlur,
|
||||
handleFileAction,
|
||||
isDraggingFiles,
|
||||
note,
|
||||
setCurrentTab,
|
||||
}) => {
|
||||
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;
|
||||
@@ -33,39 +52,35 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
const filteredList =
|
||||
searchQuery.length > 0
|
||||
? filesList.filter(
|
||||
(file) => file.nameWithExt.toLowerCase().indexOf(searchQuery) !== -1
|
||||
(file) =>
|
||||
file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1
|
||||
)
|
||||
: filesList;
|
||||
|
||||
const reloadAttachedFiles = useCallback(() => {
|
||||
setAttachedFiles(
|
||||
application.items
|
||||
.getFilesForNote(note)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||
);
|
||||
}, [application.items, note]);
|
||||
|
||||
const reloadAllFiles = useCallback(() => {
|
||||
setAllFiles(
|
||||
application
|
||||
.getItems(ContentType.File)
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) as SNFile[]
|
||||
);
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterFileStream = application.streamItems(
|
||||
ContentType.File,
|
||||
() => {
|
||||
reloadAttachedFiles();
|
||||
reloadAllFiles();
|
||||
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[]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unregisterFileStream();
|
||||
};
|
||||
}, [application, reloadAllFiles, reloadAttachedFiles]);
|
||||
}, [application, note]);
|
||||
|
||||
const handleAttachFilesClick = async () => {
|
||||
const uploadedFiles = await appState.files.uploadNewFile();
|
||||
@@ -74,7 +89,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
}
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
fileActionHandler({
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: file,
|
||||
});
|
||||
@@ -83,7 +98,15 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex flex-col"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
style={{
|
||||
border: isDraggingFiles
|
||||
? '2px dashed var(--sn-stylekit-info-color)'
|
||||
: '',
|
||||
}}
|
||||
>
|
||||
<div className="flex border-0 border-b-1 border-solid border-main">
|
||||
<button
|
||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||
@@ -94,6 +117,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AttachedFiles);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Attached
|
||||
</button>
|
||||
@@ -106,6 +130,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AllFiles);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
All files
|
||||
</button>
|
||||
@@ -122,13 +147,17 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
onInput={(e) => {
|
||||
setSearchQuery((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<button
|
||||
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon
|
||||
type="clear-circle-filled"
|
||||
@@ -140,25 +169,24 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
) : null}
|
||||
{filteredList.length > 0 ? (
|
||||
filteredList.map((file: SNFile) => {
|
||||
return (
|
||||
<PopoverFileItem
|
||||
key={file.uuid}
|
||||
file={file}
|
||||
isAttachedToNote={attachedFiles.includes(file)}
|
||||
handleFileAction={fileActionHandler}
|
||||
/>
|
||||
);
|
||||
})
|
||||
filteredList
|
||||
.filter((file) => !file.deleted)
|
||||
.map((file: SNFile) => {
|
||||
return (
|
||||
<PopoverFileItem
|
||||
key={file.uuid}
|
||||
file={file}
|
||||
isAttachedToNote={attachedFiles.includes(file)}
|
||||
handleFileAction={handleFileAction}
|
||||
getIconType={application.iconsController.getIconForFileType}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||
<div className="w-18 h-18 mb-2">
|
||||
<FilesIllustration
|
||||
style={{
|
||||
transform: 'scale(0.6)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
<FilesIllustration />
|
||||
</div>
|
||||
<div className="text-sm font-medium mb-3">
|
||||
{searchQuery.length > 0
|
||||
@@ -167,7 +195,11 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
? 'No files attached to this note'
|
||||
: 'No files found in this account'}
|
||||
</div>
|
||||
<Button type="normal" onClick={handleAttachFilesClick}>
|
||||
<Button
|
||||
type="normal"
|
||||
onClick={handleAttachFilesClick}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'}{' '}
|
||||
files
|
||||
</Button>
|
||||
@@ -181,6 +213,7 @@ export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
||||
<button
|
||||
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
|
||||
onClick={handleAttachFilesClick}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="add" className="mr-2 color-neutral" />
|
||||
{currentTab === PopoverTabs.AttachedFiles
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { StreamingFileReader } from '@standardnotes/filepicker';
|
||||
import { SNNote } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AttachedFilesPopover } from './AttachedFilesPopover';
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from './PopoverFileItemAction';
|
||||
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles,
|
||||
AllFiles,
|
||||
}
|
||||
|
||||
export type PopoverWrapperProps = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
note: SNNote;
|
||||
fileActionHandler: (action: PopoverFileItemAction) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export const PopoverDragNDropWrapper: FunctionComponent<
|
||||
PopoverWrapperProps
|
||||
> = ({ fileActionHandler, appState, application, note }) => {
|
||||
const dropzoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const handleDrag = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDragIn = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
dragCounter.current = dragCounter.current + 1;
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOut = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
dragCounter.current = dragCounter.current - 1;
|
||||
|
||||
if (dragCounter.current > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
Array.from(event.dataTransfer.items).forEach(async (item) => {
|
||||
let fileOrHandle;
|
||||
if (StreamingFileReader.available()) {
|
||||
fileOrHandle =
|
||||
(await item.getAsFileSystemHandle()) as FileSystemFileHandle;
|
||||
} else {
|
||||
fileOrHandle = item.getAsFile();
|
||||
}
|
||||
if (fileOrHandle) {
|
||||
const uploadedFiles = await appState.files.uploadNewFile(
|
||||
fileOrHandle
|
||||
);
|
||||
if (!uploadedFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
fileActionHandler({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: file,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
dragCounter.current = 0;
|
||||
}
|
||||
},
|
||||
[appState.files, currentTab, fileActionHandler]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const dropzoneElement = dropzoneRef.current;
|
||||
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement.addEventListener('dragenter', handleDragIn);
|
||||
dropzoneElement.addEventListener('dragleave', handleDragOut);
|
||||
dropzoneElement.addEventListener('dragover', handleDrag);
|
||||
dropzoneElement.addEventListener('drop', handleDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
dropzoneElement?.removeEventListener('dragenter', handleDragIn);
|
||||
dropzoneElement?.removeEventListener('dragleave', handleDragOut);
|
||||
dropzoneElement?.removeEventListener('dragover', handleDrag);
|
||||
dropzoneElement?.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [handleDrop]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropzoneRef}
|
||||
className="focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
style={{
|
||||
border: isDragging ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
||||
}}
|
||||
>
|
||||
<AttachedFilesPopover
|
||||
application={application}
|
||||
appState={appState}
|
||||
note={note}
|
||||
fileActionHandler={fileActionHandler}
|
||||
currentTab={currentTab}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +1,40 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker';
|
||||
import { SNFile } from '@standardnotes/snjs';
|
||||
import { IconType, SNFile } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { ICONS } from '../Icon';
|
||||
import { Icon, ICONS } from '../Icon';
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from './PopoverFileItemAction';
|
||||
import { PopoverFileSubmenu } from './PopoverFileSubmenu';
|
||||
|
||||
const getIconForFileType = (fileType: string) => {
|
||||
let iconType = 'file-other';
|
||||
|
||||
if (fileType === 'pdf') {
|
||||
iconType = 'file-pdf';
|
||||
}
|
||||
|
||||
if (/^(docx?|odt)/.test(fileType)) {
|
||||
iconType = 'file-doc';
|
||||
}
|
||||
|
||||
if (/^pptx?/.test(fileType)) {
|
||||
iconType = 'file-ppt';
|
||||
}
|
||||
|
||||
if (/^(xlsx?|ods)/.test(fileType)) {
|
||||
iconType = 'file-xls';
|
||||
}
|
||||
|
||||
if (/^(jpe?g|a?png|webp|gif)/.test(fileType)) {
|
||||
iconType = 'file-image';
|
||||
}
|
||||
|
||||
if (/^(mov|mp4|mkv)/.test(fileType)) {
|
||||
iconType = 'file-mov';
|
||||
}
|
||||
|
||||
if (/^(wav|mp3|flac|ogg)/.test(fileType)) {
|
||||
iconType = 'file-music';
|
||||
}
|
||||
|
||||
if (/^(zip|rar|7z)/.test(fileType)) {
|
||||
iconType = 'file-zip';
|
||||
}
|
||||
|
||||
export const getFileIconComponent = (iconType: string, className: string) => {
|
||||
const IconComponent = ICONS[iconType as keyof typeof ICONS];
|
||||
|
||||
return <IconComponent className="flex-shrink-0" />;
|
||||
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;
|
||||
};
|
||||
|
||||
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
file,
|
||||
isAttachedToNote,
|
||||
handleFileAction,
|
||||
getIconType,
|
||||
closeOnBlur,
|
||||
}) => {
|
||||
const [fileName, setFileName] = useState(file.nameWithExt);
|
||||
const [fileName, setFileName] = useState(file.name);
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const fileNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,16 +44,14 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
}, [isRenamingFile]);
|
||||
|
||||
const renameFile = async (file: SNFile, name: string) => {
|
||||
const didRename = await handleFileAction({
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file,
|
||||
name,
|
||||
},
|
||||
});
|
||||
if (didRename) {
|
||||
setIsRenamingFile(false);
|
||||
}
|
||||
setIsRenamingFile(false);
|
||||
};
|
||||
|
||||
const handleFileNameInput = (event: Event) => {
|
||||
@@ -90,15 +60,25 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
|
||||
const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
renameFile(file, fileName);
|
||||
return;
|
||||
itemRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileNameInputBlur = () => {
|
||||
renameFile(file, fileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="flex items-center justify-between p-3 focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{getIconForFileType(file.ext ?? '')}
|
||||
{getFileIconComponent(
|
||||
getIconType(file.mimeType),
|
||||
'w-8 h-8 flex-shrink-0'
|
||||
)}
|
||||
<div className="flex flex-col mx-4">
|
||||
{isRenamingFile ? (
|
||||
<input
|
||||
@@ -108,9 +88,18 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
ref={fileNameInputRef}
|
||||
onInput={handleFileNameInput}
|
||||
onKeyDown={handleFileNameInputKeyDown}
|
||||
onBlur={handleFileNameInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm mb-1">{file.nameWithExt}</div>
|
||||
<div className="text-sm mb-1 break-word">
|
||||
<span className="vertical-middle">{file.name}</span>
|
||||
{file.protected && (
|
||||
<Icon
|
||||
type="lock-filled"
|
||||
className="sn-icon--small ml-2 color-neutral vertical-middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs color-grey-0">
|
||||
{file.created_at.toLocaleString()} ·{' '}
|
||||
@@ -123,6 +112,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
isAttachedToNote={isAttachedToNote}
|
||||
handleFileAction={handleFileAction}
|
||||
setIsRenamingFile={setIsRenamingFile}
|
||||
closeOnBlur={closeOnBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch } from '../Switch';
|
||||
import { useCloseOnBlur } from '../utils';
|
||||
import { useFilePreviewModal } from '../Files/FilePreviewModalProvider';
|
||||
import { PopoverFileItemProps } from './PopoverFileItem';
|
||||
import { PopoverFileItemActionType } from './PopoverFileItemAction';
|
||||
|
||||
type Props = Omit<PopoverFileItemProps, 'renameFile'> & {
|
||||
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
|
||||
setIsRenamingFile: StateUpdater<boolean>;
|
||||
};
|
||||
|
||||
@@ -32,6 +33,8 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
handleFileAction,
|
||||
setIsRenamingFile,
|
||||
}) => {
|
||||
const filePreviewModal = useFilePreviewModal();
|
||||
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,6 +102,17 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
>
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
filePreviewModal.activate(file);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Icon type="file" className="mr-2 color-neutral" />
|
||||
Preview file
|
||||
</button>
|
||||
{isAttachedToNote ? (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
@@ -179,6 +193,20 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||
<Icon type="pencil" className="mr-2 color-neutral" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DeleteFile,
|
||||
payload: file,
|
||||
});
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="mr-2 color-danger" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
|
||||
@@ -22,22 +22,25 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
||||
} = appState.noteTags;
|
||||
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [dropdownMaxHeight, setDropdownMaxHeight] =
|
||||
useState<number | 'auto'>('auto');
|
||||
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>(
|
||||
'auto'
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(containerRef as any, (visible: boolean) => {
|
||||
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
|
||||
setDropdownVisible(visible);
|
||||
appState.noteTags.clearAutocompleteSearch();
|
||||
});
|
||||
|
||||
const showDropdown = () => {
|
||||
const { clientHeight } = document.documentElement;
|
||||
const inputRect = inputRef.current!.getBoundingClientRect();
|
||||
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
|
||||
setDropdownVisible(true);
|
||||
const inputRect = inputRef.current?.getBoundingClientRect();
|
||||
if (inputRect) {
|
||||
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
|
||||
setDropdownVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchQueryChange = (event: Event) => {
|
||||
@@ -93,7 +96,7 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (autocompleteInputFocused) {
|
||||
inputRef.current!.focus();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [appState.noteTags, autocompleteInputFocused]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { splitQueryInString } from '@/utils/stringUtils';
|
||||
import { SNTag } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
@@ -71,7 +72,7 @@ export const AutocompleteTagResult = observer(
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedTagResultUuid === tagResult.uuid) {
|
||||
tagResultRef.current!.focus();
|
||||
tagResultRef.current?.focus();
|
||||
appState.noteTags.setFocusedTagResultUuid(undefined);
|
||||
}
|
||||
}, [appState.noteTags, focusedTagResultUuid, tagResult]);
|
||||
@@ -92,9 +93,8 @@ export const AutocompleteTagResult = observer(
|
||||
{prefixTitle && <span className="grey-2">{prefixTitle}</span>}
|
||||
{autocompleteSearchQuery === ''
|
||||
? title
|
||||
: title
|
||||
.split(new RegExp(`(${autocompleteSearchQuery})`, 'gi'))
|
||||
.map((substring, index) => (
|
||||
: splitQueryInString(title, autocompleteSearchQuery).map(
|
||||
(substring, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${
|
||||
@@ -106,7 +106,8 @@ export const AutocompleteTagResult = observer(
|
||||
>
|
||||
{substring}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ type ButtonProps = {
|
||||
| TargetedEvent<HTMLFormElement>
|
||||
| TargetedMouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onBlur?: (event: FocusEvent) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
type,
|
||||
label,
|
||||
className = '',
|
||||
onBlur,
|
||||
onClick,
|
||||
disabled = false,
|
||||
children,
|
||||
@@ -46,6 +48,7 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
||||
return (
|
||||
<button
|
||||
className={`${buttonClass} ${cursorClass} ${className}`}
|
||||
onBlur={onBlur}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
e.preventDefault();
|
||||
|
||||
@@ -123,13 +123,13 @@ export class ChallengeModal extends PureComponent<Props, State> {
|
||||
})
|
||||
) {
|
||||
this.dismiss();
|
||||
this.application.signOut();
|
||||
this.application.user.signOut();
|
||||
}
|
||||
};
|
||||
|
||||
cancel = () => {
|
||||
if (this.props.challenge.cancelable) {
|
||||
this.application!.cancelChallenge(this.props.challenge);
|
||||
this.application.cancelChallenge(this.props.challenge);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
export const CircleProgress: FunctionComponent<{
|
||||
percent: number;
|
||||
className?: string;
|
||||
}> = ({ percent, className = '' }) => {
|
||||
const size = 16;
|
||||
const ratioStrokeRadius = 0.25;
|
||||
const outerRadius = size / 2;
|
||||
|
||||
const radius = outerRadius * (1 - ratioStrokeRadius);
|
||||
const stroke = outerRadius - radius;
|
||||
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (percent / 100) * circumference;
|
||||
|
||||
const transition = `transition: 0.35s stroke-dashoffset;`;
|
||||
const transform = `transform: rotate(-90deg);`;
|
||||
const transformOrigin = `transform-origin: 50% 50%;`;
|
||||
const dasharray = `stroke-dasharray: ${circumference} ${circumference};`;
|
||||
const dashoffset = `stroke-dashoffset: ${offset};`;
|
||||
const style = `${transition} ${transform} ${transformOrigin} ${dasharray} ${dashoffset}`;
|
||||
return (
|
||||
<div className="h-5 w-5 min-w-5 min-h-5">
|
||||
<svg viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
stroke="#086DD6"
|
||||
stroke-width={stroke}
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
style={style}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { CircleProgress } from './CircleProgress';
|
||||
|
||||
/**
|
||||
* Circular progress bar which runs in a specified time interval
|
||||
* @param time - time interval in ms
|
||||
*/
|
||||
export const CircleProgressTime: FunctionalComponent<{ time: number }> = ({
|
||||
time,
|
||||
}) => {
|
||||
const [percent, setPercent] = useState(0);
|
||||
const interval = time / 100;
|
||||
useEffect(() => {
|
||||
const tick = setInterval(() => {
|
||||
if (percent === 100) {
|
||||
setPercent(0);
|
||||
} else {
|
||||
setPercent(percent + 1);
|
||||
}
|
||||
}, interval);
|
||||
return () => {
|
||||
clearInterval(tick);
|
||||
};
|
||||
});
|
||||
return <CircleProgress percent={percent} />;
|
||||
};
|
||||
@@ -43,8 +43,8 @@ export const ConfirmSignoutModal = observer(
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End your session?
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
|
||||
Sign out workspace?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
@@ -93,7 +93,7 @@ export const ConfirmSignoutModal = observer(
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups();
|
||||
} else {
|
||||
application.signOut();
|
||||
application.user.signOut();
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
|
||||
194
app/assets/javascripts/components/Files/FilePreviewModal.tsx
Normal file
194
app/assets/javascripts/components/Files/FilePreviewModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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 { isFileTypePreviewable } from './isFilePreviewable';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
file: SNFile;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <img src={objectUrl} />;
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return <video className="w-full h-full" src={objectUrl} controls />;
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('audio/')) {
|
||||
return <audio src={objectUrl} controls />;
|
||||
}
|
||||
|
||||
return <object className="w-full h-full" data={objectUrl} />;
|
||||
};
|
||||
|
||||
export const FilePreviewModal: FunctionComponent<Props> = ({
|
||||
application,
|
||||
file,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const [isFilePreviewable, setIsFilePreviewable] = useState(false);
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const getObjectUrl = useCallback(async () => {
|
||||
setIsLoadingFile(true);
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
}, [application.files, file]);
|
||||
|
||||
useEffect(() => {
|
||||
const isPreviewable = isFileTypePreviewable(file.mimeType);
|
||||
setIsFilePreviewable(isPreviewable);
|
||||
|
||||
if (!objectUrl && isPreviewable) {
|
||||
getObjectUrl();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [file.mimeType, getObjectUrl, objectUrl]);
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className="sn-component"
|
||||
aria-label="File preview modal"
|
||||
onDismiss={onDismiss}
|
||||
initialFocusRef={closeButtonRef}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex flex-col rounded shadow-overlay"
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: '90%',
|
||||
minHeight: '90%',
|
||||
background: 'var(--sn-stylekit-background-color)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-shrink-0 justify-between items-center min-h-6 px-4 py-3 border-0 border-b-1 border-solid border-main">
|
||||
<div className="flex items-center">
|
||||
<div className="w-6 h-6">
|
||||
{getFileIconComponent(
|
||||
application.iconsController.getIconForFileType(file.mimeType),
|
||||
'w-6 h-6 flex-shrink-0'
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-3 font-medium">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{objectUrl && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-4"
|
||||
onClick={() => {
|
||||
application
|
||||
.getArchiveService()
|
||||
.downloadData(objectUrl, file.name);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onDismiss}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent border-0 cursor-pointer"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center min-h-0 overflow-auto">
|
||||
{objectUrl ? (
|
||||
getPreviewComponentForFile(file, objectUrl)
|
||||
) : isLoadingFile ? (
|
||||
<div className="sk-spinner w-5 h-5 spinner-info"></div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<NoPreviewIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-base mb-2">
|
||||
This file can't be previewed.
|
||||
</div>
|
||||
{isFilePreviewable ? (
|
||||
<>
|
||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
||||
There was an error loading the file. Try again, or download
|
||||
it and open it using another application.
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-3"
|
||||
onClick={() => {
|
||||
getObjectUrl();
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
type="normal"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
||||
To view this file, download it and open it using another
|
||||
application.
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
application.getAppState().files.downloadFile(file);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNFile } from '@standardnotes/snjs';
|
||||
import { createContext, FunctionComponent } from 'preact';
|
||||
import { useContext, useState } from 'preact/hooks';
|
||||
import { FilePreviewModal } from './FilePreviewModal';
|
||||
|
||||
type FilePreviewModalContextData = {
|
||||
activate: (file: SNFile) => void;
|
||||
};
|
||||
|
||||
const FilePreviewModalContext =
|
||||
createContext<FilePreviewModalContextData | null>(null);
|
||||
|
||||
export const useFilePreviewModal = (): FilePreviewModalContextData => {
|
||||
const value = useContext(FilePreviewModalContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error('FilePreviewModalProvider not found.');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const FilePreviewModalProvider: FunctionComponent<{
|
||||
application: WebApplication;
|
||||
}> = ({ application, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [file, setFile] = useState<SNFile>();
|
||||
|
||||
const activate = (file: SNFile) => {
|
||||
setFile(file);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && file && (
|
||||
<FilePreviewModal
|
||||
application={application}
|
||||
file={file}
|
||||
onDismiss={close}
|
||||
/>
|
||||
)}
|
||||
<FilePreviewModalContext.Provider value={{ activate }}>
|
||||
{children}
|
||||
</FilePreviewModalContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
12
app/assets/javascripts/components/Files/isFilePreviewable.ts
Normal file
12
app/assets/javascripts/components/Files/isFilePreviewable.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const isFileTypePreviewable = (fileType: string) => {
|
||||
const isImage = fileType.startsWith('image/');
|
||||
const isVideo = fileType.startsWith('video/');
|
||||
const isAudio = fileType.startsWith('audio/');
|
||||
const isPdf = fileType === 'application/pdf';
|
||||
|
||||
if (isImage || isVideo || isAudio || isPdf) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -23,7 +23,6 @@ import { Icon } from './Icon';
|
||||
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SyncResolutionMenu } from './SyncResolutionMenu';
|
||||
import { Fragment, render } from 'preact';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
|
||||
/**
|
||||
* Disable before production release.
|
||||
@@ -43,7 +42,6 @@ type State = {
|
||||
dataUpgradeAvailable: boolean;
|
||||
hasPasscode: boolean;
|
||||
descriptors: ApplicationDescriptor[];
|
||||
hasAccountSwitcher: boolean;
|
||||
showBetaWarning: boolean;
|
||||
showSyncResolution: boolean;
|
||||
newUpdateAvailable: boolean;
|
||||
@@ -70,7 +68,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: props.applicationGroup.getDescriptors(),
|
||||
hasAccountSwitcher: false,
|
||||
showBetaWarning: false,
|
||||
showSyncResolution: false,
|
||||
newUpdateAvailable: false,
|
||||
@@ -100,7 +97,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
arbitraryStatusMessage: message,
|
||||
});
|
||||
});
|
||||
this.loadAccountSwitcherState();
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
this.setState({
|
||||
@@ -111,18 +107,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
loadAccountSwitcherState() {
|
||||
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
|
||||
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
|
||||
/** Enable permanently for this user so they don't lose the feature after its disabled */
|
||||
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
|
||||
}
|
||||
const hasAccountSwitcher = stringValue
|
||||
? JSON.parse(stringValue)
|
||||
: ACCOUNT_SWITCHER_ENABLED;
|
||||
this.setState({ hasAccountSwitcher });
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
@@ -212,7 +196,10 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true;
|
||||
if (this.state.offline && this.application.getNoteCount() === 0) {
|
||||
if (
|
||||
this.state.offline &&
|
||||
this.application.items.getNoteCount() === 0
|
||||
) {
|
||||
this.appState.accountMenu.setShow(true);
|
||||
}
|
||||
}
|
||||
@@ -244,7 +231,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application.setDisplayOptions(
|
||||
this.application.items.setDisplayOptions(
|
||||
ContentType.Theme,
|
||||
CollectionSort.Title,
|
||||
'asc',
|
||||
@@ -330,16 +317,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
accountSwitcherClickHandler = () => {
|
||||
render(
|
||||
<AccountSwitcher
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
@@ -426,6 +403,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
onClickOutside={this.clickOutsideAccountMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -519,24 +497,6 @@ export class Footer extends PureComponent<Props, State> {
|
||||
<div className="sk-label">Offline</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasAccountSwitcher && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
onClick={this.accountSwitcherClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
(this.state.hasPasscode ? 'alone' : '') +
|
||||
' flex items-center'
|
||||
}
|
||||
>
|
||||
<Icon type="user-switch" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClearCircleFilledIcon,
|
||||
CloseIcon,
|
||||
CloudOffIcon,
|
||||
ClearCircleFilledIcon,
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
DashboardIcon,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileDocIcon,
|
||||
FileIcon,
|
||||
FileImageIcon,
|
||||
FileMovIcon,
|
||||
FileMusicIcon,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
FilePptIcon,
|
||||
FileXlsIcon,
|
||||
FileZipIcon,
|
||||
FolderIcon,
|
||||
HashtagIcon,
|
||||
HashtagOffIcon,
|
||||
HelpIcon,
|
||||
@@ -81,6 +83,7 @@ import {
|
||||
TuneIcon,
|
||||
UnarchiveIcon,
|
||||
UnpinIcon,
|
||||
UserAddIcon,
|
||||
UserIcon,
|
||||
UserSwitch,
|
||||
WarningIcon,
|
||||
@@ -97,8 +100,8 @@ export const ICONS = {
|
||||
'check-circle': CheckCircleIcon,
|
||||
'chevron-down': ChevronDownIcon,
|
||||
'chevron-right': ChevronRightIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'clear-circle-filled': ClearCircleFilledIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
'file-doc': FileDocIcon,
|
||||
'file-image': FileImageIcon,
|
||||
@@ -125,6 +128,7 @@ export const ICONS = {
|
||||
'rich-text': RichTextIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
'trash-sweep': TrashSweepIcon,
|
||||
'user-add': UserAddIcon,
|
||||
'user-switch': UserSwitch,
|
||||
accessibility: AccessibilityIcon,
|
||||
add: AddIcon,
|
||||
@@ -139,6 +143,8 @@ export const ICONS = {
|
||||
editor: EditorIcon,
|
||||
email: EmailIcon,
|
||||
eye: EyeIcon,
|
||||
file: FileIcon,
|
||||
folder: FolderIcon,
|
||||
hashtag: HashtagIcon,
|
||||
help: HelpIcon,
|
||||
history: HistoryIcon,
|
||||
@@ -159,7 +165,6 @@ export const ICONS = {
|
||||
settings: SettingsIcon,
|
||||
signIn: SignInIcon,
|
||||
signOut: SignOutIcon,
|
||||
spellcheck: NotesIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
star: StarIcon,
|
||||
sync: SyncIcon,
|
||||
|
||||
@@ -68,6 +68,7 @@ export const InputWithIcon: FunctionComponent<Props> = forwardRef(
|
||||
className={`pr-2 w-full border-0 focus:shadow-none ${
|
||||
disabled ? DISABLED_CLASSNAME : ''
|
||||
}`}
|
||||
spellcheck={false}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
|
||||
@@ -19,6 +19,7 @@ type MenuProps = {
|
||||
children: ComponentChildren;
|
||||
closeMenu?: () => void;
|
||||
isOpen: boolean;
|
||||
initialFocus?: number;
|
||||
};
|
||||
|
||||
export const Menu: FunctionComponent<MenuProps> = ({
|
||||
@@ -28,6 +29,7 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
a11yLabel,
|
||||
closeMenu,
|
||||
isOpen,
|
||||
initialFocus,
|
||||
}: MenuProps) => {
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
@@ -46,7 +48,7 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useListKeyboardNavigation(menuElementRef);
|
||||
useListKeyboardNavigation(menuElementRef, initialFocus);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && menuItemRefs.current.length > 0) {
|
||||
@@ -73,8 +75,12 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
child: ComponentChild,
|
||||
index: number,
|
||||
array: ComponentChild[]
|
||||
) => {
|
||||
if (!child) return;
|
||||
): ComponentChild => {
|
||||
if (!child || (Array.isArray(child) && child.length < 1)) return;
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
return child.map(mapMenuItems);
|
||||
}
|
||||
|
||||
const _child = child as VNode<unknown>;
|
||||
const isFirstMenuItem =
|
||||
@@ -79,7 +79,7 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
checked ? 'pseudo-radio-btn--checked' : ''
|
||||
} mr-2`}
|
||||
} mr-2 flex-shrink-0`}
|
||||
></div>
|
||||
) : null}
|
||||
{children}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Icon } from './Icon';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { SNTag } from '@standardnotes/snjs/dist/@types';
|
||||
import { SNTag } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -12,13 +12,10 @@ import {
|
||||
ComponentMutator,
|
||||
PayloadSource,
|
||||
ComponentViewer,
|
||||
ComponentManagerEvent,
|
||||
TransactionalMutation,
|
||||
ItemMutator,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
NoteViewController,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
} from '@standardnotes/snjs';
|
||||
import { debounce, isDesktopApplication } from '@/utils';
|
||||
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
|
||||
@@ -103,7 +100,6 @@ type State = {
|
||||
editorTitle: string;
|
||||
editorText: string;
|
||||
isDesktop?: boolean;
|
||||
isEntitledToFiles: boolean;
|
||||
lockText: string;
|
||||
marginResizersEnabled?: boolean;
|
||||
monospaceFont?: boolean;
|
||||
@@ -172,9 +168,6 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
editorText: '',
|
||||
editorTitle: '',
|
||||
isDesktop: isDesktopApplication(),
|
||||
isEntitledToFiles:
|
||||
this.application.features.getFeatureStatus(FeatureIdentifier.Files) ===
|
||||
FeatureStatus.Entitled,
|
||||
lockText: 'Note Editing Disabled',
|
||||
noteStatus: undefined,
|
||||
noteLocked: this.controller.note.locked,
|
||||
@@ -251,6 +244,15 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: Props, prevState: State): void {
|
||||
if (
|
||||
this.state.showProtectedWarning != undefined &&
|
||||
prevState.showProtectedWarning !== this.state.showProtectedWarning
|
||||
) {
|
||||
this.reloadEditorComponent();
|
||||
}
|
||||
}
|
||||
|
||||
private onNoteInnerChange(note: SNNote, source: PayloadSource): void {
|
||||
if (note.uuid !== this.note.uuid) {
|
||||
throw Error('Editor received changes for non-current note');
|
||||
@@ -328,15 +330,6 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
case ApplicationEvent.UserRolesChanged:
|
||||
this.setState({
|
||||
isEntitledToFiles:
|
||||
this.application.features.getFeatureStatus(
|
||||
FeatureIdentifier.Files
|
||||
) === FeatureStatus.Entitled,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.PreferencesChanged:
|
||||
this.reloadPreferences();
|
||||
break;
|
||||
@@ -481,7 +474,24 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
this.reloadEditorComponent();
|
||||
}
|
||||
|
||||
private destroyCurrentEditorComponent() {
|
||||
const currentComponentViewer = this.state.editorComponentViewer;
|
||||
if (currentComponentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
currentComponentViewer
|
||||
);
|
||||
this.setState({
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadEditorComponent() {
|
||||
if (this.state.showProtectedWarning) {
|
||||
this.destroyCurrentEditorComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
const newEditor = this.application.componentManager.editorForNote(
|
||||
this.note
|
||||
);
|
||||
@@ -494,15 +504,9 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
|
||||
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
|
||||
if (currentComponentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
currentComponentViewer
|
||||
);
|
||||
}
|
||||
if (currentComponentViewer) {
|
||||
this.setState({
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
this.destroyCurrentEditorComponent();
|
||||
}
|
||||
|
||||
if (newEditor) {
|
||||
this.setState({
|
||||
editorComponentViewer: this.createComponentViewer(newEditor),
|
||||
@@ -679,7 +683,7 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
performNoteDeletion(note: SNNote) {
|
||||
this.application.deleteItem(note);
|
||||
this.application.mutator.deleteItem(note);
|
||||
}
|
||||
|
||||
onPanelResizeFinish = async (
|
||||
@@ -817,13 +821,13 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
async disassociateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
return this.application.mutator.runTransactionalMutation(
|
||||
transactionForDisassociateComponentWithCurrentNote(component, this.note)
|
||||
);
|
||||
}
|
||||
|
||||
async associateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
return this.application.mutator.runTransactionalMutation(
|
||||
transactionForAssociateComponentWithCurrentNote(component, this.note)
|
||||
);
|
||||
}
|
||||
@@ -1043,18 +1047,17 @@ export class NoteView extends PureComponent<Props, State> {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{this.state.isEntitledToFiles &&
|
||||
window.enabledUnfinishedFeatures && (
|
||||
<div className="mr-3">
|
||||
<AttachedFilesButton
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
onClickPreprocessing={
|
||||
this.ensureNoteIsInsertedBeforeUIAction
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{window.enabledUnfinishedFeatures && (
|
||||
<div className="mr-3">
|
||||
<AttachedFilesButton
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
onClickPreprocessing={
|
||||
this.ensureNoteIsInsertedBeforeUIAction
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mr-3">
|
||||
<ChangeEditorButton
|
||||
application={this.application}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { CollectionSort, PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Icon } from './Icon';
|
||||
import { Menu } from './menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
|
||||
import { Menu } from './Menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from './Menu/MenuItem';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -160,7 +161,7 @@ const SpellcheckOptions: FunctionComponent<{
|
||||
disabled={!spellcheckControllable}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="spellcheck" className={iconClass} />
|
||||
<Icon type="notes" className={iconClass} />
|
||||
Spellcheck
|
||||
</span>
|
||||
<Switch
|
||||
@@ -236,23 +237,44 @@ export const NotesOptions = observer(
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
const downloadSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
const editor = application.componentManager.editorForNote(note);
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
const downloadAnchor = document.createElement('a');
|
||||
downloadAnchor.setAttribute(
|
||||
'href',
|
||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text)
|
||||
const getNoteFileName = (note: SNNote): string => {
|
||||
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;
|
||||
}
|
||||
|
||||
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]),
|
||||
};
|
||||
})
|
||||
);
|
||||
downloadAnchor.setAttribute('download', `${note.title}.${format}`);
|
||||
downloadAnchor.click();
|
||||
});
|
||||
dismissToast(loadingToastId);
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${notes.length} notes`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
application.duplicateItem(note);
|
||||
application.mutator.duplicateItem(note);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { Menu } from '@/components/menu/Menu';
|
||||
import { MenuItem, MenuItemType } from '@/components/menu/MenuItem';
|
||||
import { Menu } from '@/components/Menu/Menu';
|
||||
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem';
|
||||
import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
@@ -86,7 +86,7 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
) => {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
application.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
application.mutator.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
await application.runTransactionalMutations(transactions);
|
||||
await application.mutator.runTransactionalMutations(transactions);
|
||||
/** Dirtying can happen above */
|
||||
application.sync.sync();
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ export const NotesView: FunctionComponent<Props> = observer(
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onFocus={onSearchFocused}
|
||||
onBlur={onSearchBlurred}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{noteFilterText && (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useRef } from 'preact/hooks';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogDescription,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { action, makeAutoObservable, observable } from 'mobx';
|
||||
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
|
||||
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
|
||||
import {
|
||||
ComponentArea,
|
||||
ContentType,
|
||||
@@ -95,7 +95,9 @@ export class PreferencesMenu {
|
||||
|
||||
private loadLatestVersions(): void {
|
||||
ExtensionsLatestVersions.load(this.application).then((versions) => {
|
||||
this._extensionLatestVersions = versions;
|
||||
if (versions) {
|
||||
this._extensionLatestVersions = versions;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +113,7 @@ export class PreferencesMenu {
|
||||
FeatureIdentifier.CloudLink,
|
||||
];
|
||||
this._extensionPanes = (
|
||||
this.application.getItems([
|
||||
this.application.items.getItems([
|
||||
ContentType.ActionsExtension,
|
||||
ContentType.Component,
|
||||
ContentType.Theme,
|
||||
@@ -17,7 +17,7 @@ import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { useEffect, useMemo } from 'preact/hooks';
|
||||
import { ExtensionPane } from './panes/ExtensionPane';
|
||||
import { Backups } from '@/preferences/panes/Backups';
|
||||
import { Backups } from '@/components/Preferences/panes/Backups';
|
||||
import { Appearance } from './panes/Appearance';
|
||||
|
||||
interface PreferencesProps extends MfaProps {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
|
||||
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
|
||||
index,
|
||||
@@ -5,8 +5,7 @@ export const PreferencesPane: FunctionComponent = ({ children }) => (
|
||||
<div className="flex-grow flex flex-col py-6 items-center">
|
||||
<div className="w-125 max-w-125 flex flex-col">
|
||||
{children != undefined && Array.isArray(children)
|
||||
? children
|
||||
.filter((child) => child != undefined)
|
||||
? children.filter((child) => child != undefined)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
type Props = {
|
||||
classes?: string;
|
||||
};
|
||||
export const PreferencesSegment: FunctionComponent<Props> = ({
|
||||
children,
|
||||
classes = '',
|
||||
}) => <div className={`flex flex-col ${classes}`}>{children}</div>;
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Credentials,
|
||||
SignOutWrapper,
|
||||
Authentication,
|
||||
} from '@/preferences/panes/account';
|
||||
import { PreferencesPane } from '@/preferences/components';
|
||||
} from '@/components/Preferences/panes/account';
|
||||
import { PreferencesPane } from '@/components/Preferences/components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dropdown, DropdownItem } from '@/components/Dropdown';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { GetFeatures } from '@standardnotes/features';
|
||||
@@ -62,7 +62,7 @@ export const Appearance: FunctionComponent<Props> = observer(
|
||||
|
||||
useEffect(() => {
|
||||
const themesAsItems: DropdownItem[] = (
|
||||
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
application.items.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
)
|
||||
.filter((theme) => !theme.isLayerable())
|
||||
.sort(sortThemes)
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PreferencesGroup, PreferencesSegment } from '@/preferences/components';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ComponentViewer, SNComponent } from '@standardnotes/snjs';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
@@ -7,7 +10,7 @@ import { FunctionComponent } from 'preact';
|
||||
import { ExtensionItem } from './extensions-segments';
|
||||
import { ComponentView } from '@/components/ComponentView';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PreferencesMenu } from '@/preferences/PreferencesMenu';
|
||||
import { PreferencesMenu } from '@/components/Preferences/PreferencesMenu';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
interface IProps {
|
||||
@@ -54,7 +57,7 @@ export const ExtensionPane: FunctionComponent<IProps> = observer(
|
||||
extension={extension}
|
||||
first={false}
|
||||
uninstall={() =>
|
||||
application
|
||||
application.mutator
|
||||
.deleteItem(extension)
|
||||
.then(() => preferencesMenu.loadExtensionsPanes())
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const loadExtensions = (application: WebApplication) =>
|
||||
application.getItems(
|
||||
application.items.getItems(
|
||||
[ContentType.ActionsExtension, ContentType.Component, ContentType.Theme],
|
||||
true
|
||||
) as SNComponent[];
|
||||
@@ -48,7 +48,7 @@ export const Extensions: FunctionComponent<{
|
||||
)
|
||||
.then(async (shouldRemove: boolean) => {
|
||||
if (shouldRemove) {
|
||||
await application.deleteItem(extension);
|
||||
await application.mutator.deleteItem(extension);
|
||||
setExtensions(loadExtensions(application));
|
||||
}
|
||||
})
|
||||
@@ -73,7 +73,7 @@ export const Extensions: FunctionComponent<{
|
||||
};
|
||||
|
||||
const confirmExtension = async () => {
|
||||
await application.insertItem(confirmableExtension as SNComponent);
|
||||
await application.mutator.insertItem(confirmableExtension as SNComponent);
|
||||
application.sync.sync();
|
||||
setExtensions(loadExtensions(application));
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesPane } from '../components';
|
||||
import { ErrorReporting, Tools, Defaults, LabsPane } from './general-segments';
|
||||
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
|
||||
import { Advanced } from '@/preferences/panes/account';
|
||||
import { Tools, Defaults, LabsPane } from './general-segments';
|
||||
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
|
||||
import { Advanced } from '@/components/Preferences/panes/account';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface GeneralProps {
|
||||
@@ -18,7 +18,6 @@ export const General: FunctionComponent<GeneralProps> = observer(
|
||||
<PreferencesPane>
|
||||
<Tools application={application} />
|
||||
<Defaults application={application} />
|
||||
<ErrorReporting appState={appState} />
|
||||
<LabsPane application={application} />
|
||||
<Advanced
|
||||
application={application}
|
||||
@@ -104,7 +104,11 @@ export const HelpAndFeedback: FunctionComponent = () => (
|
||||
<Text>
|
||||
Send an email to help@standardnotes.com and we’ll sort it out.
|
||||
</Text>
|
||||
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="mailto: help@standardnotes.com"
|
||||
label="Email us"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
@@ -105,7 +105,7 @@ export const Listed = observer(({ application }: Props) => {
|
||||
type="normal"
|
||||
disabled={requestingAccount}
|
||||
label={
|
||||
requestingAccount ? 'Creating account...' : 'Create New Author'
|
||||
requestingAccount ? 'Creating account...' : 'Create new author'
|
||||
}
|
||||
onClick={registerNewAccount}
|
||||
/>
|
||||
@@ -3,6 +3,7 @@ import { AppState } from '@/ui_models/app_state';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesPane } from '../components';
|
||||
import { Encryption, PasscodeLock, Protections } from './security-segments';
|
||||
import { Privacy } from './security-segments/Privacy';
|
||||
import { TwoFactorAuthWrapper } from './two-factor-auth';
|
||||
import { MfaProps } from './two-factor-auth/MfaProps';
|
||||
|
||||
@@ -20,5 +21,6 @@ export const Security: FunctionComponent<SecurityProps> = (props) => (
|
||||
userProvider={props.userProvider}
|
||||
/>
|
||||
<PasscodeLock appState={props.appState} application={props.application} />
|
||||
<Privacy application={props.application} />
|
||||
</PreferencesPane>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
} from '@/components/Preferences/components';
|
||||
import { OfflineSubscription } from '@/components/Preferences/panes/account/offlineSubscription';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Extensions } from '@/components/Preferences/panes/Extensions';
|
||||
import { ExtensionsLatestVersions } from '@/components/Preferences/panes/extensions-segments';
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from '@node_modules/mobx-react-lite';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { ChangeEmail } from '@/preferences/panes/account/changeEmail';
|
||||
import { ChangeEmail } from '@/components/Preferences/panes/account/changeEmail';
|
||||
import { FunctionComponent, render } from 'preact';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PasswordWizard } from '@/components/PasswordWizard';
|
||||
@@ -45,7 +45,8 @@ export const Credentials: FunctionComponent<Props> = observer(
|
||||
<Title>Credentials</Title>
|
||||
<Subtitle>Email</Subtitle>
|
||||
<Text>
|
||||
You're signed in as <span className="font-bold">{user?.email}</span>
|
||||
You're signed in as{' '}
|
||||
<span className="font-bold wrap">{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@@ -41,12 +41,15 @@ const SignOutView: FunctionComponent<{
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>This device</Subtitle>
|
||||
<Text>This will delete all local items and preferences.</Text>
|
||||
<Subtitle>This workspace</Subtitle>
|
||||
<Text>
|
||||
Remove all data related to the current workspace from the
|
||||
application.
|
||||
</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
type="danger"
|
||||
label="Sign out and clear local data"
|
||||
label="Sign out workspace"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true);
|
||||
}}
|
||||
@@ -67,12 +70,14 @@ const ClearSessionDataView: FunctionComponent<{
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Clear session data</Title>
|
||||
<Text>This will delete all local items and preferences.</Text>
|
||||
<Title>Clear workspace</Title>
|
||||
<Text>
|
||||
Remove all data related to the current workspace from the application.
|
||||
</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
type="danger"
|
||||
label="Clear Session Data"
|
||||
label="Clear workspace"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setSigningOut(true);
|
||||
}}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
|
||||
@@ -3,9 +3,12 @@ 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>
|
||||
<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.
|
||||
Please ensure you are running the latest version of Standard Notes on
|
||||
all platforms to ensure maximum compatibility.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ModalDialogButtons,
|
||||
ModalDialogDescription,
|
||||
ModalDialogLabel,
|
||||
} from '@/components/shared/ModalDialog';
|
||||
} from '@/components/Shared/ModalDialog';
|
||||
import { Button } from '@/components/Button';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { Subtitle } from '@/preferences/components';
|
||||
import { Subtitle } from '@/components/Preferences/components';
|
||||
import { DecoratedInput } from '@/components/DecoratedInput';
|
||||
import { Button } from '@/components/Button';
|
||||
import { JSXInternal } from '@node_modules/preact/src/jsx';
|
||||
@@ -9,8 +9,8 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { STRING_REMOVE_OFFLINE_KEY_CONFIRMATION } from '@/strings';
|
||||
import { ButtonType } from '@standardnotes/snjs';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { ButtonType, ClientDisplayableError } from '@standardnotes/snjs';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication;
|
||||
@@ -49,8 +49,8 @@ export const OfflineSubscription: FunctionalComponent<IProps> = observer(
|
||||
activationCode
|
||||
);
|
||||
|
||||
if (result?.error) {
|
||||
await application.alertService.alert(result.error);
|
||||
if (result instanceof ClientDisplayableError) {
|
||||
await application.alertService.alert(result.text);
|
||||
} else {
|
||||
setIsSuccessfullyActivated(true);
|
||||
setHasUserPreviouslyStoredCode(true);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { LinkButton, Text } from '@/preferences/components';
|
||||
import { LinkButton, Text } from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { loadPurchaseFlowUrl } from '@/purchaseFlow/PurchaseFlowWrapper';
|
||||
import { loadPurchaseFlowUrl } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
|
||||
|
||||
export const NoSubscription: FunctionalComponent<{
|
||||
application: WebApplication;
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SubscriptionInformation } from './SubscriptionInformation';
|
||||
import { NoSubscription } from './NoSubscription';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { SubscriptionState } from '../../../../ui_models/app_state/subscription_state';
|
||||
import { Text } from '@/preferences/components';
|
||||
import { SubscriptionState } from '../../../../../ui_models/app_state/subscription_state';
|
||||
import { Text } from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { openSubscriptionDashboard } from '@/hooks/manageSubscription';
|
||||
@@ -95,7 +95,7 @@ export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
const performImport = async (data: BackupFile) => {
|
||||
setIsImportDataLoading(true);
|
||||
|
||||
const result = await application.importData(data);
|
||||
const result = await application.mutator.importData(data);
|
||||
|
||||
setIsImportDataLoading(false);
|
||||
|
||||
@@ -105,7 +105,7 @@ export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
|
||||
let statusText = STRING_IMPORT_SUCCESS;
|
||||
if ('error' in result) {
|
||||
statusText = result.error;
|
||||
statusText = result.error.text;
|
||||
} else if (result.errorCount) {
|
||||
statusText = StringImportError(result.errorCount);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
<div class="flex flex-row items-center mt-3">
|
||||
<Button
|
||||
type="normal"
|
||||
label="Import Backup"
|
||||
label="Import backup"
|
||||
onClick={handleImportFile}
|
||||
/>
|
||||
<input
|
||||
@@ -13,10 +13,14 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from '../../components';
|
||||
import { EmailBackupFrequency, SettingName } from '@standardnotes/settings';
|
||||
import {
|
||||
EmailBackupFrequency,
|
||||
MuteFailedBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/settings';
|
||||
import { Dropdown, DropdownItem } from '@/components/Dropdown';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
|
||||
@@ -44,14 +48,19 @@ export const EmailBackups = observer(({ application }: Props) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const userSettings = await application.listSettings();
|
||||
const userSettings = await application.settings.listSettings();
|
||||
setEmailFrequency(
|
||||
(userSettings.EMAIL_BACKUP_FREQUENCY ||
|
||||
EmailBackupFrequency.Disabled) as EmailBackupFrequency
|
||||
userSettings.getSettingValue<EmailBackupFrequency>(
|
||||
SettingName.EmailBackupFrequency,
|
||||
EmailBackupFrequency.Disabled
|
||||
)
|
||||
);
|
||||
setIsFailedBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings[SettingName.MuteFailedBackupsEmails] as string
|
||||
userSettings.getSettingValue<MuteFailedBackupsEmailsOption>(
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
MuteFailedBackupsEmailsOption.NotMuted
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -75,7 +84,10 @@ export const EmailBackups = observer(({ application }: Props) => {
|
||||
EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency];
|
||||
frequencyOptions.push({
|
||||
value: frequencyValue,
|
||||
label: application.getEmailBackupFrequencyOptionLabel(frequencyValue),
|
||||
label:
|
||||
application.settings.getEmailBackupFrequencyOptionLabel(
|
||||
frequencyValue
|
||||
),
|
||||
});
|
||||
}
|
||||
setEmailFrequencyOptions(frequencyOptions);
|
||||
@@ -88,7 +100,7 @@ export const EmailBackups = observer(({ application }: Props) => {
|
||||
payload: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await application.updateSetting(settingName, payload);
|
||||
await application.settings.updateSetting(settingName, payload, false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { ButtonType, SettingName } from '@standardnotes/snjs';
|
||||
import {
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Button } from '@/components/Button';
|
||||
import { isDev, openInNewTab } from '@/utils';
|
||||
import { Subtitle } from '@/preferences/components';
|
||||
import { Subtitle } from '@/components/Preferences/components';
|
||||
import { KeyboardKey } from '@Services/ioService';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
@@ -27,7 +26,9 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
}) => {
|
||||
const [authBegan, setAuthBegan] = useState(false);
|
||||
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false);
|
||||
const [backupFrequency, setBackupFrequency] = useState<string | null>(null);
|
||||
const [backupFrequency, setBackupFrequency] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
const disable = async (event: Event) => {
|
||||
@@ -42,10 +43,10 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
'Cancel'
|
||||
);
|
||||
if (shouldDisable) {
|
||||
await application.deleteSetting(backupFrequencySettingName);
|
||||
await application.deleteSetting(backupTokenSettingName);
|
||||
await application.settings.deleteSetting(backupFrequencySettingName);
|
||||
await application.settings.deleteSetting(backupTokenSettingName);
|
||||
|
||||
setBackupFrequency(null);
|
||||
setBackupFrequency(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
application.alertService.alert(error as string);
|
||||
@@ -66,7 +67,7 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
const performBackupNow = async () => {
|
||||
// A backup is performed anytime the setting is updated with the integration token, so just update it here
|
||||
try {
|
||||
await application.updateSetting(
|
||||
await application.settings.updateSetting(
|
||||
backupFrequencySettingName,
|
||||
backupFrequency as string
|
||||
);
|
||||
@@ -134,11 +135,11 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
if (!cloudProviderToken) {
|
||||
throw new Error();
|
||||
}
|
||||
await application.updateSetting(
|
||||
await application.settings.updateSetting(
|
||||
backupTokenSettingName,
|
||||
cloudProviderToken
|
||||
);
|
||||
await application.updateSetting(
|
||||
await application.settings.updateSetting(
|
||||
backupFrequencySettingName,
|
||||
defaultBackupFrequency
|
||||
);
|
||||
@@ -166,7 +167,9 @@ export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
if (!application.getUser()) {
|
||||
return;
|
||||
}
|
||||
const frequency = await application.getSetting(backupFrequencySettingName);
|
||||
const frequency = await application.settings.getSetting(
|
||||
backupFrequencySettingName
|
||||
);
|
||||
setBackupFrequency(frequency);
|
||||
}, [application, backupFrequencySettingName]);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { CloudBackupProvider } from './CloudBackupProvider';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
@@ -8,26 +7,24 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
} from '@/components/Preferences/components';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { CloudProvider, SettingName } from '@standardnotes/settings';
|
||||
import {
|
||||
CloudProvider,
|
||||
MuteFailedCloudBackupsEmailsOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/settings';
|
||||
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,
|
||||
},
|
||||
{ name: CloudProvider.Dropbox },
|
||||
{ name: CloudProvider.Google },
|
||||
{ name: CloudProvider.OneDrive },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
@@ -51,10 +48,13 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const userSettings = await application.listSettings();
|
||||
const userSettings = await application.settings.listSettings();
|
||||
setIsFailedCloudBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings[SettingName.MuteFailedCloudBackupsEmails] as string
|
||||
userSettings.getSettingValue(
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
MuteFailedCloudBackupsEmailsOption.NotMuted
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -89,7 +89,7 @@ export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
payload: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await application.updateSetting(settingName, payload);
|
||||
await application.settings.updateSetting(settingName, payload);
|
||||
return true;
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
|
||||
@@ -1,42 +1,36 @@
|
||||
import { displayStringForContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { Button } from '@/components/Button';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Subtitle,
|
||||
PreferencesSegment,
|
||||
} from '../../components';
|
||||
import { Title, Text, Subtitle, PreferencesSegment } from '../../components';
|
||||
|
||||
export const ConfirmCustomExtension: FunctionComponent<{
|
||||
component: SNComponent,
|
||||
callback: (confirmed: boolean) => void
|
||||
component: SNComponent;
|
||||
callback: (confirmed: boolean) => void;
|
||||
}> = ({ component, callback }) => {
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: 'Name',
|
||||
value: component.package_info.name
|
||||
value: component.package_info.name,
|
||||
},
|
||||
{
|
||||
label: 'Description',
|
||||
value: component.package_info.description
|
||||
value: component.package_info.description,
|
||||
},
|
||||
{
|
||||
label: 'Version',
|
||||
value: component.package_info.version
|
||||
value: component.package_info.version,
|
||||
},
|
||||
{
|
||||
label: 'Hosted URL',
|
||||
value: component.thirdPartyPackageInfo.url
|
||||
value: component.thirdPartyPackageInfo.url,
|
||||
},
|
||||
{
|
||||
label: 'Download URL',
|
||||
value: component.package_info.download_url
|
||||
value: component.package_info.download_url,
|
||||
},
|
||||
{
|
||||
label: 'Extension Type',
|
||||
value: displayStringForContentType(component.content_type)
|
||||
value: displayStringForContentType(component.content_type),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -45,7 +39,9 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
<Title>Confirm Extension</Title>
|
||||
|
||||
{fields.map((field) => {
|
||||
if (!field.value) { return undefined; }
|
||||
if (!field.value) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Subtitle>{field.label}</Subtitle>
|
||||
@@ -74,7 +70,6 @@ export const ConfirmCustomExtension: FunctionComponent<{
|
||||
onClick={() => callback(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</PreferencesSegment>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
PreferencesSegment,
|
||||
SubtitleLight,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useState } from 'preact/hooks';
|
||||
@@ -46,7 +46,7 @@ export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly;
|
||||
setOfflineOnly(newOfflineOnly);
|
||||
application
|
||||
application.mutator
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.offlineOnly = newOfflineOnly;
|
||||
@@ -62,7 +62,7 @@ export const ExtensionItem: FunctionComponent<ExtensionItemProps> = ({
|
||||
|
||||
const changeExtensionName = (newName: string) => {
|
||||
setExtensionName(newName);
|
||||
application
|
||||
application.mutator
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.name = newName;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FeatureDescription } from '@standardnotes/features';
|
||||
import { SNComponent, ClientDisplayableError } 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!);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { FunctionComponent } from "preact";
|
||||
import { useState, useRef, useEffect } from "preact/hooks";
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
export const RenameExtension: FunctionComponent<{
|
||||
extensionName: string, changeName: (newName: string) => void
|
||||
extensionName: string;
|
||||
changeName: (newName: string) => void;
|
||||
}> = ({ extensionName, changeName }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newExtensionName, setNewExtensionName] = useState<string>(extensionName);
|
||||
const [newExtensionName, setNewExtensionName] =
|
||||
useState<string>(extensionName);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -38,21 +40,30 @@ export const RenameExtension: FunctionComponent<{
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={!isRenaming}
|
||||
autocomplete='off'
|
||||
autocomplete="off"
|
||||
className="flex-grow text-base font-bold no-border bg-default px-0 color-text"
|
||||
type="text"
|
||||
value={newExtensionName}
|
||||
onChange={({ target: input }) => setNewExtensionName((input as HTMLInputElement)?.value)}
|
||||
onChange={({ target: input }) =>
|
||||
setNewExtensionName((input as HTMLInputElement)?.value)
|
||||
}
|
||||
/>
|
||||
<div className="min-w-3" />
|
||||
{isRenaming ?
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<a className="pt-1 cursor-pointer" onClick={confirmRename}>Confirm</a>
|
||||
<a className="pt-1 cursor-pointer" onClick={confirmRename}>
|
||||
Confirm
|
||||
</a>
|
||||
<div className="min-w-3" />
|
||||
<a className="pt-1 cursor-pointer" onClick={cancelRename}>Cancel</a>
|
||||
</> :
|
||||
<a className="pt-1 cursor-pointer" onClick={startRenaming}>Rename</a>
|
||||
}
|
||||
<a className="pt-1 cursor-pointer" onClick={cancelRename}>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a className="pt-1 cursor-pointer" onClick={startRenaming}>
|
||||
Rename
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
ComponentArea,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { Switch } from '@/components/Switch';
|
||||
|
||||
type Props = {
|
||||
@@ -34,7 +34,7 @@ const makeEditorDefault = (
|
||||
if (currentDefault) {
|
||||
removeEditorDefault(application, currentDefault);
|
||||
}
|
||||
application.changeAndSaveItem(component.uuid, (m) => {
|
||||
application.mutator.changeAndSaveItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.defaultEditor = true;
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const removeEditorDefault = (
|
||||
application: WebApplication,
|
||||
component: SNComponent
|
||||
) => {
|
||||
application.changeAndSaveItem(component.uuid, (m) => {
|
||||
application.mutator.changeAndSaveItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.defaultEditor = false;
|
||||
});
|
||||
@@ -67,6 +67,10 @@ export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
application.getPreference(PrefKey.EditorSpellcheck, true)
|
||||
);
|
||||
|
||||
const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() =>
|
||||
application.getPreference(PrefKey.NoteAddToParentFolders, true)
|
||||
);
|
||||
|
||||
const toggleSpellcheck = () => {
|
||||
setSpellcheck(!spellcheck);
|
||||
application.getAppState().toggleGlobalSpellcheck();
|
||||
@@ -148,6 +152,28 @@ export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
</div>
|
||||
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>
|
||||
Add all parent tags when adding a nested tag to a note
|
||||
</Subtitle>
|
||||
<Text>
|
||||
When enabled, adding a nested tag to a note will automatically add
|
||||
all associated parent tags.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={() => {
|
||||
application.setPreference(
|
||||
PrefKey.NoteAddToParentFolders,
|
||||
!addNoteToParentFolders
|
||||
);
|
||||
setAddNoteToParentFolders(!addNoteToParentFolders);
|
||||
}}
|
||||
checked={addNoteToParentFolders}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
|
||||
type ExperimentalFeatureItem = {
|
||||
identifier: FeatureIdentifier;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './ErrorReporting';
|
||||
export * from './Tools';
|
||||
export * from './Defaults';
|
||||
export * from './Labs';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { LinkButton, Subtitle } from '@/preferences/components';
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { LinkButton, Subtitle } from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ListedAccount, ListedAccountInfo } from '@standardnotes/snjs';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DecoratedInput } from '@/components/DecoratedInput';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import {
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
} from '@/strings';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Text,
|
||||
Title,
|
||||
} from '../../components';
|
||||
|
||||
const formatCount = (count: number, itemType: string) =>
|
||||
`${count} / ${count} ${itemType}`;
|
||||
|
||||
const EncryptionEnabled: FunctionComponent<{ appState: AppState }> = observer(
|
||||
({ appState }) => {
|
||||
const count = appState.accountMenu.structuredNotesAndTagsCount;
|
||||
const notes = formatCount(count.notes, 'notes');
|
||||
const tags = formatCount(count.tags, 'tags');
|
||||
const archived = formatCount(count.archived, 'archived notes');
|
||||
const deleted = formatCount(count.deleted, 'trashed notes');
|
||||
|
||||
const checkIcon = (
|
||||
<Icon className="success min-w-4 min-h-4" type="check-bold" />
|
||||
);
|
||||
const noteIcon = <Icon type="rich-text" className="min-w-5 min-h-5" />;
|
||||
const tagIcon = <Icon type="hashtag" className="min-w-5 min-h-5" />;
|
||||
const archiveIcon = <Icon type="archive" className="min-w-5 min-h-5" />;
|
||||
const trashIcon = <Icon type="trash" className="min-w-5 min-h-5" />;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row pb-1 pt-1.5">
|
||||
<DecoratedInput
|
||||
disabled={true}
|
||||
text={notes}
|
||||
right={[checkIcon]}
|
||||
left={[noteIcon]}
|
||||
/>
|
||||
<div className="min-w-3" />
|
||||
<DecoratedInput
|
||||
disabled={true}
|
||||
text={tags}
|
||||
right={[checkIcon]}
|
||||
left={[tagIcon]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<DecoratedInput
|
||||
disabled={true}
|
||||
text={archived}
|
||||
right={[checkIcon]}
|
||||
left={[archiveIcon]}
|
||||
/>
|
||||
<div className="min-w-3" />
|
||||
<DecoratedInput
|
||||
disabled={true}
|
||||
text={deleted}
|
||||
right={[checkIcon]}
|
||||
left={[trashIcon]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Encryption: FunctionComponent<{ appState: AppState }> = observer(
|
||||
({ appState }) => {
|
||||
const app = appState.application;
|
||||
const hasUser = app.hasAccount();
|
||||
const hasPasscode = app.hasPasscode();
|
||||
const isEncryptionEnabled = app.isEncryptionAvailable();
|
||||
|
||||
const encryptionStatusString = hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED;
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Encryption</Title>
|
||||
<Text>{encryptionStatusString}</Text>
|
||||
|
||||
{isEncryptionEnabled && <EncryptionEnabled appState={appState} />}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_NON_MATCHING_PASSCODES,
|
||||
StringUtils,
|
||||
Strings
|
||||
Strings,
|
||||
} from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
@@ -15,7 +18,12 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PreferencesSegment, Title, Text, PreferencesGroup } from '@/preferences/components';
|
||||
import {
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
PreferencesGroup,
|
||||
} from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
type Props = {
|
||||
@@ -23,23 +31,31 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const PasscodeLock = observer(({
|
||||
application,
|
||||
appState,
|
||||
}: Props) => {
|
||||
export const PasscodeLock = observer(({ application, appState }: Props) => {
|
||||
const keyStorageInfo = StringUtils.keyStorageInfo(application);
|
||||
const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions();
|
||||
const passcodeAutoLockOptions = application
|
||||
.getAutolockService()
|
||||
.getAutoLockIntervalOptions();
|
||||
|
||||
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu;
|
||||
const {
|
||||
setIsEncryptionEnabled,
|
||||
setIsBackupEncrypted,
|
||||
setEncryptionStatusString,
|
||||
} = appState.accountMenu;
|
||||
|
||||
const passcodeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [passcode, setPasscode] = useState<string | undefined>(undefined);
|
||||
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string | undefined>(undefined);
|
||||
const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState<unknown>(null);
|
||||
const [passcodeConfirmation, setPasscodeConfirmation] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [selectedAutoLockInterval, setSelectedAutoLockInterval] =
|
||||
useState<unknown>(null);
|
||||
const [isPasscodeFocused, setIsPasscodeFocused] = useState(false);
|
||||
const [showPasscodeForm, setShowPasscodeForm] = useState(false);
|
||||
const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession());
|
||||
const [canAddPasscode, setCanAddPasscode] = useState(
|
||||
!application.isEphemeralSession()
|
||||
);
|
||||
const [hasPasscode, setHasPasscode] = useState(application.hasPasscode());
|
||||
|
||||
const handleAddPassCode = () => {
|
||||
@@ -52,7 +68,9 @@ export const PasscodeLock = observer(({
|
||||
};
|
||||
|
||||
const reloadAutoLockInterval = useCallback(async () => {
|
||||
const interval = await application.getAutolockService().getAutoLockInterval();
|
||||
const interval = await application
|
||||
.getAutolockService()
|
||||
.getAutoLockInterval();
|
||||
setSelectedAutoLockInterval(interval);
|
||||
}, [application]);
|
||||
|
||||
@@ -67,13 +85,18 @@ export const PasscodeLock = observer(({
|
||||
const encryptionStatusString = hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED;
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED;
|
||||
|
||||
setEncryptionStatusString(encryptionStatusString);
|
||||
setIsEncryptionEnabled(encryptionEnabled);
|
||||
setIsBackupEncrypted(encryptionEnabled);
|
||||
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled]);
|
||||
}, [
|
||||
application,
|
||||
setEncryptionStatusString,
|
||||
setIsBackupEncrypted,
|
||||
setIsEncryptionEnabled,
|
||||
]);
|
||||
|
||||
const selectAutoLockInterval = async (interval: number) => {
|
||||
if (!(await application.authorizeAutolockIntervalChange())) {
|
||||
@@ -88,9 +111,7 @@ export const PasscodeLock = observer(({
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
|
||||
async () => {
|
||||
if (await application.removePasscode()) {
|
||||
await application
|
||||
.getAutolockService()
|
||||
.deleteAutolockPreference();
|
||||
await application.getAutolockService().deleteAutolockPreference();
|
||||
await reloadAutoLockInterval();
|
||||
refreshEncryptionStatus();
|
||||
}
|
||||
@@ -103,12 +124,18 @@ export const PasscodeLock = observer(({
|
||||
setPasscode(value);
|
||||
};
|
||||
|
||||
const handleConfirmPasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const handleConfirmPasscodeChange = (
|
||||
event: TargetedEvent<HTMLInputElement>
|
||||
) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setPasscodeConfirmation(value);
|
||||
};
|
||||
|
||||
const submitPasscodeForm = async (event: TargetedEvent<HTMLFormElement> | TargetedMouseEvent<HTMLButtonElement>) => {
|
||||
const submitPasscodeForm = async (
|
||||
event:
|
||||
| TargetedEvent<HTMLFormElement>
|
||||
| TargetedMouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!passcode || passcode.length === 0) {
|
||||
@@ -119,7 +146,7 @@ export const PasscodeLock = observer(({
|
||||
|
||||
if (passcode !== passcodeConfirmation) {
|
||||
await alertDialog({
|
||||
text: STRING_NON_MATCHING_PASSCODES
|
||||
text: STRING_NON_MATCHING_PASSCODES,
|
||||
});
|
||||
setIsPasscodeFocused(true);
|
||||
return;
|
||||
@@ -186,27 +213,28 @@ export const PasscodeLock = observer(({
|
||||
|
||||
{!hasPasscode && canAddPasscode && (
|
||||
<>
|
||||
|
||||
<Text className="mb-3">
|
||||
Add a passcode to lock the application and
|
||||
encrypt on-device key storage.
|
||||
Add a passcode to lock the application and encrypt on-device key
|
||||
storage.
|
||||
</Text>
|
||||
|
||||
{keyStorageInfo && (
|
||||
<Text className="mb-3">{keyStorageInfo}</Text>
|
||||
)}
|
||||
{keyStorageInfo && <Text className="mb-3">{keyStorageInfo}</Text>}
|
||||
|
||||
{!showPasscodeForm && (
|
||||
<Button label="Add Passcode" onClick={handleAddPassCode} type="primary" />
|
||||
<Button
|
||||
label="Add passcode"
|
||||
onClick={handleAddPassCode}
|
||||
type="primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasPasscode && !canAddPasscode && (
|
||||
<Text>
|
||||
Adding a passcode is not supported in temporary sessions. Please sign
|
||||
out, then sign back in with the "Stay signed in" option checked.
|
||||
Adding a passcode is not supported in temporary sessions. Please
|
||||
sign out, then sign back in with the "Stay signed in" option
|
||||
checked.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -229,8 +257,17 @@ export const PasscodeLock = observer(({
|
||||
placeholder="Confirm Passcode"
|
||||
/>
|
||||
<div className="min-h-2" />
|
||||
<Button type="primary" onClick={submitPasscodeForm} label="Set Passcode" className="mr-3" />
|
||||
<Button type="normal" onClick={() => setShowPasscodeForm(false)} label="Cancel" />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={submitPasscodeForm}
|
||||
label="Set Passcode"
|
||||
className="mr-3"
|
||||
/>
|
||||
<Button
|
||||
type="normal"
|
||||
onClick={() => setShowPasscodeForm(false)}
|
||||
label="Cancel"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -238,11 +275,20 @@ export const PasscodeLock = observer(({
|
||||
<>
|
||||
<Text>Passcode lock is enabled.</Text>
|
||||
<div className="flex flex-row mt-3">
|
||||
<Button type="normal" label="Change Passcode" onClick={changePasscodePressed} className="mr-3" />
|
||||
<Button type="danger" label="Remove Passcode" onClick={removePasscodePressed} />
|
||||
<Button
|
||||
type="normal"
|
||||
label="Change Passcode"
|
||||
onClick={changePasscodePressed}
|
||||
className="mr-3"
|
||||
/>
|
||||
<Button
|
||||
type="danger"
|
||||
label="Remove Passcode"
|
||||
onClick={removePasscodePressed}
|
||||
/>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
|
||||
@@ -252,19 +298,23 @@ export const PasscodeLock = observer(({
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Autolock</Title>
|
||||
<Text className="mb-3">The autolock timer begins when the window or tab loses focus.</Text>
|
||||
<Text className="mb-3">
|
||||
The autolock timer begins when the window or tab loses focus.
|
||||
</Text>
|
||||
<div className="flex flex-row items-center">
|
||||
{passcodeAutoLockOptions.map(option => {
|
||||
{passcodeAutoLockOptions.map((option) => {
|
||||
return (
|
||||
<a
|
||||
className={`sk-a info mr-3 ${option.value === selectedAutoLockInterval ? 'boxed' : ''}`}
|
||||
onClick={() => selectAutoLockInterval(option.value)}>
|
||||
className={`sk-a info mr-3 ${
|
||||
option.value === selectedAutoLockInterval ? 'boxed' : ''
|
||||
}`}
|
||||
onClick={() => selectAutoLockInterval(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
@@ -0,0 +1,166 @@
|
||||
import { HorizontalSeparator } from '@/components/Shared/HorizontalSeparator';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/components/Preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
MuteSignInEmailsOption,
|
||||
LogSessionUserAgentOption,
|
||||
SettingName,
|
||||
} from '@standardnotes/settings';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export const Privacy: FunctionalComponent<Props> = observer(
|
||||
({ application }: Props) => {
|
||||
const [signInEmailsMutedValue, setSignInEmailsMutedValue] =
|
||||
useState<MuteSignInEmailsOption>(MuteSignInEmailsOption.NotMuted);
|
||||
const [sessionUaLoggingValue, setSessionUaLoggingValue] =
|
||||
useState<LogSessionUserAgentOption>(LogSessionUserAgentOption.Enabled);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateSetting = async (
|
||||
settingName: SettingName,
|
||||
payload: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await application.settings.updateSetting(settingName, payload, false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (!application.getUser()) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const userSettings = await application.settings.listSettings();
|
||||
setSignInEmailsMutedValue(
|
||||
userSettings.getSettingValue<MuteSignInEmailsOption>(
|
||||
SettingName.MuteSignInEmails,
|
||||
MuteSignInEmailsOption.NotMuted
|
||||
)
|
||||
);
|
||||
setSessionUaLoggingValue(
|
||||
userSettings.getSettingValue<LogSessionUserAgentOption>(
|
||||
SettingName.LogSessionUserAgent,
|
||||
LogSessionUserAgentOption.Enabled
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const toggleMuteSignInEmails = async () => {
|
||||
const previousValue = signInEmailsMutedValue;
|
||||
const newValue =
|
||||
previousValue === MuteSignInEmailsOption.Muted
|
||||
? MuteSignInEmailsOption.NotMuted
|
||||
: MuteSignInEmailsOption.Muted;
|
||||
setSignInEmailsMutedValue(newValue);
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteSignInEmails,
|
||||
newValue
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
setSignInEmailsMutedValue(previousValue);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSessionLogging = async () => {
|
||||
const previousValue = sessionUaLoggingValue;
|
||||
const newValue =
|
||||
previousValue === LogSessionUserAgentOption.Enabled
|
||||
? LogSessionUserAgentOption.Disabled
|
||||
: LogSessionUserAgentOption.Enabled;
|
||||
setSessionUaLoggingValue(newValue);
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.LogSessionUserAgent,
|
||||
newValue
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
setSessionUaLoggingValue(previousValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Privacy</Title>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Disable sign-in notification emails</Subtitle>
|
||||
<Text>
|
||||
Disables email notifications when a new sign-in occurs on your
|
||||
account. (Email notifications are available to paid
|
||||
subscribers).
|
||||
</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleMuteSignInEmails}
|
||||
checked={
|
||||
signInEmailsMutedValue === MuteSignInEmailsOption.Muted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Session user agent logging</Subtitle>
|
||||
<Text>
|
||||
User agent logging allows you to identify the devices or
|
||||
browsers signed into your account. For increased privacy, you
|
||||
can disable this feature, which will remove all saved user
|
||||
agent values from our server, and disable future logging of
|
||||
this value.
|
||||
</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleSessionLogging}
|
||||
checked={
|
||||
sessionUaLoggingValue === LogSessionUserAgentOption.Enabled
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
} from '@/preferences/components';
|
||||
} from '@/components/Preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
type Props = {
|
||||
@@ -3,5 +3,7 @@ import { FunctionComponent } from 'preact';
|
||||
export const Bullet: FunctionComponent<{ className?: string }> = ({
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={`min-w-1 min-h-1 rounded-full bg-inverted-default ${className} mr-2`} />
|
||||
<div
|
||||
className={`min-w-1 min-h-1 rounded-full bg-inverted-default ${className} mr-2`}
|
||||
/>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user