Merge branch 'release/3.8.21' into main

This commit is contained in:
Antonella Sgarlatta
2021-09-01 14:00:15 -03:00
63 changed files with 1437 additions and 351 deletions

View File

@@ -19,7 +19,10 @@
"camelcase": "warn",
"sort-imports": "off",
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
"react-hooks/exhaustive-deps": "error" // Checks effect dependencies
"react-hooks/exhaustive-deps": "error", // Checks effect dependencies
"eol-last": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error"
},
"env": {
"browser": true

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
# OS & IDE
.DS_Store
.idea
# Ignore bundler config.
/.bundle

View File

@@ -213,7 +213,7 @@ DEPENDENCIES
dotenv-rails
haml
lograge (~> 0.11.2)
newrelic_rpm
newrelic_rpm (~> 7.0)
non-stupid-digest-assets
puma
rack-cors

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66724 3.66626C1.66724 2.56169 2.56267 1.66626 3.66724 1.66626H11.3339C12.4385 1.66626 13.3339 2.56169 13.3339 3.66626V13.3329H3.66724C2.56267 13.3329 1.66724 12.4375 1.66724 11.3329V3.66626ZM16.3339 6.66626C17.4385 6.66626 18.3339 7.56169 18.3339 8.66626V16.3329C18.3339 17.4375 17.4385 18.3329 16.3339 18.3329H8.66724C7.56267 18.3329 6.66724 17.4375 6.66724 16.3329V14.9996H15.0006V6.66626H16.3339ZM3.3339 3.33293V11.6663H11.6672V3.33293H3.3339Z" fill="#72767E"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3.5V9.5H12.17L10 11.67L7.83 9.5H9V3.5H11ZM13 1.5H7V7.5H3L10 14.5L17 7.5H13V1.5ZM17 16.5H3V18.5H17V16.5Z" fill="#72767E"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16675 7.50008H10.8334V5.83342H9.16675V7.50008ZM10.0001 16.6667C6.32508 16.6667 3.33341 13.6751 3.33341 10.0001C3.33341 6.32508 6.32508 3.33341 10.0001 3.33341C13.6751 3.33341 16.6667 6.32508 16.6667 10.0001C16.6667 13.6751 13.6751 16.6667 10.0001 16.6667ZM10.0001 1.66675C8.90573 1.66675 7.8221 1.8823 6.81105 2.30109C5.80001 2.71987 4.88135 3.3337 4.10752 4.10752C2.54472 5.67033 1.66675 7.78994 1.66675 10.0001C1.66675 12.2102 2.54472 14.3298 4.10752 15.8926C4.88135 16.6665 5.80001 17.2803 6.81105 17.6991C7.8221 18.1179 8.90573 18.3334 10.0001 18.3334C12.2102 18.3334 14.3298 17.4554 15.8926 15.8926C17.4554 14.3298 18.3334 12.2102 18.3334 10.0001C18.3334 8.90573 18.1179 7.8221 17.6991 6.81105C17.2803 5.80001 16.6665 4.88135 15.8926 4.10752C15.1188 3.3337 14.2002 2.71987 13.1891 2.30109C12.1781 1.8823 11.0944 1.66675 10.0001 1.66675ZM9.16675 14.1667H10.8334V9.16675H9.16675V14.1667Z" fill="#72767E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -65,7 +65,7 @@ import { NotesContextMenuDirective } from './components/NotesContextMenu';
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
import { IconDirective } from './components/Icon';
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
import { PreferencesDirective } from './components/preferences';
import { PreferencesDirective } from './preferences';
function reloadHiddenFirefoxTab(): boolean {
/**

View File

@@ -0,0 +1,32 @@
import { FunctionComponent } from 'preact';
const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`;
const normalClass = `${baseClass} bg-default color-text border-solid border-gray-300 border-1 \
focus:bg-contrast hover:bg-contrast`;
const primaryClass = `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 \
focus:brightness-130`;
export const Button: FunctionComponent<{
className?: string;
type: 'normal' | 'primary';
label: string;
onClick: () => void;
disabled?: boolean;
}> = ({ type, label, className = '', onClick, disabled = false }) => {
const buttonClass = type === 'primary' ? primaryClass : normalClass;
const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer';
return (
<button
className={`${buttonClass} ${cursorClass} ${className}`}
onClick={(e) => {
onClick();
e.preventDefault();
}}
disabled={disabled}
>
{label}
</button>
);
};

View File

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,27 @@
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

@@ -0,0 +1,42 @@
import { FunctionalComponent, ComponentChild } from 'preact';
interface Props {
className?: string;
disabled?: boolean;
left?: ComponentChild[];
right?: ComponentChild[];
text?: string;
}
/**
* Input that can be decorated on the left and right side
*/
export const DecoratedInput: FunctionalComponent<Props> = ({
className = '',
disabled = false,
left,
right,
text,
}) => {
const base =
'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center gap-4';
const stateClasses = disabled
? 'no-border bg-grey-5'
: 'border-solid border-1 border-gray-300';
const classes = `${base} ${stateClasses} ${className}`;
return (
<div className={`${classes} focus-within:ring-info`}>
{left}
<div className="flex-grow">
<input
type="text"
className="w-full no-border color-black focus:shadow-none"
disabled={disabled}
value={text}
/>
</div>
{right}
</div>
);
};

View File

@@ -23,6 +23,9 @@ import SettingsIcon from '../../icons/ic-settings.svg';
import StarIcon from '../../icons/ic-star.svg';
import ThemesIcon from '../../icons/ic-themes.svg';
import UserIcon from '../../icons/ic-user.svg';
import CopyIcon from '../../icons/ic-copy.svg';
import DownloadIcon from '../../icons/ic-download.svg';
import InfoIcon from '../../icons/ic-info.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
@@ -52,6 +55,9 @@ const ICONS = {
star: StarIcon,
themes: ThemesIcon,
user: UserIcon,
copy: CopyIcon,
download: DownloadIcon,
info: InfoIcon,
};
export type IconType = keyof typeof ICONS;
@@ -61,7 +67,7 @@ type Props = {
className?: string;
};
export const Icon: FunctionalComponent<Props> = ({ type, className }) => {
export const Icon: FunctionalComponent<Props> = ({ type, className = '' }) => {
const IconComponent = ICONS[type];
return <IconComponent className={`sn-icon ${className}`} />;
};

View File

@@ -1,53 +1,38 @@
import { FunctionComponent } from 'preact';
import { Icon, IconType } from './Icon';
const ICON_BUTTON_TYPES: {
[type: string]: { className: string };
} = {
normal: {
className: '',
},
primary: {
className: 'info',
},
};
export type IconButtonType = keyof typeof ICON_BUTTON_TYPES;
interface IconButtonProps {
interface Props {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void;
type: IconButtonType;
className?: string;
iconType: IconType;
icon: IconType;
}
/**
* CircleButton component with an icon for SPA
* IconButton component with an icon
* preventDefault is already handled within the component
*/
export const IconButton: FunctionComponent<IconButtonProps> = ({
export const IconButton: FunctionComponent<Props> = ({
onClick,
type,
className,
iconType,
icon,
}) => {
const click = (e: MouseEvent) => {
e.preventDefault();
onClick();
};
const typeProps = ICON_BUTTON_TYPES[type];
return (
<button
className={`sn-icon-button ${typeProps.className} ${className ?? ''}`}
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
onClick={click}
>
<Icon type={iconType} />
<Icon type={icon} />
</button>
);
};

View File

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

View File

@@ -3,19 +3,21 @@ import { toDirective } from './utils';
import NotesIcon from '../../icons/il-notes.svg';
import { observer } from 'mobx-react-lite';
import { NotesOptionsPanel } from './NotesOptionsPanel';
import { WebApplication } from '@/ui_models/application';
type Props = {
application: WebApplication;
appState: AppState;
};
const MultipleSelectedNotes = observer(({ appState }: Props) => {
const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount;
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<NotesOptionsPanel appState={appState} />
<NotesOptionsPanel application={application} appState={appState} />
</div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<NotesIcon className="block" />

View File

@@ -3,12 +3,14 @@ import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
type Props = {
application: WebApplication;
appState: AppState;
};
const NotesContextMenu = observer(({ appState }: Props) => {
const NotesContextMenu = observer(({ application, appState }: Props) => {
const {
contextMenuOpen,
contextMenuPosition,
@@ -46,7 +48,11 @@ const NotesContextMenu = observer(({ appState }: Props) => {
maxHeight: contextMenuMaxHeight,
}}
>
<NotesOptions appState={appState} closeOnBlur={closeOnBlur} />
<NotesOptions
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
/>
</div>
) : null;
});

View File

@@ -9,15 +9,33 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import { SNNote } from '@standardnotes/snjs/dist/@types';
import { WebApplication } from '@/ui_models/application';
import { KeyboardModifier } from '@/services/ioService';
type Props = {
application: WebApplication;
appState: AppState;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
onSubmenuChange?: (submenuOpen: boolean) => void;
};
type DeletePermanentlyButtonProps = {
closeOnBlur: Props["closeOnBlur"];
onClick: () => void;
}
const DeletePermanentlyButton = ({
closeOnBlur,
onClick,
}: DeletePermanentlyButtonProps) => (
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}>
<Icon type="close" className="color-danger mr-2" />
<span className="color-danger">Delete permanently</span>
</button>
);
export const NotesOptions = observer(
({ appState, closeOnBlur, onSubmenuChange }: Props) => {
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => {
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
const [tagsMenuPosition, setTagsMenuPosition] = useState<{
top: number;
@@ -29,6 +47,7 @@ export const NotesOptions = observer(
});
const [tagsMenuMaxHeight, setTagsMenuMaxHeight] =
useState<number | 'auto'>('auto');
const [altKeyDown, setAltKeyDown] = useState(false);
const toggleOn = (condition: (note: SNNote) => boolean) => {
const notesMatchingAttribute = notes.filter(condition);
@@ -59,6 +78,22 @@ export const NotesOptions = observer(
}
}, [tagsMenuOpen, onSubmenuChange]);
useEffect(() => {
const removeAltKeyObserver = application.io.addKeyObserver({
modifiers: [KeyboardModifier.Alt],
onKeyDown: () => {
setAltKeyDown(true);
},
onKeyUp: () => {
setAltKeyDown(false);
}
});
return () => {
removeAltKeyObserver();
};
}, [application]);
const openTagsMenu = () => {
const defaultFontSize = window.getComputedStyle(
document.documentElement
@@ -235,18 +270,26 @@ export const NotesOptions = observer(
Unarchive
</button>
)}
{notTrashed && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(true);
}}
>
<Icon type="trash" className={iconClass} />
Move to Trash
</button>
)}
{notTrashed &&
(altKeyDown ? (
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await appState.notes.deleteNotesPermanently();
}}
/>
) : (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(true);
}}
>
<Icon type="trash" className={iconClass} />
Move to Trash
</button>
))}
{trashed && (
<>
<button
@@ -259,16 +302,12 @@ export const NotesOptions = observer(
<Icon type="restore" className={iconClass} />
Restore
</button>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await appState.notes.deleteNotesPermanently();
}}
>
<Icon type="close" className="color-danger mr-2" />
<span className="color-danger">Delete permanently</span>
</button>
/>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"

View File

@@ -10,12 +10,14 @@ import {
import { useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { WebApplication } from '@/ui_models/application';
type Props = {
application: WebApplication;
appState: AppState;
};
export const NotesOptionsPanel = observer(({ appState }: Props) => {
export const NotesOptionsPanel = observer(({ application, appState }: Props) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
@@ -76,6 +78,7 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
>
{open && (
<NotesOptions
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}

View File

@@ -1,23 +0,0 @@
import { Icon, IconType } from '@/components/Icon';
import { FunctionComponent } from 'preact';
interface PreferencesMenuItemProps {
iconType: IconType;
label: string;
selected: boolean;
onClick: () => void;
}
export const PreferencesMenuItem: FunctionComponent<PreferencesMenuItemProps> =
({ iconType, label, selected, onClick }) => (
<div
className={`preferences-menu-item ${selected ? 'selected' : ''}`}
onClick={(e) => {
e.preventDefault();
onClick();
}}
>
<Icon className="icon" type={iconType} />
{label}
</div>
);

View File

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

View File

@@ -12,7 +12,7 @@ export type SwitchProps = HTMLProps<HTMLInputElement> & {
checked?: boolean;
onChange: (checked: boolean) => void;
className?: string;
children: ComponentChildren;
children?: ComponentChildren;
};
export const Switch: FunctionalComponent<SwitchProps> = (
@@ -22,7 +22,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
const checked = props.checked ?? checkedState;
const className = props.className ?? '';
return (
<label className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}>
<label
className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}
>
{props.children}
<CustomCheckboxContainer
checked={checked}

View File

@@ -1,28 +0,0 @@
import { FunctionalComponent } from 'preact';
export const Title: FunctionalComponent = ({ children }) => (
<h2 className="text-base m-0 mb-3">{children}</h2>
);
export const Subtitle: FunctionalComponent = ({ children }) => (
<h4 className="font-medium text-sm m-0 mb-1">{children}</h4>
);
export const Text: FunctionalComponent = ({ children }) => (
<p className="text-xs">{children}</p>
);
export const Button: FunctionalComponent<{ label: string; link: string }> = ({
label,
link,
}) => (
<a
target="_blank"
className="block bg-default color-text rounded border-solid border-1
border-gray-300 px-4 py-2 font-bold text-sm fit-content mt-3
focus:bg-contrast hover:bg-contrast "
href={link}
>
{label}
</a>
);

View File

@@ -1,22 +0,0 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { toDirective } from '../utils';
import { PreferencesView } from './view';
interface WrapperProps {
appState: { preferences: { isOpen: boolean; closePreferences: () => void } };
}
const PreferencesViewWrapper: FunctionComponent<WrapperProps> = observer(
({ appState }) => {
if (!appState.preferences.isOpen) return null;
return (
<PreferencesView close={() => appState.preferences.closePreferences()} />
);
}
);
export const PreferencesDirective = toDirective<WrapperProps>(
PreferencesViewWrapper
);

View File

@@ -1,23 +0,0 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { PreferencesMenuItem } from '../PreferencesMenuItem';
import { Preferences } from './preferences';
interface PreferencesMenuProps {
preferences: Preferences;
}
export const PreferencesMenu: FunctionComponent<PreferencesMenuProps> =
observer(({ preferences }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{preferences.items.map((pref) => (
<PreferencesMenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
onClick={() => preferences.selectItem(pref.id)}
/>
))}
</div>
));

View File

@@ -1,33 +0,0 @@
import { FunctionalComponent } from 'preact';
const HorizontalLine: FunctionalComponent<{ index: number; length: number }> =
({ index, length }) =>
index < length - 1 ? (
<hr className="h-1px w-full bg-border no-border" />
) : null;
export const PreferencesSegment: FunctionalComponent = ({ children }) => (
<div>{children}</div>
);
export const PreferencesGroup: FunctionalComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
{!Array.isArray(children)
? children
: children.map((c, i, arr) => (
<>
{c}
<HorizontalLine index={i} length={arr.length} />
</>
))}
</div>
);
export const PreferencesPane: FunctionalComponent = ({ children }) => (
<div className="preferences-pane flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center">
<div className="max-w-124 flex flex-col gap-3">{children}</div>
</div>
<div className="flex-basis-55 flex-shrink-max" />
</div>
);

View File

@@ -1,55 +0,0 @@
import { IconType } from '@/components/Icon';
import { action, computed, makeObservable, observable } from 'mobx';
interface PreferenceItem {
icon: IconType;
label: string;
}
interface PreferenceListItem extends PreferenceItem {
id: number;
}
const predefinedItems: PreferenceItem[] = [
{ label: 'General', icon: 'settings' },
{ label: 'Account', icon: 'user' },
{ label: 'Appearance', icon: 'themes' },
{ label: 'Security', icon: 'security' },
{ label: 'Listed', icon: 'listed' },
{ label: 'Shortcuts', icon: 'keyboard' },
{ label: 'Accessibility', icon: 'accessibility' },
{ label: 'Get a free month', icon: 'star' },
{ label: 'Help & feedback', icon: 'help' },
];
export class Preferences {
private readonly _items: PreferenceListItem[];
private _selectedId = 0;
constructor(items: PreferenceItem[] = predefinedItems) {
makeObservable<Preferences, '_selectedId'>(this, {
_selectedId: observable,
selectedItem: computed,
items: computed,
selectItem: action,
});
this._items = items.map((p, idx) => ({ ...p, id: idx }));
this._selectedId = this._items[0].id;
}
selectItem(id: number) {
this._selectedId = id;
}
get items(): (PreferenceListItem & { selected: boolean })[] {
return this._items.map((p) => ({
...p,
selected: p.id === this._selectedId,
}));
}
get selectedItem(): PreferenceListItem {
return this._items.find((item) => item.id === this._selectedId)!;
}
}

View File

@@ -1,45 +0,0 @@
import { IconButton } from '@/components/IconButton';
import { TitleBar, Title } from '@/components/TitleBar';
import { FunctionComponent } from 'preact';
import { Preferences } from './preferences';
import { PreferencesMenu } from './menu';
import { HelpAndFeedback } from './help-feedback';
import { observer } from 'mobx-react-lite';
interface PreferencesViewProps {
close: () => void;
}
export const PreferencesCanvas: FunctionComponent<{
preferences: Preferences;
}> = observer(({ preferences: prefs }) => (
<div className="flex flex-row flex-grow min-h-0 justify-between">
<PreferencesMenu preferences={prefs}></PreferencesMenu>
{/* Temporary selector until a full solution is implemented */}
{prefs.selectedItem.label === 'Help & feedback' ? (
<HelpAndFeedback />
) : null}
</div>
));
export const PreferencesView: FunctionComponent<PreferencesViewProps> =
observer(({ close }) => {
const prefs = new Preferences();
return (
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
<TitleBar className="items-center justify-between">
{/* div is added so flex justify-between can center the title */}
<div className="h-8 w-8" />
<Title className="text-lg">Your preferences for Standard Notes</Title>
<IconButton
onClick={() => {
close();
}}
type="normal"
iconType="close"
/>
</TitleBar>
<PreferencesCanvas preferences={prefs} />
</div>
);
});

View File

@@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application';
import { WebDirective } from './../../types';
import template from '%/directives/actions-menu.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { SNItem, Action, SNActionsExtension, UuidString } from '@standardnotes/snjs';
import { SNItem, Action, SNActionsExtension, UuidString, CopyPayload } from '@standardnotes/snjs';
import { ActionResponse } from '@standardnotes/snjs';
import { ActionsExtensionMutator } from '@standardnotes/snjs';
@@ -27,7 +27,7 @@ type ActionsMenuState = {
extensions: SNActionsExtension[]
extensionsState: Record<UuidString, ExtensionState>
selectedActionId?: number
menu: {
menuItems: {
uuid: UuidString,
name: string,
loading: boolean,
@@ -57,7 +57,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
});
this.loadExtensions();
this.autorun(() => {
this.rebuildMenu({
this.rebuildMenuState({
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions
});
});
@@ -65,13 +65,20 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
/** @override */
getInitialState() {
const extensions = this.application.actionsManager!.getExtensions().sort((a, b) => {
const extensions = this.application.actionsManager.getExtensions().sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
}).map((extension) => {
return new SNActionsExtension(CopyPayload(extension.payload, {
content: {
...extension.payload.safeContent,
actions: []
}
}));
});
const extensionsState: Record<UuidString, ExtensionState> = {};
extensions.map((extension) => {
extensionsState[extension.uuid] = {
loading: false,
loading: true,
error: false,
};
});
@@ -79,11 +86,11 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
extensions,
extensionsState,
hiddenExtensions: {},
menu: [],
menuItems: [],
};
}
rebuildMenu({
rebuildMenuState({
extensions = this.state.extensions,
extensionsState = this.state.extensionsState,
selectedActionId = this.state.selectedActionId,
@@ -93,7 +100,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
extensions,
extensionsState,
selectedActionId,
menu: extensions.map(extension => {
menuItems: extensions.map(extension => {
const state = extensionsState[extension.uuid];
const hidden = hiddenExtensions[extension.uuid];
return {
@@ -136,7 +143,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
async executeAction(action: Action, extensionUuid: UuidString) {
if (action.verb === 'nested') {
this.rebuildMenu({
this.rebuildMenuState({
selectedActionId: action.id
});
return;
@@ -220,7 +227,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
}
return ext;
});
await this.rebuildMenu({
await this.rebuildMenuState({
extensions
});
}
@@ -236,7 +243,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
}
return ext;
});
this.rebuildMenu({
this.rebuildMenuState({
extensions
});
}
@@ -248,7 +255,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
private setLoadingExtension(extensionUuid: UuidString, value = false) {
const { extensionsState } = this.state;
extensionsState[extensionUuid].loading = value;
this.rebuildMenu({
this.rebuildMenuState({
extensionsState
});
}
@@ -256,7 +263,7 @@ class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements
private setErrorExtension(extensionUuid: UuidString, value = false) {
const { extensionsState } = this.state;
extensionsState[extensionUuid].error = value;
this.rebuildMenu({
this.rebuildMenuState({
extensionsState
});
}

View File

@@ -0,0 +1,20 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { MenuItem } from './components';
import { PreferencesMenu } from './preferences-menu';
export const PreferencesMenuView: FunctionComponent<{
menu: PreferencesMenu;
}> = observer(({ menu }) => (
<div className="min-w-55 overflow-y-auto flex flex-col px-3 py-6">
{menu.menuItems.map((pref) => (
<MenuItem
key={pref.id}
iconType={pref.icon}
label={pref.label}
selected={pref.selected}
onClick={() => menu.selectPane(pref.id)}
/>
))}
</div>
));

View File

@@ -0,0 +1,87 @@
import { RoundIconButton } from '@/components/RoundIconButton';
import { TitleBar, Title } from '@/components/TitleBar';
import { FunctionComponent } from 'preact';
import { AccountPreferences, HelpAndFeedback, Security } from './panes';
import { observer } from 'mobx-react-lite';
import { PreferencesMenu } from './preferences-menu';
import { PreferencesMenuView } from './PreferencesMenuView';
import { WebApplication } from '@/ui_models/application';
const PaneSelector: FunctionComponent<{
prefs: PreferencesMenu;
application: WebApplication;
}> = observer(({ prefs: menu, application }) => {
switch (menu.selectedPaneId) {
case 'general':
return null;
case 'account':
return <AccountPreferences application={application} />;
case 'appearance':
return null;
case 'security':
return <Security />;
case 'listed':
return null;
case 'shortcuts':
return null;
case 'accessibility':
return null;
case 'get-free-month':
return null;
case 'help-feedback':
return <HelpAndFeedback />;
}
});
const PreferencesCanvas: FunctionComponent<{
preferences: PreferencesMenu;
application: WebApplication;
}> = observer(({ preferences: prefs, application }) => (
<div className="flex flex-row flex-grow min-h-0 justify-between">
<PreferencesMenuView menu={prefs}></PreferencesMenuView>
<PaneSelector prefs={prefs} application={application} />
</div>
));
const PreferencesView: FunctionComponent<{
close: () => void;
application: WebApplication;
}> = observer(
({ close, application }) => {
const prefs = new PreferencesMenu();
return (
<div className="sn-full-screen flex flex-col bg-contrast z-index-preferences">
<TitleBar className="items-center justify-between">
{/* div is added so flex justify-between can center the title */}
<div className="h-8 w-8" />
<Title className="text-lg">Your preferences for Standard Notes</Title>
<RoundIconButton
onClick={() => {
close();
}}
type="normal"
icon="close"
/>
</TitleBar>
<PreferencesCanvas preferences={prefs} application={application} />
</div>
);
}
);
export interface PreferencesWrapperProps {
appState: { preferences: { isOpen: boolean; closePreferences: () => void } };
application: WebApplication;
}
export const PreferencesViewWrapper: FunctionComponent<PreferencesWrapperProps> =
observer(({ appState, application }) => {
if (!appState.preferences.isOpen) return null;
return (
<PreferencesView
application={application}
close={() => appState.preferences.closePreferences()}
/>
);
});

View File

@@ -0,0 +1,26 @@
import { FunctionComponent } from 'preact';
export const Title: FunctionComponent = ({ children }) => (
<h2 className="text-base m-0 mb-1">{children}</h2>
);
export const Subtitle: FunctionComponent = ({ children }) => (
<h4 className="font-medium text-sm m-0 mb-1">{children}</h4>
);
export const Text: FunctionComponent = ({ children }) => (
<p className="text-xs">{children}</p>
);
const buttonClasses = `block bg-default color-text rounded border-solid \
border-1 border-gray-300 px-4 py-1.75 font-bold text-sm fit-content mt-3 \
focus:bg-contrast hover:bg-contrast `;
export const LinkButton: FunctionComponent<{ label: string; link: string }> = ({
label,
link,
}) => (
<a target="_blank" className={buttonClasses} href={link}>
{label}
</a>
);

View File

@@ -0,0 +1,27 @@
import { Icon, IconType } from '@/components/Icon';
import { FunctionComponent } from 'preact';
interface Props {
iconType: IconType;
label: string;
selected: boolean;
onClick: () => void;
}
export const MenuItem: FunctionComponent<Props> = ({
iconType,
label,
selected,
onClick,
}) => (
<div
className={`preferences-menu-item ${selected ? 'selected' : ''}`}
onClick={(e) => {
e.preventDefault();
onClick();
}}
>
<Icon className="icon" type={iconType} />
{label}
</div>
);

View File

@@ -0,0 +1,35 @@
import { FunctionComponent } from 'preact';
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
index,
length,
}) =>
index < length - 1 ? (
<hr className="h-1px w-full bg-border no-border" />
) : null;
export const PreferencesSegment: FunctionComponent = ({ children }) => (
<div className="flex flex-col">{children}</div>
);
export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
{!Array.isArray(children)
? children
: children.map((c, i, arr) => (
<>
{c}
<HorizontalLine index={i} length={arr.length} />
</>
))}
</div>
);
export const PreferencesPane: FunctionComponent = ({ children }) => (
<div className="preferences-pane flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center">
<div className="w-125 max-w-125 flex flex-col gap-3">{children}</div>
</div>
<div className="flex-basis-55 flex-shrink" />
</div>
);

View File

@@ -0,0 +1,3 @@
export * from './Content';
export * from './MenuItem';
export * from './Pane';

View File

@@ -0,0 +1,9 @@
import { toDirective } from '../components/utils';
import {
PreferencesViewWrapper,
PreferencesWrapperProps,
} from './PreferencesView';
export const PreferencesDirective = toDirective<PreferencesWrapperProps>(
PreferencesViewWrapper
);

View File

@@ -0,0 +1,12 @@
import { Sync } from '@/preferences/panes/account';
import { PreferencesPane } from '@/preferences/components';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
export const AccountPreferences = observer(({application}: {application: WebApplication}) => {
return (
<PreferencesPane>
<Sync application={application} />
</PreferencesPane>
);
});

View File

@@ -1,12 +1,20 @@
import { FunctionalComponent } from 'preact';
import { PreferencesGroup, PreferencesPane, PreferencesSegment } from './pane';
import { Title, Subtitle, Text, Button } from './content';
import { FunctionComponent } from 'preact';
import {
Title,
Subtitle,
Text,
LinkButton,
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
} from '../components';
export const HelpAndFeedback: FunctionalComponent = () => (
export const HelpAndFeedback: FunctionComponent = () => (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Frequently asked questions</Title>
<div className="h-2 w-full" />
<Subtitle>Who can read my private notes?</Subtitle>
<Text>
Quite simply: no one but you. Not us, not your ISP, not a hacker, and
@@ -44,7 +52,7 @@ export const HelpAndFeedback: FunctionalComponent = () => (
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>Cant find your question here?</Subtitle>
<Button label="Open FAQ" link="https://standardnotes.com/help" />
<LinkButton label="Open FAQ" link="https://standardnotes.com/help" />
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
@@ -59,7 +67,7 @@ export const HelpAndFeedback: FunctionalComponent = () => (
</a>{' '}
before advocating for a feature request.
</Text>
<Button
<LinkButton
label="Go to the forum"
link="https://forum.standardnotes.org/"
/>
@@ -73,7 +81,7 @@ export const HelpAndFeedback: FunctionalComponent = () => (
Want to share your feedback with us? Join the Standard Notes Slack
group for discussions on security, themes, editors and more.
</Text>
<Button
<LinkButton
link="https://standardnotes.com/slack"
label="Join our Slack group"
/>
@@ -85,7 +93,7 @@ export const HelpAndFeedback: FunctionalComponent = () => (
<Text>
Send an email to help@standardnotes.org and well sort it out.
</Text>
<Button link="mailto: help@standardnotes.org" label="Email us" />
<LinkButton link="mailto: help@standardnotes.org" label="Email us" />
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>

View File

@@ -0,0 +1,9 @@
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { TwoFactorAuthWrapper } from './two-factor-auth';
export const Security: FunctionComponent = () => (
<PreferencesPane>
<TwoFactorAuthWrapper />
</PreferencesPane>
);

View File

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

View File

@@ -0,0 +1 @@
export { default as Sync } from './Sync';

View File

@@ -0,0 +1,3 @@
export * from './HelpFeedback';
export * from './Security';
export * from './AccountPreferences';

View File

@@ -0,0 +1,59 @@
import { Icon, IconType } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import { useState, useRef, useEffect } from 'react';
const DisclosureIconButton: FunctionComponent<{
className?: string;
icon: IconType;
}> = ({ className = '', icon }) => (
<DisclosureButton
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
className ?? ''
}`}
>
<Icon type={icon} />
</DisclosureButton>
);
/**
* AuthAppInfoPopup is an info icon that shows a tooltip when clicked
* Tooltip is dismissible by clicking outside
*
* Note: it can be generalized but more use cases are required
* @returns
*/
export const AuthAppInfoTooltip: FunctionComponent = () => {
const [isShown, setShown] = useState(false);
const ref = useRef(null);
useEffect(() => {
const dismiss = () => setShown(false);
document.addEventListener('mousedown', dismiss);
return () => {
document.removeEventListener('mousedown', dismiss);
};
}, [ref]);
return (
<Disclosure open={isShown} onChange={() => setShown(!isShown)}>
<div className="relative">
<DisclosureIconButton icon="info" className="mt-1" />
<DisclosurePanel>
<div
className={`bg-black color-white text-center rounded shadow-overlay
py-1.5 px-2 absolute w-103 -top-10 -left-51`}
>
Some apps, like Google Authenticator, do not back up and restore
your secret keys if you lose your device or get a new one.
</div>
</DisclosurePanel>
</div>
</Disclosure>
);
};

View File

@@ -0,0 +1,90 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
export const SaveSecretKey: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(act.secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 2 of 3 - Save secret key
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex-grow flex flex-col gap-2">
<div className="flex flex-row items-center gap-1">
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<DecoratedInput
disabled={true}
right={[copy, download]}
text={act.secretKey}
/>
</div>
<div className="text-sm">
You can use this key to generate codes if you lose access to your
authenticator app.{' '}
<a
target="_blank"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
</a>
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openScanQRCode()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openVerification()}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,78 @@
import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import { DecoratedInput } from '../../../components/DecoratedInput';
import { IconButton } from '../../../components/IconButton';
import { Button } from '@/components/Button';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
import { AuthAppInfoTooltip } from './AuthAppInfoPopup';
export const ScanQRCode: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(act.secretKey);
}}
/>
);
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 1 of 3 - Scan QR code
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex flex-row gap-3 items-center">
<div className="w-25 h-25 flex items-center justify-center bg-info">
QR code
</div>
<div className="flex-grow flex flex-col gap-2">
<div className="flex flex-row gap-1 items-center">
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<AuthAppInfoTooltip />
</div>
<div className="flex flex-row items-center">
<div className="text-sm flex-grow">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
<div className="w-56">
<DecoratedInput
disabled={true}
text={act.secretKey}
right={[copy]}
/>
</div>
</div>
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
<Button
className="min-w-20"
type="normal"
label="Cancel"
onClick={() => act.cancelActivation()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.openSaveSecretKey()}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,18 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import { SaveSecretKey } from './SaveSecretKey';
import { ScanQRCode } from './ScanQRCode';
import { Verification } from './Verification';
export const TwoFactorActivationView: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => (
<>
{act.step === 'scan-qr-code' && <ScanQRCode activation={act} />}
{act.step === 'save-secret-key' && <SaveSecretKey activation={act} />}
{act.step === 'verification' && <Verification activation={act} />}
</>
));

View File

@@ -0,0 +1,53 @@
import { FunctionComponent } from 'preact';
import {
Title,
Text,
PreferencesGroup,
PreferencesSegment,
} from '../../components';
import { Switch } from '../../../components/Switch';
import { observer } from 'mobx-react-lite';
import {
is2FAActivation,
is2FADisabled,
is2FAEnabled,
TwoFactorAuth,
} from './model';
import { TwoFactorDisabledView } from './TwoFactorDisabledView';
import { TwoFactorEnabledView } from './TwoFactorEnabledView';
import { TwoFactorActivationView } from './TwoFactorActivationView';
export const TwoFactorAuthView: FunctionComponent<{
auth: TwoFactorAuth;
}> = observer(({ auth }) => (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex-grow flex flex-col">
<Title>Two-factor authentication</Title>
<Text>
An extra layer of security when logging in to your account.
</Text>
</div>
<Switch
checked={!is2FADisabled(auth.status)}
onChange={() => auth.toggle2FA()}
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
{is2FAEnabled(auth.status) && (
<TwoFactorEnabledView
secretKey={auth.status.secretKey}
authCode={auth.status.authCode}
/>
)}
{is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
{!is2FAEnabled(auth.status) && <TwoFactorDisabledView />}
</PreferencesSegment>
</PreferencesGroup>
));

View File

@@ -0,0 +1,64 @@
import { ComponentChildren, FunctionComponent } from 'preact';
import { IconButton } from '../../../components/IconButton';
import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
import { useRef } from 'preact/hooks';
/**
* TwoFactorDialog is AlertDialog styled for 2FA
* Can be generalized but more use cases are needed
*/
export const TwoFactorDialog: FunctionComponent<{
children: ComponentChildren;
}> = ({ children }) => {
const ldRef = useRef<HTMLButtonElement>();
return (
<AlertDialog leastDestructiveRef={ldRef}>
{/* sn-component is focusable by default, but doesn't stretch to child width
resulting in a badly focused dialog. Utility classes are not available
at the sn-component level, only below it. tabIndex -1 disables focus
and enables it on the child component */}
<div tabIndex={-1} className="sn-component">
<div
tabIndex={0}
className="w-160 bg-default rounded shadow-overlay focus:padded-ring-info"
>
{children}
</div>
</div>
</AlertDialog>
);
};
export const TwoFactorDialogLabel: FunctionComponent<{
closeDialog: () => void;
}> = ({ children, closeDialog }) => (
<AlertDialogLabel className="">
<div className="px-4 pt-4 pb-3 flex flex-row">
<div className="flex-grow color-black text-lg font-bold">{children}</div>
<IconButton
className="color-grey-1 h-5 w-5"
icon="close"
onClick={() => closeDialog()}
/>
</div>
<hr className="h-1px bg-border no-border m-0" />
</AlertDialogLabel>
);
export const TwoFactorDialogDescription: FunctionComponent = ({ children }) => (
<AlertDialogDescription className="px-4 py-4">
{children}
</AlertDialogDescription>
);
export const TwoFactorDialogButtons: FunctionComponent = ({ children }) => (
<>
<hr className="h-1px bg-border no-border m-0" />
<div className="px-4 py-4 flex flex-row justify-end gap-3">{children}</div>
</>
);

View File

@@ -0,0 +1,14 @@
import { Text } from '../../components';
import { FunctionComponent } from 'preact';
export const TwoFactorDisabledView: FunctionComponent = () => (
<Text>
Enabling two-factor authentication will sign you out of all other sessions.{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
Learn more
</a>
</Text>
);

View File

@@ -0,0 +1,45 @@
import { CircleProgressTime } from '@/components/CircleProgressTime';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { Text } from '../../components';
export const TwoFactorEnabledView: FunctionComponent<{
secretKey: string;
authCode: string;
}> = ({ secretKey, authCode }) => {
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(secretKey);
}}
/>
);
const progress = <CircleProgressTime time={30000} />;
return (
<div className="flex flex-row gap-4">
<div className="flex-grow flex flex-col">
<Text>Secret Key</Text>
<DecoratedInput
disabled={true}
right={[copy, download]}
text={secretKey}
/>
</div>
<div className="w-30 flex flex-col">
<Text>Authentication Code</Text>
<DecoratedInput disabled={true} text={authCode} right={[progress]} />
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
TwoFactorDialogDescription,
TwoFactorDialogButtons,
} from './TwoFactorDialog';
export const Verification: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => {
const borderInv =
act.verificationStatus === 'invalid' ? 'border-dark-red' : '';
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
Step 3 of 3 - Verification
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
<div className="flex-grow flex flex-col gap-1">
<div className="flex flex-row items-center gap-2">
<div className="text-sm">
Enter your <b>secret key</b>:
</div>
<DecoratedInput className={borderInv} />
</div>
<div className="flex flex-row items-center gap-2">
<div className="text-sm">
Verify the <b>authentication code</b> generated by your
authenticator app:
</div>
<DecoratedInput className={`w-30 ${borderInv}`} />
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
{act.verificationStatus === 'invalid' && (
<div className="text-sm color-dark-red">
Incorrect credentials, please try again.
</div>
)}
<Button
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openSaveSecretKey()}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.enable2FA('X', 'X')}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>
);
});

View File

@@ -0,0 +1,13 @@
// Temporary implementation until integration
export function downloadSecretKey(text: string) {
const link = document.createElement('a');
const blob = new Blob([text], {
type: 'text/plain;charset=utf-8',
});
link.href = window.URL.createObjectURL(blob);
link.setAttribute('download', 'standardnotes_2fa_key.txt');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(link.href);
}

View File

@@ -0,0 +1,10 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { TwoFactorAuth } from './model';
import { TwoFactorAuthView } from './TwoFactorAuthView';
export const TwoFactorAuthWrapper: FunctionComponent = () => {
const [auth] = useState(() => new TwoFactorAuth());
return <TwoFactorAuthView auth={auth} />;
};

View File

@@ -0,0 +1,168 @@
import { action, makeAutoObservable, observable, untracked } from 'mobx';
function getNewAuthCode() {
const MIN = 100000;
const MAX = 999999;
const code = Math.floor(Math.random() * (MAX - MIN) + MIN);
return code.toString();
}
const activationSteps = [
'scan-qr-code',
'save-secret-key',
'verification',
] as const;
type ActivationStep = typeof activationSteps[number];
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const;
private _step: ActivationStep;
private _secretKey: string;
private _authCode: string;
private _2FAVerification: 'none' | 'invalid' | 'valid' = 'none';
constructor(
private _cancelActivation: () => void,
private _enable2FA: (secretKey: string) => void
) {
this._secretKey = 'FHJJSAJKDASKW43KJS';
this._authCode = getNewAuthCode();
this._step = 'scan-qr-code';
makeAutoObservable<
TwoFactorActivation,
'_secretKey' | '_authCode' | '_step' | '_enable2FAVerification'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
},
{ autoBind: true }
);
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
get step() {
return this._step;
}
get verificationStatus() {
return this._2FAVerification;
}
cancelActivation() {
this._cancelActivation();
}
openScanQRCode() {
this._step = 'scan-qr-code';
}
openSaveSecretKey() {
this._step = 'save-secret-key';
}
openVerification() {
this._step = 'verification';
this._2FAVerification = 'none';
}
enable2FA(secretKey: string, authCode: string) {
if (secretKey === this._secretKey && authCode === this._authCode) {
this._2FAVerification = 'valid';
this._enable2FA(secretKey);
return;
}
// Change to invalid upon implementation
this._2FAVerification = 'valid';
// Remove after implementation
this._enable2FA(secretKey);
}
}
export class TwoFactorEnabled {
public readonly type = 'two-factor-enabled' as const;
private _secretKey: string;
private _authCode: string;
constructor(secretKey: string) {
this._secretKey = secretKey;
this._authCode = getNewAuthCode();
makeAutoObservable<TwoFactorEnabled, '_secretKey' | '_authCode'>(this, {
_secretKey: observable,
_authCode: observable,
});
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
refreshAuthCode() {
this._authCode = getNewAuthCode();
}
}
type TwoFactorStatus =
| TwoFactorEnabled
| TwoFactorActivation
| 'two-factor-disabled';
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
s === 'two-factor-disabled';
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
(s as any).type === 'two-factor-activation';
export const is2FAEnabled = (s: TwoFactorStatus): s is TwoFactorEnabled =>
(s as any).type === 'two-factor-enabled';
export class TwoFactorAuth {
private _status: TwoFactorStatus = 'two-factor-disabled';
constructor() {
makeAutoObservable<TwoFactorAuth, '_status'>(this, {
_status: observable,
});
}
private startActivation() {
const cancel = action(() => (this._status = 'two-factor-disabled'));
const enable = action(
(secretKey: string) => (this._status = new TwoFactorEnabled(secretKey))
);
this._status = new TwoFactorActivation(cancel, enable);
}
private deactivate2FA() {
this._status = 'two-factor-disabled';
}
toggle2FA() {
if (this._status === 'two-factor-disabled') this.startActivation();
else this.deactivate2FA();
}
get status() {
return this._status;
}
}

View File

@@ -0,0 +1,71 @@
import { IconType } from '@/components/Icon';
import { makeAutoObservable, observable } from 'mobx';
const PREFERENCE_IDS = [
'general',
'account',
'appearance',
'security',
'listed',
'shortcuts',
'accessibility',
'get-free-month',
'help-feedback',
] as const;
export type PreferenceId = typeof PREFERENCE_IDS[number];
interface PreferencesMenuItem {
readonly id: PreferenceId;
readonly icon: IconType;
readonly label: string;
}
/**
* Items are in order of appearance
*/
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
{ id: 'get-free-month', label: 'Get a free month', icon: 'star' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
];
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'general';
constructor(
private readonly _menu: PreferencesMenuItem[] = PREFERENCES_MENU_ITEMS
) {
makeAutoObservable<PreferencesMenu, '_selectedPane' | '_twoFactorAuth'>(
this,
{
_twoFactorAuth: observable,
_selectedPane: observable,
}
);
}
get menuItems(): (PreferencesMenuItem & {
selected: boolean;
})[] {
return this._menu.map((p) => ({
...p,
selected: p.id === this._selectedPane,
}));
}
get selectedPaneId(): PreferenceId {
return (
this._menu.find((item) => item.id === this._selectedPane)?.id ?? 'general'
);
}
selectPane(key: PreferenceId) {
this._selectedPane = key;
}
}

View File

@@ -56,7 +56,8 @@ export function StringEmptyTrash(count: number) {
/** @account */
export const STRING_ACCOUNT_MENU_UNCHECK_MERGE =
'Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?';
export const STRING_SIGN_OUT_CONFIRMATION = 'This will delete all local items and extensions.';
export const STRING_SIGN_OUT_CONFIRMATION =
'This will delete all local items and extensions.';
export const STRING_ERROR_DECRYPTING_IMPORT =
'There was an error decrypting your items. Make sure the password you entered is correct and try again.';
export const STRING_E2E_ENABLED =
@@ -111,7 +112,8 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT =
export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade';
export const Strings = {
protectingNoteWithoutProtectionSources: 'Access to this note will not be restricted until you set up a passcode or account.',
protectingNoteWithoutProtectionSources:
'Access to this note will not be restricted until you set up a passcode or account.',
openAccountMenu: 'Open Account Menu',
trashNotesTitle: 'Move to Trash',
trashNotesText: 'Are you sure you want to move these notes to the trash?',
@@ -157,4 +159,4 @@ export const StringUtils = {
? "This note has editing disabled. If you'd like to delete it, enable editing, and try again."
: "One or more of these notes have editing disabled. If you'd like to delete them, make sure editing is enabled on all of them, and try again.";
},
};
};

View File

@@ -28,6 +28,7 @@
)
preferences(
app-state='self.appState'
application='self.application'
)
challenge-modal(
ng-repeat="challenge in self.challenges track by challenge.id"
@@ -37,6 +38,7 @@
on-dismiss="self.removeChallenge(challenge)"
)
notes-context-menu(
application='self.application'
app-state='self.appState'
)

View File

@@ -1,6 +1,6 @@
#editor-column.section.editor.sn-component(aria-label='Note')
protected-note-panel.h-full.flex.justify-center.items-center(
ng-if='self.appState.notes.showProtectedWarning'
ng-if='self.state.showProtectedWarning'
app-state='self.appState'
on-view-note='self.dismissProtectedWarning()'
)
@@ -49,6 +49,7 @@
) {{self.state.noteStatus.message}}
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
notes-options-panel(
application='self.application',
app-state='self.appState',
ng-if='self.appState.notes.selectedNotesCount > 0'
)

View File

@@ -64,7 +64,6 @@ type EditorState = {
showOptionsMenu: boolean;
showEditorMenu: boolean;
showHistoryMenu: boolean;
altKeyDown: boolean;
spellcheck: boolean;
/**
* Setting to false then true will allow the current editor component-view to be destroyed
@@ -74,6 +73,7 @@ type EditorState = {
/** Setting to true then false will allow the main content textarea to be destroyed
* then re-initialized. Used when reloading spellcheck status. */
textareaUnloading: boolean;
showProtectedWarning: boolean;
};
type EditorValues = {
@@ -106,7 +106,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
onEditorLoad?: () => void;
private scrollPosition = 0;
private removeAltKeyObserver?: any;
private removeTrashKeyObserver?: any;
private removeTabObserver?: any;
@@ -143,8 +142,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.editor.clearNoteChangeListener();
this.removeComponentsObserver();
(this.removeComponentsObserver as any) = undefined;
this.removeAltKeyObserver();
this.removeAltKeyObserver = undefined;
this.removeTrashKeyObserver();
this.removeTrashKeyObserver = undefined;
this.removeTabObserver && this.removeTabObserver();
@@ -203,6 +200,11 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
}
});
this.autorun(() => {
this.setState({
showProtectedWarning: this.appState.notes.showProtectedWarning
});
});
}
/** @override */
@@ -217,10 +219,10 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
showOptionsMenu: false,
showEditorMenu: false,
showHistoryMenu: false,
altKeyDown: false,
noteStatus: undefined,
editorUnloading: false,
textareaUnloading: false,
showProtectedWarning: false,
} as EditorState;
}
@@ -277,7 +279,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
showOptionsMenu: false,
showEditorMenu: false,
showHistoryMenu: false,
altKeyDown: false,
noteStatus: undefined,
});
this.editorValues.title = note.title;
@@ -613,7 +614,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
setShowProtectedWarning(show: boolean) {
this.application.getAppState().notes.setShowProtectedWarning(show);
this.appState.notes.setShowProtectedWarning(show);
}
async deleteNote(permanently: boolean) {
@@ -843,22 +844,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
}
registerKeyboardShortcuts() {
this.removeAltKeyObserver = this.application
.io
.addKeyObserver({
modifiers: [KeyboardModifier.Alt],
onKeyDown: () => {
this.setState({
altKeyDown: true,
});
},
onKeyUp: () => {
this.setState({
altKeyDown: false,
});
},
});
this.removeTrashKeyObserver = this.application
.io
.addKeyObserver({

View File

@@ -1,5 +1,6 @@
.h-full
multiple-selected-notes-panel.h-full(
application='self.application'
app-state='self.appState'
ng-if='self.state.showMultipleSelectedNotes'
)

View File

@@ -199,6 +199,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
}
}
.cursor-default {
cursor: default;
}
.fill-current {
fill: currentColor;
}

View File

@@ -7,7 +7,7 @@
target='blank'
)
menu-row(label="'Download Actions'")
div(ng-repeat='extension in self.state.menu track by extension.uuid')
div(ng-repeat='extension in self.state.menuItems track by extension.uuid')
.sk-menu-panel-header(
ng-click='self.toggleExtensionVisibility(extension.uuid); $event.stopPropagation();'
)
@@ -33,11 +33,11 @@
menu-row(
faded='true',
label="'No Actions Available'",
ng-if='!extension.actions.length'
ng-if='!extension.actions.length && !extension.hidden'
)
menu-row(
faded='true',
label="'Error loading actions'",
subtitle="'Please try again later.'"
ng-if='extension.error'
ng-if='extension.error && !extension.hidden'
)

View File

@@ -1,6 +1,6 @@
{
"name": "standard-notes-web",
"version": "3.8.18",
"version": "3.8.21",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
@@ -55,7 +55,7 @@
"pug-loader": "^2.4.0",
"sass-loader": "^8.0.2",
"serve-static": "^1.14.1",
"sn-stylekit": "5.2.5",
"sn-stylekit": "5.2.8",
"ts-loader": "^8.0.17",
"typescript": "4.2.3",
"typescript-eslint": "0.0.1-alpha.0",
@@ -71,8 +71,8 @@
"@reach/checkbox": "^0.13.2",
"@reach/dialog": "^0.13.0",
"@standardnotes/sncrypto-web": "1.2.10",
"@standardnotes/snjs": "2.7.21",
"mobx": "^6.1.6",
"@standardnotes/snjs": "2.7.23",
"mobx": "^6.3.2",
"mobx-react-lite": "^3.2.0",
"preact": "^10.5.12"
}

View File

@@ -2016,6 +2016,11 @@
resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.1.1.tgz#834701c2e14d31eb204bff90457fa05e9183464a"
integrity sha512-E9zDYZ1gJkVZBEzd7a1L2haQ4GYeH1lUrY87UmDH1AMYUHW+c0SqZ71af1fBNqGzrx3EZSXk+Qzr7RyOa6N1Mw==
"@standardnotes/features@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.0.0.tgz#906af029b6e58241689ca37436982c37a888a418"
integrity sha512-PEQyP/p/TQLVcNYcbu9jEIWNRqBrFFG1Qyy8QIcvNUt5o4lpLZGEY1T+PJUsPSisnuKKNpQrgVLc9LjhUKpuYw==
"@standardnotes/sncrypto-common@^1.2.7", "@standardnotes/sncrypto-common@^1.2.9":
version "1.2.9"
resolved "https://registry.yarnpkg.com/@standardnotes/sncrypto-common/-/sncrypto-common-1.2.9.tgz#5212a959e4ec563584e42480bfd39ef129c3cbdf"
@@ -2029,12 +2034,13 @@
"@standardnotes/sncrypto-common" "^1.2.7"
libsodium-wrappers "^0.7.8"
"@standardnotes/snjs@2.7.21":
version "2.7.21"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.7.21.tgz#db451e5facaf5fa41fa509eb1f304723929c3541"
integrity sha512-GhkGk1LJmD494COZkSOgyHaUnGnLWNLlSuCZMTwbw3dgkN5PjobbRhfDvEZaLqjwok+h9nkiQt3hugQ3h6Cy5w==
"@standardnotes/snjs@2.7.23":
version "2.7.23"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.7.23.tgz#fedc9c025301dbe20ed2d598fb378e36f90ff64e"
integrity sha512-eoEwKlV2PZcJXFbCt6bgovu9nldVoT7DPoterTBo/NZ4odRILOwxLA1SAgL5H5FYPb9NHkwaaCt9uTdIqdNYhA==
dependencies:
"@standardnotes/auth" "3.1.1"
"@standardnotes/features" "1.0.0"
"@standardnotes/sncrypto-common" "^1.2.9"
"@svgr/babel-plugin-add-jsx-attribute@^5.4.0":
@@ -6192,10 +6198,10 @@ mobx-react-lite@^3.2.0:
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.2.0.tgz#331d7365a6b053378dfe9c087315b4e41c5df69f"
integrity sha512-q5+UHIqYCOpBoFm/PElDuOhbcatvTllgRp3M1s+Hp5j0Z6XNgDbgqxawJ0ZAUEyKM8X1zs70PCuhAIzX1f4Q/g==
mobx@^6.1.6:
version "6.1.6"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.1.6.tgz#ae75e57ec07d190ed187273864002163fa357224"
integrity sha512-3by0Avodad/3cdPAuJuj8jWXhe1YByHKaEkonD9yOdcMoMuot2jrjlSXmQPhR1bJpNHfSsOx122tM9Pv3IzFWA==
mobx@^6.3.2:
version "6.3.2"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.2.tgz#125590961f702a572c139ab69392bea416d2e51b"
integrity sha512-xGPM9dIE1qkK9Nrhevp0gzpsmELKU4MFUJRORW/jqxVFIHHWIoQrjDjL8vkwoJYY3C2CeVJqgvl38hgKTalTWg==
move-concurrently@^1.0.1:
version "1.0.1"
@@ -7908,10 +7914,10 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
sn-stylekit@5.2.5:
version "5.2.5"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.5.tgz#85a28da395fedbaae9f7a91c48648042cdcc8052"
integrity sha512-8J+8UtRvukyJOBp79RcD4IZrvJJbjYY6EdN4N125K0xW84nDjgURuPuCjwm4lnp6vcXODU6r5d3JMDJoXYq8wA==
sn-stylekit@5.2.8:
version "5.2.8"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.8.tgz#320d7f536036110fe57e0392cb7ff52076186d9a"
integrity sha512-08eKl2Eigb8kzxl+Tqp7PyzSVA0psCR6+hIVNL4V7//J7DjmM2RanBPMRxIUqBBTX/1b+CbZdZb8wnbzD18NZw==
dependencies:
"@reach/listbox" "^0.15.0"
"@reach/menu-button" "^0.15.1"