Merge branch 'release/10.17.0'

This commit is contained in:
Mo
2022-03-25 09:21:23 -05:00
256 changed files with 4056 additions and 8732 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ export abstract class PureComponent<
);
}
onAppStateEvent(eventName: any, data: any) {
onAppStateEvent(_eventName: any, _data: any) {
/** Optional override */
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,6 +194,7 @@ export const NotesView: FunctionComponent<Props> = observer(
onKeyUp={onNoteFilterKeyUp}
onFocus={onSearchFocused}
onBlur={onSearchBlurred}
autocomplete="off"
/>
{noteFilterText && (
<button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,7 +104,11 @@ export const HelpAndFeedback: FunctionComponent = () => (
<Text>
Send an email to help@standardnotes.com and well sort it out.
</Text>
<LinkButton className="mt-3" link="mailto: help@standardnotes.com" label="Email us" />
<LinkButton
className="mt-3"
link="mailto: help@standardnotes.com"
label="Email us"
/>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

View File

@@ -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';

View File

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

View File

@@ -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';

View File

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

View File

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

View File

@@ -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';

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -1,4 +1,3 @@
export * from './ErrorReporting';
export * from './Tools';
export * from './Defaults';
export * from './Labs';

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import {
PreferencesSegment,
Title,
Text,
} from '@/preferences/components';
} from '@/components/Preferences/components';
import { Button } from '@/components/Button';
type Props = {

View File

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