UI for email subscription (#537)

* Add api methods for subscription

* Small changes

* little changes in remark.tsx
* disallow pass className to Button and Input

* Subscribe block

* add RSS and  Email subscription drobdowns
* add hook useTheme
* unify dropdown import/export

* Change API

* rename subscribe methods
* add unsubscribe method

* Add email subscription to settings and user

* Refactor and add new steps

* render by single component
* add final step
* add unsubscribe step it user is subscribed

* Add tests

* Update subscription logic

* test without mocking redux methods but with mocking store
* update user in store after subscribe and unsubscribe
* little changes in subscription flow

* fix drobdown size

* Fix RSS subscription link for site

* fix link
* add test for RSS subscription

* Fix showing email subscription

* it don't show to unauth users
* it don't show to anonymous users
* it tested
* isUserAnonymous is a bit rewrited
* isUserAnonymous is tested

* Make Email button visible for anon users

* React X supporst Fragments thats why .babelrc changed
* disabled button is more visible
* fix hovering on disabled buttons
* create mocks for tests
*  move email dropdown to __subscribe-by-email
This commit is contained in:
Pavel Mineev
2020-01-08 13:55:50 -06:00
committed by Umputun
parent bfa308e8da
commit 7d9e8ce076
42 changed files with 787 additions and 90 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ module.exports = {
'@babel/preset-react',
{
pragma: 'h',
pragmaFrag: 'div',
pragmaFrag: 'Fragment',
},
],
],
+22
View File
@@ -275,6 +275,28 @@ export const uploadImage = (image: File): Promise<Image> => {
}));
};
/**
* Start process of email subscription to updates
* @param emailAddress email for subscription
*/
export const emailVerificationForSubscribe = (emailAddress: string) =>
fetcher.post({
url: `/email/subscribe?site=${siteId}&address=${emailAddress}`,
withCredentials: true,
});
/**
* Confirmation of email subscription to updates
* @param token confirmation token from email
*/
export const emailConfirmationForSubscribe = (token: string) =>
fetcher.post({ url: `/email/confirm?site=${siteId}&tkn=${encodeURIComponent(token)}`, withCredentials: true });
/**
* Decline current subscription to updates
*/
export const unsubscribeFromEmailUpdates = () => fetcher.delete({ url: `/email`, withCredentials: true });
export default {
logIn,
logOut,
+1
View File
@@ -28,6 +28,7 @@ export const StaticStore: StaticStoreType = {
max_image_size: 0,
simple_view: false,
anon_vote: false,
email_notifications: false,
},
query: querySettings as QuerySettingsType,
};
+2
View File
@@ -6,6 +6,7 @@ export interface User {
admin: boolean;
block: boolean;
verified: boolean;
email_subscription?: boolean;
}
/** data which is used on user-info page */
@@ -113,6 +114,7 @@ export interface Config {
max_image_size: number;
simple_view: boolean;
anon_vote: boolean;
email_notifications: boolean;
}
export interface RemarkConfig {
@@ -11,7 +11,7 @@ import debounce from '@app/utils/debounce';
import postMessage from '@app/utils/postMessage';
import { StoreState } from '@app/store';
import { ProviderState } from '@app/store/provider/reducers';
import Dropdown, { DropdownItem } from '@app/components/dropdown';
import { Dropdown, DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { AnonymousLoginForm } from './__anonymous-login-form';
@@ -6,4 +6,9 @@
&:hover {
color: #06c5c5;
}
&:disabled,
&:hover:disabled {
color: #0aa;
}
}
@@ -6,7 +6,7 @@
background: #06c5c5;
}
&:hover&:disabled {
&:hover:disabled {
background: #259c9a;
}
}
@@ -2,3 +2,10 @@
background: #22201c;
color: #ddd;
}
.button_theme_dark.button_kind_link {
&:disabled,
&:hover:disabled {
color: #fff;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
}
&:disabled {
opacity: 0.4;
opacity: 0.6;
cursor: default;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper';
import { Theme } from '@app/common/types';
interface Props extends Omit<JSX.HTMLAttributes, 'size'> {
interface Props extends Omit<JSX.HTMLAttributes, 'size' | 'className'> {
kind?: 'primary' | 'secondary' | 'link';
size?: 'middle' | 'large';
theme?: Theme;
@@ -1,4 +1,8 @@
.comment-form__button {
margin: 8px 8px 0 0;
align-self: flex-start;
& + .comment-form__button {
margin-right: 20px;
}
}
@@ -0,0 +1,64 @@
.comment-form__subscribe-by-email {
display: flex;
flex-wrap: wrap;
flex-direction: column;
padding: 8px;
width: 200px;
box-sizing: border-box;
line-height: 1.2;
text-align: left;
}
.comment-form__subscribe-by-email_token {
padding-top: 0;
}
.comment-form__subscribe-by-email_subscribed,
.comment-form__subscribe-by-email_unsubscribed {
padding: 8px 12px;
text-align: left;
font-size: 14px;
}
.comment-form__subscribe-by-email__title {
margin-bottom: 12px;
}
.comment-form__subscribe-by-email__button {
margin-top: 10px;
flex-grow: 1;
}
.comment-form__subscribe-by-email__preloader {
margin: 0 auto;
}
.comment-form__subscribe-by-email__token-input {
resize: vertical;
border: 1px solid #c4c4c4;
padding: 4px;
font-family: inherit;
font-size: 0.8em;
font-weight: normal;
line-height: 1.5;
&:focus {
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
border-color: #259c9a;
outline: none;
}
}
.comment-form__subscribe-by-email__error {
margin-top: 8px;
padding: 6px 8px;
line-height: 1.2;
}
.comment-form__subscribe-by-email_theme_dark .comment-form__subscribe-by-email__error {
background: #672323;
color: #f98989;
}
.comment-form__subscribe-by-email_theme_light .comment-form__subscribe-by-email__error {
background: #ffd7d7;
color: #9a0000;
}
@@ -0,0 +1,149 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { Provider } from 'react-redux';
import { Middleware } from 'redux';
import createMockStore from 'redux-mock-store';
import '@app/testUtils/mockApi';
import { user, anonymousUser } from '@app/testUtils/mocks/user';
import * as api from '@app/common/api';
import { sleep } from '@app/utils/sleep';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { Dropdown } from '@app/components/dropdown';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { SubscribeByEmail, SubscribeByEmailForm } from './';
const initialStore = {
user,
theme: 'light',
} as const;
const mockStore = createMockStore([] as Middleware[]);
const makeInputEvent = (value: string) => ({
preventDefault: jest.fn(),
target: {
value,
},
});
describe('<SubscribeByEmail', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<Provider store={store}>
<SubscribeByEmail />
</Provider>
);
it('should be rendered with disabled email button when user is anonymous', () => {
const store = mockStore({ ...initialStore, user: anonymousUser });
const wrapper = createWrapper(store);
const dropdown = wrapper.find(Dropdown);
expect(dropdown.prop('disabled')).toEqual(true);
expect(dropdown.prop('buttonTitle')).toEqual('Available only for registered users');
});
it('should be rendered with enabled email button when user is registrated', () => {
const store = mockStore(initialStore);
const wrapper = createWrapper(store);
const dropdown = wrapper.find(Dropdown);
expect(dropdown.prop('disabled')).toEqual(false);
expect(dropdown.prop('buttonTitle')).toEqual('Subscribe by Email');
});
});
describe('<SubscribeByEmailForm>', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<Provider store={store}>
<SubscribeByEmailForm />
</Provider>
);
it('should render email form by default', () => {
const store = mockStore(initialStore);
const wrapper = createWrapper(store);
const title = wrapper.find('.comment-form__subscribe-by-email__title');
const button = wrapper.find(Button);
expect(title.text()).toEqual('Subscribe to replies');
expect(button.prop('children')).toEqual('Submit');
expect(button.prop('disabled')).toEqual(true);
});
it('should render subscribed state if user subscribed', () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store);
expect(wrapper.find('.comment-form__subscribe-by-email_subscribed')).toHaveLength(1);
expect(wrapper.text()).toStartWith('You are subscribed on updates by email');
});
it('should pass throw subscribe process', async () => {
const wrapper = createWrapper();
const emailVerificationForSubscribe = jest.spyOn(api, 'emailVerificationForSubscribe');
const emailConfirmationForSubscribe = jest.spyOn(api, 'emailConfirmationForSubscribe');
const onInputEmail = wrapper.find(Input).prop('onInput');
const form = wrapper.find('form');
expect(onInputEmail).toBeFunction();
act(() => onInputEmail(makeInputEvent('some@email.com')));
expect(form).toHaveLength(1);
form.simulate('submit');
expect(emailVerificationForSubscribe).toHaveBeenCalledWith('some@email.com');
await sleep(0);
wrapper.update();
const textarea = wrapper.find(TextareaAutosize);
const onInputToken = textarea.prop('onInput') as (e: any) => void;
const button = wrapper.find(Button);
expect(textarea).toHaveLength(1);
expect(onInputToken).toBeFunction();
expect(button.at(0).text()).toEqual('Back');
expect(button.at(1).text()).toEqual('Subscribe');
act(() => onInputToken(makeInputEvent('tokentokentoken')));
wrapper.find('form').simulate('submit');
expect(emailConfirmationForSubscribe).toHaveBeenCalledWith('tokentokentoken');
await sleep(0);
wrapper.update();
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
});
it('should pass throw unsubscribe process', async () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store);
const onClick = wrapper.find(Button).prop('onClick');
const unsubscribeFromEmailUpdates = jest.spyOn(api, 'unsubscribeFromEmailUpdates');
expect(onClick).toBeFunction();
act(() => onClick());
expect(unsubscribeFromEmailUpdates).toHaveBeenCalled();
await sleep(0);
wrapper.update();
expect(wrapper.text()).toStartWith('You have been unsubscribed by email to updates');
expect(wrapper.find(Button).prop('children')).toEqual('Close');
});
});
@@ -0,0 +1,254 @@
/** @jsx createElement */
import { createElement, FunctionComponent } from 'preact';
import { useState, useCallback, useEffect, useRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux';
import b from 'bem-react-helper';
import { User } from '@app/common/types';
import { StoreState } from '@app/store';
import { setUserSubscribed } from '@app/store/user/actions';
import { sleep } from '@app/utils/sleep';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import useTheme from '@app/hooks/useTheme';
import { getHandleClickProps } from '@app/common/accessibility';
import {
emailVerificationForSubscribe,
emailConfirmationForSubscribe,
unsubscribeFromEmailUpdates,
} from '@app/common/api';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
import { Dropdown } from '@app/components/dropdown';
import { Preloader } from '@app/components/preloader';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
const emailRegex = /[^@]+@[^.]+\..+/;
enum Step {
Email,
Token,
Final,
Close,
Subscribed,
Unsubscribed,
}
const renderEmailPart = (
loading: boolean,
emailAddress: string,
handleChangeEmail: (e: Event) => void,
emailAddressRef: ReturnType<typeof useRef>
) => [
<div className="comment-form__subscribe-by-email__title">Subscribe to replies</div>,
<Input
ref={emailAddressRef}
mix="comment-form__subscribe-by-email__input"
placeholder="Email"
value={emailAddress}
onInput={handleChangeEmail}
disabled={loading}
/>,
];
const renderTokenPart = (
loading: boolean,
token: string,
handleChangeToken: (e: Event) => void,
setEmailStep: () => void
) => [
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
Back
</Button>,
<TextareaAutosize
className="comment-form__subscribe-by-email__token-input"
placeholder="Token"
autofocus
onInput={handleChangeToken}
disabled={loading}
value={token}
/>,
];
export const SubscribeByEmailForm: FunctionComponent = () => {
const theme = useTheme();
const dispatch = useDispatch();
const subscribed = useSelector<StoreState, boolean>(({ user }) =>
user === null ? false : Boolean(user.email_subscription)
);
const emailAddressRef = useRef<HTMLInputElement>();
const previousStep = useRef<Step | null>(null);
const [step, setStep] = useState(subscribed ? Step.Subscribed : Step.Email);
const [token, setToken] = useState('');
const [emailAddress, setEmailAddress] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChangeEmail = useCallback((e: Event) => {
const value = (e.target as HTMLInputElement).value;
e.preventDefault();
setError(null);
setEmailAddress(value);
}, []);
const handleChangeToken = useCallback((e: Event) => {
const value = (e.target as HTMLInputElement).value;
e.preventDefault();
setError(null);
setToken(value);
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
switch (step) {
case Step.Email:
await emailVerificationForSubscribe(emailAddress);
setStep(Step.Token);
break;
case Step.Token:
await emailConfirmationForSubscribe(token);
dispatch(setUserSubscribed(true));
previousStep.current = Step.Token;
setStep(Step.Subscribed);
break;
default:
break;
}
} catch (e) {
setError(extractErrorMessageFromResponse(e));
} finally {
setLoading(false);
}
};
const isValidEmailAddress = emailRegex.test(emailAddress);
useEffect(() => {
if (emailAddressRef.current) {
emailAddressRef.current.focus();
}
}, []);
const setEmailStep = useCallback(async () => {
await sleep(0);
setStep(Step.Email);
}, [setStep]);
/**
* It needs for dropdown closing by click on button
* More info below
*/
if (step === Step.Close) {
return null;
}
if (step === Step.Subscribed) {
const handleUnsubscribe = useCallback(async () => {
setLoading(true);
try {
await unsubscribeFromEmailUpdates();
dispatch(setUserSubscribed(false));
previousStep.current = Step.Subscribed;
setStep(Step.Unsubscribed);
} catch (e) {
setError(extractErrorMessageFromResponse(e));
} finally {
setLoading(false);
}
}, [setLoading, setStep, setError]);
const text =
previousStep.current === Step.Token
? 'You have been subscribed on updates by email'
: 'You are subscribed on updates by email';
return (
<div className={b('comment-form__subscribe-by-email', { mods: { subscribed: true } })}>
{text}
<Button
kind="primary"
size="middle"
mix="comment-form__subscribe-by-email__button"
theme={theme}
onClick={handleUnsubscribe}
>
Unsubscribe
</Button>
</div>
);
}
if (step === Step.Unsubscribed) {
/**
* It works because click on button changes step
* And dropdown doesn't find event target in rerendered view
* NOTE: If you can suggest more elegant solve you can open issue or PR
*/
return (
<div className={b('comment-form__subscribe-by-email', { mods: { unsubscribed: true } })}>
You have been unsubscribed by email to updates
<Button
kind="primary"
size="middle"
mix="comment-form__subscribe-by-email__button"
theme={theme}
onClick={() => setStep(Step.Close)}
>
Close
</Button>
</div>
);
}
const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe';
return (
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
{step === Step.Email && renderEmailPart(loading, emailAddress, handleChangeEmail, emailAddressRef)}
{step === Step.Token && renderTokenPart(loading, token, handleChangeToken, setEmailStep)}
{error !== null && (
<div className="comment-form__subscribe-by-email__error" role="alert">
{error}
</div>
)}
<Button
mix="comment-form__subscribe-by-email__button"
kind="primary"
size="large"
type="submit"
disabled={!isValidEmailAddress || loading}
>
{loading ? <Preloader mix="comment-form__subscribe-by-email__preloader" /> : buttonLabel}
</Button>
</form>
);
};
export const SubscribeByEmail: FunctionComponent = () => {
const theme = useTheme();
const user = useSelector<StoreState, User | null>(({ user }) => user);
const isAnonymous = isUserAnonymous(user);
const buttonTitle = isAnonymous ? 'Available only for registered users' : 'Subscribe by Email';
return (
<Dropdown
mix="comment-form__email-dropdown"
title="Email"
theme={theme}
disabled={isAnonymous}
buttonTitle={buttonTitle}
>
<SubscribeByEmailForm />
</Dropdown>
);
};
@@ -0,0 +1 @@
export { SubscribeByEmail, SubscribeByEmailForm } from './comment-form__subscribe-by-email';
@@ -0,0 +1,14 @@
.comment-form__rss-dropdown {
text-align: left;
}
.comment-form__rss-dropdown__link {
font-weight: 700;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
color: #0aa;
&:hover {
color: #06c5c5;
}
}
@@ -0,0 +1,30 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { shallow } from 'enzyme';
import { SubscribeByRSS, createSubscribeUrl } from './';
jest.mock('react-redux', () => ({
useSelector: jest.fn(fn => fn({ theme: 'light' })),
}));
describe('<SubscribeByRSS/>', () => {
let wrapper: ReturnType<typeof shallow>;
beforeAll(() => {
wrapper = shallow(<SubscribeByRSS userId="user-1" />);
});
it('should be render links in dropdown', () => {
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
});
it('should have userId in site link', () => {
expect(
wrapper
.find('.comment-form__rss-dropdown__link')
.at(1)
.prop('href')
).toEqual(createSubscribeUrl('site', '&user=user-1'));
});
});
@@ -0,0 +1,41 @@
/** @jsx createElement */
import { createElement, FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks';
import useTheme from '@app/hooks/useTheme';
import { siteId, url } from '@app/common/settings';
import { BASE_URL, API_BASE } from '@app/common/constants';
import { Dropdown, DropdownItem } from '@app/components/dropdown';
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams: string = '') =>
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ userId }) => {
const theme = useTheme();
const items: Array<[string, string]> = useMemo(
() => [
[createSubscribeUrl('post'), 'Thread'],
[createSubscribeUrl('site', `&user=${userId}`), 'Site'],
[createSubscribeUrl('reply', `&url=${url}`), 'Replies'],
],
[userId]
);
return (
<Dropdown
title="RSS"
titleClass="comment-form__rss-dropdown__title"
buttonTitle="Subscribe by RSS"
mix="comment-form__rss-dropdown"
theme={theme}
>
{items.map(([href, label]) => (
<DropdownItem>
<a href={href} className="comment-form__rss-dropdown__link" target="_blank">
{label}
</a>
</DropdownItem>
))}
</Dropdown>
);
};
@@ -0,0 +1 @@
export { SubscribeByRSS, createSubscribeUrl } from './comment-form__subscribe-by-rss';
@@ -6,3 +6,11 @@
border-width: 6px 12px 12px 12px;
border-radius: 2px;
}
.comment-form__dropdown_rss {
text-align: left;
.dropdown__content {
width: 8em;
}
}
@@ -2,14 +2,45 @@
import { createElement } from 'preact';
import { shallow } from 'enzyme';
import { user } from '@app/testUtils/mocks/user';
import { StaticStore } from '@app/common/static_store';
import { CommentForm, Props } from './comment-form';
import { SubscribeByEmail } from './__subscribe-by-email';
const DEFAULT_PROPS: Readonly<Props> = {
mode: 'main',
theme: 'light',
onSubmit: () => Promise.resolve(),
getPreview: () => Promise.resolve(''),
user: null,
};
describe('<CommentForm />', () => {
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
const element = shallow(<CommentForm {...({ simpleView: true } as Props)} />);
it('should render without control panel, preview button, and rss links in "simple view" mode', () => {
const props = { ...DEFAULT_PROPS, simpleView: true };
const wrapper = shallow(<CommentForm {...props} />);
expect(element.exists('.comment-form__control-panel')).toEqual(false);
expect(element.exists('.comment-form__button_type_preview')).toEqual(false);
expect(element.exists('.comment-form__rss')).toEqual(false);
expect(wrapper.exists('.comment-form__control-panel')).toEqual(false);
expect(wrapper.exists('.comment-form__button_type_preview')).toEqual(false);
expect(wrapper.exists('.comment-form__rss')).toEqual(false);
});
it('should be rendered with email subscription button', () => {
StaticStore.config.email_notifications = true;
const props = { ...DEFAULT_PROPS, user };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.exists(SubscribeByEmail)).toEqual(true);
});
it('should be rendered without email subscription button when email_notifications disabled', () => {
StaticStore.config.email_notifications = false;
const props = { ...DEFAULT_PROPS, user };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.exists(SubscribeByEmail)).toEqual(false);
});
});
@@ -1,28 +1,25 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import { createElement, Component, createRef, Fragment } from 'preact';
import b, { Mix } from 'bem-react-helper';
import { User, Theme, Image, ApiError } from '@app/common/types';
import { BASE_URL, API_BASE } from '@app/common/constants';
import { StaticStore } from '@app/common/static_store';
import { siteId, url, pageTitle } from '@app/common/settings';
import { pageTitle } from '@app/common/settings';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import { sleep } from '@app/utils/sleep';
import { replaceSelection } from '@app/utils/replaceSelection';
import { Button } from '@app/components/button';
import { SubscribeByEmail } from './__subscribe-by-email';
import { SubscribeByRSS } from './__subscribe-by-rss';
import MarkdownToolbar from './markdown-toolbar';
import TextareaAutosize from './textarea-autosize';
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
let textareaId = 0;
export interface Props {
/** user id for rss link generation */
userId?: User['id'];
user: User | null;
errorMessage?: string;
value?: string;
mix?: Mix;
@@ -101,10 +98,13 @@ export class CommentForm extends Component<Props, State> {
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
const userId = this.props.user !== null && this.props.user.id;
const nextUserId = nextProps.user !== null && nextProps.user.id;
return (
nextUserId !== userId ||
nextProps.mode !== this.props.mode ||
nextProps.theme !== this.props.theme ||
nextProps.userId !== this.props.userId ||
nextProps.value !== this.props.value ||
nextProps.errorMessage !== this.props.errorMessage ||
nextState !== this.state
@@ -417,22 +417,17 @@ export class CommentForm extends Component<Props, State> {
Styling with{' '}
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
Markdown
</a>{' '}
is supported
</a>
{' is supported'}
</div>
Subscribe to&nbsp;the{' '}
<a className="comment-form__rss-link" href={RSS_THREAD_URL} target="_blank">
Thread
</a>
{', '}
<a className="comment-form__rss-link" href={RSS_SITE_URL} target="_blank">
Site
</a>{' '}
or&nbsp;
<a className="comment-form__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
Replies
</a>{' '}
by&nbsp;RSS
{'Subscribe by '}
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
{StaticStore.config.email_notifications && (
<Fragment>
{' or '}
<SubscribeByEmail />
</Fragment>
)}
</div>
)}
</div>
@@ -22,6 +22,8 @@ import './__rss-link/comment-form__rss-link.scss';
import './__markdown/comment-form__markdown.scss';
import './__markdown-link/comment-form__markdown-link.scss';
import './__markdown-toolbar/comment-form__markdown-toolbar.scss';
import './__subscribe-by-email/comment-form__subscribe-by-email.scss';
import './__subscribe-by-rss/comment-form__subscribe-by-rss.scss';
import './_theme/_dark/comment-form_theme_dark.scss';
import './_theme/_light/comment-form_theme_light.scss';
@@ -725,6 +725,7 @@ export class Comment extends Component<Props, State> {
{isReplying && props.view === 'main' && (
<CommentForm
user={props.user}
theme={props.theme}
value=""
mode="reply"
@@ -740,6 +741,7 @@ export class Comment extends Component<Props, State> {
{isEditing && props.view === 'main' && (
<CommentForm
user={props.user}
theme={props.theme}
value={o.orig}
mode="edit"
@@ -6,7 +6,7 @@
top: 100%;
left: 0%;
transform: translate(-0.5em, 5px);
min-width: 170px;
min-width: 120px;
max-width: 260px;
border: 2px solid #259c9a;
border-radius: 3px;
@@ -1,13 +1,11 @@
/** @jsx createElement */
import { createElement, JSX, RenderableProps } from 'preact';
import { createElement, JSX, FunctionComponent } from 'preact';
import b from 'bem-react-helper';
interface Props {
export interface Props extends JSX.HTMLAttributes {
separator?: boolean;
}
export default function DropdownItem(props: RenderableProps<Props> & JSX.HTMLAttributes & { separator?: boolean }) {
const { children, separator = false } = props;
return <div className={b('dropdown__item', {}, { separator })}>{children}</div>;
}
export const DropdownItem: FunctionComponent<Props> = ({ children, separator = false }) => (
<div className={b('dropdown__item', {}, { separator })}>{children}</div>
);
@@ -1,3 +1 @@
import Dropdown__Item from './dropdown__item';
export default Dropdown__Item;
export { DropdownItem } from './dropdown__item';
+21 -19
View File
@@ -1,29 +1,31 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import { createElement, Component, createRef, RenderableProps } from 'preact';
import b from 'bem-react-helper';
import { Theme } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { Button } from '@app/components/button';
interface Props {
type Props = RenderableProps<{
title: string;
titleClass?: string;
heading?: string;
isActive?: boolean;
disabled?: boolean;
buttonTitle?: string;
onTitleClick?: () => void;
mix?: string;
theme: Theme;
onOpen?: (root: HTMLDivElement) => unknown;
onClose?: (root: HTMLDivElement) => unknown;
}
}>;
interface State {
isActive: boolean;
contentTranslateX: number;
}
export default class Dropdown extends Component<Props, State> {
export class Dropdown extends Component<Props, State> {
rootNode = createRef<HTMLDivElement>();
storedDocumentHeight: string | null = null;
storedDocumentHeightSet: boolean = false;
@@ -115,7 +117,7 @@ export default class Dropdown extends Component<Props, State> {
// TODO: use ref
const dc = this.rootNode.current.querySelector<HTMLDivElement>('.dropdown__content');
if (!dc) return;
await sleep(10);
await sleep(0);
const rect = dc.getBoundingClientRect();
if (rect.left > 0) {
const wWindow = window.innerWidth;
@@ -177,10 +179,7 @@ export default class Dropdown extends Component<Props, State> {
window.removeEventListener('message', this.receiveMessage);
}
render() {
const { title, titleClass, heading, children, mix, theme } = this.props;
const { isActive } = this.state;
render({ title, titleClass, heading, children, mix, theme, disabled, buttonTitle }: Props, { isActive }: State) {
return (
<div className={b('dropdown', { mix }, { theme, active: isActive })} ref={this.rootNode}>
<Button
@@ -190,19 +189,22 @@ export default class Dropdown extends Component<Props, State> {
theme={theme}
mix={['dropdown__title', titleClass]}
kind="link"
disabled={disabled}
title={buttonTitle}
>
{title}
</Button>
<div
className="dropdown__content"
tabIndex={-1}
role="listbox"
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
>
{heading && <div className="dropdown__heading">{heading}</div>}
<div className="dropdown__items">{children}</div>
</div>
{isActive && (
<div
className="dropdown__content"
tabIndex={-1}
role="listbox"
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
>
{heading && <div className="dropdown__heading">{heading}</div>}
<div className="dropdown__items">{children}</div>
</div>
)}
</div>
);
}
+2 -5
View File
@@ -1,8 +1,5 @@
import Dropdown from './dropdown';
export default Dropdown;
export { default as DropdownItem } from './__item';
export { Dropdown } from './dropdown';
export { DropdownItem } from './__item';
import './dropdown.scss';
import './_active/dropdown_active.scss';
+1 -1
View File
@@ -4,7 +4,7 @@ import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper';
import { Theme } from '@app/common/types';
interface Props extends JSX.HTMLAttributes {
interface Props extends Omit<JSX.HTMLAttributes, 'className'> {
kind?: 'primary' | 'secondary';
theme?: Theme;
mods?: Mods;
+1 -1
View File
@@ -246,7 +246,7 @@ export class Root extends Component<Props, State> {
theme={props.theme}
mix="root__input"
mode="main"
userId={this.props.user!.id}
user={props.user}
onSubmit={(text, title) => this.props.addComment(text, title)}
getPreview={this.props.getPreview}
uploadImage={imageUploadHandler}
+10
View File
@@ -0,0 +1,10 @@
import { useSelector } from 'react-redux';
import { StoreState } from '@app/store';
import { Theme } from '@app/common/types';
export default function useTheme() {
const theme = useSelector<StoreState, Theme>(({ theme }) => theme);
return theme;
}
+9 -10
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-console */
/** @jsx createElement */
import loadPolyfills from '@app/common/polyfills';
import { createElement, render } from 'preact';
@@ -34,7 +33,7 @@ async function init(): Promise<void> {
const node = document.getElementById(NODE_ID);
if (!node) {
console.error("Remark42: Can't find root node.");
console.error("Remark42: Can't find root node."); // eslint-disable-line no-console
return;
}
@@ -60,7 +59,7 @@ async function init(): Promise<void> {
StaticStore.config = await api.getConfig();
if (params.page === 'user-info') {
render(
return render(
<div id={NODE_ID}>
<div className="root root_user-info">
<Provider store={reduxStore}>
@@ -70,12 +69,12 @@ async function init(): Promise<void> {
</div>,
node
);
} else {
render(
<Provider store={reduxStore}>
<ConnectedRoot />
</Provider>,
node
);
}
render(
<Provider store={reduxStore}>
<ConnectedRoot />
</Provider>,
node
);
}
+6
View File
@@ -11,6 +11,7 @@ import {
USER_HIDELIST_SET,
USER_HIDE,
USER_UNHIDE,
USER_SUBSCRIPTION_SET,
SETTINGS_VISIBLE_SET,
} from './types';
import { unsetCommentMode } from '../comments/actions';
@@ -155,3 +156,8 @@ export const setSettingsVisibility = (state: boolean): StoreAction<boolean> => d
});
return state;
};
export const setUserSubscribed = (isSubscribed: boolean) => ({
type: USER_SUBSCRIPTION_SET,
payload: isSubscribed,
});
+11
View File
@@ -11,6 +11,7 @@ import {
USER_HIDELIST_SET,
USER_HIDE,
USER_UNHIDE,
USER_SUBSCRIPTION_SET,
} from './types';
export const user = (state: User | null = null, action: USER_ACTIONS): User | null => {
@@ -18,6 +19,16 @@ export const user = (state: User | null = null, action: USER_ACTIONS): User | nu
case USER_SET: {
return action.user;
}
case USER_SUBSCRIPTION_SET: {
if (state === null) {
return state;
}
return {
...state,
email_subscription: action.payload,
};
}
default:
return state;
}
+9 -1
View File
@@ -56,6 +56,13 @@ export interface SETTINGS_VISIBLE_SET_ACTION {
state: boolean;
}
export const USER_SUBSCRIPTION_SET = 'USER_SUBSCRIPTION/SET';
export interface USER_SUBSCRIPTION_SET_ACTION {
type: typeof USER_SUBSCRIPTION_SET;
payload: boolean;
}
export type USER_ACTIONS =
| USER_SET_ACTION
| USER_BANLIST_SET_ACTION
@@ -64,4 +71,5 @@ export type USER_ACTIONS =
| USER_HIDELIST_SET_ACTION
| USER_HIDE_ACTION
| USER_UNHIDE_ACTION
| SETTINGS_VISIBLE_SET_ACTION;
| SETTINGS_VISIBLE_SET_ACTION
| USER_SUBSCRIPTION_SET_ACTION;
+1
View File
@@ -23,5 +23,6 @@ beforeEach(() => {
version: 'jest-test',
simple_view: false,
anon_vote: false,
email_notifications: false,
};
});
+1
View File
@@ -0,0 +1 @@
jest.mock('@app/common/api');
+19
View File
@@ -0,0 +1,19 @@
import { User } from '@app/common/types';
const user: Readonly<User> = {
id: 'email_1',
name: 'John',
picture: 'some_picture',
admin: false,
ip: '127.0.0.1',
block: false,
verified: false,
email_subscription: false,
};
const anonymousUser: Readonly<User> = {
...user,
id: 'anonymous_1',
};
export { user, anonymousUser };
+6 -5
View File
@@ -47,7 +47,8 @@ export type FetcherError =
};
export function extractErrorMessageFromResponse(response: FetcherError): string {
const defaultErrorMessage = 'Something went wrong. Please try again a bit later.';
const defaultErrorMessage = errorMessageForCodes.get(0) as string;
if (!response) {
return defaultErrorMessage;
}
@@ -60,13 +61,13 @@ export function extractErrorMessageFromResponse(response: FetcherError): string
return response.error;
}
if (typeof response.details === 'string') {
return response.details.charAt(0).toUpperCase() + response.details.substring(1);
}
if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) {
return errorMessageForCodes.get(response.code)!;
}
if (typeof response.details === 'string') {
return response.details;
}
return defaultErrorMessage;
}
@@ -0,0 +1,13 @@
import { isUserAnonymous } from './isUserAnonymous';
import { User } from '@app/common/types';
describe('isUserAnonymous', () => {
test('user is anonymous', () => {
expect(isUserAnonymous(null)).toEqual(true);
expect(isUserAnonymous({ id: 'anonymous_1' } as User)).toEqual(true);
});
test('user is not anonymous', () => {
expect(isUserAnonymous({ id: 'email_1' } as User)).toEqual(false);
});
});
+2 -2
View File
@@ -3,6 +3,6 @@ import { User } from '@app/common/types';
/**
* Defines whether current client is logged in via `Anonymous provider`
*/
export function isUserAnonymous(user?: User | null): boolean {
return user! && user!.id.substr(0, 10) === 'anonymous_';
export function isUserAnonymous(user: User | null) {
return user === null || user.id.substr(0, 10) === 'anonymous_';
}